renderToStringとhydrateを作って学ぶReactのSSR・SSG
renderToString
と hydrate
を自作し、今一度 SSR・SSG の仕組みを実装から理解してみましょう。
はじめに
本稿では、以下のリポジトリを用いて実装を進めます。
また、お手元に node の環境があれば、拙作の create-toy-react-app
という CLI ツール を用いて、こちらのリポジトリに含まれる、実装のテンプレートや完成形のダウンロードが行なえます。
それでは node の環境(16系推奨)をご準備いただき、実装の準備として SSR と SSG 実装のテンプレートをダウンロードしましょう。
npx create-toy-react-app 2023 ssr-template <your-ssr-app>
npx create-toy-react-app 2023 ssg-template <your-ssg-app>
ダウンロードしたディレクトリに移動すると、以下の構成になっているはずです。
.
├── assets
├── docs
├── fixtures
│ ├── counter-app
│ ├── ssg-app # ※ssr-template には含まれていません。
│ └── ssr-app
└── packages
├── toy-react
├── toy-react-dom
├── toy-react-reconciler
└── toy-scheduler
以上に含まれる package の
-
toy-react
(ToyReact) -
toy-react-dom
(ToyReactDOM) -
toy-react-reconciler
(ToyReactReconciler) -
toy-scheduler
(ToyScheduler)
は、https://github.com/pomber/didact と 2021 年頃の React(v17前後) を元に、React を再実装したものです。こちらを拡張して、SSR・SSG の実装を行っていきます。
なお、React の再実装の解説は割愛しますが、リポジトリの docs に資料が含まれているので、ご興味あればこちらをご覧ください(はじめに から読み進めてください)。
必要な知識の整理
実装を始める前に、必要な知識を整理します。SSR と SSG についてご存知の方は、読み飛ばして頂いて構いません。
SSR とは
Server Side Rendering の略です。
サーバーサイドで、React コンポーネントを HTML 文字列に変換し、クライアントサイドで完全な HTML として表示する仕組みです。
この「クライアントサイドで完全な HTML として表示する」とは、どういうことでしょう?
まずは実際にサーバーサイドで生成された HTML 文字列を見てみましょう。以下の React コンポーネントを SSR してみます。
const App = () => {
return (
<div style={{ backgroundColor: 'olive', color: 'gray' }}>
<h1>App</h1>
<button onClick={() => console.log('clicked')}>Click Me!</button>
</div>
);
};
実際の React では、react-dom/server
に含まれる、renderToString
という関数を使用することで、このコンポーネントを文字列に変換します。
import ReactDOMServer from 'react-dom/server';
const html = ReactDOMServer.renderToString(<App />);
html
の値を、クライアントサイドに送信することで、コンポーネントをページに表示できます。
次に、html
の値を見てみましょう。
<div>
<h1>App</h1>
<button>Click Me!</button>
</div>
コンポーネントに含まれているはずの、 style
や onClick
のプロパティが全て失われてしまいました。失われた値はどのように補えば良いのでしょうか?
この処理はサーバーサイドで行わず、HTML 文字列が送信された先のクライアント(ブラウザ)で行うことになります。これを hydration と呼びます。
では react-dom
の hydrate
という関数に、サーバーサイドで文字列に変換したものと同じコンポーネントを渡してみましょう。
import ReactDOM from 'react-dom';
ReactDOM.hydrate(<App />, document.getElementById('root'));
ビルドした後、ブラウザで見てみると、コンポーネントが完全な HTML として表示されます!
つまり、hydration という処理によって、サーバーサイドで抜け落ちたスタイルや Event Listener をクライアントサイドで適用しているのです。
SSG とは
Static Site Generation の略です。pre-rendering と呼ばれることもあります。
SSR との違いとして、生成した HTML 文字列をサーバーからクライアントに送信するのではなく、静的ファイル(HTML)として書き出す点が挙げられます。
簡単に実装してみると、renderToString
した HTML 文字列を fs
などでファイルに書き込むことで実現できます。
import ReactDOMServer from 'react-dom/server';
const html = ReactDOMServer.renderToString(<App />);
fs.writeFileSync('./dist/index.html', html);
/dist
をクライアントサイドで開くと、hydration が行われ、完全な HTML が表示されます。
ほとんど SSR と仕組みは変わりませんが、リクエストに紐づいてサーバーサイドでデータを取得し、ページを更新するような、更新頻度の多いサイトには不向きです。
その代わりブログやドキュメントなど、更新頻度の少ないサイトでは SSR よりもページの表示速度の向上が見込めるため、向いています。
renderToStringを実装する
まずは、コンポーネントを HTML 文字列に変換する renderToString
関数を実装します。
/packages/toy-react-dom/src/server
ディレクトリに移動してみましょう。ToyReactDOMStringRenderer.js
と ToyReactPartialRenderer.js
の2ファイルが含まれています。
ToyReactPartialRenderer.js
を以下のように変更しましょう。
'use strict';
class ToyReactPartialRenderer {
constructor(children, options) {}
}
export default ToyReactPartialRenderer;
ToyReactPartialRenderer
は class で、renderToString
側でインスタンスを生成して使います。これを踏まえ ToyReactDOMStringRenderer.js
を以下のように変更しましょう。
'use strict';
import ToyReactPartialRenderer from './ToyReactPartialRenderer';
export function renderToString(element, options) {
const renderer = new ToyReactPartialRenderer(element, options);
}
ToyReactPartialRenderer
クラスには、renderToString
の引数の element
と options
をそのまま渡してインスタンス化します。
今回、options
は特に使用しませんが、元の react-dom の仕様に沿う形で用意しています。
それでは ToyReactPartialRenderer.js
を変更し、プロパティとメソッドを用意しましょう。
'use strict';
function isEvent(key) {
return key.startsWith('on');
}
function isStyle(key) {
return key === 'style';
}
function isProperty(key) {
return key !== 'children' && !isEvent(key) && !isStyle(key);
}
class ToyReactPartialRenderer {
constructor(children, options) {
this.stack = children;
this.options = options;
this.exhausted = false;
}
destroy() {
if (!this.exhausted) {
this.exhausted = true;
}
}
read() {
if (this.exhausted) {
return null;
}
return this.renderElement(this.stack);
}
renderElement(element) {
return '';
}
}
export default ToyReactPartialRenderer;
しれっと、ここで追加した isEvent
isStyle
isProperty
は後ほど使用します。ToyReact の reconciler 実装を行った方は見覚えがあるものかもしれません。
ToyReactPartialRenderer
を呼び出す側の ToyReactDOMStringRenderer.js
も変えてしまいましょう。
'use strict';
import ToyReactPartialRenderer from './ToyReactPartialRenderer';
export function renderToString(element, options) {
const renderer = new ToyReactPartialRenderer(element, options);
try {
const markup = renderer.read();
return markup;
} finally {
renderer.destroy();
}
}
renderToString
関数では、ToyReactPartialRenderer
クラスの read
と destroy
が呼び出されています。
read
は、stack
プロパティに格納されたコンポーネントを、renderElement
関数に渡し、その戻り値を返すメソッドです。
renderElement
のロジックは後ほど書きますが、文字列を返すメソッドになります。
destroy
は read
の終了後に呼び出され、これが呼び出された以降、コンポーネントの HTML 文字列への変換を停止します。
今回、これの恩恵を受ける場面はありませんが、 react-dom の実装 をなるべく再現する目的で用意してみました。
では、ToyReactPartialRenderer
クラスを完成させましょう。ToyReactDOMStringRenderer.js
を開き、renderElement
の中を記述してください。
'use strict';
// ~~~
class ToyReactPartialRenderer {
constructor(children, options) {
this.stack = children;
this.options = options;
this.exhausted = false;
}
// ~~~
renderElement(element) {
if (element.type === 'TEXT_ELEMENT') {
return element.props.nodeValue;
}
if (element.type instanceof Function) {
const component = element.type(element.props || {});
const child = component.render ? component.render() : component;
return this.renderElement(child);
}
const props = element.props || {};
const attributes = Object.keys(props)
.filter(isProperty)
.map((key) => `${key}="${props[key]}"`)
.join(' ');
const children = (props.children || [])
.map((child) => this.renderElement(child))
.join('');
return `<${element.type} ${attributes}>${children}</${element.type}>`;
}
}
export default ToyReactPartialRenderer;
1つずつ処理を見ていきましょう。
element.type === 'TEXT_ELEMENT'
で比較している箇所は、JSX要素ではなくただのテキストを処理するためのものです。
例えば <div>Hello</div>
が渡されたとして、これを ToyReact で要素ツリーに変換すると、以下になります。
{
type: 'div',
props: {
children: [
{
type: 'TEXT_ELEMENT',
props: {
nodeValue: 'Hello',
},
},
],
},
};
type
が TEXT_ELEMENT
の要素ツリーはHTMLに変換できないため、その値である nodeValue
を取り出します。
これは React 自作パートの、DOMを描画する で触れています。一度ご覧ください。
renderElement
の実装に戻ります。以下のブロックでは ToyReact の Functional Component を処理しています。
if (element.type instanceof Function) {
const component = element.type(element.props);
const child = component.render ? component.render() : component;
return this.renderElement(child);
}
Functional Component は、type
が TEXT_ELEMENT
のものと同様、HTML に変換することが出来ません。
その代わり、props を引数に渡して type
を実行することで、戻り値の JSX が得られます。
そして、最後に以下のブロックで HTML 文字列を生成します。
const props = element.props || {};
const attributes = Object.keys(props)
.filter(isProperty)
.map((key) => `${key}="${props[key]}"`)
.join(' ');
const children = (props.children || [])
.map((child) => this.renderElement(child))
.join('');
return `<${element.type} ${attributes}>${children}</${element.type}>`;
props に含まれる HTML 属性はこの時点で HTML 文字列に埋め込みます。また、children のそれぞれに対して renderElement
を呼び出すようにします。
最後にこれらを結合して、文字列として返します。
HTML の生成に document.createElement() を使えば、もっと楽に出来ませんか?
その通りです。しかし、サーバーサイドでは Web API が利用できないため、このように文字列で行う他ないのです。
ここで抜け落ちた値を埋めるのが hydration です。
hydrateを実装する
次に style や event listener を反映するための、hydrate
関数を実装します。
/packages/toy-react-dom/src/client
ディレクトリに移動すると、ToyReactDOMLegacy.js
というファイルがあるはずです。
このファイルを開き、hydrate
関数を追加しましょう。
// ~~~
export function hydrate(element, container) {
const prevChildren = Array.from(container.childNodes);
const nextChildren = Array.isArray(element) ? element : [element];
}
hydrate
関数は以下を引数に取ります。同ファイルに含まれる render
関数と変わらないインターフェースの関数ですね。
-
element
: React コンポーネント -
container
: ルートとなる HTML 要素
render
との違いとしては、container
には既にサーバーサイドで生成された HTML が append されているため、HTML の生成ではなく更新を行う点です。
container
の値を prevChildren
、element
の値を nextChildren
として、hydrate
関数に HTML の更新の処理を追加してみましょう。
// ~~~
function isEvent(key) {
return key.startsWith('on');
}
function isStyle(key) {
return key === 'style';
}
function isChildren(key) {
return key === 'children';
}
function isProperty(key) {
return !isChildren() && !isEvent(key) && !isStyle(key);
}
export function hydrate(element, container) {
const prevChildren = Array.from(container.childNodes);
const nextChildren = Array.isArray(element) ? element : [element];
nextChildren.forEach((nextChild, index) => {
const prevChild = prevChildren[index];
if (prevChild) {
if (nextChild.type === 'TEXT_ELEMENT') {
prevChild.textContent = nextChild.props.nodeValue;
} else if (nextChild.type instanceof Function) {
const component = nextChild.type(nextChild.props);
const child = component.render ? component.render() : component;
for (const prop in child.props) {
if (isChildren(prop)) {
continue;
}
if (isStyle(prop)) {
const styles = Object.entries(child.props[prop]);
styles.forEach(([key, value]) => {
prevChild[prop][key] = value;
});
}
if (isProperty(prop)) {
prevChild[prop] = nextChild.props[prop];
}
if (isEvent(prop)) {
const eventType = prop.toLowerCase().substring(2);
prevChild.addEventListener(
eventType,
nextChild.props[prop]
);
}
}
hydrate(child.props.children, prevChild);
} else {
hydrate(nextChild.props.children, prevChild);
for (const prop in nextChild.props) {
if (isChildren(prop)) {
continue;
}
if (isStyle(prop)) {
const styles = Object.entries(nextChild.props[prop]);
styles.forEach(([key, value]) => {
prevChild[prop][key] = value;
});
}
if (isProperty(prop)) {
prevChild[prop] = nextChild.props[prop];
}
if (isEvent(prop)) {
const eventType = prop.toLowerCase().substring(2);
prevChild.addEventListener(
eventType,
nextChild.props[prop]
);
}
}
}
} else {
container.appendChild(createDom(nextChild));
}
});
function createDom(element) {}
}
処理の流れとしては、以下の通りです。
-
nextChildren
の各要素を走査 -
nextChildren
のindexに対応する、prevChildren
の要素(prevChild
)を取り出す -
prevChild
が無ければ、createDom
関数を呼び出し、新たな要素をcontainer
に追加 -
prevChild
があれば、それのtype
で処理を分岐-
type
がTEXT_ELEMENT
の場合、prevChild
のtextContent
に値を代入する -
type
が Functional Component の場合、type
を実行した戻り値に対して、hydrate
を呼び出す -
type
が HTML 要素の Tag Name の場合、style の適用や Event Listener の登録を行う
-
最後に、createDom
関数の実装を追加しましょう。
// ~~~
export function hydrate(element, container) {
// ~~~
function createDom(element) {
const dom =
element.type === 'TEXT_ELEMENT'
? document.createTextNode(element.props.nodeValue)
: document.createElement(element.type);
Object.keys(element.props).forEach((key) => {
if (isChildren(key)) {
element.props[key].forEach((child) => {
dom.appendChild(createDom(child));
});
}
if (isStyle(key)) {
dom.style[key] = element.props[key];
}
if (isProperty(key)) {
dom[key] = element.props[key];
}
if (isEvent(key)) {
const eventType = key.toLowerCase().substring(2);
dom.addEventListener(eventType, dom.props[key]);
}
});
return dom;
}
これで SSR 向けの API が全て完成しました!/fixtures/ssr-app
に実装を追加し、API を使用してみましょう。
/fixtures/ssr-app/src/server.js
を開き、以下を記述してください。
'use strict';
const path = require('path');
const fs = require('fs');
const ToyReact = require('toy-react');
const ToyReactDOMServer = require('toy-react-dom/server');
const { App } = require('./components/App');
const express = require('express');
const app = express();
app.use(express.static(path.resolve(__dirname, 'public')));
app.get('*', (req, res) => {
const content = ToyReactDOMServer.renderToString(<App />);
const template = fs.readFileSync(
path.resolve(__dirname, 'public/app.html'),
'utf-8'
);
const html = template.replace('<!-- app -->', content);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
});
app.listen(3001, () => {
console.log('Start on http://localhost:3001');
});
テンプレートの HTML に、renderToString
の戻り値を埋め込み、レスポンスとして返しています。
次に、/fixtures/ssr-app/src/index.js
を開き、以下を記述してください。
'use strict';
const ToyReact = require('toy-react');
const ToyReactDOM = require('toy-react-dom');
const { App } = require('./components/App');
ToyReactDOM.hydrate(<App />, document.getElementById('root'));
それでは npm run dev -w ssr-app
した後、npm run start -w ssr-app
でサーバーを起動してみましょう。
http://localhost:3001 を開くと、以下のような表示になっていれば正常に動いています!
実装済みのものは npx create-toy-react-app 2023 ssr-completed <your-ssr-app>
で手元にダウンロード出来ます。
余談:SSGを実装する
SSR の実装で作成した API を用いると SSG も行うことができます。
ToyReact 側の API は全て出揃っているので、/fixtures
側に実装を追加します。
/fixtures/ssg-app/src/generate.js
ファイルを開き、以下の処理を追加しましょう。
'use strict';
const path = require('path');
const fs = require('fs');
const ToyReact = require('toy-react');
const ToyReactDOMServer = require('toy-react-dom/server');
const { App } = require('./components/App');
const routes = fs
.readdirSync(path.resolve(__dirname, './pages'), { withFileTypes: true })
.filter((dirent) => dirent.name.endsWith('.js'))
.map((dirent) => dirent.name.replace(/\.js$/, ''));
(() => {
routes.forEach((route) => {
const content = ToyReactDOMServer.renderToString(<App />);
const template = fs.readFileSync(
path.resolve(__dirname, 'app.html'),
'utf-8'
);
const html = template.replace('<!-- app -->', content);
if (!fs.existsSync(path.resolve(__dirname, '../out'))) {
fs.mkdirSync(path.resolve(__dirname, '../out'));
}
const outPath = path.resolve(__dirname, `../out/${route}.html`);
fs.writeFileSync(outPath, html);
fs.copyFileSync(
path.resolve(__dirname, `./${route}.css`),
path.resolve(__dirname, `../out/${route}.css`)
);
if (!fs.existsSync(path.resolve(__dirname, '../out/pages'))) {
fs.mkdirSync(path.resolve(__dirname, '../out/pages'));
}
fs.copyFileSync(
path.resolve(__dirname, `./pages/${route}.js`),
path.resolve(__dirname, `../out/pages/${route}.js`)
);
});
})();
Next.js の Pages Router のように、/pages
ディレクトリに追加したファイルが、そのまま HTML ファイルとして出力されるようにします。
SSR と同様に、テンプレートの HTML ファイルに renderToString
で生成した HTML 文字列を埋め込み、これをファイルとして /out
ディレクトリに書き出します。
/fixtures/ssg-app/src/pages/
ディレクトリに、以下のような index.js
を追加してみましょう。
'use strict';
const ToyReact = require('toy-react');
const ToyReactDOM = require('toy-react-dom');
require('../assets/styles/global.css');
const { App } = require('../components/App');
ToyReactDOM.hydrate(<App />, document.getElementById('root'));
npm run dev -w ssg-app
を実行し、npm run export -w ssg-app
を実行してみましょう。
/ssg-app
の直下に /out
ディレクトリが生成されてるはずです。npm run serve -w ssg-app
を実行し、サーバーを立ち上げてみましょう。
http://localhost:3002 を開くと、以下のような表示になっていれば正常に動いています!
実装済みのものは npx create-toy-react-app 2023 ssg-completed <your-ssg-app>
で手元にダウンロード出来ます。
おわりに
SSR と SSG を数10 ~ 100行実装で再現し、その仕組みを理解してきました。
近年の Web 開発におけるフロントエンドでは、フレームワークによって隠蔽されることが多い箇所ではあるので、実際に手を動かして再現してみることで得られる知見もあると思います。
以上、ご精読いただきありがとうございました。
Discussion