とろろこんぶろぐ

かけだしR&Dフロントエンジニアの小言

next.js x amp-mustache x storybook の罠

はじめに

この記事は Recruit Engineers Advent Calendar 2020 の18日目の記事です。

adventar.org

17日目は mic_psm さんの

KubernetesとIstioを使ったDX改善 - 後ろを向いて後退します

でした。

本記事の内容に入る前に、 本記事は複合的な条件が重なった際に表出する問題に注目したとてもニッチな内容なので、 Next.js や AMP を利用するケースが必ず起こりうるような問題ではないことにご注意ください。

Next.js と AMP オプションを使った Web アプリケーションに Storybook を導入した際に躓いた部分があったので、 備忘の意味を込めて記事に残し、調査した内容を共有します。 記事執筆時点でまだスマートな解決策は見つかっていません。

概要

Next.js の AMP オプションを使って SSR で配信している Web アプリケーションに、 デザインシステムや VRT(ビジュアルリグレッションテスト) のために Storybook を導入しました。 しかし AMP の amp-list / amp-mustache で CSR を行っていると、 Storybook 上では思ったように描画しないことがありました。

結論からいうと、 amp-mustache で利用される template タグが React で利用できないため、 Storybook では描画できていませんでした。

Next.js x AMP について

Next.js では言わずと知れた React (react.js) を活用した Web フレームワークです。 導入した瞬間に煩わしい開発環境の設定がほとんど必要ない状態で React を書き始めることができます。

Next.js by Vercel - The React Framework

Next.js では AMP での開発を公式にサポートしています。

next/amp | Next.js

AMP 化したいページに対して export const config = { amp: true } を 1行足すだけで、 AMP コンポーネントに最低限必要なスタイルやスクリプトを追加してくれたり、 amphtml-validator や AMP optimizer といった周辺ツールを自動的に利用してくれます。

以下 GitHub のリンクです。

amphtml/validator/js/nodejs at master · ampproject/amphtml · GitHub

amp-toolbox/packages/optimizer at main · ampproject/amp-toolbox · GitHub

AMP optimizer に関してはこちらの Qiita 記事が詳しいです。

詳解 AMP Optimizer - Qiita

Next.js で AMP を利用する場合、 AMP は任意の JavaScript を利用できないため、React での CSR は一切行われません。 つまり React をテンプレートエンジンとして利用しているような形になります。

Storybook について

Storybook はデザインシステムや UI コンポーネントのカタログとして利用できる Web アプリケーション開発者向けツールです。

Storybook: UI component explorer for frontend developers

React の場合は @storybook/react のパッケージを導入することで React で作られたコンポーネントを管理することができます。 React 以外でも Vue や Angular、 Web Components などでも利用でき、フレームワークに限定したツールではありません。

また現在実際に開発時に利用しているユースケースの1つとして、 reg-suit というツールを Storybook と連携させることで、VRT(ビジュアルリグレッションテスト)にも利用しています。

reg-suit

Storybook で管理している UI コンポーネントスクリーンショットをコミットごとに比較することで、 コードの修正による不必要な UI 変更を検出しやすくなります。

情報が若干古いかもしれませんが、参考記事としてこちらをあげておきます。

Storybookとreg-suitで気軽にはじめるVisual Regression Testing - wadackel.me

React と AMP と Storybook の組み合わせによる問題

描画に関する違い

React で AMP コンポーネントをラップする形で個々のコンポーネントを開発し、 (Next.js の特徴である) pages 配下のファイルにそれらのコンポーネントを配置して Web ページの開発を行っています。

ただし繰り返しになりますが Next.js から実際に Web ページを配信する際には、 最終的に next.js 内で (おそらく) ReactDOM.renderToString()SSR された HTML を配信しています。

一方 @storybook/react は React を Storybook 上で CSR で描画を行っています。

storybook/render.tsx at next · storybookjs/storybook · GitHub

Storybook からすれば React で書かれているコンポーネントなので当然といえば当然です。

98%は問題ない

AMP のコンポーネントは Web Components ですが、Next.js 、つまり React で書いて利用することも可能です。 Next.js で公式に AMP をサポートしていることもあり、 Next.js で AMP コンポーネントを書く際にそこまで書き方を工夫する必要はありません(※1)。

