React
server-side-rendering
ssr
napajs

Napa.js + React でサーバーサイドレンダリングしてみた

More than 1 year has passed since last update.

8/11に最新JavaScript開発というすごくわかりやすく現代的なJavaScriptを学べる本が出ました、ごきげんよう。

MicrosoftからNapa.js: a multi-threaded JavaScript runtime というのがリリースされました。これはV8ベースのマルチスレッドで動く一種のワーカーを作る為のライブラリです。Node.jsからrequireして動かす事ができるのですが、ワーカー(zoneと呼びます)はNode.jsとは独立したV8ベースのJavaScriptエンジンで動きます。

メリットはCPU boundの処理をやらせるのに向いている事です。Node.js自体はイベントドリブン型のシングルスレッドなので、I/O boundな一般的なウェブサービスには向いてますが、CPU boundな処理をやらせたい場合には工夫が必要でした。デメリットは、zoneの中ではNode.jsと多少の互換性しかなくコアなAPIのうち少ししか実装されていません。

これまでReactのSSRは重いだのなんだの言われてましたが、これを使えば少しはマシになるのでは?などと思って実験してみました。

注意: マルチスレッド自体は全然試してません。napa.js上でReactのサーバーサイドレンダリングができるかどうかの検証だけしました。

napa.js

$ npm i cmakejs -D
$ npm i napajs -S

cmakejsはグローバルで入れてもいいんですが、好みとしてローカルで入れました。

const napa = require('napajs')
const zone1 = napa.zone.create('zone1', { workers: 4} )

zone1.execute((arg) => {
    // zone の中です。
    console.log(`zone1: ${arg}`)
    return arg + 1
}, [1])
    .then(res => console.log(res.value))
    .catch(err => console.dir(err))

これを実行するとまず zone1: 1 という出力がされてから、2が出力されます。

zoneの制約その1

const hoge = 'hoge'
zone1.execute(() => {
  console.log(hoge)
})

なんとなくこういうコードを実行したくなりますが、ReferenceError: hoge is not definedというふうに怒られてしまいます。zoneの中にクロージャーを使えないためで、何か渡したい物があれば、引数経由で渡すしかありません。

rollupでバンドルする

zoneの中ではrequireとかできないのでバンドルして食わせてみました。

npm i rollup rollup-plugin-node-resolve rollup-plugin-commonjs -D

rollup.config.js はこんな感じで

import nodeResolve  from 'rollup-plugin-node-resolve'
import commonJS from 'rollup-plugin-commonjs'
export default {
  entry: 'render.js',
  dest: 'bundle.js',
  plugins: [
      nodeResolve(),
      commonJS(),
  ]
}

react, react-dom を入れる

npm i react react-dom -D

今回は検証目的だったのでJSXとかそういうのは無視で。

実際にレンダリングするコードを書く

// render.js (zone内部向けのコード)
const {createElement} = require('react')
const {renderToString} = require('react-dom/server')

function render(arg) {
    return renderToString(createElement('div', {}, [arg]))
}
global.render = render

なぜglobalなどという邪悪なものを使っているのか?それはNode.js側とzoneではコンテキストが完全に違っていて、僕が実験した限りではこれくらいしかrender関数をzoneに渡すことができなかったためです。

// index.js (node側)
const fs = require('fs')
const napa = require('napajs')

const render = fs.readFileSync('bundle.js').toString()
const zone1 = napa.zone.create('zone1', { workers: 4} )

zone1.execute((f, arg) => {
    Function(f)()
    return global.render(arg)
}, [render, 'hoge'])
    .then(res => console.log(res.value))
    .catch(err => console.dir(err))

これを実行すると、

<div data-reactroot="" data-reactid="1" data-react-checksum="1789140184"><!-- react-text: 2 -->hoge<!-- /react-text --></div>

このようにレンダリングされたものが無事帰ってきました。

zoneの制約その2

さきほどのコードですが、requireではなくてファイル読み出しをなぜ使っているのかそれはたぶん関数のコンテキストの問題とバンドラーの問題かなーと思います。

const fuga = arg => console.log(arg)
const hoge = arg => fuga(arg)

zone1.execute((hoge, arg) => {
    hoge(arg)
}, [hoge, 'hoge'])

こうした時、'ReferenceError: fuga is not defined'というエラーが出る通りです。確かに制約その1でクロージャーがダメというのはわかったものの、このパターンでもダメなのです。

function render(arg) {
    const {createElement} = require('react')
    const {renderToString} = require('react-dom/server')
    return renderToString(createElement('div', {}, [arg]))
}
module.exports = render

こういうのも試してみましたが'ReferenceError: react is not defined'というエラーが出てしまいます。ということは、バンドラーが吐き出すコードが少しでもクロージャーを使ってるとダメなのでしょう。おそらくrequireした時点でクロージャー処理の解決が済んでしまっている為です。JSの場合、requireするというのはJSのコードを実行するに他ならないので仕方ないと言えば仕方ないかもしれません。

なので、ファイル読み出しをした上で、zoneの中でevalと同じようなことができるFunctionコンストラクタを使いました。あとはバンドラーという仕組み上、eval/Functionしたときの戻り値の制御とかが面倒なため、値の受け渡しに支障が出たので苦肉の策としてglobal.renderを登録するという形にしました。

最後に

まぁ現状だとこんな感じですが、どうせすぐもっと簡単にできるツールなり、napajs側の対応なりが進むと思います。(あと僕の実験が間違ってる可能性も)

サーバーサイドレンダリングをマルチスレッドで出来れば夢広がる感じだと思うので、もうちょっと実験を続けてみようかなーとは思います。

あと、冒頭にも書いた通り8/11に最新JavaScript開発というすごくわかりやすく現代的なJavaScriptを学べる本が出ました。感想とかいただけるととてもうれしいです。レビューとかも。