85
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

fly.io で React アプリケーションの SSR を行う

Last updated at Posted at 2019-02-25

最近 fly.io という Edge Worker で遊んでいます。

Edge Worker, 地理的に分散された CDN の Edge Location でSSRを実行するので、レスポンスが高速です。
また、 ほぼフルスペックの node が動くので、 単にCDNのキャッシュ機能付きの node の PaaS として使うにも便利です。

fly.io がどういうものか、どういう利点があるかについては、こちらの記事を参考にしてください

Edge Worker PaaS の fly.io が面白い - mizchi's blog

で、簡単な typescript + react + react-router + styled-components の SSR を行うアプリを書いてみたんですが、このコードの解説をしようと思います。
src/ 以下だけなら 284 行で、比較的コンパクトです。

自分の容量削減のために他のリポジトリと同居した monorepo になってますが、ルートディレクトリの package.json と examples/simple-ssr/package.json の deps/devDeps を合成すれば動くと思います。

そのまま動かすなら、次のような感じ

git clone https://github.com/mizchi/flyio-playground
cd flyio-playground/examples/simple-ssr
yarn install
yarn dev # localhost:3000 で起動

これで動くと思います。動かなかったら twitter の @mizchi まで

SSR用の React Application を書く

単に SSRが出来るかどうか確認するなら、すごく単純な

src/shared/App.tsx
import React from 'react'
export const App = () => <h1>Hello</h1>

でもいいんですが、今回は react-router と styled-components を使った SSR までやりたいので、ちょっとしたアプリケーションを構築します。

redux を使わない代わりに、 React Hooks の useReducer を使った簡単な Flux を実装します。

src/shared/App.tsx
import React, { Dispatch, useContext, useReducer, useState, useCallback } from "react";
import { Route, Switch, Link } from "react-router-dom";
import styled, { createGlobalStyle } from "styled-components";
import { Action, reducer, RootState } from "./reducer";
import * as actions from "./reducer";

// RootState context

export const RootContext = React.createContext<RootState>(null as any);
export const DispatchContext = React.createContext<Dispatch<Action>>(
  null as any
);

export function useRootState(): RootState {
  return useContext(RootContext);
}

export function useDispatch() {
  return useContext(DispatchContext);
}

export function App(props: RootState) {
  const [rootState, dispatch] = useReducer(reducer, props);

  return (
    // ssr warning
    <RootContext.Provider value={rootState}>
      <DispatchContext.Provider value={dispatch}>
        <RootContainer suppressHydrationWarning={true}>
          <RootContent suppressHydrationWarning={true}>
            <GlobalStyle />
            <Header {...props} />
            <Switch>
              <Route exact path="/counter" component={Counter} />
              <Route exact path="/" component={Index} />
            </Switch>
          </RootContent>
        </RootContainer>
      </DispatchContext.Provider>
    </RootContext.Provider>
  );
}

const GlobalStyle = createGlobalStyle`
  html, body, .root {
    padding: 0;
    margin: 0;
  }
  body {
    background-color: #eee;
  }

  * {
    box-sizing: border-box;
  }
`;

const RootContainer = styled.div`
  width: 100%;
`;

const RootContent = styled.div`
  padding: 10px;
`;

// Header

export const HEADER_LINKS: { name: string; path: string }[] = [
  {
    name: "Index",
    path: "/"
  },
  {
    name: "Counter",
    path: "/counter"
  }
];

function Header(props: RootState) {
  return (
    <header suppressHydrationWarning={true}>
      <h1>SSR on fly.io playground</h1>
      <div>generated {props.timestamp}</div>
      {HEADER_LINKS.map(link => {
        return (
          <span key={link.path}>
            <Link to={link.path}>{link.name}</Link>
            |&nbsp;
          </span>
        );
      })}
    </header>
  );
}

// Index

function Index() {
 return (
    <div>
      <h1>Index</h1>
    </div>
  );
}

// Counter
function Counter() {
  const rootState = useRootState();
  const dispatch = useDispatch();
  const onClickIncrement = useCallback(() => dispatch(actions.increment()), []);
  const onClickDecrement = useCallback(() => dispatch(actions.decrement()), []);
  return (
    <div>
      <h2>Counter</h2>
      <button onClick={onClickIncrement}>+1</button>
      <button onClick={onClickDecrement}>-1</button>
      <div>value: {rootState.page.counter.value}</div>
    </div>
  );
}

大したことはしていません。普通のSSRです。何かあるとしたら、 react-router のBrowserRouter をこの時点ではまだ使っていません。

本記事では reducer のコードを省略しますが、 https://github.com/mizchi/flyio-playground/blob/master/examples/simple-ssr/src/shared/reducer.ts といった普通のCounterです。

Appをマウントするクライアントの実装

SSR なので、 ReactDOM.render ではなく ReactDOM.hydrate で App を注入します。
初期状態は window.__initialState で渡すとしましょう。

src/client/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { App } from "../shared/App";
import { BrowserRouter } from "react-router-dom";

//@ts-ignore
const initialState = window.__initialState;
const root = document.querySelector(".root") as HTMLElement;
ReactDOM.hydrate(
  <BrowserRouter>
    <App {...initialState} />
  </BrowserRouter>,
  root
);

ここまで React としては特に特殊なことをしていません。

fly.io 上でのSSRの実装

ここからが本番

src/edge-worker.ts
import { mount } from "@fly/fetch/mount";
import staticServer from "@fly/static";
import { ssr } from "./ssr";

