最近 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が出来るかどうか確認するなら、すごく単純な
import React from 'react'
export const App = () => <h1>Hello</h1>
でもいいんですが、今回は react-router と styled-components を使った SSR までやりたいので、ちょっとしたアプリケーションを構築します。
redux を使わない代わりに、 React Hooks の useReducer
を使った簡単な Flux を実装します。
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>
|
</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
で渡すとしましょう。
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の実装
ここからが本番
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 の実装は、次のようになっています。
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
にビルドします。
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.yml
の files
フィールドで static
ディレクトリを静的アセットとして fly cdn に認識させるスコープを書きます。
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 に指定したファイルで起動します。
module.exports = {
...require("../../webpack.shared.config"),
entry: "./src/edge-worker/index"
};
(これも ts-loader の設定を使いまわしています)
起動スクリプト
package.json
に npm-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 をキャッシュして返却すると速い、というのがフロントエンドの真理の一つです。みなさんやっていきましょう。