新ブログをいじくり回す:ギアコアの「機組」をブログに引っ越した
この記事は、私の消え去らぬ童心から生まれました。
4月のある旅の途中で、私は強烈な衝動に駆られて旅行記を書き、まずは「機核(ギアコア)」というサイトに投稿しました。そしていつもの習慣通り、Markdown形式に整理して自分のブログにも投稿しようと思いました。
ところが、編集を終えた後、ブログが壊れていることに気づいたのです。
以前のブログ
個人ブログを構築するブームは、私が大学3年生の時に学科で流行りました。7年前に書いたこの記事(今読むと恥ずかしくなる文章です)が、私が最初にこのブログを構築した時の心境を紹介しています。
その理由は、まず自分に何かを書くことを促したかったからです。次に、学校では教えてくれない、最新で流行りの華やかな技術を組み合わせたエンジニアリングの実践をしたかったからです。そして、独自ドメインを持つのは本当にかっこいいと思ったからです。少なくとも私はそう思っていました。
当時の様子を再現してみましょう。
何か新しいことを少し学ぶたびに、興奮した気持ちで、心に浮かんだアイデアをMarkdown形式の文書に書き留めました。
書き終えた後、さっそうと数行のコマンドを実行して新しい記事をプッシュします:
$ git add .
$ git commit -m "new blog! hahahaha"
$ git push origin master
それからワクワクしながらCIの画面を開きます。当時、CIはまだ比較的新しい概念で、DevOpsの理念が提唱されてから数年しか経っていませんでした。CIのログページで、この記事を含むブログ全体が静的なhtmlサイトとしてビルドされるのを待ち、Travisが私の鍵を使ってビルド成果物をgitリポジトリにプッシュするのを待ち、Githubがビルド成果物を公開するのを待ちました。
最後に、私のドメイン https://blog.thrimbda.com にアクセスし、満足げにその新しい記事がページの一番上に表示されているのを見るのでした。
長い年月を経て、このプロセスは退屈なものになりました。退屈ではありましたが、それでも有効に機能し続け、ついにそれが機能しなくなる日が訪れました。
新しいブログ
4月末のある日、CIパイプラインを3回連続で再実行しましたが、すべて失敗しました。ログをざっと見ると、ビルドに使用するある依存パッケージがあまりにも古すぎて、インターネット上から消えてしまったようでした。私は、この業界において7年という時間がどれほど巨大な隔たりなのかを思い知らされました。砂嵐が城を壊し、海水が山を飲み込むほどに長い時間です。
そこで、仕事の合間の「サボり時間」に、あまり面白くはない要件をいくつか掲げて技術選定を行いました:十分にシンプルで、十分に便利であること。
こうしてこの新しいブログが誕生しました。いつもの通り、紹介しておきましょう:
旧ブログの代替として、この新しいブログは完成しました。
童心に帰ったいじくり回しの過程
おそらく、十分に新しい技術だったから、あるいは技術選定が十分にシンプルだったから、もしくはフロントエンド作業のフィードバックループが十分に短かったからかもしれません。それに、阿錚(ア・ジョン)と一緒に仕事をしていると、自然とフロントエンドの知識が耳に入ってきて、大学1年生の時にあまり真剣に学ばなかった知識の穴を埋めることができました。その結果、ブログをいじくり回す過程が再び面白くなってきたのです。
ほんの少し手を加えたCSSスタイルの修正、新しいgitcus(Giscus)の追加、コメント欄をGitHubにホスティングなど。私は7年前の自分と意見が一致しました:コーディングとエンジニアリングをいじくり回すことは、やはり楽しいことです。それは労働の喜びであり、遊びの喜びです。
私はこの喜びの余韻に乗じて、自分が「機組(ギアコアのタイムライン投稿機能)」をブログに移行した技術的な過程を(独りよがりで)詳しく説明したいと思います。
データ
以前のある調査で、私は機核(ギアコア)がクローラーに対して驚くほど親切であることに気づいていました。そのデータインターフェースは非常に統一されており、表現力に富んでいます。例えば、私の「機組」を調べてみましょう:

このAPIが「機組」データをリクエストするために使用されていることがわかります。
https://www.gcores.com/gapi/v1/users/464460/recommend?talk-include=topic%2Cuser%2Chelpful%2Cpoll-options%2Crelated-content.radio%2Crelated-content.video%2Crelated-content.article%2Crelated-content.game%2Crelated-content.film%2Crelated-content.album%2Crelated-content.album-bundle%2Crelated-content.product%2Crelated-content.discussion&original-include=user%2Cdjs%2Ccategory&order-by=time&before=1718720726.986&fields[articles]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags&fields[videos]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Calbums%2Cpublished-albums&fields[radios]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Clatest-album%2Calbums%2Cis-free%2Cis-require-privilege
生データ
情報を整理してみましょう:
const user = 464460;
const url = new URL(
`https://www.gcores.com/gapi/v1/users/${user}/recommend?talk-include=topic%2Cuser%2Chelpful%2Cpoll-options%2Crelated-content.radio%2Crelated-content.video%2Crelated-content.article%2Crelated-content.game%2Crelated-content.film%2Crelated-content.album%2Crelated-content.album-bundle%2Crelated-content.product%2Crelated-content.discussion&original-include=user%2Cdjs%2Ccategory&order-by=time&fields[articles]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags&fields[videos]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Calbums%2Cpublished-albums&fields[radios]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Clatest-album%2Calbums%2Cis-free%2Cis-require-privilege`
);
さらに観察すると、ページネーションは before というパラメータで決定されているようです。このパラメータのタイムスタンプを前に進め続け、データが取得できなくなるまで繰り返せば良いのです。これにより、以下のようなコードが得られます:
import {
EMPTY,
Observable,
expand,
map,
mergeAll,
mergeMap,
of,
shareReplay,
skip,
takeWhile,
tap,
toArray,
} from "https://esm.sh/[email protected]";
const user = 464460;
const url = new URL(
`https://www.gcores.com/gapi/v1/users/${user}/recommend?talk-include=topic%2Cuser%2Chelpful%2Cpoll-options%2Crelated-content.radio%2Crelated-content.video%2Crelated-content.article%2Crelated-content.game%2Crelated-content.film%2Crelated-content.album%2Crelated-content.album-bundle%2Crelated-content.product%2Crelated-content.discussion&original-include=user%2Cdjs%2Ccategory&order-by=time&fields[articles]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags&fields[videos]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Calbums%2Cpublished-albums&fields[radios]=title%2Cdesc%2Cexcerpt%2Cis-published%2Cthumb%2Capp-cover%2Ccover%2Ccomments-count%2Clikes-count%2Cbookmarks-count%2Cis-verified%2Cpublished-at%2Coption-is-official%2Coption-is-focus-showcase%2Cduration%2Cdraft%2Caudit-draft%2Cuser%2Ccomments%2Ccategory%2Ctags%2Centries%2Centities%2Csimilarities%2Clatest-collection%2Ccollections%2Coperational-events%2Cportfolios%2Ccatalog-tags%2Cmedia%2Cdjs%2Clatest-album%2Calbums%2Cis-free%2Cis-require-privilege`
);
const imageUrl = (image: string) =>
`https://image.gcores.com/${image}?x-oss-process=image/quality,q_90/format,webp`;
// ページネーション
url.searchParams.set("before", `${Date.now() / 1000}`);
interface IGcoresTalk {
text: string;
images: string[];
published_at: number;
tags: string[];
}
const rawGcoresTalkData$: Observable<any[]> = of({
before: Date.now() / 1000,
}).pipe(
// 生データ
expand(async ({ before }) => {
url.searchParams.set("before", `${before}`);
const res = await fetch(url);
const data = await res.json();
if (!data.data || data.data.length === 0) {
return EMPTY;
}
return {
before:
new Date(
data.data[data.data.length - 1].attributes["published-at"]
).getTime() / 1000,
...data,
};
}),
skip(1),
// デバッグ用
// take(1),
takeWhile((v) => !!v.data),
// filter((v) => v.data.length > 0),
tap((v) => {
console.info(v);
}),
toArray(),
shareReplay(1)
);
これで、あるユーザーのすべての「機組」生データを保持するRxストリームが得られました。
データ処理
生データが手に入ったので、次はMarkdownに処理します。直接処理を始めましょう。ポイントは以下の通りです:
- 表示する内容を決める
- 機核のインターフェースフィールドを理解する
「機組」は、SNSのタイムラインや微博(ウェイボー)と似ていて、文字と画像、それにいくつかのタグで構成されています。そのまま平たく表示しましょう。
「機組」のデータは以下のように定義できます:
{
"blocks": [
{
"data": { "spoiler": false },
"depth": 0,
"entityRanges": [{ "key": 0, "length": 1, "offset": 0 }],
"inlineStyleRanges": [],
"key": "7u4tf",
"text": "-",
"type": "atomic"
},
{
"data": { "spoiler": false },
"depth": 0,
"entityRanges": [],
"inlineStyleRanges": [],
"key": "wisz4",
"text": "核聚变好玩,和雨川西蒙合了影,见到了做志愿者的 merz,然而社恐差点没敢上前搭话,腿快走断了,给没能来的小朋友们买了点纪念品,Celeste 随机异变速通震撼我妈,全程硬是没把因为震惊而张开的大嘴合上。可惜周天广州下雨航班被取消所以急匆匆买了深圳回上海的高铁票就没去成周天的。",
"type": "unstyled"
},
{
"data": { "spoiler": false },
"depth": 0,
"entityRanges": [],
"inlineStyleRanges": [],
"key": "nzffe",
"text": "",
"type": "unstyled"
},
{
"data": { "spoiler": false },
"depth": 0,
"entityRanges": [],
"inlineStyleRanges": [],
"key": "7f8m6",
"text": "下次还来!",
"type": "unstyled"
}
],
"entityMap": {
"0": {
"data": {
"caption": "",
"images": [
{
"path": "0ad6514d154c80a9ef6b4b0d6173d132-3024-4032.HEIC",
"width": 3024,
"height": 4032
},
{
"path": "69cea053377d3155d5f7e22e8584f289-4032-3024.HEIC",
"width": 4032,
"height": 3024
},
{
"path": "aedf102fa319ce3df2438386f894def4-4032-3024.HEIC",
"width": 4032,
"height": 3024
}
]
},
"mutability": "IMMUTABLE",
"type": "GALLERY"
}
}
}
出力したい内容と合わせて、以下のような構造体を定義します:
interface IGcoresTalk {
text: string;
images: string[];
published_at: number;
tags: string[];
}
出力するのは、string型のMarkdownテキストにします。
const cookedData$ = rawGcoresTalkData$.pipe(
mergeAll(),
mergeMap(({ data, included }): IGcoresTalk[] => {
// 「機組」のタグとしてタイトルだけが必要
const mapTypeIdToTitle = Object.fromEntries(
included.map((v: any) => [`${v.type}-${v.id}`, v.attributes.title])
);
console.info(mapTypeIdToTitle);
const talks = data
.filter((v: any) => v.type === "talks")
.map((v: any) => {
const content = JSON.parse(v.attributes.content);
const text = content.blocks
.filter((v: any) => v.type === "unstyled")
.map((v: any) => v.text.replace(/\#/, "\\#"))
.join("\n");
const images = (content.entityMap?.[0]?.data?.images ?? []).map(
(v: any) => v.path
);
const published_at = new Date(v.attributes["published-at"]).getTime();
const tags = Object.values(v.relationships as any[])
.filter((v) => !!v.data)
.filter((v) => ["topics", "games"].includes(v.data.type))
.map(({ data }) => mapTypeIdToTitle[`${data.type}-${data.id}`]);
return {
text,
images,
published_at,
tags,
};
});
return talks;
})
);
これで、欲しいデータ構造が大量に得られました。次はこれをstringに処理するだけです。
cookedData$
.pipe(
//
tap((v) => {
console.info(v);
}),
map((v: IGcoresTalk): string => {
const published_time = new Date(v.published_at);
const title = `## ${published_time.getFullYear()}-${
published_time.getMonth() + 1
}-${published_time.getDate()}`;
const content = v.text;
const images = v.images.map((v) => `})`).join("\n");
const tags = v.tags.map((v) => `- ${v}`).join("\n");
return `${title}\n\n${images}\n\n${content}\n\n${tags}\n`;
}),
toArray(),
map(
(all) =>
`---\ntitle: '0xc1 の機組ログ'\ndate: ${new Date().toISOString()}\n---\n元のリンク:[Thrimbda の機組](https://www.gcores.com/users/464460/talks)\n${all.join(
"\n\n---\n---\n\n"
)}`
),
tap((v) => {
console.info(v);
}),
tap((v) => Deno.writeTextFile(`./content/gcores-talks.md`, v))
)
.subscribe();
私は最も単純な処理方法を選び、直接近くのディレクトリに出力しました。
このコードの完全版はこちらにあり、denoを使って実行します:
deno cache get-gcores-talk.ts
deno run -A get-gcores-talk.ts
画像の表示とレンダリング
これで、有効なMarkdownが出力され、見栄えのするWebページとしてレンダリングできるようになりました。

しかし、画像の表示は少し変です:Markdownを直接レンダリングして平たく並べると、画像と文字のバランスが非常に悪く、頭でっかちな印象を与えます。機核のようにスライド式の画像グループにできれば良いのですが。
方案の選定
MarkdownはHTMLを使って表現力を拡張できるので、どのみち独自のコードで画像を直接HTMLとしてレンダリングし、望む効果を得ることができます。しかし、Zolaは別の方法を提供しており、より簡単に目標を達成できます:Shortcodes | Zola
私たちがやるべきことは、適切なCSSでスタイルを提供し、JSコードでインタラクションを担当するHTMLテンプレートを書くことです。そして、本文のMarkdownの中で直接このHTMLを呼び出します。
待って?HTML + CSS + JSを書くって、大学1年生の頃に戻ったみたいですね。
フロントエンドの作成
HTML
HTMLの部分は非常にシンプルで退屈です。各画像グループに対して以下の要素を提供します:
- スライド画像グループ自体のコンテナ
- 画像
- 下のインデックスを示すドット
- 左右の画像を切り替えるための矢印ボタン
<div class="slider-container">
<div class="slider-wrapper">
<div class="slider">
{% for slide in slides %}
<div class="slider-item">
<img src="{{slide}}" />
{% if slide.caption %}
<div class="caption">{{slide.caption}}</div>
{% endif %}
</div>
{% endfor %}
</div>
<button class="slider-prev" type="button">❮</button>
<button class="slider-next" type="button">❯</button>
</div>
<!-- ドット/円 -->
<div class="slider-dot-container">
{% for slide in slides %}
<span class="slider-dot"></span>
{% endfor %}
</div>
</div>
CSS
よりモダンなフロントエンド技術のおかげで、今ではflexレイアウトを使ってこれを実現するのは非常に簡単です。核心的な考え方は、画像を一列に並べるが、1枚の画像の位置だけを表示し、他の画像はすべて後ろに隠すことです:
.slider-wrapper {
overflow: hidden;
position: relative;
box-sizing: border-box;
width: 100%;
}
.slider {
position: relative;
display: flex;
box-sizing: border-box;
}
.slider-item {
position: relative;
flex: 1 0 100%;
min-height: 150px;
max-height: min(550px, 55svh);
display: flex;
align-items: center;
justify-items: center;
box-sizing: border-box;
overflow: visible;
}
.slider-item img {
position: relative;
width: auto;
max-height: 100%;
margin: auto; /* 水平方向に中央揃え */
}
/* 前へ・次へボタン */
.slider-prev,
.slider-next {
/* cursor: pointer; */
position: absolute;
/* top: 50%; */
width: auto;
height: 100%;
transform: translateY(-100%);
font-weight: bold;
font-size: 1.2rem;
z-index: 1;
color: white;
transition: 0.6s ease;
border-radius: 5px;
border: none;
background-color: transparent;
}
/* "次へ"ボタンを右に配置 */
.slider-next {
right: 0;
border-radius: 5px;
/* border-radius: 3px 0 0 3px; */
}
.slider-prev:hover:enabled,
.slider-next:hover:enabled {
border: none;
color: var(--accent);
background-color: rgba(71, 71, 71, 0.3);
}
.slider-dot-container {
text-align: center;
}
.slider-dot {
cursor: pointer;
height: 15px;
width: 15px;
margin: 0 2px;
background-color: #bbb;
border-radius: 50%;
display: inline-block;
transition: background-color 0.6s ease;
}
.slider-dot:hover,
.slider-dot-active {
background-color: #717171;
}
JavaScript
JSの部分は、ユーザーがインタラクションを通じて表示領域の画像を切り替えられるようにする責任があります。これらのインタラクションには以下が含まれます:
- マウスで左右のボタンをクリック
- マウスでドットをクリック
- タッチデバイスでの左右スワイプ
Web APIがJQueryを取り込み、さらにrxjsの助けを借りれば、これは非常に簡単です:
const ELS = (selector, parent) =>
(parent || document).querySelectorAll(selector);
const EL = (selector, parent) => (parent || document).querySelector(selector);
const mod = (n, m) => ((n % m) + m) % m;
ELS(".slider-container").forEach((EL_parent) => {
const EL_slider = EL(".slider", EL_parent);
const ELS_items = ELS(".slider-item", EL_parent);
const ELS_dots = ELS(".slider-dot", EL_parent);
const total = ELS_items.length;
let c = 0;
const setDotActive = () => {
ELS_dots.forEach((EL_dot, i) => {
EL_dot.classList.toggle("slider-dot-active", i === c);
});
};
setDotActive();
const anim = () => {
EL_slider.style.transform = `translateX(-${c * EL_slider.offsetWidth}px)`;
};
const prev = () => {
distance = 0;
startX = 0;
c = mod(c - 1, total);
setDotActive();
anim();
};
const next = () => {
distance = 0;
startX = 0;
c = mod(c + 1, total);
setDotActive();
anim();
};
EL(".slider-prev", EL_parent).addEventListener("click", prev);
EL(".slider-next", EL_parent).addEventListener("click", next);
ELS(".slider-dot", EL_parent).forEach((dot, i) => {
dot.addEventListener("click", () => {
c = i;
setDotActive();
anim();
});
});
const touchstart$ = fromEvent(EL_parent, "touchstart");
const touchend$ = fromEvent(EL_slider, "touchend");
const touchmove$ = fromEvent(EL_slider, "touchmove");
touchstart$
.pipe(
tap(() => {
EL_slider.style.transition = "none";
}),
switchMap((start) =>
animationFrames().pipe(
withLatestFrom(touchmove$),
map(([, touchEvent]) => {
const distance =
touchEvent.touches[0].clientX - start.touches[0].clientX;
EL_slider.style.transform = `translateX(-${
c * EL_slider.offsetWidth - distance
}px)`;
return distance;
}),
takeUntil(touchend$),
defaultIfEmpty(0),
last()
)
),
tap({
next: (distance) => {
EL_slider.style.transition = "transform 0.3s ease-in-out";
if (distance / EL_slider.offsetWidth > 0.2) {
c = mod(c - 1, total);
} else if (distance / EL_slider.offsetWidth < -0.2) {
c = mod(c + 1, total);
}
setDotActive();
anim();
},
}),
repeat()
)
.subscribe();
});
このコード全体の大意は、各スライド画像グループに対して、現在表示されている画像のインデックス [0, 1, ..., imageNumbers - 1] を記録することです。ユーザーのインタラクションはインデックスを変更し、それによってスライド画像グループがインデックスが示す画像を表示するために何ピクセルずらすべきかを計算します。
私が特に気に入っているのは、最後のタッチインタラクション用のRxコードです。
タッチインタラクションはボタンインタラクションよりも複雑です。なぜなら、ユーザーがスワイプしている間、画像はユーザーの指と一緒に動かなければならず、この感触をうまく調整しないとユーザーをがっかりさせてしまうからです。
この複雑さは、インタラクションにより多くの情報と、より複雑なWEB APIを必要とすることを意味します。
インタラクションのロジックから言えば、タッチ開始時の指のピクセル位置と、初期位置に対する各レンダリングフレームでの指のピクセル位置のオフセットを記録する必要があります。そして、各フレームをレンダリングするときに、このオフセットをスライド画像グループに追加して、画像が指と一緒に動くようにします。ある距離を移動した後、指が画面から離れたときに、画像を切り替えるかどうかを決定します。例えば、指が画面を軽くタップしただけで、2ピクセルの右オフセットが生じた場合、画像は切り替わるべきではありません。一方、指が画面の半分を右にスワイプした場合、画像は切り替わるべきです。オフセットのコンテナ全体に対するしきい値を、例えば20%に設定してみましょう。指のスワイプ距離が画像グループの幅の20%を超えた場合、画像を切り替えるべきだと考えます。
APIの観点から言えば、関連するイベントは3つあります:
- touchstart - ユーザーが画面に触れ始めたことを示し、このイベントは最初の指のピクセル座標を持ちます。
- touchmove - ユーザーの指が動くたびにトリガーされ、現在の指の座標を持ちます。
- touchend - 指が画面から離れたことを示します。
Rxはこの種の要件を実装するのに非常に適しています。ユーザーの指のタッチイベントをデータストリームにラップします。処理後、指の水平オフセットのストリームになり、各アニメーションフレームで画像の位置を更新するために使用されます。最後に、タッチ終了後、オフセットとコンテナ幅の比率に基づいて画像を切り替えるかどうかを決定します。
喜びの源
仕事は人に喜びをもたらします。なぜなら、私たちの脳はフィードバックとループを好むからです。
苦痛は、不完全なループや長すぎるループから生まれます。フロントエンドコードを書くことはとても楽しいです。この喜びは、そのループが非常に効率的だからです。コードを変更するたびに、保存と更新を伴い、変更の効果を直接見ることができます。その結果、数分で何度もこのフィードバックループを完了させることができ、それによって喜びがもたらされます。
このような喜びが皆さんに常に伴うことを願っています。
まとめ
今日に至るまで、この記事が述べている内容は、大学生のブログとしてはまだ意欲的ですが、私の職歴に比べるとその深さはかなり浅く、書いている間、しばしば恥ずかしさを感じました。
しかし、いずれにせよ、前回ブログを構築した時の丸写しに