const mounts = mount({
  "/static/": staticServer({ root: "/" }),
  "/": ssr
});

fly.http.respondWith(mounts);

@fly/fetch/mount は指定したルート要素を指定したモジュールでマウントします。今回は /static で静的なアセットの配信として @fly/static でマウントし、 / 以下で SSR を行います。

@fly/* のモジュールは fly.io によって注入されるので、 npm/yarn install する必要はありません。

この ssr の実装は、次のようになっています。

src/edge-worker/ssr.tsx
import url from "url";
import { getInitialState } from "../shared/reducer";
import { ServerStyleSheet } from "styled-components";
import { RootState } from "../shared/reducer";
import React from "react";
import { StaticRouter } from "react-router";
import { App } from "../shared/App";
import ReactDOMServer from "react-dom/server";

export const ssr = async (req: Request, _init: any) => {
  const pathname = url.parse(req.url).pathname as string;
  const initialState = getInitialState(pathname, Date.now());
  const html = renderApp(initialState);
  return new Response(html);
};

function renderApp(state: RootState) {
  const sheet = new ServerStyleSheet();
  const element = sheet.collectStyles(
    <StaticRouter location={state.url}>
      <App {...state} />
    </StaticRouter>
  );

  const html = ReactDOMServer.renderToString(element);
  const styleTags = sheet.getStyleTags();
  const serializedState = JSON.stringify(state).replace(/</g, "\\u003c");
  return `<!DOCTYPE html>
<html lang="en-US">
<head>
  <title>flyio-example</title>
  <meta charset="utf-8"/>
  ${styleTags}
</head>
<body>
  <div class="root">${html}</div>
  <!-- SSR -->
  <script>window.__initialState = ${serializedState};</script>
  <script src="/static/bundle.js"></script>
</body>
</html>
`;
};

renderApp 関数が React のSSRの実装です。

  • ReactRouter の StaticRouter を pathname 付きで実行
  • reducer の初期状態を構築元に、 App コンポーネントを初期化
  • styled-components でラップしてReactDOMServer.renderToString() を実行して、 SSR で注入する初期CSSを収集
  • クライアントに状態を引き継ぐため、 window.__initialState = ... に初期状態を注入

この関数は、単に html の文字列を組み立てて終わりです。

fly が実行する ssr 関数は、この文字列を HTML のレスポンスとして返却します。 return new Response(html); ですね。

静的アセットの配信

↑のHTMLの <script src="/static/bundle.js"></script> と edge-worker/index の "/static/": staticServer({ root: "/" }), から、 /static/bundel.js というファイルを生成することを期待しているのがわかると思いますが、このままではまだアセットの配信ができていません。

先に用意した src/client/index.tsx/static/bundle.js にビルドします。

webpack.config.client.js
module.exports = {
  ...require("../../webpack.shared.config"),
  entry: "./src/client/index",
  output: {
    path: __dirname + "/static",
    filename: "bundle.js"
  }
};

webpack.shared.config は単に ts-loader の設定が書いてあるだけです。使い回すので、別に切り出しています。

yarn webpack --config webpack.config.client.js と叩くと /static/bundle.js を生成するのがわかると思います。

.fly.yml

つぎに、 .fly.ymlfiles フィールドで static ディレクトリを静的アセットとして fly cdn に認識させるスコープを書きます。

.fly.fml
app: aquatic-dream-897
files:
  - static/**

これで、 @fly/static の がこれを認識して "/static/": staticServer({ root: "/" }) が static/bundle.js を読み取れるようになるはずです。

今こうなってるはずです。

$ tree . -d 2
.
├── package.json
├── src
│   ├── client
│   ├── edge-worker
│   └── shared
└── static
    └── bundle.js

.fly.yml のapp の部分は自分が fly.io で登録したアプリ名です。このアプリ名は自分が登録してるので、他の人には使えません。各自自分で取得してください。

fly アプリケーションは webpack.cofig.fly.js で entry に指定したファイルで起動します。

webpack.cofig.fly.js
module.exports = {
  ...require("../../webpack.shared.config"),
  entry: "./src/edge-worker/index"
};

(これも ts-loader の設定を使いまわしています)

起動スクリプト

package.jsonnpm-run-all のヘルパを使って、次のようなコマンドを書きます。

  "scripts": {
    "dev": "run-p dev:*",
    "dev:edge-server": "fly server",
    "dev:watch-client": "webpack --config webpack.client.config.js -w --mode development",
    "build": "run-s build:*",
    "build:client": "webpack --config webpack.client.config.js --mode production",
    "test": "jest",
    "deploy": "yarn build && fly deploy"
  },

yarn dev を叩くと、flyの開発サーバーとwebpackのビルドサーバーが並列に起動します。fly コマンドがインストールされていれば、 fly server で開発用のサーバーが立ち上がってるわけですね。

あとは適当に動作確認して、yarn deploy で本番環境にリリース。おわり。

おわり

fly.io というより、実際にはSSRの統括 + HTML を fly.io で返す、という作例でした。

なぜこれらのSSRを fly.io でやるかというと、 Edge Worker でこの作業にやることに価値があるからです。今回はやっていませんが、 SSR したHTML を fly のストレージでキャッシュして、キャッシュがあればそれを返却、ということを、CDNのネットワーク上で高速に行えます。

Edge ロケーションで SSR した HTML をキャッシュして返却すると速い、というのがフロントエンドの真理の一つです。みなさんやっていきましょう。

85
65
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
85
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?