loadable-components + TypeScriptでSSRとCSRに対応したdynamic importを実現する
react-loadableをSSRで使う場合にハマった所が多かったのでまとめました。
PR
結論と注意点
- babel-pluginである
loadable-components/babel
は必ずしも必要ない- つまりbabelを通さなくてもTyepScriptのまま使える
module
を指定しないとcomponentId
が引けずに落ちるので必ず書く必要がある- トップレベルにdynamic importしたもののファイル群(e.g. router)をimportしないといけない
module
を書かないとbabel-pluginが入ってないんじゃないか?っていうエラーになるのですが、babel-pluginは必要ないです。
loadable-components
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>
そして、クライアント側でこの挿入されたコードを使い、クライアント側のリゾルバが比較してあっているかの確認が行われます。