🌈

React で JSX をそのままレンダリングできるコンポーネントを作った

2021/05/31に公開

ユーザーにテンプレートエンジンを提供する際、さまざまなテンプレートエンジンがありますが、その中に 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 に渡していない関数呼び出しを防御
    • fetchdocument などに自由に触れないようにしています。
  • elementFiltersfragmentFilters による書き出し対象へのフィルタ
    • 要素名単位、属性名単位で書き換えられるようにしたり、削除したりできるようにすることで、 onClicksrc など、危険な属性を排除したりできるようにしてあります。
  • 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