Storybook においても、 React で作られてさえいれば Story に配置することは簡単にできます。 AMP を正しく動作させるために必要なスタイルや JavaScriptpreview-head.html に書いておくことで、 各 Story が読み込まれる際に head タグの中に差し込んでくれます。

Story rendering

ただし AMP として公式に扱ってもらえるわけではないので、 不要な JavaScript の読み込みが発生してしまい AMP valid な状態で表示することはできません。 これにより、 Story を行き来していると正しく動作しないことがあります。

しかし今回あげている VRT として利用するのであれば、初期描画の見た目が想定通りに表示されれば問題ありません。

※1 React で書く AMP コンポーネントを、TypeScript や styled-components などの言語やライブラリと組み合わせた場合には、 多少工夫が必要になることがあります。詳しく知りたい方は以下の記事をご覧ください。

モダンなWebフロントエンドの技術とAMP - Tech Blog - Recruit Lifestyle Engineer

快適なUXの裏には泥臭さがあった? 新サービス開発に「React×Next.js×AMP」採用のワケ (1/2):CodeZine(コードジン)

残り 2% の問題

AMP は JavaScript を任意に動かすことができないため、クライアント側で任意の描画を行わせるためには AMP コンポーネントを利用する必要があります。 AMP のコンポーネントの中で amp-list と amp-mustache というコンポーネントを組み合わせることで、任意の CSR を実現することができます。 amp-list はデータを動的に取得することができるコンポーネントで、 amp-mustache は上で取得したデータを利用して動的に DOM を描画することができるコンポーネントです。 詳しい利用方法についてはドキュメントをご参照ください。

ドキュメント:<amp-list> - amp.dev

ドキュメント:<amp-mustache> - amp.dev

amp-mustache では HTML5 の template タグを利用します(※2)。 例えば、以下のような書き方になります。

<template type="amp-mustache">
  <div class="greeting">Hello {{world}}!</div>
</template>

余談ですが、 {{}} で変数を囲う記法が mustache の書き方で、 amp-mustache では mustache.js が利用されています。

amphtml/mustache.js at master · ampproject/amphtml · GitHub

GitHub - janl/mustache.js: Minimal templating with {{mustaches}} in JavaScript

この amp-mustache を利用したコンポーネントは React で SSR した HTML では正しく描画されますが、 React で CSR した際には正しく描画されません。

※2 amp-mustache は script タグでも記述できます。それについては後述します。

原因調査

template タグを使って記述すると template タグ配下が DocumentFragment として扱われます。 template タグや DocumentFragment については MDN とともに以下の記事が分かりやすいです。

<template>: コンテンツテンプレート要素 - HTML: HyperText Markup Language | MDN

DocumentFragment - Web API | MDN

七章第四回 ノードをまとめて扱う:DocumentFragment — JavaScript初級者から中級者になろう — uhyohyo.net

これにより amp-mustache タグは CSR を実現しています。

React が SSR する際は template タグの配下で DocumentFragment の要素として mustache で記述した内容が正しく描画されるため問題なく動作します。 しかし、 React の CSR では残念ながら DocumentFragment は template タグ内の DocumentFragment の要素として描画されません。

f:id:ka2jun8:20201213121053p:plain
SSRの場合

f:id:ka2jun8:20201213121105p:plain
CSRの場合

図では少し分かりづらいですが、 CSR の場合に template タグの DocumentFragment の要素として見なされていないことがわかります。 React は template タグの場合でも DOM 要素を子要素に追加していくため、 DocumentFragment の要素として内容を保持してくれません。 React からすれば React そのものが CSR するための仕組みなので、 template タグを使う必要はなく、「哲学が違うから対応しない」と言われてしまえば「すまん、そうだよな...」という気持ちになります。 それについては以下のリンク先の Issue や Stack Overflow でも触れられています。

Better support <template> tags · Issue #19932 · facebook/react · GitHub

reactjs - How can I render a <template /> tag with react? - Stack Overflow

