いかそばの部屋へようこそ

当サイトはリンクフリーです!
Sorry. this site is written only Japanese.

フロントエンドフレームワークを自作してみた話

どうも、いかそば(@ikasoba)です。

今回はみすてむず いず みすきーしすてむず (2) Advent Calendar 2023の5日目への投稿となります。やったね。

フロントエンドフレームワークというものを自作してみたので、そのお話をやっていきます。

*筆者は説明が下手なので、この記事はだいぶ分かりづらいかもしれないです、

成果物のリポジトリ

https://github.com/ikasoba/ofuro-js

きっかけ

作ったきっかけは楽しそうだったからです。

実はもともと投稿を考えてたものは、JSのコードを変態糞土方のコピペで難読化するものですが、
汚いものをこういう場に持ってくるのはやめようと思ったのでこちらを投稿することにしました。

JSXについて

得られた知見としてJSXのことを書いていきます。 react-jsx でコンパイルされるものとして書いていきます。

大体こんな感じ

TypeScriptでは、JSXに対しても型付けができます。

JSX用のコードが <oackage>/jsx-runtime としてインポートできことが第1条件です。

// JSXというモジュールからJSXの型付けが行われる
export declare module JSX {
  // この型を元に属性の型付けが行われる、typeでも可
  interface IntrinsicElements {
    [name: string]: ...
  }

  // この型がJSXで生成されたノードの型となる
  type Element = ...

  // この型がクラスコンポーネントの元になる
  interface ElementClass {
    ...
  }

  // この型で指定されてるキー名がクラスコンポーネントの属性の型の指定に使われる
  // キーに対する値の型がどんなものでも `コンポーネント#<キー名>` の型が参照される
  interface ElementAttributesProperty {
    props: {};
  }

  // この型で指定されてるキー名がコンポーネントのchildrenの型の指定に使われる
  // キーに対する値の型がどんなものでも `属性#<キー名>` の型が参照される
  // コンパイル後の `jsx` への引数のキー名はchildrenから置換されないので注意
  interface ElementChildrenAttribute {
    children: {};
  }
}

export const jsx = ...;
export const jsxs = ...;

ElementAttributesProperty について

この型で指定されてるキー名がクラスコンポーネントの属性の型の指定に使われます。 キーに対する値の型がどんなものでも コンポーネント[<キー名>] の型が参照されるのが特徴です。

ややこしいと思うので実践形式で補足していきます。

interface ElementAttributesProperty {
  piyo: {};
}

と定義した場合

class Hoge {
  piyo!: {
    fuga: string;
  }

  /** 例なので適当に */
  render() {
    return (
      <div>
        { this.piyo.fuga }
      </div>
    );
  }
}

ElementAttributesPropertyで指定したキーと、Hogeのpiyoプロパティが対応しているので、 上のようなの定義をします。

<Hoge fuga="..." />

このコードで割り当てられてるコンポーネントのpropsがpiyoに代入されます。

また、ElementAttributesProperty で指定したキーへの値の割当は jsx 関数などで実装しておく必要があります。

ElementChildrenAttribute について

JSX.ElementChildrenAttribute を定義せずにコンポーネントを書いてみましょう。

function Hoge(props: { children: string }) {
  return (
    <div>
      {props.children}
    </div>
  )
}

このコンポーネント、実は使おうとすると型エラーが出ます。

<Hoge>
  {"ピギモンゴ"}
</Hoge>

こういったコードで、以下のエラーが出てしまいます。

Property 'children' is missing in type '{}' but required in type '{ children: string; }'.

なんと、このエラーでは children が型上では引数に渡ってないように見えますね。

そこで JSX.ElementChildrenAttribute を定義してあげる必要があります。

export module JSX {
  interface ElementChildrenAttribute {
    /** コンポーネントのchildrenはchildrenという名前の属性に渡すよう指定される */
    children: {};
  }
}

この定義があると型エラーは出なくなります。

すこし変なところもあり、ElementChildrenAttributeで指定した属性の名前がchildren以外だった場合でも

<Hoge>
  {"ピギモンゴ"}
</Hoge>

というコードは、以下のようにトランスパイルされてしまいます。

jsx(Hoge, {
  children: "ピギモンゴ"
})

JSXファクトリ

JSXの構文は jsx関数と jsxs関数の呼び出しとしてトランスパイルされます。

jsx関数

jsx関数(以下 jsx)は、以下のような型になります。

(簡略化のため、propsの型は省略します。)

function jsx(type: string | Component, props: ..., key?: any): JSX.Element;

childrenは、以下のようにpropsを介して一つの要素が渡されます。

jsx(..., {
  children: ...
})

jsxs関数

jsxs関数(以下 jsxs)は、以下のような型になります。

(説明を簡単にするために一部の型は省略します。)

function jsxs(type: string | Component, props: ..., key?: any): JSX.Element;

childrenは、以下のようにpropsを介して複数の要素からなる配列が渡されます。

jsxs(..., {
  children: [...]
})

jsxDEV関数

これは、デバッグ向けのファクトリでviteなどのバンドラーから読み込まれます。

jsxDEV関数(以下 jsxDEV)は、以下のような型になります。

(説明を簡単にするために一部の型は省略します。)

function jsxDEV(type: string | Component, props: ..., key?: any, source: ..., self: any): JSX.Element;

引数 source

これは以下のような型の値が渡されるようです。

{
  fileName: string;
  lineNumber: number;
  columnNumber: number;
}

実装する上でJSXの型付けについて上記のような知識が得られました。

次は実装したフックの解説に移ります。

実装した主なフック

今回自作したフロントエンドフレームワークでは、以下のフックを実装しました。

signal

状態を保持するためのフックの一つです。 Stateに比べて実装が楽そうだと判断したので今回はこちらを実装しました。

Signalの値が変更された時に呼び出されるイベントハンドラーと、その値を持っています。

function Counter() {
  const count = signal(0);

  return (
    <button onClick={() => count.value++}>
      count: {count}
    </button>
  );
}

useEffect

コンポーネント内で生成されたSignalを保持しておくことで依存しているフックの収集を自動で行えるようにしました。

computed

参照しているSignalが変更された時にDOMへ変更を反映させるフックです。

一度、計算してから参照されたSignalを保持しておくことで自動で依存しているSignalを保持するようにしました。

function Counter() {
  const count = signal(0);

  return (
    <button onClick={() => count.value++}>
      count: {computed(() => count.value * 2)}
    </button>
  );
}

サーバーサイドレンダリング

サーバーサイドレンダリングをするために deno_dom を使用しました。

これらは、内部で使用しているdom apiをrender内でdeno_domへ置き換えることで実現しました。

import { signal, computed } from "ofuro-js/mod.ts";
import { render } from "ofuro-js/server.ts";

function Counter() {
  const count = signal(0);

  return (
    <button onClick={() => count.value++}>
      count: {computed(() => count.value)}
    </button>
  );
}

console.log(
  render(() => <Counter/>)
);

さいごに

今回はなんとなく、フロントエンドフレームワークを自作するのに役に立ちそうな情報をまとめてみました。

12/13も記事を出すので良ければそちらも御覧ください!

以上、いかそばの記事でした。

12/6の記事はみのかわさんの記事です。楽しみですね。

最後に、リポジトリにstarをくださったsanaoさんに感謝申し上げます。