業務で使える簡単なSSR + SPA のテンプレートを公開した
久しぶりのブログです。
よくNode.jsの人と思われがちですが、普段はNode.jsでのバックエンド開発はもちろんですがReactやVueを書いていますので、たまにはフロントエンドネタを投稿しようと思います。
リポジトリにあるコード見たほうが早いと思いますので、ここでは注意点等を列挙していこうかなと思います。
主な技術スタック
dependencies
- react@16
- react-router-dom@4
- react-helmet@5
- react-loadable@5
- redux@4
- redux-saga@0.16
- styled-components@3
- express@4
- dotenv@6
devDependencies
- typescript@3
- storybook@4.0.0-alpha.9
- jest@23
- ts-node@
- webpack@4
- workbox@3
注意
今回は、自分の好き嫌いも含め以下のことを導入しませんでした。
- Atomic Design
- こんな簡単なコードに5層もいらない
- decorators
@connect
を普段使わないのとまだ実験中なため
bindActionCreators
- 途中でカスタマイズが必要になってはじめ使ってて対応できなくなって結局消すため(経験談)
- サンプルコードなので、最低限必要なものしかいれない
Server
Server Side Renderingを行う
一番キモになる部分です。
その中では、SPA時と同様に動かすためフロントエンドのコードを使って実行します。
// [server] renderer.ts const store = configureStore(); const sheet = new ServerStyleSheet(); // styled-components用 const jsx = ( <Provider store={store}> <StaticRouter location={req.url} context={{}}> <div id="root"> <Router /> </div> </StaticRouter> </Provider> ); // sagaの処理が停止するとここが解決される store .runSaga(rootSaga) .done.then(() => { const preloadedState = JSON.stringify(store.getState()); const helmetContent = Helmet.renderStatic(); const meta = ` // helmetからheadに入れる情報を取得する ${helmetContent.title.toString()} ${helmetContent.meta.toString()} `.trim(); const style = sheet.getStyleTags(); const body = renderToString(jsx); // sagaにより更新されたstoreを使い再度レンダリングする res.send(renderFullPage({ meta, assets, body, style, preloadedState })); // html生成 }) .catch((e: Error) => { res.status(500).send(e.message); }); // redux-sagaの起動及び非同期処理とstyled-componentsがstyleを抜く作業をさせる renderToStaticMarkup(sheet.collectStyles(jsx)); // forkで動いているredux-sagaを止める(そうしないとずっと起動していてレスポンスが返せない) store.close();
https://github.com/hiroppy/ssr-sample/blob/master/src/server/controllers/renderer/renderer.tsx
// [client] configureStore.ts const sagaMiddleware = createSagaMiddleware(); export const configureStore = (preloadedState: Object = {}) => { const enhancer = createEnhancer(); const store: Store & { runSaga: SagaMiddleware<typeof rootSaga>['run']; // 型を追加 close: () => void; } = createStore(rootReducer, preloadedState, enhancer); sagaMiddleware.run(rootSaga); store.runSaga = sagaMiddleware.run; // renderer.tsで呼べるように追加 store.close = () => { // renderer.tsからsagaを止める命令を送るメソッドを追加 store.dispatch(END); // forkされているsagaを止めてrenderer.tsのstore.runSaga(rootSaga).doneを解決させる };
https://github.com/hiroppy/ssr-sample/blob/master/src/client/store/configureStore.ts
SSR時には<html>
, <head>
を含むHTMLを生成しないといけないため文字列として手動で生成する必要があります。
また、react-helmetはSSR時には生成されないため、手動でmeta
などを抽出する必要があります。
それ以外は、クライアント側のコードを利用します。
html生成コード はこちら。
以下がクライアント同様に行うこととなります。
redux-sagaを使って非同期処理
render系のメソッドを使って、redux-sagaを起動させます。
あくまでも目的がキックであり差分更新に関する処理が必要ないため、renderToString
より軽量なrenderToStaticMarkup
を使います。処理が終わり次第、redux-sagaへ止める命令を送ります。(
store.close()
を経由し、END
アクションを発行)store.runSaga(rootSaga).done
が解決されるため、その中で更新されたstoreのデータを抽出しクライアントに初期のstateをわたすように<script>
タグに埋め込みます。storeが更新されたため、再度レンダリング(
renderToString
)を行い、storeの結果を反映させたHTMLを生成します。
一回目のレンダリングではもちろんstoreの値は空なのでその戻り値のHTMLにはデータは存在しません。
つまり、キック用とHTML生成用のレンダリングが最低2回は必要ということです。
react-helmetを使ってheadタグを生成
クライアントで行うときと異なって、サーバーではheadタグに対して動的差し込みができないためレンダリングをし、手動でhtmlに差し込む必要があります。
なので、redux-sagaの一回目のレンダリングに便乗して、react-helmetの要素を抽出します。
redux-sagaの解決後、そこで必要なタグを取得(helmetContent.title.toString()
)しHTMLの生成関数へ流します。
styled-componentsで使われているcssを抽出
redux-sagaの一回目のレンダリングに便乗して、styled-componentsで書かれたコンポーネントのcssを抽出します。
クライアントがHTMLを受け取った時にレンダリングされたDOMのcssがないと見た目が一致しないためです。
そこで抽出したstyle
タグをHTMLの生成関数へ流しhead
へ挿入します。
Development
Hot Module Replacementを有効にする
開発時にはHMRを有効化するために、webpackのビルドをNodeサーバー起動時に行います。(また、フロントエンドではhydrate
ではなく、render
にします ※後述)
if (process.env.NODE_ENV !== 'production') { const webpack = require('webpack'); const webpackHotMiddleware = require('webpack-hot-middleware'); const webpackDevMiddleware = require('webpack-dev-middleware'); const config = require('../../webpack.config'); const compiler = webpack(config); app.use(webpackHotMiddleware(compiler)); app.use( webpackDevMiddleware(compiler, { publicPath: config.output.publicPath }) ); }
https://github.com/hiroppy/ssr-sample/blob/master/src/server/server.ts#L15-L28
Production
今回は、webpackをかけずにts-nodeで開発も本番も起動させます。
Manifestを読み込む
本番環境では、ファイル名にハッシュを含めるためコアコードはmanifestファイルを参照しクライアントに返す<script>
を生成していきます。
今の自分のコードでは、起動時に読み込むようにしていますが、サーバーを再起動させたくなければリクエストが来た時にfsを使ってmanifestを読み込むように変えればいいです。
基本、サーバーのコードもクライアントのコードべったりなので起動時になると思いますが。。
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');
Clusterを使う
負荷分散のためにClusterを行います。
const numCPUs = cpus().length; if (cluster.isMaster) { [...new Array(numCPUs)].forEach(() => cluster.fork()); // もし落ちたら再起動をかける cluster.on('exit', (worker, code, signal) => { console.log(`Restarting ${worker.process.pid}. ${code || signal}`); cluster.fork(); }); } else { runServer(); }
https://github.com/hiroppy/ssr-sample/blob/master/src/server/index.ts#L13-L25
Benckmark
SSRするサーバーのパフォーマンスチューニングを必要とする場面はあると思います。
autocannonを使い、server側のベンチマークを取ります。
これでReactから静的なHTMLを生成し返すまでのLatencyを計測します。
> autocannon http://localhost:3000 -c100 Running 10s test @ http://localhost:3000 100 connections Stat Avg Stdev Max Latency (ms) 153.89 138.41 2479.02 Req/Sec 643.5 86.29 758 Bytes/Sec 1.64 MB 214 kB 1.94 MB 6k requests in 10s, 16.5 MB read
もっと詳細に知りたい場合はperf_hooks
を使い、renderToString
の実行時間を測ることが可能です。
可視化する
clinicを使い詳細な情報を可視化して確認することが可能です。
イベントループの情報やflameの情報を表示でき、かなりわかりやすく便利なのでオススメです。
https://github.com/hiroppy/ssr-sample/blob/master/package.json#L14-L16
renderToNodeStreamをなぜ使わないか?
gistに貼られた以下のコードはrenderToNodeSteam
で書いたコードです。https://gist.github.com/hiroppy/1c89d73a12073bad0c187aaab4ca92c2
互いに文字列を挟んで書くのが個人的に好きじゃないのと、react-helmetがまだsteamに対応していない(PR: nfl/react-helmet#296)のが主な理由です。
res.write('<html><head><title>Test</title></head><body>'); const stream = sheet.interleaveWithNodeStream(renderToNodeStream(jsx)); stream.pipe(res, { end: false }); stream.on('end', () => res.end('</body></html>'));
自分的には、パフォーマンスがやばくなってきたら考える程度の温度感です。
Client
UI構成
PWAと同様に、App ShellとContentに分けています。
次のページに行った時に前のページと同じApp Shellの場合はContentだけをレンダリングします。(connect
してある場合はその箇所だけレンダリングします)
今回は、headerがApp Shellであり、Contentはreact-routerで選ばれたdynamic importされているコンポーネントです。
export const Main = ({ children }: Props) => ( <React.Fragment> <Header /> <Container>{children}</Container> {/* このchildrenはreact-routerから来たcontent*/} </React.Fragment> );
https://github.com/hiroppy/ssr-sample/blob/master/src/client/components/templates/Main/Main.tsx
// App がheader等を持っている export const Router = () => ( <App> <Switch> <Route exact path="/" component={LoadableTop} /> <Route path="/orgs/:org" component={LoadableOrgs} /> </Switch> </App> );
https://github.com/hiroppy/ssr-sample/blob/master/src/client/Router/Router.tsx
Meta
metaタグの決定は、Atomic Designでいうpages
で行います。
これはSSR時にも使われるため共通化された処理です。
export const Top = () => ( <React.Fragment> <Head title="top" /> <h1>Top</h1> </React.Fragment> );
https://github.com/hiroppy/ssr-sample/blob/master/src/client/components/pages/Top/Top.tsx
render vs hydrate
開発時には、フロントエンドのコード変更が多くサーバから作られたHTMLと一致しない場面が多くなるため、hydrate
は使いません。
本番では、hydrate
を使います。
const renderMethod = module.hot ? ReactDOM.render : ReactDOM.hydrate;
https://github.com/hiroppy/ssr-sample/blob/master/src/client/index.tsx#L14
redux-sagaのテスト
自分の場合はredux-saga-test-planを使いテストのシナリオを作ります。
また、コード置換としてproxyquire、rewire、HTTPサーバーのmockとしてnockを使います。
例えば、今回のようなAPIを叩くテストは以下のように書きます。
const initialState = { name: 'name', repos: [] }; test('should take on the FETCH_REPOS action', () => { nock('https://api.github.com') // https://api.github.com/orgs/test/repos の返す値を設定する .get('/orgs/test/repos') .reply(200, [ { forks_count: 100, name: 'foo', html_url: 'url', language: 'lang', open_issues_count: 200, stargazers_count: 300, watchers_count: 400 } ]); return expectSaga(orgsProcess) .withState(initialState) .put({ type: 'FETCH_REPOS_SUCCESS', payload: { name: 'test', repos: [ { forksCount: 100, name: 'foo', url: 'url', language: 'lang', issuesCount: 200, stargazersCount: 300, watchersCount: 400 } ] } }) .dispatch({ type: 'FETCH_REPOS', payload: { org: 'test' } }) .run(); });
https://github.com/hiroppy/ssr-sample/blob/master/src/client/sagas/orgs.test.ts
Misc
Dotenv
docker-composeで起動する時や本番デプロイ時には、.env
を使って環境変数を入れることが多いとも思います。
今回のサンプルでは、クライアント側ではdotenv-webpack、サーバー側ではwebpackを通さないためdotenvを使い共通の.env
を読み込みます。
https://github.com/hiroppy/ssr-sample/blob/master/webpack.config.js#L36-L39
Dynamic Import
tsconfig
clientとserverのtsconfigを分ける必要があります。
"module": "commonjs",
と指定した場合、
Promise.resolve().then(function () { return require('./foo'); });
と置換してしまい、webpackでチャンクとして切れないためです。
webpack側にdynamic importということを知らせるため、esnext
を指定し、変換をさせないようにする必要があります。
しかし、esnext
と書くと無変換になるためNode.jsではESMのシンタックスが存在しないため、サーバー側がエラーとなります。(つまりcommonjs
でないとダメ)
故に、以下のように分ける必要があります。
// server { "extends": "tsconfig.base.json", "compilerOptions": { "module": "commonjs", "moduleResolution": "node" } } // client { "extends": "tsconfig.base.json", "compilerOptions": { "module": "esnext", "moduleResolution": "node" } }
今Node.jsではESMが実験中で動きますが、それが本番に入ればっていう話でもなく、なぜかというとNode.jsにおいてESMは拡張子が.mjs
であるからです。
なので、ts側が吐くファイルの拡張子を.mjs
にしないといけなく、一筋縄ではいかないように思えます。
結論としては、TypeScript使っててwebpackでdynamic importされたファイルをチャンクとして切りたい場合は、module: esnext
にしましょう!
react-lodable
活発ではなく、今から選ぶのはあまり良くないと思います。
また、issueがないのが情報量少なく、個人的にはつらいです。
react-loadable、webpack4対応もしてないし、willMount使って警告でるし、PRはなぜかクローズされるしであまり未来が明るくない。https://t.co/9WIp5v1YJV
— hiroppy😶 (@about_hiroppy) 2018年8月4日
問題点
webpack4に対応していない
Migrate to webpack@4 API by 7rulnik · Pull Request #110 · jamiebuilds/react-loadable · GitHub
なぜかwebpack4のPRの会話がspanとしてロックされた
結構致命的だと思いますが、webpack4だとSSR時にLoadable.Capture
からdynamic importで使われるスクリプト名を取得できないです。
Lodable.Capute
を実行しなくてもHTML的にはdynamic importも展開してくれるのでSEO等には問題ありません。
HTMLにscriptタグを埋め込まない場合、すでに読み込み済みのHTMLに対して、client側は認知していなく、dynamic importされるファイルをサーバーへ取得しにいくため、すでに表示されているのにローディングにUIを切り替えてしまうのが問題となります。
componentWillMount
等を使っているため警告がでる
警告が出ます。
型定義がおかしい
render
はoptionalなのに、現在は必須です。
本来、OptionsWithoutRender
に行くべきなのにOptionsWithRender
が優先されるのが問題です。(PRを出す必要あり)
なので現在は、以下のように再定義を行っています。
export const LoadableOrgs = Loadable({ loader: () => import(/* webpackChunkName: "Orgs" */ '../containers/Orgs').then(({ Orgs }) => Orgs), loading: () => <div>loading ...</div> } as Loadable.OptionsWithoutRender<unknown>);
https://github.com/hiroppy/ssr-sample/blob/master/src/client/Router/Routes.tsx
loadable-components
APIがシンプルで、すごいわかりやすく好きです。
ただ、babelプラグインに依存しており、loadable-components/babel
を使わないとSSRは実行できないため必須です。
そこだけどうにかしてほしい。。
さいごに
まだ、dynamic import周りが自分の中で何をデファクトにするか悩んでいます。(といっても、このままいくと自然とloadable-componentsしかない)
もし更に改善点があればPRお待ちしております😁
また何か質問がありましたら、ついったーまでどうぞ🙃