React で JSX をそのままレンダリングできるコンポーネントを作った
ユーザーにテンプレートエンジンを提供する際、さまざまなテンプレートエンジンがありますが、その中に React コンポーネントを組み込みたい場合(ex: <FollowButton>
のようなものがあって、それを呼び出すとフォローする)、いくらかのハックが必要になったり、カスタムコンポーネントを用意したりと様々な工夫が必要になります。
結局テンプレートというよりサイトスタイルを提供したいだけだし……ということであれば、 JSX を書いてもらってそのままそれが動けばいいのにな……と思ったので、 JSX をそのまま動かす仕組みを作りました。毎夜ちまちま作り続け、土日を経てそれなりの完成度になったので v1.0.0 で公開したし、ということでお披露目です。
React JSX Renderer
React JSX Renderer(以後 RJR)は JSX を解釈して React Node としてレンダリングしてくれるコンポーネントです。特に考えることはなく、いきなり JSX コードを入れていきなり動きます。
ブラウザデモもあります → LIVE DEMO
import { JSXRenderer } from 'react-jsx-renderer';
render(<JSXRender code="<p>Hello, World</p>" />, mount)
// => <p>Hello, World</p>
Binding を渡すことで、外部から変数も与えることが出来ます。
render(
<JSXRender
binding={{ name: 'rosylilly' }}
code="<p>Hello, {name}</p>"
/>,
mount,
)
// => <p>Hello, rosylilly</p>
React コンポーネントも渡せるので、ユーザーにコンポーネントを使ってもらうのも簡単です。
const Hello = ({ name }) => <>Hello, {name}</>;
render(
<JSXRender
binding={{ name: 'rosylilly' }}
components={{ Hello }}
code="<div><Hello name={name} /></div>"
/>,
mount,
)
// => <div>Hello, rosylilly</div>
JSX なので大抵の JavaScript っぽいものが動きます。
const members = ['Ada', 'Bob', 'Chris'];
render(
<JSXRender
binding={{ members }}
code="<ul>{members.map((member) => <li>{member}</li>)}</ul>"
/>,
mount,
)
// => <ul><li>Ada</li><li>Bob</li><li>Chris</li></ul>
RJR は現時点で async / generator を除くほぼ全ての JavaScript の機能に対応しているため、ユーザーはかなりの自由度で記述することが出来ます。
実行フロー
RJR は JSX の実行に eval
を利用しておらず、 meriyah によって JSX をパースし、得られた AST を都度解釈し、実行することによって React Node としてレンダリングをしています。
各 AST Node に対応する挙動は src/evaluate/expression.ts あたりを眺めてみると、本当に全 AST に対して1つ1つ挙動を作っているのが確認できるかと思います。また、AST を評価して実行する都合上、コールスタックっぽいものや変数スコープっぽいものも実装しています。
これらの関数などが呼び出せないもっと小さな実装である react-jsx-parser という先行実装もあり、これを利用するのも考えたのですが
- 関数呼び出しが一切封じられているので、リストに対する
map
なども使えず、リストレンダリングするにはなんらかの実装が都度必要 - 変数宣言が出来ないので一時的な値の保存なども難しく、『必要なデータは渡すからユーザー側でいろいろいじってね』ということが出来ず、結局サービス側がどれだけ機能提供するかでテンプレートの限界が決まる
といった懸念点から、今回は利用を諦め、自前で作ることにしました。関数呼び出しなどを封じているのは XSS に対する懸念から、ということなので、基本的に正しい判断だと思います。
ただ、リストに対するループ操作などもできないとなると、それはもう {}
記法が使えて HTML が書けるだけみたいな状態で、ほぼ Mustache なんかと変わりないので、それなら Mustache 使うよ……となって JS の機能がだいたい動く RJR を作った、という次第です。
XSS に対する防御策
RJR でも XSS を埋め込ませたい訳ではないので、いくつかのオプションで挙動を制御できるようにしています。
-
disableCall
/disableNew
による Call / New Expression の全面的な停止- これを有効にすると関数呼び出しができなくなるので、自由なコードの実行を抑制できます。
- 無名関数などを作った場合もすべてラップすることで、
binding
に渡していない関数呼び出しを防御-
fetch
やdocument
などに自由に触れないようにしています。
-
-
elementFilters
やfragmentFilters
による書き出し対象へのフィルタ- 要素名単位、属性名単位で書き換えられるようにしたり、削除したりできるようにすることで、
onClick
やsrc
など、危険な属性を排除したりできるようにしてあります。
- 要素名単位、属性名単位で書き換えられるようにしたり、削除したりできるようにすることで、
-
allowedFuncitons
/deniedFunctions
で Allow list / Deny list として実行を許可する関数を選別できます- 『なんでも実行されては困るが、 String の toLowerCase だけは許したい』などの場合に有効です
使う人はいない……と思いますが、一応 evaluate
関数として export してあるので、僕の作った JS サンドボックスめいたものを RJR とは関係なしに使うこともできます。
パフォーマンスに対するアプローチ
RJR はパース・AST実行・React レンダリングと3ステップほど踏むので、パフォーマンスは結構気をつけており、以下のような対処をしています。
- コンポーネント内でのパースと実行のステップを分割
- コードが変更されない限り、パース結果の AST を保持します
- meriyah はかなり早いパーサーですが、それでもパースの時間は結構かかるので、パース後 AST を保持することで再レンダリングのコストを下げています
- 実行後は key をすべての要素に付与することで React の再レンダリングを小さくするよう努めています
-
disableKeyGeneration
でキーの自動付与を停止することもできます - ユーザーが JSX 内で
key
を付与している場合、そちらが優先されます - ユーザーの
key
と重複する可能性がある場合はkeyPrefix
オプションで自動生成されるキーにプレフィックスをつけられます
-
-
debug
オプションを有効にすることで各ステップでかかった時間を console に出力します-
React JSX Renderer
でグルーピングされている部分です
-
なぜ JSX をユーザーに渡すのか
いろいろなテンプレートエンジンがありますが、そのテンプレートエンジン毎に覚えることがあったり、なんだったらローカルでの表示確認のために HTML で作ってからそのテンプレートエンジン用に書き直すといったケースも多く、それならもう最初から HTML 書かせてくれ、HTML がそのまま動いてくれればいい、という発想からです。
実際、RJR は単なる HTML を描画するものとしても使えますし、それだけでもまぁまぁ作った甲斐はあったかなと思っています。
Markdown から出力した HTML を RJR に流し込んでもいいでしょうし、別エンジンで生み出した HTML をレンダリングするのにも使えます。elementFilters
があるので、最後の最後のフィルタとしてだけ使うことも可能です。
大体出来たと自分では思っているのですが、まだまだ足りない機能やバグなどがあるかもしれません。もし何か見つけたら GitHub の Issue や Pull Request でサポートしていただけると幸いです。
それでは、よい HTML ライフをお送りください。
Discussion