template タグの DocumentFragment が正しく書き出されていない以上、 amp-mustache で以下のように参照しても空の情報が返ってきてしまうため描画できません。

  return template.content.cloneNode(true);

amphtml/dom.js at 07551bbf822dceef05e93e17cbb8e86acea7c467 · ampproject/amphtml · GitHub

解決策の調査

解決案1「template タグ部分で SSR する」

上記 Stack Overflow の記事に書かれている通り、 React では amp-mustache として描画したい部分で template タグを dangerouslySetInnerHTML で文字列として書き出せば動きます。

しかしながら、SSR では正しく動作しているプロダクションコードに対して、 Storybook 上で動かない問題を解決するために、 dangerouslySetInnerHTML と ReactDOM.renderToString を用いたコードに変更するのは、 大掛かりでリスクも高そうなため、解決策としては適さないと判断しました。

解決案2「template タグを使わない」

amp-mustache は template タグを利用する代わりに script タグでも記述することができます。 公式のドキュメントにも書かれている通り推奨されていないものの、以下の template タグのものは、

<template type="amp-mustache">
  Hello {{world}}!
</template>

下記のような script タグに書き変えても動作します。

<script type="text/plain" template="amp-mustache">
  Hello {{world}}!
</script>

しかし、以下のようなコードは

<template type="amp-mustache">
  <div class="greeting">Hello {{world}}!</div>
</template>

React では以下のように書き換えても問題は解決しません。

<script type="text/plain" template="amp-mustache">
  <div class="greeting">Hello {{world}}!</div>
</script>

上記のコードを動作させてみると、template から書き出された DOM には class 属性が描画されず、 DOM の情報が抜け落ちていることがわかります。

f:id:ka2jun8:20201213124600p:plain
class 名が描画されない

これは amp-mustache が内部で textContent を参照して template の中身を読み取る処理が書かれているのですが、 React では script の中の要素も DOM として判断されてしまうため、 textContent では DOM 内のテキスト情報(上の例では Hello {{world}}! )のみが取得されてしまうからです。

amphtml/amp-mustache.js at 07551bbf822dceef05e93e17cbb8e86acea7c467 · ampproject/amphtml · GitHub

つまりこの script タグを用いる場合も解決案1と同様に dangerouslySetInnerHTML と ReactDOM.renderToString を利用するような React コンポーネントの文字列化が必要になってしまい、 正しく動作させるための修正が大掛かりになります。

解決案3「Storybook で SSR する」

Next.js 側のコードを修正して解決できなさそうなのであれば、 Storybook 側でなんとかできないか考えます。 Storybook が @storybook/react で template タグを React で CSR しようとしてしまうことがそもそも問題なので、 React で SSR した結果の HTML を Storybook から参照できないか検討してみました。

Storybook ではサーバサイドで動作させることを前提にした @storybook/server があります。 もしくは @storybook/html では html をコンポーネント単位として管理する方法もあります。

storybook/app/server at next · storybookjs/storybook · GitHub

storybook/app/html at next · storybookjs/storybook · GitHub

サンプルが参考になります。

storybook/examples/server-kitchen-sink at next · storybookjs/storybook · GitHub

storybook/examples/html-kitchen-sink at next · storybookjs/storybook · GitHub

ちなみに、@storybook/server の README に書かれている npx -p @storybook/cli sb init -t server は動きません。 -t のオプションは type 指定ですが、以下の type 一覧内に server がないのでそんな type ないぜ、と怒られて動作しないようです。

storybook/project_types.ts at next · storybookjs/storybook · GitHub

@storybook/server を使うために SSR した HTML を返すサーバーを書くか、 @storybook/html を使うために SSR した HTML をどこかに吐き出して、参照するようにすればできそうです。

ただし、どちらにせよこれまで書き溜めてきた @storybook/react 向けの story が一切利用できなくなります。 また、@storybook/server だとしたら以下のような server.js で コンポーネントSSR して html を返すようなコードを書く必要がありそうです。

storybook/server.js at next · storybookjs/storybook · GitHub

大変な修正になりそうなので、対応することを断念しました...。 もしトライしたらまた別の記事にしようと思います。

余談「@storybook/react で SSR するなら」

