Twitter ツイート埋め込み機能完全に理解した
Tweet URL を Tweet 埋め込みに変換して表示するように実装しました
Tweet の URL だけを Markdown に記述すると ↓
https://twitter.com/jack/status/20
Tweet 埋め込みの iframe
が展開されるようにブログを実装しました!↓
この記事では Twitter Embedded Tweets について調べたことと、それを React コンポーネントに落とし込む際に考えたことを書きたいと思います。
結論
React コンポーネントの実装を見たい人 ↓
Twitter の埋め込みツイートについて知りたい人 ↓
調べたこと
このブログサイトに Tweet 表示機能を実装するに当たり、まず catnose さんの以下の記事を思い出しました。
ざっくり読んで軽く把握した上で、公式ドキュメントに当たります。
ウェブサイトに Twitter のウィジェット類を表示する解説は下記の URL にぶら下がる形で公開されています。
一番手っ取り早い方法
Twitter の Tweet 詳細ページで三点リーダーをクリックすると、「ツイートを埋め込む」という項目があります。ここから埋め込み要素のコードをコピペできます。
例として jack さんのツイートの「ツイートを埋め込む」からコードをコピペすると次のようになっています(整形してあります)。
<blockquote class="twitter-tweet">
<p lang="en" dir="ltr">just setting up my twttr</p>
— jack⚡️ (@jack)
<a href="https://twitter.com/jack/status/20?ref_src=twsrc%5Etfw">March 21, 2006</a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
これをウェブサイトに貼り付ければ、ブラウザに読み込まれて目に見える頃には blockquote
要素がまるっと iframe
に置き換えられます。置き換えているのは "https://platform.twitter.com/widgets.js"
で指定された JavaScript です(以後、このスクリプトのことを単に widgets.js と呼びます)。
widgets.js による変換後の HTML ↓
<div class="twitter-tweet twitter-tweet-rendered">
<iframe
id="twitter-widget-1"
src="https://platform.twitter.com/embed/Tweet.html..."
></iframe>
</div>
しかしこの手順だと同一ページに複数個のツイートを埋め込むと、その個数分だけ widgets.js を読み込む <script>
が挿入されることになります。それに埋め込む方法があまりに面倒。 URL だけ指定してサクッと iframe
を表示してほしいですね。
複数個の script タグを回避する
これについては公式が方法を提示してくれています(catnose さんの記事でも言及されています)。
次のスクリプトを 1 度だけ実行すればいいです。
<script>
window.twttr = (function (d, s, id) {
var js,
fjs = d.getElementsByTagName(s)[0],
t = window.twttr || {};
if (d.getElementById(id)) return t;
js = d.createElement(s);
js.id = id;
js.src = "https://platform.twitter.com/widgets.js";
fjs.parentNode.insertBefore(js, fjs);
t._e = [];
t.ready = function (f) {
t._e.push(f);
};
return t;
})(document, "script", "twitter-wjs");
</script>
このスクリプトを <head>
に配置しておけば、後から widgets.js を読み込んだとしても実行されません。ただし、このスクリプトを読み込んだ後に動的に追加された blockquote
に関しては置換されずに放置されてしまいます。
その場合は、 widgets.js のロードによって生成された twttr.widgets.load()
関数を実行します。この関数は document.body
の中から <blockquote class="twitter-tweet">
を探して iframe
に置き換えていきます(widgets.js ロード時に置き換えが発生するのもこの関数の実行によるものでしょう)。
document.body
は範囲が広すぎるので特定の要素の中だけの探索に限定したいという場合は、 twttr.widgets.load(target)
のように引数に Element
を渡します。
埋め込み要素をカスタマイズする
blockquote
に予め決められた data-*
属性を渡しておくことで、 iframe
内の見た目や言語を指定できます。どんな data-*
属性があるかは次のリンク先のページに一覧されています。
<blockquote class="twitter-tweet" data-lang="ja" data-theme="dark">
<p lang="en" dir="ltr">just setting up my twttr</p>
— Jack (@jack) <a href="https://twitter.com/jack/status/20">March 21, 2006</a>
</blockquote>
その他の方法
他にも widgets.js を読み込むことで生成される twttr.widgets.createTweet
を使うことで iframe を生成することができますが、 blockquote
を用意しておき twttr.widgets.load
を実行することでできるものと違いがないため、 twttr.widgets.load
だけ把握しておけば良いと思われます。
React コンポーネントに落とし込む
さて、ここからが本題です。
僕は React ユーザーなので、 React で扱えるようにする必要があります。毎回 <blockquote className="twitter-tweet">
を書くのはしんどいので、使いやすいインターフェイスを備えたコンポーネント化を目指します。
スクリプト読み込み用コンポーネント
export const EmbeddedTweetScript: VFC = () => {
return (
<script
dangerouslySetInnerHTML={{
__html: `
window.twttr = (function(d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0],
t = window.twttr || {};
if (d.getElementById(id)) return t;
js = d.createElement(s);
js.id = id;
js.src = "https://platform.twitter.com/widgets.js";
fjs.parentNode.insertBefore(js, fjs);
t._e = [];
t.ready = function(f) {
t._e.push(f);
};
return t;
}(document, "script", "twitter-wjs"));
`,
}}
/>
);
};
公式が提供してくれている効率化版のスクリプトをまるっと script
タグの dangerouslySetInnerHTML
にぶち込みます。React では通常の HTML のように <script>console.log("foo")</script>
と書いてもスクリプトは実行されませんが、 dangerouslySetInnerHTML
に文字列のスクリプトを渡すと実行してくれます(用法用量に注意)。これをアプリのルート位置 (このブログサイトは Next.js なので _app.tsx) に差し込みます。
追記修正
Next.js 環境では上記のコンポーネントを差し込むだけではだめでした(Chrome がよしなにやってくれていたっぽく、開発中と執筆中はまったく気づかなかった)。
次の節で紹介する React コンポーネントには「ツイートを埋め込む」でコピペする HTML とは違いスクリプトタグを含まないため、上記コードのように 1 度だけ widgets.js が読み込まれることを保証する必要なんてなかった。
Next.js 環境の場合は次のような Next.js 提供の Script
コンポーネントを _app.tsx に差し込むことで解決しました。
import Script from "next/script";
<Script
id="twitter-embed-script"
src="https://platform.twitter.com/widgets.js"
strategy="beforeInteractive"
/>;
strategy="beforeInteractive
によってページが操作可能になる前にスクリプトを読み込むことを指定します(参照)。
完全に理解したと宣言した人らしく、全然理解していませんでした。(追記終わり)
iframe
になるコンポーネント
こちらはまずインターフェイスから考えます。
React のコンポーネントなので、 props
でツイートの URL を指定することで iframe
になることを期待するのではないでしょうか。
const App = () => {
return <EmbeddedTweet url="https://twitter.com/jack/status/20" />;
};
また、 <blockquote>
の data-*
属性で渡す予定のパラメーターもオプショナルな props
として受け付けてもらえると便利です。
const App = () => {
return (
<EmbeddedTweet
url="https://twitter.com/jack/status/20"
theme="dark"
align="center"
lang="ja"
/>
);
};
ということで、型定義は次のようになります。
type EmbeddedTweetProps = {
url: string;
id?: number;
cards?: "hidden";
conversation?: "none";
theme?: "light" | "dark";
width?: number;
align?: "left" | "right" | "center";
lang?: string;
dnt?: true;
};
const EmbeddedTweet:VFC<EmbeddedTweetProps> = (props) => { ... }
EmbeddedTweet
コンポーネントは blockquote
を返しますが、その内側の要素には url
を持った a
タグを起きます。「ツイートを埋め込む」でコピーされる HTML には p
タグも含まれますが実は必須では有りません(ただし iframe
に変換される前は画面に描画されるため、予測可能な UI の提供という観点ではあるべきかもしれません)。
それでは実装しましょう。
export const EmbeddedTweet: VFC<EmbeddedTweetProps> = (props) => {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rootRef.current !== null) {
twttr.widgets.load(rootRef.current);
}
}, []);
return (
<div ref={rootRef}>
<blockquote
className="twitter-tweet"
data-id={props.id}
data-cards={props.cards}
data-conversation={props.conversation}
data-theme={props.theme}
data-width={props.width}
data-align={props.align}
data-lang={props.lang}
data-dnt={props.dnt}
>
<a href={props.url} target="_blank" rel="noreferrer noopener">
{props.url}
</a>
</blockquote>
</div>
);
};
className="twitter-tweet"
を渡した blockquote
の内側に、 href={props.url}
を渡した a
タグを置いています。また、 blockquote
には先程紹介したパラメーターが data-*
属性で渡されています。
blockquote
を div
で囲い、その実 DOM を useRef
で掴んでいます。 マウント後に実行される useEffect
内で twttr.widgets.load(rootRef.current)
によって無駄な探索なしに blockquote
を iframe
に変換してもらえます。
これでひとまずは埋め込みツイートが表示されます。
しかしこれだと React 的ではない問題を孕んでいます。 props
を変更しても再描画されないということです。
props
は blockquote
にアタッチされていますが、 useEffect
実行後は twttr.widgets.load
の効果で blockquote
は消えて iframe
になっています。しかしこの変更を React は把握していません。React が管理している仮想 DOM は <blockquote>
が生きていると信じ込んでいます。
その状態で props
が変更されると(theme="light"
から theme="dark"
に変更されたとしましょう)、仮想 DOM 上は <blockquote data-theme="light">
から <blockquote data-theme="dark">
の変更なので、 React は差分から blockquote.setAttribute("data-theme", "dark")
を実行しようとするでしょう。しかしその頃には blockquote
の実 DOM は存在しないため、その更新に失敗します(厳密には DOM ツリーから外れただけでインスタンスとしては残っているので画面外で成功している)。よって props
の変更は見た目の変更を発生させません。
これを解決するには、 props
が変更されたら実 DOM から消えていた blockquote
を復活させた後、再度 twttr.widgets.load()
を実行する必要があります。
では消えた実 DOM はどうやって復活させればいいのでしょう? key
を変化させればいいのですね。
export const EmbeddedTweet: VFC<EmbeddedTweetProps> = (props) => {
const rootRef = useRef<HTMLDivElement>(null);
const key = JSON.stringify(props);
useEffect(() => {
if (rootRef.current !== null) {
twttr.widgets.load(rootRef.current);
}
}, [key]);
return (
<div ref={rootRef} key={key}>
{/* 属性一部省略 */}
<blockquote className="twitter-tweet" data-id={props.id} data-dnt={props.dnt}>
<a href={props.url} target="_blank" rel="noreferrer noopener">
{props.url}
</a>
</blockquote>
</div>
);
};
key
は配列から JSX.Element
を生成する際に指定しますが、ある要素やその子要素を強制的に作り直すようなケースでも利用可能です。
今回の場合、 props
をまるっと文字列化した値を最上位要素の div
に渡しているため、 props
の変化を検知して blockquote
も作り直されます。そして実 DOM に反映された後、同じく props
を文字列化した key
の変化を検知して発火する useEffect
によって twttr.widgets.load
が実行され、 blockquote
が iframe
に変換されます。
この実装によって React コンポーネントらしく props
の変化に従って再レンダリングが行われる様になりました(blockquote
から作り直しなのでちらついてしまうのはご愛嬌)。
この key
を使ったテクニックは、この Twitter のケースだけでなく、jQuery などの仮想 DOM をスルーして実 DOM を破壊してくる様々なライブラリに応用できそうですね(絶対にやりたくないですが)。
EmbeddedTweet
を使う
実装した EmbeddedTweet
コンポーネントを Markdown の変換に使いましょう。
リンクカードを表示している箇所で href
が "twitter.com/:user_name/status/:tweet_id"
を指していたら、リンクカードの代わりに EmbeddedTweet
を返すようにするだけです。
このブログサイトは Chakra UI の theming 機能によってライトモード・ダークモードの切り替えができるようになっているため、 useColorMode
で取得した値を theme
に渡しておきました。僕の一番伸びたツイートを貼り付けておくので(?)、ページ右上のモード切り替えボタンをクリックして埋め込みツイートのカラーテーマも切り替わることを確認してみてください。
まとめ
Twitter のツイートを埋め込む機能を実装するに当たって調べたことと、React コンポーネントに落とし込む方法を紹介しました。
ちなみに、 npm にはツイートを埋め込む React 用ライブラリがいくつか存在するので自作する必要はなさそうです(?)
Instagram などの他のサービスの埋め込みも頑張って作ります〜。
それではまた!