kqito's 技術ブログ

技術やプログラミングについて思ったことを呟くブログ

@loadable/componentでSSRする

はじめに

私はよくReactをSSRする際にはNext.jsを用いて開発することが殆どです。その弊害?なのか、SSRの仕組みを自分で実装したことがないため必要最低限の機能を持つサーバーを実装してみました。

作成したサンプルについて

サンプルはこちらになります

github.com

目指した部分

SSRサーバを作成するにあたって以下の機能を実装しました

  • @loadable/componentを利用してパフォーマンスに注力したい
  • 開発時にはHMRでリアルタイムでブラウザに反映されるようにしたい

上記を実装するにあたってこちらを参考にしました。

loadable-components.com

github.com

Webpack

バンドラーはWebpackを用いて、clientとserver側でconfigファイルを分けました。

その上で、開発する際に工夫が必要だった部分を抜粋してメモします。

CSS周り

clientとserver側で共通のwebpack.config.jsでは、以下のようにmofule.rulescssの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-loaderSSRには対応していない為必要に応じてisomorphic-style-loaderなども導入する必要がありそうです。(今回はパス

LoadablePlugin

loadable-components.com

上記のリンクでも説明されているように@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でリレンダリングを登録しています。

webpack.js.org

また、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でキャッシュまでを自分で実装する予定です。

以上です。