技術探し

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

業務で使える簡単なSSR + SPA のテンプレートを公開した

久しぶりのブログです。

よくNode.jsの人と思われがちですが、普段はNode.jsでのバックエンド開発はもちろんですがReactやVueを書いていますので、たまにはフロントエンドネタを投稿しようと思います。

github.com

リポジトリにあるコード見たほうが早いと思いますので、ここでは注意点等を列挙していこうかなと思います。

主な技術スタック

dependencies

  • react@16
  • react-router-dom@4
  • react-helmet@5
  • react-loadable@5
  • redux@4
  • [email protected]
  • styled-components@3
  • express@4
  • dotenv@6

devDependencies


注意

今回は、自分の好き嫌いも含め以下のことを導入しませんでした。

  • 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を使って非同期処理

  1. render系のメソッドを使って、redux-sagaを起動させます。
    あくまでも目的がキックであり差分更新に関する処理が必要ないため、renderToStringより軽量な renderToStaticMarkupを使います。

  2. 処理が終わり次第、redux-sagaへ止める命令を送ります。(store.close()を経由し、ENDアクションを発行)

  3. store.runSaga(rootSaga).done が解決されるため、その中で更新されたstoreのデータを抽出しクライアントに初期のstateをわたすように<script>タグに埋め込みます。

  4. 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');

https://github.com/hiroppy/ssr-sample/blob/master/src/server/controllers/renderer/renderer.tsx#L18-L26

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を計測します。

github.com

> 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の実行時間を測ることが可能です。

blog.hiroppy.me

可視化する

clinicを使い詳細な情報を可視化して確認することが可能です。

github.com

f:id:about_hiroppy:20180807080521p:plainf:id:about_hiroppy:20180807080529p:plain

イベントループの情報や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に分けています。

blog.hiroppy.me

次のページに行った時に前のページと同じ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を使います。

github.com

blog.hiroppy.me

例えば、今回のような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

https://github.com/hiroppy/ssr-sample/blob/be419ff689a5a6ca5e2b8beb2d8f58b7e74af803/src/server/index.ts#L10

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がないのが情報量少なく、個人的にはつらいです。

問題点

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を出す必要あり)
なので現在は、以下のように再定義を行っています。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react-loadable/index.d.ts#L72-L107

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

github.com

APIがシンプルで、すごいわかりやすく好きです。
ただ、babelプラグインに依存しており、loadable-components/babel を使わないとSSRは実行できないため必須です。
そこだけどうにかしてほしい。。

さいごに

まだ、dynamic import周りが自分の中で何をデファクトにするか悩んでいます。(といっても、このままいくと自然とloadable-componentsしかない)

github.com

もし更に改善点があればPRお待ちしております😁
また何か質問がありましたら、ついったーまでどうぞ🙃