@storybook/react のオプションとして SSR オプションがあったらどうだろうか検討してみました。

現在の @storybook/react の render は以下で呼び出されています。

storybook/render.tsx at 981e6c8b5dfde9613d4820c240e9c8f4e49a7f96 · storybookjs/storybook · GitHub

ここで、 ReactDOM.render() の代わりに ReactDOMServer.renderToString() を使うように変えてみます。

  const html = ReactDOMServer.renderToString(element);
  rootEl.innerHTML = '';
  const div = document.createElement('div')
  div.innerHTML = html;
  rootEl.appendChild(div);

余談の余談ですが、 ReactDOMServer の renderToString はクライアント側でも利用することができます。

以下のメソッドはサーバとブラウザの両方の環境で使用できます:

  • renderToString()
  • renderToStaticMarkup()

ReactDOMServer – React

上記のように ReactDOMServer.renderToString を利用した @storybook/react をローカルでビルドし、 開発中の Web アプリケーションで node_modules の @storybook 配下の render.js と置き換えて動作を確認してみたところ、 きちんと React コンポーネントSSR され、 Storybook でも amp-mustache が描画されることが確認できました。

しかし、 HTML が正しく描画されても CSS は描画されませんでした。 なぜなら、アプリケーションが CSS in JS である styled-components を利用しているからでした...。 そのため本格的にこの方法で取り込む場合は仮に @storybook/react で SSR オプションが導入されたとしても、 アプリケーションの Storybook 側でコンポーネントで利用している適切な CSS を収集して SSR 時に style タグを注入する必要があります。 以下はイメージですが、 コンポーネントstories 側で、今まで以下にしていた部分を、

const Template: Story<React.ComponentProps<typeof MustacheComponent>> = (
  args
) => <MustacheComponent {...args} />;

以下のように対象の CSS を書き出す形に変える必要がありそうです。

const Template: Story<React.ComponentProps<typeof MustacheComponent>> = (
  args
) => {
  const sheet = new ServerStyleSheet();
  try {
    const StyledComponent = sheet.collectStyles(
      <MustacheComponent {...args} />
    );
    // style 収集のために一度 render する
    renderToString(StyledComponent);
    return (
      <>
        {sheet.getStyleElement()}
        {StyledComponent}
      </>
    );
  } finally {
    sheet.seal();
  }
};

結局こちらもあまりスマートな解決策とは言えなさそうです...。

解決策検討の結果

  • 解決案1「template タグ部分で SSR する」: Next.js 側のコードにリスキーな変更を加える必要があり有効ではない
  • 解決案2「template タグを使わない」: Next.js 側のコードにリスキーな変更を加える必要があり有効ではない
  • 解決案3「Storybook で SSR する」: Storybook の資産を流用できないため大幅な改修が必要

最終的にスマートに解決する方法は見つかりませんでした。 その中で、解決案2の script タグを利用した template の書き方を、 影響範囲の少ないところから取り込んでいくことが良さそうかなと感じています。

今から同様の構成で新規で Storybook を取り込む場合には、 上記について検討した上で @storybook/server や @storybook/html で Storybook を取り入れる形式を選択すべきかなと思います。

私の方でもまだ調査しきれていませんが、 ampproject でも storybook の addon を開発中のようです。 こちらは React ではなく preact で使われているようなので、React ではそのまま利用できないかもしれませんが、 このようなツールの導入を検討してみるのも良いかもしれません。

GitHub - ampproject/storybook-addon-amp: The storybook AMP addon

まとめ

Next.js (React) と AMP と Storybook を組み合わせた際に起こりうるニッチな問題について、調査した内容をブログ記事にしました。

Next.js の AMP オプションを使って SSR で配信している Web アプリケーションに、 デザインシステムや VRT のために Storybook を導入しました。 しかし AMP の amp-list / amp-mustache で CSR を行っていると、 Storybook 上では思ったように描画しないことがありました。

これは amp-mustache で利用される template タグが React で利用できないためでした。 その代わりの解決策はいくつかありそうでしたが、 結果的にスマートな解決策が見つけられませんでした。 もし何かアドバイスありましたらご教示いただけると幸いです。