Babel を使った JSX のトランスパイルを理解したメモ
モチベーションは何気なくReact, (まれに Vue) で使用してる JSX の正体を理解することで、主に以下記事をハンズオンしたり気になったとこ深堀りしたりした。
要約
- JSX は ECMAScript の標準ではないし、標準を目指す Proposal でもないが、各種トランスパイラーで適切に JavaScript に変換できるように仕様が定められている
- JSX はただの JavaScript のシンタックスシュガーに過ぎない
- Babel を使った場合、@babel/parser が JSX の構文解析を行うが、構文解析後の AST を使ってトランスパイルを行うには別途プラグインが必要 (今回は
@babel/plugin-transform-react-jsx
がそれ) -
@babel/plugin-transform-react-jsx
の場合、トランスパイル後に使用される関数はデフォルトでReact.createElement
だが、アノテーションコメントを使って任意の関数に差し替えられる - 上記記事では、
h
関数を再実装することで、仮想DOM っぽいものから実DOM を生成する流れをハンズオンで行った
記事読みながら並行してハンズオンするので、空のプロジェクト作る
$ mkdir babel-jsx-test
$ cd babel-jsx-test
$ yarn init
// 適当にエンター連打
babel のパッケージを追加。
記事だと babel 本体の機能で JSX を解釈できるみたいに書いてるけど、解釈はできてもトランスパイルが出来なくて実行エラーになっちゃう。
2015年の記事なので、最新版の Babel バージョンだと変わったのかもしれないし、便宜上ここでは react のプリセットを使用して進めることに。
$ yarn add -D @babel/core @babel/cli @babel/preset-react
Babelでのトランスパイルに @babel/preset-react
を使用することを宣言した設定ファイルを作成する。
module.exports = {
presets: ["@babel/preset-react"],
};
適当に JSX を含んだ JS ファイルを作成する
const foo = <div id="foo">Hello!</div>;
トランスパイルを実行すると、React のレンダリング関数に変換されていることが確認できる。
$ yarn babel main.js
const foo = /*#__PURE__*/React.createElement("div", {
id: "foo"
}, "Hello!");
今回は React を使う気がないので、コメントを使って JSX のトランスパイルで置き換える関数を h
に変更。
/** @jsx h */
const foo = <div id="foo">Hello!</div>;
h
はこの手のレンダリング処理の始祖的存在である hyperscript
から来てるのかな。
再度トランスパイルすると、 JSX が h
の呼び出しに書き換わっている。
$ yarn babel main.js
/** @jsx h */
const foo = h("div", {
id: "foo"
}, "Hello!");
このままだとトランスパイル後のコードを実行しても、 h
って関数がないよってエラーになるので、 h
関数を作っていく。
とりあえず構文解析後の AST ノードをほぼそのまま受け取る構成に。(記事で children
が空の場合に null にしてるのはどういう意図なのか最後までわからなかったけど)
function h(nodeName, attributes, ...args) {
const children = args.length ? [].concat(...args) : null;
return { nodeName, attributes, children };
}
JSX を代入した変数を標準出力するだけのコードを用意して動作確認する。
/** @jsx h */
const foo = <div id="foo">Hello</div>;
console.log(foo);
babel でトランスパイルしたコードを node で実行するだけで充分だけど、 @babel/node
がそれを一括でやってくれるらしいので使ってみる。
$ yarn add -D @babel/node
これで実行すると、仮想DOM っぽいノードが出来上がってることがわかる。
$ yarn babel-node main.js
{ nodeName: 'div', attributes: { id: 'foo' }, children: [ 'Hello' ] }
次に仮想DOM を受け取って実DOM を生成する render
関数を実装する。
学習用途としては必要十分な感じ。
function render(vNode) {
// Stringの場合はテキストノードを生成して終了
if (vNode.split) return document.createTextNode(vNode);
// テキストノードでない場合はDOM要素を作成する
const el = document.createElement(vNode.nodeName);
// DOM要素の属性を設定する
const attributes = vNode.attributes || {};
Object.keys(attributes).forEach((key) => {
el.setAttribute(key, attributes[key]);
});
// 子要素についても再帰的にレンダリングする
(vNode.children || []).forEach((child) =>el.appendChild(render(child)));
// レンダリングしたDOM要素を返す
return el;
}
render
関数を使って JSX から実DOM をレンダリングする動作確認用コードも用意する。
/** @jsx h */
const vDom = (
<div id="foo">
<span class="message">Hello</span>
</div>
);
const dom = render(vDom);
document.body.appendChild(dom);
ここまで書いたコードは、 document.createElement
みたいな、Document インタフェースを利用したブラウザ向けコードになってるので、Node だと実行できない。
なので今回は jsdom を使用して実行する。
$ yarn add -D jsdom
簡単な使い方はこんな感じで、ブラウザ互換の DOM をノード上で作成できる。
> const { JSDOM } = require('jsdom')
undefined
> const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
undefined
> dom.window.document.querySelector('p').textContent
'Hello world'
JSDOM を使って、こんな感じに document
を定義する。
これでブラウザでの実行を想定したコードを Node で動かせる。
const { JSDOM } = require("jsdom");
const document = new JSDOM(`<!DOCTYPE html><html><body></body></html>`).window.document;
最後に JSDOM 上の body の内容を出力
console.log(document.body.innerHTML);
ここまでのコードまとめ
const { JSDOM } = require("jsdom");
const document = new JSDOM(`<!DOCTYPE html><html><body></body></html>`).window.document;
function h(nodeName, attributes, ...args) {
const children = args.length ? [].concat(...args) : null;
return { nodeName, attributes, children };
}
function render(vNode) {
// Stringの場合はテキストノードを生成して終了
if (vNode.split) return document.createTextNode(vNode);
// テキストノードでない場合はDOM要素を作成する
const el = document.createElement(vNode.nodeName);
// DOM要素の属性を設定する
const attributes = vNode.attributes || {};
Object.keys(attributes).forEach((key) => {
el.setAttribute(key, attributes[key]);
});
// 子要素についても再帰的にレンダリングする
(vNode.children || []).forEach((child) => el.appendChild(render(child)));
// レンダリングしたDOM要素を返す
return el;
}
/** @jsx h */
const vDom = <div id="foo">Hello</div>;
const dom = render(vDom);
document.body.appendChild(dom);
console.log(document.body.innerHTML);
実行すると、JSX が実DOM に反映されていることがわかる。
$ yarn babel-node main.js
<div id="foo">Hello</div>
もうちょっとネストしたり子要素増やしたり細かくしてみる。
/** @jsx h */
const vDom = (
<div id="foo">
<h1>Hello</h1>
<h2>World</h2>
<div class="contents">
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
</div>
</div>
);
大丈夫そう
$ yarn babel-node main.js
<div id="foo"><h1>Hello</h1><h2>World</h2><div class="contents"><ul><li>1</li><li>2</li><li>3</li></ul></div></div>
JSX は純粋な JavaScript を使用可能なので、ビューの切り出しや分岐などのロジックも新しい構文を覚えずに使えることが出来るよという話。
const items = ["foo", "bar", "baz"];
function item(text) {
return <li>{text}</li>;
}
const list = render(<ul>{items.map(item)}</ul>);
document.body.appendChild(list);
console.log(document.body.innerHTML);
$ yarn babel-node main.js
<ul><li>foo</li><li>bar</li><li>baz</li></ul>
ハンズオンはコレで終わり。
JSX がトランスパイルされて呼び出される関数を自前で実装して、仮想DOM を実DOM に変換するプロセスはイメージできた。
じゃあ JSX のトランスパイルは誰がどこでやってるのかを調べよう。
今回は @babel/preset-react
を使用したので、これに含まれる何らかのパッケージで依存してるはず。
プリセットには3種類のプラグインが内包されてる
- @babel/plugin-syntax-jsx
- @babel/plugin-transform-react-jsx
- @babel/plugin-transform-react-display-name
今回は React を一切使ってないので、最初の plugin-syntax-jsx
が多分それ。
Using this plugin directly only enables Babel to parse this syntax. If you want to transform JSX syntax then use the transform-react-jsx plugin or react preset to both parse and transform this syntax.
Babel Plugin には Transform Plugins
と Syntax Plugins
に分類されるらしく、だいたいのコードはそのまま前者でトランスフォーム出来るけど、JSXみたいな拡張構文の場合は後者を挟む必要があるって感じみたい。
つまり @babel/plugin-syntax-jsx
は、JSX 構文を parse して、 Transform Plugins での変換を可能にするだけで、変換はしないと思って良いのかな。
そうなると実際にトランスパイルしてるのは @babel/plugin-transform-react-jsx
のほうか。
module.exports = {
plugins: ["@babel/plugin-transform-react-jsx"],
};
この設定でこれまでのコードも通るからそれっぽい。
syntax プラグインの方にも依存してるみたい。
$ yarn why @babel/plugin-syntax-jsx
yarn why v1.22.19
[1/4] 🤔 Why do we have the module "@babel/plugin-syntax-jsx"...?
[2/4] 🚚 Initialising dependency graph...
[3/4] 🔍 Finding dependency...
[4/4] 🚡 Calculating file sizes...
=> Found "@babel/plugin-syntax-jsx@7.18.6"
info Reasons this module exists
- "@babel#preset-react#@babel#plugin-transform-react-jsx" depends on it
- Hoisted from "@babel#preset-react#@babel#plugin-transform-react-jsx#@babel#plugin-syntax-jsx"
ソースコードはここ
コード見る限り、このプラグインは syntax plugin のほうを継承してるように見える。
@babel/plugin-syntax-jsx
で JSX を解析するまでを継承して、そこからトランスフォームしてるのかな。
pragma
が JSX の変換に使用する関数で、このプラグインでのデフォルトは React.createelement
コメントでのアノテーション (/** @jsx h **/
) が見つかると pragma
を差し替える
あとは小難しいけど pragma を呼び出すコードへの変換を行うと。
@babel/plugin-syntax-jsx
のコードを読むと、 parserOpts.plugins
に jsx
を push してるだけだった。
ってことは Babel 本体にその機能があるんかい。
本体(@babel/core) じゃなくて @babel/parser がそれみたい。
Support for JSX, Flow, Typescript.
リポジトリ
jsx プラグインはここ。
後は気合で構文解析して AST ノードを作るとこまで頑張ってる。
本当は JSX から AST を生成するのも自前でやってみるみたいなのもイメージしてたけど、それは JSX 自体の学習から離れそうなのでここでは割愛。
JSX に対する解像度が上がったのでとりあえず満足。