JSXを使うライブラリを作るには
こんにちは。チームラボフロントエンド班の鹿島です。最近の趣味は Factorio です。緑バイターに怯えています。
この記事では、React や Preact、Hono のような、JSX を扱うライブラリを作るために必要なあれこれについて、調べたことを紹介します。内容としては以前社内勉強会で発表したものと同じですが、記事にするにあたってまとめ直しています。すこし長くなってしまいましたが、ぜひ最後までお付き合いください!
JSX とは
JSX は、JavaScript の拡張構文です。主に React でコンポーネントを書く際に用いられ、HTML のような見た目を持ちます。
<div className={style["todo"]}>
<h2 className={style["heading"]}>Todo</h2>
<TodoList>
<TodoItem checked={true}>aaa</TodoItem>
<TodoItem>bbb</TodoItem>
</TodoList>
</div>
元々は React でコンポーネントを簡潔に書くために考案されたものですが、現在では React に限らず、Preact や Hono をはじめとする他のライブラリ・フレームワークでも用いられています。
JSX を使っているライブラリ
世の中には JSX を使用するライブラリが数多く存在しているのですが、実はライブラリによって JSX や React との関係が異なり、いくつかに分類することができます。
React の Renderer として実装されているもの
3D グラフィックライブラリの Three.js のオブジェクトを React で書けるようにするReact Three Fiberや、インタラクティブな CLI アプリケーションを React で書けるinkがこれに当たります。
React で Web アプリケーションを実装する場合、npm パッケージのreact
とreact-dom
を使いますが、React Three Fiber や ink では、このうちreact
のみを使っています。react-dom
は React ノードを DOM にマウントする役割を担っていますが、これらのライブラリはそれに代わって Three.js のレンダリングツリーやターミナルにマウントを行います。
React Native も同じパターンで、コアの部分はreact
をそのまま使い、マウント先が DOM ではなくネイティブコンポーネントになっています。
これらのライブラリではreact
パッケージをそのまま使うため、React の機能である hook などは React のものをそのまま使うことができます(恐らく)。
React の JSX トランスパイラを使っているもの
React の軽量サブセットであるPreactや、Web サーバフレームワークのHono、Slack の Block Kit 形式の JSON を JSX で組み立てられるjsx-slackがこれにあたります。
これらのライブラリは、React と同じ JSX トランスパイラを使用していますが、react
本体は使っていません。コンポーネントのライフサイクル管理や、hook のような仕組みが必要な場合は自前での実装が必要になります。
JSX を使ったライブラリを作りたい場合、多くの場合これに該当するかと思います。
JSX の構文だけを使っているもの
Vue.jsがこれに当たります。React と対比されることの多い Vue ですが、JSX を使用することもできます。
しかし、Vue は React 系統の JSX トランスパイラは用いず、独自の JSX トランスパイラを使用しています。
JSX を使うために必要なもの
JSX を(快適に)使えるライブラリを作るためには、主に以下の 2 つを実装する必要があります。
- JSX の変換先となるランタイム関数
- TypeScript の型定義
ここからは、それぞれについての実装方法や、そのために必要な知識について説明していきます。
JSX のトランスパイル
JSX で記述されたプログラムは純粋な JavaScript のプログラムではないため、そのままではブラウザなどの JavaScript ランタイムで実行することはできません。そのため、事前に JSX を JavaScript に変換する作業が必要となります。これを担うのがトランスパイラ(Transpiler)です。
古くは Babel、最近では TypeScript、Vite(が内部で使用している esbuild や Rollup)、SWC などがこのトランスパイラとして機能します。
React の JSX トランスパイラでは、上記の JSX は以下のように変換されます。
// "jsx": "react"
React.createElement(
"div",
{ className: style["todo"] },
React.createElement("h2", { className: style["heading"] }, "Todo"),
React.createElement(
TodoList,
null,
React.createElement(TodoItem, { checked: true }, "aaa"),
React.createElement(TodoItem, null, "bbb")
)
);
React の JSX トランスパイル
React の JSX トランスパイルには、上で上げたReact.createElement
に変換する方式の他に、React17 から追加された新しい方式の 2 種類があります。
ここではBabel における方式名に合わせ、前者を Classic、後者を Automatic と呼ぶことにします。
Classic 方式
React.createElement
関数の呼び出しに変換する方式です。
props や children はReact.createElement(tag, props, children)
の順で引数に渡されます。
tag
の部分には、それが小文字から始まる場合、その文字列"div"
が入り、大文字から始まる場合は関数名Greeting
がそのまま入ります(厳密にはもう少し複雑な条件になっていますが、ここでは細かく触れません)。
React.createElement
に変換されるため、実行時にはReact
がスコープに必要となります。Classic 方式では自動でReact
の import が行われたりはしないため、JSX/TSX の時点でReact
を import 等で事前に用意しておく必要があります。
TypeScript では、tsconfig.json のjsxオプションに"react"
を指定するとこの変換が行われます。Babel ではruntimeオプションがそれに相当します。
// source
import React from "react";
const Component = ({ names }: { names: string[] }) => {
return (
<div>
<h1>Welcome!</h1>
<ul>
{names.map((name) => (
<li>
<Greeting name={name} />
</li>
))}
</ul>
</div>
);
};
const Greeting = ({ name }: { name: string }) => {
return <p>Hello! {name}</p>;
};
// dist
import React from "react";
const Component = ({ names }) => {
return React.createElement(
"div",
null,
React.createElement("h1", null, "Welcome!"),
React.createElement(
"ul",
null,
names.map((name) =>
React.createElement(
"li",
null,
React.createElement(Greeting, { name: name })
)
)
)
);
};
const Greeting = ({ name }) => {
return React.createElement("p", null, "Hello! ", name);
};
JSX のフラグメント<></>
はReact.Fragment
に変換が行われます。
// source
import React from "react";
const elem = <>{"hey"}</>;
// dist
import React from "react";
const elem = React.createElement(React.Fragment, null, "hey");
なお、主要な JSX トランスパイラでは、React.createElement
のシンボルを別のものに変更することができます。TypeScript ではjsxFactory、Babel ではpragmaで指定が可能です。Preact はこの設定でh
に変換することを前提とした実装になっています。
Fragment の変換先についても、jsxFragmentFactory、pragmaFlagで変更できるようになっています。
Automatic 方式
React17 以降から使用できるようになった、_jsx
, _jsxs
関数の呼び出しに変換する方式です。
TypeScript では、tsconfig.json のjsxオプションに"react-jsx"
または"react-jsxdev"
を指定するとこの変換が行われます。
この方式では、_jsx(tag, props, key)
の順で引数が渡されます。children
は独立した引数ではなく、props
の一つとして含まれる形となっています。また、3 つ目の引数にkey
が増えています。Classic 方式ではprops
に含まれていましたが、Automatic 方式では独立した引数になっています。ちょうど children と入れ替わった形ですね。tag
については Classic 方式と変わりません。
import 文の自動追加
また、大きな違いとして、React
の import が不要になっている点が挙げられます。Automatic 方式では、トランスパイラによって import 文(あるいは require)が自動で挿入されます。TypeScript ではデフォルトでreact/jsx-runtime
から import するようになっています。
react/jsx-runtime
から named export されているjsx
, jsxs
関数は、スコープ内のシンボルと衝突しない形で import されます。多くの場合はas _jsx
,as _jsxs
の形になりますが、既に_jsx
が存在している場合には、as _jsx_1
のようになります。この辺りはトランスパイラがよしなにやってくれるため、ライブラリ側はjsx
, jsxs
を named export してさえいれば特に考慮すべきことはありません。
これに関しても、トランスパイラの設定によって import 元を変更することができます。TypeScript ではjsxImportSource、Babel ではimportSourceです。ただし、ここで変更可能なのはreact
の部分だけとなっています。つまり、jsxImportSource
に"preact"
を指定したとしても、挿入される import 文はpreact/jsx-runtime
になり、jsx-runtime
の部分は変更できません。そのため、Automatic 方式に対応するライブラリは、package.json
のexports
フィールドで/jsx-runtime
をエントリーポイントとして用意する必要があります。
フラグメントについても、_jsx
同様にreact/jsx-runtime
から import されます。
// source
import type React from "react";
const Component = ({ names }: { names: string[] }) => {
return (
<div>
<Greeting />
<ul>
{names.map((name) => (
<li>{name}</li>
))}
</ul>
</div>
);
};
const Greeting = () => {
return <h1>Hello!</h1>;
};
// dist
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const Component = ({ names }) => {
return _jsxs("div", {
children: [
_jsx(Greeting, {}),
_jsx("ul", {
children: names.map((name) => _jsx("li", { children: name })),
}),
],
});
};
const Greeting = () => {
return _jsx("h1", { children: "Hello!" });
};
development 向け変換
Automatic 方式では production 向けの変換の他に、development 向けの変換が用意されています。TypeScript では、jsxオプションに"react-jsxdev"
を渡した場合、development 向けの変換となります。
development 向けの変換では、関数が`_jsxDEV `になり、第 3 引数以降にデバッグ用の情報が追加されます。
_jsxDEV(tag, props, key, isStaticChildren, source, this)
isStaticChildren
はchildren
が静的な配列であるかどうかの boolean 値です。prod 向け変換ではこれがtrue
になるような場合に_jsxs
へと変換されていましたが、dev 向け変換では_jsxDEV
なのは変わらずにここに boolean 値が入ります。
source
はトランスパイル元の該当する位置を示すオブジェクトです。ファイル名、行、列のフィールドを持ちます。この値はレンダリング時のエラー表示を親切にすることに使用できます。役割としてはsource mapに少し似ています。レンダリング時のエラーなどで、エラーの該当箇所をトランスパイル元のソースコードの位置で示したい場合には、この値を参照すると便利です。
最後のthis
には JS のthisが入りますが、これはクラスコンポーネントでたまに必要になるだけのものなので、関数コンポーネントのみを使う場合は気にしなくて問題ありません。
// source
import type React from "react";
const Greeting = ({ name }: { name: string }) => {
return <p>Hello! {name}</p>;
};
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const _jsxFileName = "file:///input.tsx";
const Greeting = ({ name }) => {
return _jsxDEV(
"p",
{ children: ["Hello! ", name] },
void 0,
true,
{ fileName: _jsxFileName, lineNumber: 5, columnNumber: 9 },
this
);
};
ライブラリでは何を実装すれば良いのか
JSX の変換先である、React.createElement
やjsx
などの関数をライブラリ側で実装する必要があります。tag
が文字列の場合は組み込みの要素として扱い、関数の場合はコンポーネントとして扱う必要があります。関数の場合は、props
やchildren
を引数として関数を呼び出すことになります。ここの実装についてはライブラリで何をしたいかによって異なるため、一律に説明することは難しいです。既存のライブラリのソースコードを参考にすると良いと思います。
Classic 方式のみに対応する場合は、Classic 方式のランタイム関数を実装する必要があります。すなわち、createElement
関数とFragment
コンポーネントを実装し、ライブラリのルートから export します。
Automatic 方式に対応する場合は、それに加え Automatic 方式のランタイム関数の実装が必要です。Automatic 方式のランタイム関数として、jsx
, jsxs
, Fragment
を実装し、ライブラリエントリポイントの/jsx-runtime
と/jsx-dev-runtime
から named export する必要があります。children が静的な配列かどうかでの最適化が必要ない場合は、jsxs
の中身はjsx
と同じで問題ありません。
JSX への型付け
JSX を使いたくなる理由の一つが、TypeScript による型付けサポートがあることです。JSX 要素や属性、使用できる組み込み要素について、型による制約やエディタ上の補完が提供されます。
JSX を含む TypeScript ソースコードには.tsx
拡張子を使用します。TypeScript は JSX トランスパイラでもあるため、tsc
によって.tsx
から直接.js
にコンパイルが可能です。
JSX namespace
JSX に対する型付けは、JSX
namespace 内の決められた名前を持つ型によって行われます。ここで参照されるJSX
namespace はどの JSX ランタイムを使用するかによって決まります。
Classic 方式の場合、JSX
namespace は JSX ファクトリ関数の最上位識別子の中にある必要があります。JSX ファクトリ関数がReact.createElement
の場合は、React
がそれです。Preact のように JSX ファクトリ関数をh
とした場合は、h
になります。
// React.createElement
declare namespace React {
namespace JSX {
interface IntrinsicElements {
// ...
}
}
}
// h
declare namespace h.JSX {
interface IntrinsicElements {
// ...
}
}
Automatic 方式の場合は、/jsx-runtime
エントリポイントとなるモジュールにJSX
namespace を用意します。
// jsx-runtime.ts
export namespace JSX {
interface IntrinsicElements {
// ...
}
}
特殊なインターフェース
JSX
namespace 内の以下に挙げる名前の interface(あるいは type alias)は、JSX に型を当てる特殊な interface として扱われます。
なお、ここではクラスコンポーネントのみに関係する型については取り上げません。
IntrinsicElements
Intrinsic 要素の型を決める型です。key が要素名、value がその要素の属性を指定します。ここに無い key の要素は、存在しない要素として扱われ型エラーになります。
namespace JSX {
interface IntrinsicElements {
foo: { bar?: number }
}
}
<foo /> // ok
<foo bar={42} /> // ok
<foo baz={42} /> // error
<baz /> // error
interface
ではなくtype
でも機能しますが、interface
で宣言するのが一般的なようです(ユーザコード側で拡張できた方が嬉しい?)。
@types/react
ではこんな風になっています。
Element
JSX ファクトリ関数や関数コンポーネントの返値の型です。つまり、<foo />
や<Com />
の型です。この型はユーザコード側でもJSX.Element
の形で使われているのを目にするのではないでしょうか。
この型はライブラリ側の都合に合わせて自由に決定することができますが、一般的には木構造のノードを表す様な型になると思います。
namespace JSX {
export interface Element {
type: string;
props: any;
children: JSX.Element[]
}
}
IntrinsicAttributes
全ての組み込み要素、関数コンポーネントの属性を指定できる型です。IntrinsicElements
は特定の組み込み要素に対しての属性を指定できましたが、こちらはそれに加えて全ての要素、コンポーネントに対して適用されます。
namespace JSX {
export interface IntrinsicAttributes {
key?: string | undefined | null;
}
}
React ではkey
属性がIntrinsicAttributes
によって定義されています。
ElementChildrenAttribute
JSX の子として扱う属性の key と型を指定する型です。React の関数コンポーネントでは props のchildren
で子要素として受け入れることができる値の型を指定できますが、これはElementChildrenAttribute
型によってそう指定されているためです。
namespace JSX {
exportinterface ElementChildrenAttribute {
children: {}
}
}
値の方に意味があるかについては TypeScript のドキュメントにも記載がありませんでした。実際に適当な型を入れて試してみましたが、特に型チェックに影響する様子は無かったため、何でも良いのではないかと思います。
LibraryManagedAttributes<C, P>
全ての関数コンポーネントの属性の型を置換できる型です。見て分かるとおり、型引数をとる型になっていて、C
には関数コンポーネント(あるいはクラスコンポーネント)そのものの型、P
には元の属性の型が入ります。
追加ではなく置き換えであるというのがポイントになっていて、
namespace JSX {
export type LibraryManagedAttributes<C, P> = Omit<P, "children"> & {
children: undefined;
};
}
上記のように定義すると、いかなる関数コンポーネントも子を取れなくなります。関数コンポーネント型でFC<{ children: JSX.Element }>
のように指定されていても、この型によって置き換えられます。つまり、IntrinsicAttributes
をラップするような型で、上位互換です。
React ではdefaultProps
やpropTypes
の型をつけるためにLibraryManagedAttributes
が使われています。というか、React でその型付けをしたいがためにTypeScript 3.0でこの型が導入されたようです。
ElementType
JSX 要素のタグとして認められる値の型を指定できる型です。これは TypeScript5.1 で導入された比較的新しい型で、この型を使うと(props: any) => JSX.Element
の形で無い関数を関数コンポーネントとして扱うことができます。
例えば次のように定義すると、
namespace JSX {
export type ElementType =
| keyof JSX.IntrinsicElements
| ((props: any) => JSX.Element | Promise<JSX.Element>);
}
非同期関数も関数コンポーネントとして扱えます。React19 の Server Component がちょうどこれですね。React では、(props: P) => ReactNode
の形で定義されています。
なお、この型は props の型を絞りません。
実装例
これまでの実践として、Markdown を JSX で記述できるライブラリを作ってみました。Markdown が簡易にテキストをマークアップするための言語であることを考えるとやや本末転倒感がありますが、実装例なのでヨシとします。
(お試しで作ったものなので、コードが散乱しているのはご容赦ください)
以下のように変換されます。
expect(
render(
<markdown>
<h1>Hello</h1>
<p>
<i>Italic</i>
<b>Bold</b>
<s>Strikethrough</s>
</p>
<p>
<i>
<b>italic & bold</b>
</i>
<s>
<b>bold & strikethrough</b>
</s>
</p>
</markdown>
)
).toMatchInlineSnapshot(`
# Hello
"*Italic***Bold**~~Strikethrough~~
***italic & bold***~~**bold & strikethrough**~~
"
`);
render
関数は木構造を元にMdastという Markdown の AST を作成し、mdast-util-to-markdown
によって Markdown として出力しています。
まとめ
この記事では JSX を使うライブラリを作るために必要な実装について説明しました。JSX の変換先であるランタイム関数の実装と JSX namespace での型定義を行うことで、ライブラリのユーザが JSX を使ってコンポーネントを記述できるようになります。
ここまで読んだことで、JSX を使うライブラリを作りたくなってきたかと思います。ぜひ挑戦してみてください。
Discussion