はじめましての方ははじめまして。 ピクシブ百科事典バックエンドエンジニアの Javakky と、フロントエンドエンジニアの ahu です!
今回は、ピクシブ百科事典に新しく「X のポスト埋め込み記法」を実装した話です! 機能としては「記事の中に X のポストが貼れるようになった」というシンプルなものですが、その裏側では Core Web Vitals を意識したパフォーマンスチューニングなど、技術的な工夫をいくつか行いました。
この記事では、エンジニアとして埋め込みの利用規約やパフォーマンスを最大限守るためにした実装について紹介します。
なぜ埋め込みの仕組みを自前で実装するのか?
X のポストを表示するだけなら、公式の widgets.js を使えば一瞬です。
しかし、読み物として Web サービスの品質を保つためには、以下の指標も重要になります。
- TTFB (Time to First Byte):
SSR を採用しているため、サーバーサイドでの処理時間がそのままページの表示速度に直結します。 記事を表示するたびに同期的に X の API を叩いていたら、応答待ちによってユーザーを待たせることになります。 - CLS (Cumulative Layout Shift):
埋め込まれたポストの表示上の高さは、公式のウィジェットを使っていてもレンダリングされるまでは分かりません。 仮であってもポストの高さは事前に決めておかないと、ページやポストのロード中にガクッと埋め込み以降の文章がずれる Layout Shift が発生し、閲覧体験が悪くなります。
これらを解決するために、「バックエンド (PHP) でデータを永続キャッシュして TTFB を守り、フロントエンド (Next.js) でポストを表示する領域の UI サイズを固定して CLS を防ぐ」 という戦略を取りました。
API レスポンスのキャッシュと埋め込み判定
1. MySQL へのキャッシュ
TTFB を短縮し API のレート制限を回避するために、取得した oEmbed の HTML はキャッシュが必要です。 今回は Redis などのインメモリキャッシュではなく、MySQL を選択しました。
レスポンスに含まれる cache_age をベースに再取得を行いますが、ドキュメントの sample response にある通り、多くのポストで 3153600000 (100年) が設定されるため、実質的に永続化する形になっています。
ちなみに、ポストが削除された場合はフロントエンドのレンダリングで表示が無効化されます。
create table tweet_embed ( tweet_id bigint not null comment 'ポストID' primary key, html text null comment '埋め込みHTML', omit_media_html text null comment 'メディアなしHTML', created_at varchar(64) not null comment 'ポストの作成日時', media_type tinyint(1) null comment 'null: メディアタイプ未判定, 1: 画像あり...' )
data-cards 属性を利用することで埋め込みの有無を制御することはできますが、 widget.js の仕様変更などを考慮すると、ハードコードを避けてキャッシュで切り替えられるようにした方が実装コストを抑えることができると判断しました。
2. メディアタイプの判定ロジック
X のポストには、画像や外部リンク、引用ポストなどが含まれます。
返るレスポンスは一律ですが、「画像があるポスト」などを識別するため、独自の判定ロジックを実装しました。
特に厄介なのが t.co 短縮 URL です。
この種別を判別するため、実際にリクエストを送りリダイレクト先を確認しています。
<?php private function detectHasMedia(string $html): int { // ポスト本文に含まれるリンクのような文字列を取得 $media_links = $this->getMediaLinks($html); // pic.twitter.com なら画像 if ($this->hasTwitterPictureLink($media_links)) { return self::MEDIA_TYPE_IMAGE_OR_VIDEO; } // t.co なら実際にリクエストし、 Location を確認する foreach ($media_links as $url) { $request = $this->request_factory ->createRequest('GET', $url) ->withHeader('Origin', 'dic.pixiv.net'); $res = $this->http_client->sendRequest($request); if ($res->getStatusCode() !== 301) { return self::MEDIA_TYPE_NOT_HAVE; } $location = $res->getHeader('location') ?? ''; $host = parse_url($location, PHP_URL_HOST); // ドメインが X なら引用ポストとして扱う if ($host === 'twitter.com' || $host === 'x.com') { // 再帰的に引用元のポストを取得する return self::MEDIA_TYPE_TWEET_HAVE_IMAGE_OR_VIDEO; } } return self::MEDIA_TYPE_NOT_HAVE; }
パフォーマンス最適化のための多段階キャッシュ
バックエンドの実装に合わせて、フロントエンドでは以下のフローを実装しました。
- SSR 時に、埋め込みのキャッシュがあるかを確認する(MySQL のキャッシュを参照)
- キャッシュがないか通信に失敗したら、クライアントからバックエンドの API を叩く
この API コールで MySQL のキャッシュが作られ、1 と同じ構造のデータが降ってくる - 埋め込みのあるページで一度だけ
widgets.jsを読み込む - ページ全体に対してではなく、埋め込みを表示するコンポーネントで個別に
load()する
まず SSR の時点で、バックエンドから記事データと一緒に埋め込みのデータが渡されます。 ここで埋め込みのデータがあれば、2 はスキップできるので1往復分だけ表示が早くなります。
次に X 公式の widgets.js を読み込み、埋め込みポストをレンダリングしてもらいます。
公式ドキュメントにあるように、widgets.js を読み込むのは1度だけでよいので、公式が推奨しているスニペットと類似の処理をフックとして実装します。
class TwttrLoader { private static twttrPromise: Promise<Twitter> | undefined; /** * Promise が解決済みなら、解決済みの値への同期的なアクセスを許すためのプロパティ */ public static resolvedTwttr: Awaited<typeof TwttrLoader.twttrPromise>; private destroyed: boolean = false; public load(onSuccess: (twttr: Twitter) => void) { // Promise が空だったら作っておく if (!TwttrLoader.twttrPromise) { TwttrLoader.twttrPromise = new Promise((resolve) => { const script = document.createElement("script"); script.src = "https://platform.x.com/widgets.js"; script.async = true; script.onload = () => { window.twttr.ready((twttr) => { TwttrLoader.resolvedTwttr = twttr; resolve(twttr); }); }; document.body.appendChild(script); }); } // 共有の Promise にコールバックを登録して、解決がブロードキャストされるようにする // ただし、destroy() されたインスタンスではコールバックを呼ばない void TwttrLoader.twttrPromise.then((twttr) => { if (!this.destroyed) { onSuccess(twttr); } }); } public destroy() { this.destroyed = true; } } export const useLoadTwttrJs = () => { // 初期値が取得できるときは取得することで、描画までのタイミングを短縮できる const [twttr, setTwttr] = useState(TwttrLoader.resolvedTwttr); useEffect(() => { if (twttr) return; const loader = new TwttrLoader(); loader.load(setTwttr); return () => loader.destroy(); }, [twttr]); return twttr; };
window.twttr.ready() のようなコールバック関数と React のフックを統合する場合、コンポーネントのアンマウント後は setTwttr() などが呼ばれないように設計した方が堅牢です。*1
クラスを使って static な共有の Promise を作りつつ、インスタンス固有の destroyed も用意することで、useEffect() と自然な統合ができるようになります。
これにより、SPA 遷移であれば widgets.js は1度しかロードされないし、script タグが作られるのも1度だけにできます。
またこのフックを埋め込みを表示するコンポーネントで呼び出すことで、埋め込みが一切ないページでは widgets.js が読み込まれないようになり、パフォーマンスの悪化も最小限に抑えられます。
これで useLoadTwttrJs()?.widgets.load() とすればポストが表示されるようになりますが、そのままだと body 以下から埋め込みの URL を探すという処理が走るらしく、少し効率が悪いです。
load() に HTMLElement を渡して検索対象を減らすことで、さらにもう少しだけパフォーマンスを良くしています。
const x = useLoadTwttrJs(); useEffect(() => { // スクリプトのロードと、対象の要素の描画を待つ if (!x || !ref.current) { return; } x.widgets.load(ref.current); },[x]);
この辺りのテクニックは、他サービスなどでも使われているようです。
高さ固定の UI による CLS 対策
冒頭でも説明しましたが、仮であってもポストの高さは事前に決めておかないと、ページやポストのロード中にガクッと埋め込み以降の文章がずれる Layout Shift が発生し、閲覧体験が悪くなります。
ポストのレンダリング後の高さは事前には分からないため、「折りたたみ状態」のような初期表示の UI を導入することで、SSR 時に要素としての高さが決まるように設計しました。 挙動としては以下のような形になります。
- 長いポストの場合:
「折りたたみ状態」で表示され、ユーザーが「すべて見る」ボタンを押すとポストの全体が表示される。 (このときポストより後ろの要素は下に移動するが、ユーザー操作による移動なので CLS 的な懸念はない。) - 短いポストの場合:
ResizeObserverでポストのレンダリング後の高さを検知し、「すべて見る」ボタンを非表示にする。 (勝手に展開状態になるが後ろの要素を上に詰めることはしない。)
「すべて見る」の位置は SSR 直後とポストのレンダリング後で変わっておらず、CLS が抑制できている
この UI の導入にあたり、X の表示ガイドライン の「ポストに対する反応 UI の表示」が満たせなくなってしまう場合があるため、ガイドライン通り「View on X」と投稿日時をポストへのリンクとして表示しています。
In lieu of post Actions, “View on X” may be shown next to the timestamp, linking the user to the post permalink.
おわりに
「ポストを埋め込む」という一見シンプルな機能ですが、外部サービスの規約を守り、 TTFB や CLS といった指標を落とさないように実装しようとすると、意外と細々とした対応が必要でした。
X のポスト埋め込み記法は X を情報源とする事象の背景や文脈をわかりやすく伝える機能です! この記事を読んで興味を持った方は一度使ってみてください!
また、ピクシブ百科事典では一緒にプロダクトを作り上げていく仲間を募集しています! エンジニアだけでなく、プロダクトマネージャー (企画・進行・ユーザーコミュニケーションを設計できる方) も募集中です! ご応募たのしみにしております!
*1:React 18 以降では「Warning: Can't perform a React state update on an unmounted component.」の警告は出ないので必須ではない
