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 パッケージのreactreact-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 トランスパイラを使用しています。

https://ja.vuejs.org/guide/extras/render-function.html#jsx-tsx

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);
};

TypeScript Playground

JSX のフラグメント<></>React.Fragmentに変換が行われます。

// source
import React from "react";

const elem = <>{"hey"}</>;
// dist
import React from "react";
const elem = React.createElement(React.Fragment, null, "hey");

TypeScript Playground

なお、主要な JSX トランスパイラでは、React.createElementのシンボルを別のものに変更することができます。TypeScript ではjsxFactory、Babel ではpragmaで指定が可能です。Preact はこの設定でhに変換することを前提とした実装になっています。

Fragment の変換先についても、jsxFragmentFactorypragmaFlagで変更できるようになっています。

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.jsonexportsフィールドで/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!" });
};

TypeScript Playground

development 向け変換

Automatic 方式では production 向けの変換の他に、development 向けの変換が用意されています。TypeScript では、jsxオプションに"react-jsxdev"を渡した場合、development 向けの変換となります。

development 向けの変換では、関数が`_jsxDEV `になり、第 3 引数以降にデバッグ用の情報が追加されます。
_jsxDEV(tag, props, key, isStaticChildren, source, this)

isStaticChildrenchildrenが静的な配列であるかどうかの 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
  );
};

TypeScript Playground

ライブラリでは何を実装すれば良いのか

JSX の変換先である、React.createElementjsxなどの関数をライブラリ側で実装する必要があります。tagが文字列の場合は組み込みの要素として扱い、関数の場合はコンポーネントとして扱う必要があります。関数の場合は、propschildrenを引数として関数を呼び出すことになります。ここの実装についてはライブラリで何をしたいかによって異なるため、一律に説明することは難しいです。既存のライブラリのソースコードを参考にすると良いと思います。

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ではこんな風になっています。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3bd3a71c3954ed99b7fcbc61b75b6a455580d8bc/types/react/index.d.ts#L4336-L4341

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によって定義されています。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3bd3a71c3954ed99b7fcbc61b75b6a455580d8bc/types/react/index.d.ts#L4333

ElementChildrenAttribute

JSX の子として扱う属性の key と型を指定する型です。React の関数コンポーネントでは props のchildrenで子要素として受け入れることができる値の型を指定できますが、これはElementChildrenAttribute型によってそう指定されているためです。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3bd3a71c3954ed99b7fcbc61b75b6a455580d8bc/types/react/index.d.ts#L4320-L4322

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 ではdefaultPropspropTypesの型をつけるためにLibraryManagedAttributesが使われています。というか、React でその型付けをしたいがためにTypeScript 3.0でこの型が導入されたようです。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3bd3a71c3954ed99b7fcbc61b75b6a455580d8bc/types/react/index.d.ts#L4326-L4331

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の形で定義されています。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/3bd3a71c3954ed99b7fcbc61b75b6a455580d8bc/types/react/index.d.ts#L115

なお、この型は props の型を絞りません。

実装例

これまでの実践として、Markdown を JSX で記述できるライブラリを作ってみました。Markdown が簡易にテキストをマークアップするための言語であることを考えるとやや本末転倒感がありますが、実装例なのでヨシとします。

(お試しで作ったものなので、コードが散乱しているのはご容赦ください)

https://github.com/elecdeer/markdown-jsx

以下のように変換されます。

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