SitePointの優れたコンテンツは記事に限ったことではありません。最近のトップページリニューアルは、SitePoint Premiumの書籍、セミナー、動画やフォーラムでのディスカッションなど、記事以外のコンテンツをいっそう引き立てることが1つの目的でした。このためメインのWordPressに外部の複数のソースからデータを取り込む必要が生じ、課題が持ち上がりました。
開発チームはReactを大いに活用しています。コンポーネントモデルやJavaScriptの「データストア」を含むパターンはとても役立ち、結果的にReactはお気に入りのコードベースになっています。
これらの使い慣れたツールで私たちは作業したいと思いましたが、多種多様なデータソースがすべてロード、レンダリングされるまで、ユーザーにほとんど白紙のトップページを表示しておくのはいただけないだろうということになりました。
そこで、Reactで作成したコンテンツをサーバーでレンダリングして、WordPressのサイトで提供すればよいということに気づきました。さらにJavaScriptを有効にしなくても、またはなんらかの理由でJavaScriptが動作しない状況でも、ユーザーにサイトを満喫してもらいたいとも考えたのです。
ReactのユニバーサルレンダリングとPHP
WordPressはPHPベースで、ReactはJavaScriptベースです。サーバーサイドでどのように両方を実行すればよいのでしょうか。解決策として、Node.jsでプロキシサーバーを構築し、すべてのリクエストはNodeサーバーからWordPressサイトに直接渡します。プロキシは返されるページを検索し、ブラウザーに送る前にReactを実行してレンダリングします。結果として、クライアント側でもサーバー側でもJavaScript経由でレンダリング・更新できる一連のコンポーネントを持つWordPressの基本的なページよりも、サーバーサイドで完全にレンダリングされる場合が少なくなりました。
このアイデアはとてもシンプルに思え、React経由でページ全体がレンダリングされる例が多かったとはいえ、比較的少数ながら既存ページの更新が必要な例もありました。このために次のツールを選択したのです。
- node-http-proxy:Node.jsのフル装備HTTPプロキシ
- Harmon:node-http-proxyがtrumpetでリモートWebサイトのレスポンスを変更するためのミドルウェア
このプロセスについては例を挙げて説明するのがベストでしょう。シンプルなデモはこちらです。デモのURLの末尾に/_srcを追加してアクセスするか、GitHubリポジトリを参照してソースコード全体を確認できます。
- server/target.js:とてもシンプルなHTTPサーバーで、常にもとのHTMLをそのまま返す
- server/proxy.js:すべてのリクエストをターゲットサーバーに転送するnode-http-proxyを実装
- server/basicHarmon:Harmonを使ったExpressのミドルウェアを提供し、任意の<header></header>タグの内容を更新してより見栄えのする(fancier)コンテンツに書き換える
- server/express.js:プロキシとHarmonミドルウェアを使うExpressサーバーを構築
デモの表示結果に「fancy header」が含まれているのが確認できます。
この段階ではheaderタグ内のテキストを置換する簡単な例を示したに過ぎませんが、このあと、同じ手法を使ってどのようにReactコンポーネントをレンダリングできるか説明します。この方法はとても柔軟に使えます。Node.jsはWordPressの動きと連携する必要はありません。同様にRailsなどのフロントにも簡単に適用できます。
SitePoint.comのコンポーネント
先に述べたようにReactを大いに活用しました。そしてSitePointでReactを使った方法は、サーバーサイドへの移行にとても役に立ったのです。
Reactを使ったことがあるなら次のコードはおなじみでしょう。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello React!</title>
<script src="build/react.js"></script>
<script src="build/react-dom.js"></script>
<script src="https://unpkg.com/babel-core@5.8.38/browser.min.js"></script>
</head>
<body>
<div id="example"></div>
<script type="text/babel">
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('example')
);
</script>
</body>
</html>
このコードはReactの「getting started」ページそのままで、1ページに単一のコンポーネントをレンダリングする方法を示しています。1ページに複数のコンポーネントが使われている例はそれほど多くなく、完全なシングルページではなくても複数のコンポーネントを使えます。Reactでは複数のマウントポイントも自由に追加できるので、正真正銘のすばらしいパターンです。簡単な例を以下に示します。
SitePoint.comでは要素のIDではなくカスタムタグを使いました。なぜなら繰り返し使うパターンであり、処理に役立つヘルパー関数がいくつかあるからです。最初のヘルパー関数として、render()があり、ブラウザーでカスタムタグを登録し、タグのすべてのインスタンスをドキュメント内に配置します。
function render (tag, Comp) {
document.createElement(tag);
const nodes = Array.from(document.getElementsByTagName(tag));
nodes.map((node, i) => renderNode(tag, Comp, node, i));
return Comp;
}
次はrenderNode()関数で、ノードの属性をpropsオブジェクトに変換したのち、各ノードは実際にはReactでレンダリングを実行します。
function renderNode (tag, Comp, node, i) {
let attrs = Array.prototype.slice.call(node.attributes);
let props = {
key: `${ tag }-${ i }`,
};
attrs.map((attr) => props[attr.name] = attr.value);
if (!!props.class) {
props.className = props.class;
delete props.class;
}
ReactDOM.render(
<Comp { ...props }/>,
node
);
}
こちらのCodePenでは、カスタムタグの複数のインスタンスがReactコンポーネントとして固有のプロパティでレンダリングされているのが確認できます。Reactは、1つだけでなく一連の有効なコンポーネントのエントリポイントです。DOMをスキャンしてタグを認識し、それぞれをReactでレンダリングします。一時期、SitePoint.comはクライアントサイドでこの技術をうまく使っていました。
小さな変更を加えればサーバーサイドでも同じパターンが使えるというのは大きなメリットです。
const hasDocument = typeof document !== "undefined";
function render (tag, Comp) {
if (hasDocument) {
document.createElement(tag);
const nodes = Array.from(document.getElementsByTagName(tag));
nodes.map((node, i) => renderNode(tag, Comp, node, i));
} else {
__tags = [...__tags, {
query: tag,
func: (node, req) => serverRenderNode(tag, Comp, node, req)
}];
}
return Comp;
}
上のコードはサーバー用に変更したrender関数で、下のコードはサーバー版renderNodeです。
let __tags = [];
let __id = 0;
function serverRenderNode (tag, Comp, node, req) {
let props = node.getAttributes();
__id++;
if (!!props.class) {
props.className = props.class;
delete props.class;
}
const nodeStream = node.createStream({outer: false});
try {
const html = ReactDOMServer.renderToString(
<Comp { ...props }/>
);
nodeStream.end(html);
} catch (err) {
nodeStream.end();
console.log("Rendered tag failed", tag, err);
};
}
__tagsは、Harmonがノードのプロキシレスポンスへのサーバーレンダリングに適用するために使えるqueryタグと render関数が入った配列になっています。変更後のシンプルなデモとリポジトリを確認してください。
デモのURLの末尾に_srcを追加してアクセスするとソースコード全体が分かります。
- app/components/:Reactコンポーネントを保持
- app/tools/:render関数とヘルパーのhasDocumentを保持
- app/index.js:ページを初期化
- server/tagsHarmon.js:Reactコンポーネントをレンダリングしてhtmlレスポンスを返す、追加されたミドルウェア
ついにデータ処理へ
ここまでで基本的なコンポーネントの使い勝手は良くなりました。いよいよデータ処理です。SitePoint.comでは過去にFluxやReduxを使ってきましたが、SitePoint.comのデータストアはとてもシンプルなのでMobXがぴったりだろうということになりました。MobXのstoreを使うと、1つのスポットで単一のデータドメインについて単独でロジックをフェッチ、パースできます。
MobXでのスタートでつまずく
最初に投稿のコレクションを保持する基本の投稿storeを作成し、作成中のAPIからの投稿をフェッチし、次いで一定の期間で更新します。記事ではMobXについて詳しく説明しませんが、以下にポイントを簡単に示します(Matt Rubyの記事『Reactでも使える!シンプルなJavaScriptステート管理ライブラリー Mobxを試す』も参考にしてください)。
- observable:時系列的に変化する値を通知
- action:observableステートを変更する
- runInAction:observableステートを非同期的に変更する
- observer:observableの変更時に自動的に実行
stores/index.jsにobservableオブジェクトのstoresを作成し、個々のstoreをすべてまとめてobservable stateの単一の最小単位とします。このデータを使うために、投稿のリストをレンダリングして出力するPostListコンポーネントを追加します。PostListコンポーネントは必要な個々のstoreを引き出してstateに出力します。更新されたデモ(リポジトリはこちら)を実行すると、投稿storeが再度更新されるまではうまくいきましたが、そののち次のように表示されました。
Warning: forceUpdate(...): Can only update a mounting component. This usually means you called forceUpdate()
outside componentWillMount() on the server. This is a no-op. Please check the code for the PostList component.
困ったことになりました!
Storeとstate
この問題を解決し、単一のページリクエストに対するレンダリングがすべて確実に同じstateを使うようにするため(複数のコンポーネントが特定の投稿storeを使う場合もあるので)、stateのスナップショットcurrentStateを作成します。サーバーサイドでの実行時にリクエストオブジェクトに対してcurrentStateへの参照を格納しておくと、クライアントサイドではobservableのstoreを使い続けられます。
次のメソッドで実行できます。
export var storeOrState = (req) => {
if (hasDocument || typeof req === "undefined") {
return stores;
} else {
if (typeof req.SITEPOINT_state === "undefined") req.SITEPOINT_state = currentState;
return req.SITEPOINT_state;
}
}
また、ロジックを移動してstoreをコンポーネントに割り当てることも必要です。ロジックをrenderNode関数とserverRenderNode関数内に移動します。propsFnのコンポーネントをチェックして、受け取るpropsを変更できるようにします。
if (typeof Comp.propsFn === "function") props = {
...props,
...Comp.propsFn(props, __id, req),
};
このフックとstoreOrState関数を使ってコンポーネントのpropsに追加できます。
更新したデモとリポジトリで確認してください。デモのURL末尾に_srcを追加してアクセスすると、ソースコード全体が分かります。
StoreのFactory
デモは動くようになりましたが、まだ改善が必要でした。
この段階ではデモのスタートアップ時にすべてのstoreの作成が必要で、そうしないとstoreOrState()の呼び出しに失敗します。これではすべてのstoreがクライアントサイドで有効になり、ことによると現在のページやユーザーに必要ないデータがロードされてしまいます。
この課題に対処するために、最初にstoreOrStateを変更し、検索する個々のstoreを決定するキーと、storeが存在しない場合は作成するfactory関数を供給します。
次に、投稿storeのfactoryを作成するロジックを格納するヘルパー関数postsStoreForを作成し、次いで更新版のstoreOrState()を使います。こうすればコンポーネントは直接storeOrState()に進まず、ヘルパー関数を利用します。
最後にstoresオブジェクトの作成を変更し、sub-storesが必要になるまでクライアントサイドでは空の状態のままにします。サーバーサイドではスタートアップ時に異なるタイプの投稿storeを作成し、そのまま待機して必要なときにデータを受け取れるようにします。
クライアントデータ
サーバーサイドのデータは整理できましたがクライアントサイドには課題があります。クライアントはデータがまったくない状態でスタートするため、サーバーサイドでフェッチされたすべてのデータのフェッチを繰り返すことになります。クライアントには必要なデータを送らなければなりませんが、必要以上に送るのは望ましくありません。
最初に、リクエストやstoreOrState関数で使われるstoreのリストをアタッチします。
if (typeof req.SITEPOINT_stores === "undefined") req.SITEPOINT_stores = [];
req.SITEPOINT_stores.push(key);
次にヘルパー関数storesForReqを作成し、stateのスナップショット全体からリクエストに関係したstoreだけに絞ります。
export var storesForReq = (req) => {
if(typeof req.SITEPOINT_stores === "undefined") return {};
let reqStores = Array.from(new Set(req.SITEPOINT_stores))
.reduce((acc, key) => {
_set(acc, key, sn(key, req.SITEPOINT_state));
return acc;
}, {});
return reqStores;
}
最後にHarmonのタグセレクタと、それをDOMにレンダリングする関数を作成します。
export default {
query: "client-initial-state",
func: (node, req) => {
node.createWriteStream({outer: true}).end(
`<script> window.INITIAL_STATE = ${JSON.stringify(storesForReq(req))}; </script>`
);
},
};
このコードをknownTags関数に追加したので、<client-initial-state>タグがあるどのページにもデータが追加できるようになりました。デモを確認すると、サーバーで「Dogs」と「Chickens」に関するデータもフェッチされているにもかかわらず「Normal」と「Cats」に関する投稿データだけが入っていることが分かるでしょう。
最後にここですべきことは、クライアントサイドで最初のクライアントデータが利用できるならそれを使うように投稿storeを更新することです。
Staticレンダリング
デモはとても良くなりましたが、SitePoint.comにはまだ気になる部分があります。コンポーネントの多くは最初のレンダリング以降変化しませんが、コンポーネントがHTMLだけを使う場合、なぜクライアントにHTMLを送信し、ロジックやデータをレンダリングするのでしょうか。
コンポーネントにはpropsに必要なstoreを追加できるようになっているので、実際必要なデータがサーバーサイドで完全にレンダリングされるかどうかをチェックするのはとても簡単です。コンポーネントのクラスに別のstaticメソッドを追加します。
static shouldServerRenderStatic(props, req) {
const { store: { loading, posts, type } } = props;
const hasCompleteData = posts.length && !loading;
if (hasCompleteData) deregisterStore(`posts.${type}`, req);
return hasCompleteData;
}
このメソッドはコンポーネントのpropsが完全なデータを格納しているかどうかをチェックしてtrueまたはfalseを返すだけのとてもシンプルなものです。追加のヘルパー関数deregisterStore()も使うので、サーバーでレンダリングできたコンポーネントに関するデータはクライアントに送信されません。serverRenderNode()関数も更新され、コンポーネントのshouldServerRenderStaticメソッドで使う2つの変数が追加されます。
const shouldServerRenderStatic = (typeof Comp.shouldServerRenderStatic === "function")
? Comp.shouldServerRenderStatic(props, req)
: false;
const renderTo = (shouldServerRenderStatic)
? "renderToStaticMarkup"
: "renderToString";
これを使うことでReactDOMServer.renderToStringはReactDOMServer[renderTo]となり、さらに注目できる点としてnode.createStream({outer: false})はnode.createStream({outer: shouldServerRenderStatic})となります。
node.createStreamに渡されるオプションouterがtrueの場合ノード全体が置換され、それ以外の場合ノードのコンテンツだけが置換されます。これがとても便利なのは、trueに設定するとカスタムタグがHTMLから削除され、レンダリングされたコンテンツで置き換えられるからです。つまりクライアントサイドのコードが実行されてReactコンポーネントに変換するタグを探しても見つからなければ、ほかの普通のHTMLとまったく同じように扱われることになります。
本番環境のコードにはwebpackのcode splittingも導入しているので、クライアントサイドで必要ないコンポーネントに関してはJavaScriptさえもクライアントにロードされません。この点は記事の範囲を超えますので、webpackのcode splittingに関するドキュメントを参考にしてください。
完成版のデモとリポジトリで確認してください。これまでと同様に、デモのURLの末尾に_srcを追加してアクセスすると、ソースコード全体が分かります。
完成
このソリューションにはとても満足しています。第一にSitePoint.comの優れたコンテンツの表示が大きく改善されたこと、第二に技術ソリューションの性能が向上し、メンテナンスしやすくなったことです。
さらに柔軟性にも優れています。いつでもNode.jsのプロキシを完全にシャットダウンでき、しかもクライアントサイドでレンダリングを実行することでサイトは通常どおりの稼働を続けられます。プロキシの動作中、ユーザーはJavaScriptを有効にしなくてもサイトを十分活用できるのです。
WordPressで使っていた機能を変更・削除する必要はなにもありませんでした。同様の手法をRailsなどさまざまな方法で構築されたサイトのフロントにも適用できればと考えています。
※本記事はStuart Mitchell、Matt Burnett、Joan Yinが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:Universal React Rendering: How We Rebuilt SitePoint)
[翻訳:新岡祐佳子/編集:Livit]