Puppeteer と React でコンポーネントを js と一緒にサーバサイドレンダリングする

2019/12/10 17:36:042021/11/12 16:14:53

"renderToStaticMarkup だとテンプレートにデータ投入できるけど、じゃあコンポーネントまで含んだフルの html を構成してやろうとするとデータを投入する手筈が考えつかなくなった" - @ci7lus - 12:05pm · 10 Dec 2019


ReactDom の renderToStaticMarkup を使うと React のコンポーネントを型を伴いながら簡単に html に変換することができて大変便利です。これを用いて生成した html を Puppeteer でパシャっとしてやると引数で渡した情報を簡単に画像にすることができます。使ったことないですが OGP タグも生成できるのではないでしょうか。
  • というかメインの用途これかもしれない。
renderToStaticMarkup では非同期処理を待つことができませんが、時には投入したデータに対して Google Charts を用いたグラフが生成されてほしいときもあります。
この場合、特段工夫を用いずにやると、合わせる html 側に型推論も何も効かない生の js を書くことになり、非常に屈辱的です。データの受け渡しも const data = JSON.parse(\{JSON.stringify(data)}\) とかになってしまいます。
これを避けるために、今回は若干強引な方法で js も生成させて描画することに成功したので、そのやり方について書き残します。
あと FC が好みなので今回は全部 FC を使います。

何がしたいのか
  • 投入したデータに対して Google Charts を用いたグラフが生成されてほしい。

考えつく別解
  • renderToStaticMarkup で生成するテキスト内にうまいこと js を埋め込む。
  • 投げ込むデータ等部分的に型をもたせることができるかもしれないが、生の js を script タグ内にベタ書きはやりたくない。
  • POST したデータに対して html を生成するサーバを立てておき、そちらへのリクエスト結果を setContent する。
  • 面倒め。
  • parcel-bundler で build した dist を何かしらの手段でロードする。
  • ペイロードデータのセット方法は考えつかず。

実装
インターネットを駆けずり回って探した以下の質問に対する回答を参考にして TypeScript で書き直した形になります。
勘所
tsx の書き方にポイントがあります。
  • 末尾で window に代入しておく。
src/template/index.tsx
declare global { 
    interface Window { 
        render: (payload: Payload, element: Element | null) => void 
    } 
} 
 
export type Payload = { 
    [age: number]: number 
} 
 
export const MyComponent: React.FC<{ 
    payload: Payload 
}> = ({ payload }) => { 
    return ( 
        <div className="container"> 
            <Chart 
                chartType="ScatterChart" 
                data={[["Age", "Weight"], ...Object.entries(payload)]} 
                width="100%" 
                height="400px" 
            /> 
        </div> 
    ) 
} 
 
if (window != undefined) { 
    window.render = (payload, element) => { 
        ReactDOM.render(<MyComponent payload={payload} />, element) 
    } 
}
  • evaluate から呼び出せるようにするため、window への代入を行う。
  • 実体の renderProxy を呼び出すとコケてしまうので、呼び出さないようにしてください。 そもそも window への直代入にして読み込めないように。declare しているので他からの import で window.render として呼び出せるようになってかなり平和。
生成の流れ
  • webpack で tsx を bundle.js にビルドする。
  • コード内でビルドを行うか、webpack.config.js でビルドを行うかはおそらく問いません。
  • tsx は実行中に変化しないため。
  • 以下にコード内でビルドを行う例を挙げておきます。ts-loader が必要。
src/index.ts
await new Promise((res, rej) => { 
    webpack( 
        { 
            mode: "development", 
            entry: "./src/template/index.tsx", 
            output: { 
                filename: "./bundle.js", 
            }, 
            module: { 
                rules: [{ test: /\.tsx?$/, use: "ts-loader" }], 
            }, 
            resolve: { 
                extensions: [".ts", ".tsx", ".js", ".json"], 
            }, 
        }, 
        (err, stats) => { 
            if (err || stats.hasErrors()) { 
                rej(err || stats.hasErrors()) 
            } else { 
                res(stats) 
            } 
        } 
    ) 
})
  • 動的生成部以外の html を page.setContentでロードさせる。
  • html 内にエントリするエレメントを含めておく必要があります。
src/index.ts#content.html
<div id="app"></div>
  • css などもここに記述しておきます。
  • page.addScriptTagbundle.js を読み込ませる。
src/index.ts
await page.addScriptTag({ path: require.resolve(`${__dirname}/../dist/bundle.js`) })
  • page.evaluate にデータを投入、定義しておいた render 関数でレンダリングする。
src/index.ts
onst data: Payload = { 
    4: 5.5, 
    8: 12, 
} // 渡したいデータ 
 
await page.evaluate((payload: Payload) => { // 後ろで渡したデータに型を付けている 
    window.render(payload, document.getElementById("app")) // bundle 内で露出しておいた関数を実行 
}, data) // 渡したいデータは後ろに
  • スクリーンショットする。
  • ここが案外曲者で、dom 内で処理が行われている間は page.waitForNavigation が効かないようで、page.waitFor しておかないとグラフが現れる前に撮られてしまったりします。事前に指定しておいた dom の出現を待つ方法もあるらしいんですが、画像リソースなどのロードはどうなってしまうんでしょうか。
  • これ今一番知りたいので解法よろしくおねがいします。
  • こうして私達のもとへ届けられる

おわりに
怪文書になってしmあった…
おわり


著者の画像

ci7lus

@ci7lus

Caramelize - Made withCaramelizeand / Privacy