@loadable/componentでSSRする
はじめに
私はよくReactをSSRする際にはNext.js
を用いて開発することが殆どです。その弊害?なのか、SSRの仕組みを自分で実装したことがないため必要最低限の機能を持つサーバーを実装してみました。
作成したサンプルについて
サンプルはこちらになります
目指した部分
SSRサーバを作成するにあたって以下の機能を実装しました
- @loadable/componentを利用してパフォーマンスに注力したい
- 開発時にはHMRでリアルタイムでブラウザに反映されるようにしたい
上記を実装するにあたってこちらを参考にしました。
Webpack
バンドラーはWebpackを用いて、clientとserver側でconfigファイルを分けました。
その上で、開発する際に工夫が必要だった部分を抜粋してメモします。
CSS周り
clientとserver側で共通のwebpack.config.js
では、以下のようにmofule.rules
でcssのloaderを記述しました(一部抜粋
{ test: /.css$/, use: [ isProduction && { loader: MiniCssExtractPlugin.loader, }, isDevelopment && { loader: 'style-loader', }, { loader: 'css-loader', options: { sourceMap: isDevelopment, }, }, ].filter(Boolean), },
production環境ではMiniCssExtractPlugin
を用いることは多いと思います。
しかしdevelop環境におけるHMRを機能させる為にstyleタグ(linkタグも含め)としてcssを適用させて必要がある為上記のようにしました。
style-loader
はSSRには対応していない為必要に応じてisomorphic-style-loader
なども導入する必要がありそうです。(今回はパス
LoadablePlugin
上記のリンクでも説明されているように@loadable-componentを利用する際にはwebpack.pluginsにプラグイン を追加する必要があります。
以下がその該当部分です。
plugins: [ isDevelopment && new webpack.HotModuleReplacementPlugin(), isProduction && new workboxPlugin.GenerateSW({ swDest: 'sw.js', clientsClaim: true, skipWaiting: false, }), new LoadablePlugin(), ].filter(Boolean)
Loadableはdevelopmentとproductionの両方とも利用する為、普通に配列の要素として代入していますが、HMRやworkboxなどは基本的に限られた時にしか利用したいためprocess.env.NODE_ENV
をみてpluginを利用するかを分けています。
Client
エントリーになっているsrc/client/index.tsx
は以下のようになっています
export const render = () => { ReactDom.hydrate( <BrowserRouter> <App /> </BrowserRouter>, document.getElementById("app") ); }; if (module.hot) { module.hot.accept("./app", () => { render(); }); } loadableReady(() => { render(); });
module.hot
がtrueのときはHMRが有効(development)と判断しmodule.hot.acceptでリレンダリングを登録しています。
また、loadableReady
の部分ですが、@loadable/componentを利用すると、SSR後のクライアントにてasync属性が有効になってているscriptファイルを並列でダウンロードするように自動でよしなにしてくれるのですが、その並列のダウンロードが終わり次第、レンダリング(hydrate)を行うと言う処理になります。
hydarteが遅いほどscriptに依存している要素がInteractiveな状態になりませんので、このような並列 + asyncな処理を行っています。
Server
SSRに該当するファイルは以下です。
import React from "react"; import { Request, Response } from "express"; import { renderToString } from "react-dom/server"; import { StaticRouter } from "react-router-dom"; import { ChunkExtractor } from "@loadable/server"; import { App } from "../../../client/app"; import { appResolve } from "../../../utils/path"; import { renderHtml } from "./renderHtml"; const statsFile = appResolve("./dist/client/loadable-stats.json"); export function renderer(req: Request, res: Response) { const context = {}; const Root = () => ( <div id="app"> <StaticRouter location={req.url} context={context}> <App /> </StaticRouter> </div> ); try { const extractor = new ChunkExtractor({ statsFile }); const jsx = extractor.collectChunks(<Root />); const body = renderToString(jsx); const script = extractor.getScriptTags(); const style = extractor.getStyleTags(); const html = renderHtml({ body, script, style }); res.send(html); } catch (e) { console.error(e); res.status(500).send(e.message); } }
まず、以下の行にてwebpackのバンドルによって生成されたloadable-stats.jsonのパスを取得します。
const statsFile = appResolve("./dist/client/loadable-stats.json");
これを元にjsxやscriptなどの取得を行います。
const extractor = new ChunkExtractor({ statsFile }); const jsx = extractor.collectChunks(<Root />); const body = renderToString(jsx); const script = extractor.getScriptTags(); const style = extractor.getStyleTags();
取得できたこれらをrenderHTMLという関数にてhtmlを生成します。
renderHTMLはstringを返す純粋な関数です。
type Renderer = { body: string; script: string; style: string; }; export const renderHtml = ({ body, script, style }: Renderer) => { return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <title>SSR example</title> ${style} </head> <body> ${body} ${script} </body> </html> `; };
そしてその結果をexpressサーバがresponseとして返却することで最初のHTMLをブラウザが取得できるようになります。
まとめ
本当に今更だったのですが、SSRサーバーを作成したことがなかったので新しい知見が増えました。
これをもとにfastyなどのCDNでキャッシュまでを自分で実装する予定です。
以上です。