Closed31

Babel を使った JSX のトランスパイルを理解したメモ

shingo.sasakishingo.sasaki

モチベーションは何気なくReact, (まれに Vue) で使用してる JSX の正体を理解することで、主に以下記事をハンズオンしたり気になったとこ深堀りしたりした。
https://jasonformat.com/wtf-is-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 を生成する流れをハンズオンで行った
shingo.sasakishingo.sasaki

記事読みながら並行してハンズオンするので、空のプロジェクト作る

$ mkdir babel-jsx-test
$ cd babel-jsx-test
$ yarn init
// 適当にエンター連打
shingo.sasakishingo.sasaki

babel のパッケージを追加。

記事だと babel 本体の機能で JSX を解釈できるみたいに書いてるけど、解釈はできてもトランスパイルが出来なくて実行エラーになっちゃう。

2015年の記事なので、最新版の Babel バージョンだと変わったのかもしれないし、便宜上ここでは react のプリセットを使用して進めることに。

$ yarn add -D @babel/core @babel/cli @babel/preset-react
shingo.sasakishingo.sasaki

Babelでのトランスパイルに @babel/preset-react を使用することを宣言した設定ファイルを作成する。

babel.config.js
module.exports = {
  presets: ["@babel/preset-react"],
};
shingo.sasakishingo.sasaki

トランスパイルを実行すると、React のレンダリング関数に変換されていることが確認できる。

$ yarn babel main.js 
const foo = /*#__PURE__*/React.createElement("div", {
  id: "foo"
}, "Hello!");
shingo.sasakishingo.sasaki

今回は React を使う気がないので、コメントを使って JSX のトランスパイルで置き換える関数を h に変更。

main.js
/** @jsx h */
const foo = <div id="foo">Hello!</div>;

h はこの手のレンダリング処理の始祖的存在である hyperscript から来てるのかな。
https://github.com/hyperhype/hyperscript

shingo.sasakishingo.sasaki

再度トランスパイルすると、 JSX が h の呼び出しに書き換わっている。

$ yarn babel main.js 
/** @jsx h */
const foo = h("div", {
  id: "foo"
}, "Hello!");
shingo.sasakishingo.sasaki

このままだとトランスパイル後のコードを実行しても、 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);
shingo.sasakishingo.sasaki

babel でトランスパイルしたコードを node で実行するだけで充分だけど、 @babel/node がそれを一括でやってくれるらしいので使ってみる。

$ yarn add -D @babel/node

これで実行すると、仮想DOM っぽいノードが出来上がってることがわかる。

$ yarn babel-node main.js 
{ nodeName: 'div', attributes: { id: 'foo' }, children: [ 'Hello' ] }
shingo.sasakishingo.sasaki

次に仮想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;
}
shingo.sasakishingo.sasaki

render 関数を使って JSX から実DOM をレンダリングする動作確認用コードも用意する。

/** @jsx h */
const vDom = (
  <div id="foo">
    <span class="message">Hello</span>
  </div>
);

const dom = render(vDom);

document.body.appendChild(dom);
shingo.sasakishingo.sasaki

ここまで書いたコードは、 document.createElement みたいな、Document インタフェースを利用したブラウザ向けコードになってるので、Node だと実行できない。

なので今回は jsdom を使用して実行する。
https://github.com/jsdom/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'
shingo.sasakishingo.sasaki

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);
shingo.sasakishingo.sasaki

ここまでのコードまとめ

main.js
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>
shingo.sasakishingo.sasaki

もうちょっとネストしたり子要素増やしたり細かくしてみる。

/** @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>
shingo.sasakishingo.sasaki

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>
shingo.sasakishingo.sasaki

ハンズオンはコレで終わり。

JSX がトランスパイルされて呼び出される関数を自前で実装して、仮想DOM を実DOM に変換するプロセスはイメージできた。

じゃあ JSX のトランスパイルは誰がどこでやってるのかを調べよう。

shingo.sasakishingo.sasaki

Babel Plugin には Transform PluginsSyntax Plugins に分類されるらしく、だいたいのコードはそのまま前者でトランスフォーム出来るけど、JSXみたいな拡張構文の場合は後者を挟む必要があるって感じみたい。

https://babeljs.io/docs/en/plugins/#syntax-plugins

つまり @babel/plugin-syntax-jsx は、JSX 構文を parse して、 Transform Plugins での変換を可能にするだけで、変換はしないと思って良いのかな。

shingo.sasakishingo.sasaki
babel.config.js
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"
shingo.sasakishingo.sasaki

pragma が JSX の変換に使用する関数で、このプラグインでのデフォルトは React.createelement
https://github.com/babel/babel/blob/ae5182a2c52ca3e87c70f1cd53fb74e9f683a316/packages/babel-plugin-transform-react-jsx/src/create-plugin.ts#L23-L28

コメントでのアノテーション (/** @jsx h **/) が見つかると pragma を差し替える
https://github.com/babel/babel/blob/ae5182a2c52ca3e87c70f1cd53fb74e9f683a316/packages/babel-plugin-transform-react-jsx/src/create-plugin.ts#L208-L212

あとは小難しいけど pragma を呼び出すコードへの変換を行うと。

shingo.sasakishingo.sasaki

本当は JSX から AST を生成するのも自前でやってみるみたいなのもイメージしてたけど、それは JSX 自体の学習から離れそうなのでここでは割愛。

JSX に対する解像度が上がったのでとりあえず満足。

このスクラップは2022/08/26にクローズされました