🚰

renderToStringとhydrateを作って学ぶReactのSSR・SSG

2023/07/31に公開

renderToStringhydrate を自作し、今一度 SSR・SSG の仕組みを実装から理解してみましょう。

はじめに

本稿では、以下のリポジトリを用いて実装を進めます。

https://github.com/shuta13/react-deep-dive

また、お手元に 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>

コンポーネントに含まれているはずの、 styleonClick のプロパティが全て失われてしまいました。失われた値はどのように補えば良いのでしょうか?

この処理はサーバーサイドで行わず、HTML 文字列が送信された先のクライアント(ブラウザ)で行うことになります。これを hydration と呼びます。

では react-domhydrate という関数に、サーバーサイドで文字列に変換したものと同じコンポーネントを渡してみましょう。

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.jsToyReactPartialRenderer.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 の引数の elementoptions をそのまま渡してインスタンス化します。

今回、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 クラスの readdestroy が呼び出されています。

read は、stack プロパティに格納されたコンポーネントを、renderElement 関数に渡し、その戻り値を返すメソッドです。

renderElement のロジックは後ほど書きますが、文字列を返すメソッドになります。

destroyread の終了後に呼び出され、これが呼び出された以降、コンポーネントの 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',
                },
            },
        ],
    },
};

typeTEXT_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 は、typeTEXT_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 の値を prevChildrenelement の値を 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) {}
}

処理の流れとしては、以下の通りです。

  1. nextChildren の各要素を走査
  2. nextChildren のindexに対応する、prevChildrenの要素(prevChild)を取り出す
  3. prevChild が無ければ、createDom 関数を呼び出し、新たな要素を container に追加
  4. prevChild があれば、それの type で処理を分岐
    • typeTEXT_ELEMENT の場合、prevChildtextContent に値を代入する
    • 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