技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

loadable-components + TypeScriptでSSRとCSRに対応したdynamic importを実現する

react-loadableをSSRで使う場合にハマった所が多かったのでまとめました。

PR

github.com

結論と注意点

  • babel-pluginであるloadable-components/babelは必ずしも必要ない
    • つまりbabelを通さなくてもTyepScriptのまま使える
  • moduleを指定しないとcomponentIdが引けずに落ちるので必ず書く必要がある
  • トップレベルにdynamic importしたもののファイル群(e.g. router)をimportしないといけない
    • 起動時に読み込まないといけないため(当たり前)
    • クライアントとバックエンド側のリゾルバが違いクライアントサイドでは走査できない
    • 例えば、HMR用にcjsを使っていてトップレベルに存在しない場合

moduleを書かないとbabel-pluginが入ってないんじゃないか?っていうエラーになるのですが、babel-pluginは必要ないです。

loadable-components

github.com

react-loadableからloadable-componentsへ

今まで自分はreact-loadableを使ってました。
しかし、個人プロダクトなので特に意見はありませんが、開発の停滞、issueの提出不可等がずっと自分には合わなかったです。
また、シンタックスの複雑さもです。

react-loadableよりもシンタックスが簡潔で、コアコードが読みやすく、issueが開いているためこちらを今後は使っていく予定です。

問題点

loadable-componentsにも自分には以下の問題点が存在します。

  • loadable-components/server の型定義が存在しない(issue済み)
  • SSRでのdynamic importの解決方法はreact-loadableの方が好き
    • 自分はreact-loadableの用にwebpackでpluginを使用しビルドして、ファイルパスの一覧を生成してそれをserverが読み込む方法の方がスマートだと思っています
    • loadable-componentsの場合、promiseで待機しないといけなくて正直つらい。。

二番目の問題点により、SSR時に他のなにか(e.g. redux-saga)を併用する場合、Promise.allにしないといけなくなります。

書き方

dynamic import

react-routerにわたすコンポーネントをdynamic importした例です。

ssr-sample/src/client/Router/Routes.ts

import loadable from 'loadable-components';

export const LoadableTop = loadable(
  () => import(/* webpackChunkName: "Top" */ '../containers/Top').then(({ Top }) => Top),
  {
    modules: ['../containers/Top'] // SSR用に必ず書く、CSRのみなら必要ない
  }
);

export const LoadableNotFound = loadable(
  () =>
    import(/* webpackChunkName: "404" */ '../containers/NotFound').then(({ NotFound }) => NotFound),
  {
    modules: ['../containers/NotFound']
  }
);

Client Renderer

react-domへ挿入するファイルです。

ssr-sample/src/client/index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { loadComponents } from 'loadable-components';
import { configureStore, history } from './store/configureStore';
import { Router } from './Router'; // 上記のファイル、必ずトップレベルに書かないとクライアントとバックエンドでリゾルバが違うと怒られる

const renderMethod = module.hot ? ReactDOM.render : ReactDOM.hydrate;
const initialData = JSON.parse(document.getElementById('initial-data')!.getAttribute('data-json')!);
const store = configureStore(initialData);

// Routerは外から渡せるようにしたほうがよい(HMRのため)
const render = (RouterComponent: typeof Router) => {
  renderMethod(
    <Provider store={store}>
      <ConnectedRouter history={history}>
        <RouterComponent />
      </ConnectedRouter>
    </Provider>,
    document.getElementById('root')
  );
};

// ここで解決を待つ
loadComponents().then(() => {
  render(Router);
});

if (module.hot) {
  module.hot.accept('./Router', () => {
    const { Router: RouterComponent }: { Router: typeof Router } = require('./Router');

    render(RouterComponent);
  });
}

Server Renderer

SSRを行うファイルです。

ssr-sample/src/server/controllers/renderer/renderer.tsx

import { Request, Response } from 'express';
import * as React from 'react';
import { Provider } from 'react-redux';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Helmet from 'react-helmet';
import { ServerStyleSheet } from 'styled-components';
import { getLoadableState } from 'loadable-components/server';
import { renderFullPage } from '../../renderFullPage';
import { Router } from '../../../client/Router';
import { configureStore } from '../../../client/store/configureStore';
import { rootSaga } from '../../../client/sagas';

const assets = (process.env.NODE_ENV === 'production'
  ? (() => {
      const manifest = require('../../../../dist/manifest');
      return [manifest['vendor.js'], manifest['main.js']];
    })()
  : ['/public/main.bundle.js']
)
  .map((f) => `<script src="${f}"></script>`)
  .join('\n');

export async function get(req: Request, res: Response) {
  const store = configureStore();
  const sheet = new ServerStyleSheet();
  const jsx = (
    <Provider store={store}>
      <StaticRouter location={req.url} context={{}}>
        <div id="root">
          <Router />
        </div>
      </StaticRouter>
    </Provider>
  );

  try {
    const [loadableState] = await Promise.all([
      // loadable-componentsの走査をキックし、さらにredux-sagaやstyled-componentsの実行も走らせる
      getLoadableState(jsx), 
      // 上記で実行したredux-sagaが終了した時に呼び出され、storeが更新される
      store.runSaga(rootSaga).done
    ]);

    const preloadedState = JSON.stringify(store.getState());
    const helmetContent = Helmet.renderStatic();
    const meta = `
        ${helmetContent.meta.toString()}
        ${helmetContent.title.toString()}
        ${helmetContent.link.toString()}
      `.trim();
    const style = sheet.getStyleTags();
    const body = renderToString(jsx);

    // そのページでdynamic importされたファイルが書かれたコードをscriptタグへ変換
    const scripts = loadableState.getScriptTag();

    // react-loadableの結果(scriptタグ)をHTMLへ挿入する
    res.send(renderFullPage({ meta, assets, body, style, preloadedState, scripts }));
  } catch (e) {
    res.status(500).send(e.message);
  }
}

これを実行すると、HTMLへ以下のようなコードが挿入されます。

<script>window.__LOADABLE_STATE__ = {"children":[{"id":"../containers/Top"}]};</script>

そして、クライアント側でこの挿入されたコードを使い、クライアント側のリゾルバが比較してあっているかの確認が行われます。

関連記事

github.com

blog.hiroppy.me