Reactなしでjsxを使う(javascript)

JavaScript

jsx/tsxは好きだ。素晴らしい発明だと思う。
一方でReactは好きか?と問われると、、、
React本体はシンプルだしエコシステムも発展しているので使いやすいとは思う。

私がReactを難しいと感じる領域がデータやステート管理です。データを変えればUIに変更が吐き出される、という機構は単一コンポーネントでは極めてシンプルで明瞭です。しかし、コンポーネントを跨いだ処理になると途端に複雑度が増して私には理解しきれなくなる。

多少複雑になっても最終的に出力されるhtmlは想像できるんです。ですがその経路上で何が起こるのかが把握しきれない。useContextしている場合、どの要素がどこまで再レンダリングされるのか、どこで意図しないuseEffectが走るのか、逆に走らないのか。小要素だけを変更するつもりだったのに、useContextのルートから再レンダリングされたり、と意図しない挙動を幾度も経験してきた。私の理解が足りないと言われればそれまでだが純粋に難しいです。これ以外にもコンポーネントにuesMemoを使うだの使わないだ議論が活発だったりする。そもそも再レンダリングをコントロールできていたら必要のない機能なのに、議論になる時点で設計が悪いか、正確にReactを使いこなせていないのだろう。なお、私ではReactを使いこなせません。

前置きが長くなりましたが、JSXは大好きです。かつてはjQueryで$(‘<div></div>’)なんてことをやっていた人間なので、const div = <div></div>と書けるjsxの記法は画期的です。 const div = document.createElement(‘div’)よりも視認性が高く自明です。DOMの生成を簡潔に記載できるのであれば、DOM操作を自力で担保できる程度の小さいアプリケーションなら考えることが減って有利だと私は思っています。依存関係も減りますし保守も楽でしょう。フレームワークのバージョンアップに付き合うのは大変ですからね。

ということで今回はReactなしでjsxだけ都合よく使う方法を考えます。

開発環境

・vite
・typescript

設定ファイル

修正が必要なのは以下の2つです。
jsxFactoryというのは<div></div>をどの関数に変換しますか?というのを示します。
jsxFragmentは<></>をどの関数に変換するか、ですね。
jsx => jsへのトランスパイルにはこの2つの関数がコアの概念となります。

// vite.config.ts

export default defineConfig({
  esbuild: {
    jsxFactory: "h",
    jsxFragment: "Fragment",
  },
});
// tsconfig.json

{
  "compilerOptions": {
    "jsx": "preserve", // 'react', 'react-jsx'
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment",

h、Fragment関数

トランスパイルに使う関数を定義していきます。
以下の関数はchildrenを正しく扱えませんが最低限は動作します。

// jsxFactory.ts

type Props = { [key: string]: any };
type Child = string | Element;
type FunctionComponent = (props: Props) => Element;

export function h(
  tag: string | FunctionComponent | typeof Fragment,
  props: Props,
  ...children: Child[]
) {
  const isString = typeof tag === "string";

  const dom = isString
    ? document.createElement(tag)
    : tag({ children, ...(props ?? {}) }); // todo treat children as props

  if (isString) {
    Object.entries(props ?? {}).forEach(([key, value]) => {
      if (!(dom instanceof Element)) throw new Error();

      if (typeof value === "boolean") {
        if (value) {
          dom.setAttribute(key, "");
        }
      } else if (typeof value === "function") {
        // @ts-ignore
        dom[key] = value;
      } else {
        dom.setAttribute(key, value);
      }
    });
  }

  dom.append(...children);

  return dom;
}

export const Fragment = () => document.createDocumentFragment();

declare global {
  namespace JSX {
    type Element = HTMLElement;
    interface IntrinsicElements extends ValuePartial<HTMLElementTagNameMap> {}
  }
}

type ValuePartial<T> = {
  [K in keyof T]: Partial<T[K]> | { class: string };
};

ここまでできれば以下のtsxが正しくトランスパイルできるはずです。
トランスパイラが識別できるようにするため、h関数をインポートする必要があります。明示的に呼び出しはしませんが、書かないとエラーになります。

import { h } from "./jsxFactory";

export const Button = ({
  label,
  onClick: onClick,
}: {
  label: string;
  onClick: (e: MouseEvent) => void;
}) => (
  <button onclick={onClick}>
    {label}
  </button>
);

function main(){
    const button = <Button label='my button' onClick={()=>{console.log('clicked')}}/>
    document.body.appendChild(button);
}

h関数

以下のようなjsx定義があった場合、

<div id="123">hoge</div>

このように変換されます。

h('div', {id: '123'}, 'hoge')

引数は順にタグ名、属性、可変長引数で子要素が並びます。

これを意図したものが出力されるよう、h関数を定義すればOKです。
reactの場合はReact.createElementが呼ばれます。

今回はDOMをそのまま出力したいので、最小構成だと以下のようになります。

function h(tag, attributes, ...children){
    // DOMの作成
    const dom = document.createElement(tag)

    // 属性の適用
    Object.entries(attributes).forEach(([key, value]) => {
         dom.setAttribute(key, value)
    })

    // 子要素を追加
   dom.append(...children);

   return dom;
}

実際には先ほど示した例のように自作のコンポーネントを使って
const button = <Button label=’my button’ onClick={()=>{console.log(‘clicked’)}}/>
のようなことを書きたいため、tagの部分には文字列以外に自作の関数がくるパターンがあり、これを処理しなければなりません。

  const dom = typeof tag === "string"
    ? document.createElement(tag)
    : tag({ children, ...(props ?? {}) });

この例だとFunctionコンポーネントで定義した関数がtagの部分に来ていますので、そのtag関数を適切な引数で呼び出します。
他にもonclickなどの関数を渡すのもsetAttributeではできないため小細工が必要になります。

Fragment関数

これはtagが無指定の時に呼ばれる関数ですね。
<></>を呼び出した時、tag名がないため特別扱いをしてFragment関数を呼び出します。

jsにはまさにこの用途で使うDocumentFragmentというElementがありますので、これを返せばOKです。

const Fragment = () => document.createDocumentFragment()

これで空タグはDocumentFragmentに変換され、以後はh関数で他のタグと同様に扱われます。
注意事項として属性などはDocumentFragmentには適用できないため、これらも迂回させる必要があります。

JSXの型

何も書かなければJSXの戻り値はanyです。
これに型をつけるためにJSX namespaceのElementに任意の型をつけます。
今回はHTMLElementを返す仕組みなのでこうしています。

declare global {
  namespace JSX {
    type Element = HTMLElement;
    interface IntrinsicElements extends ValuePartial<HTMLElementTagNameMap> {}
  }
}

type ValuePartial<T> = {
  [K in keyof T]: Partial<T[K]> | { class: string };
};

IntrinsicElementsに関しては利用して良い組み込み型(例えばdivとかinputとか)を指定します。
1つずつdivのタグは{ id: string, className: stirng ,,,}と定義しても良いのですが、 HTMLElementTagNameMapという定義済みの型があるのでこれを利用します。

これをそのまま使うと全ての属性がRequiredになってしまって使いづらいため、Partialに変換して適用します。

まとめ

自力でDOM操作をするのは時代に逆行している気がしないでもないですが、ReactなしでJSXを解説しました。個人的には単機能のアプリケーションなど、小規模アプリではReactなどを使わない方が保守メンテが楽な気がします。機能的にもjQuery(もう私は使ってませんが)で十分なレベルのサイトも多いですし、無理してReact使う必要もないかな、と。

https://qiita.com/murasuke/items/774f30aada568175275b
https://qiita.com/http_kato83/items/eacc2fc4a9319da61684
https://github.com/kato83/hiroshi/blob/master/src/hiroshi.ts
https://zenn.dev/kaorun343/articles/06411305299414
https://jasonformat.com/wtf-is-jsx/

コメント