🦔

作って分かる Signal を用いた宣言的 UI

2024/05/17に公開

はじめに

Zenn では初投稿です。かわえもんと申します。
普段はバックエンドを書くことが多いのですが、たまに React 等フロントエンドも書きます。
最近 Solid.js を触りました。何やら React のように宣言的に書けるのに VDOM が要らないとか。その仕組みを勉強して基本的なアイデアは理解できた気がするので、備忘録としてアウトプットしておこうと思います。

この記事でやること

Solid.js がイベントに応答する仕組みである Signal を実装し、最終的にこれを作ります。

UI 宣言部はこうなります。

function MyCounter() {
  const [count, setCount] = state(0);

  return element("div", {
    children: [
      element("button", {
        onclick: () => setCount(count() + 1),
        children: [text("Increment")],
      }),
      element("button", {
        onclick: () => setCount(count() - 1),
        children: [text("Decrement")],
      }),
      element("p", {
        children: [text(() => `value: ${count()}`)],
      }),
    ],
  });
}

function App() {
  return element("div", {
    children: [MyCounter(), MyCounter(), MyCounter(), MyCounter()],
  });
}

なぜ Signal が必要なのか

そもそもなぜ今のフロントエンド界隈が VDOM だ Signal だと言っているのか、時々忘れそうになるので自分の理解をまとめておきます。

宣言的 UI が流行る前は、「手続き的」な UI 宣言が主流でした。試しに先程のコードを手続き的に書くと、次のように書けます。(長いので一部の機能は省きます)

function MyCounter() {
  let count = 0;

  const stateLabel = document.createElement("p");
  const updateLabel = () => {
    stateLabel.innerText = `value: ${count}`;
  };
  updateLabel();

  const incButton = document.createElement("button");
  incButton.innerText = "Increment";
  incButton.onclick = () => {
    count += 1;
    updateLabel();
  };

  const div = document.createElement("div");
  div.appendChild(incButton);
  div.appendChild(stateLabel);

  return div;
}

const app = document.createElement("div");
app.appendChild(MyCounter());

この手続き的 UI には以下のような問題があります。

  • 実際にレンダリングされる DOM 構造をコードからイメージしづらい
  • 状態更新後に DOM を更新するのを忘れるなど、ミスしやすい

これらの課題を解決すべく、宣言的 UI が生まれました。最終的に UI がどうなっているべきかをコードで示し、実際の DOM への反映は都度フレームワークが行います。

ここで生まれた問題は、宣言された UI からどう「変化」を読み取るかということです。DOM 操作は遅いため、更新の必要なところだけ更新しないとパフォーマンスに影響が出ます。

React は、ある状態が変化すると、それを所有するコンポーネント及びその子から仮の DOM 木(= VirtualDOM, VDOM)を構築し、実際の DOM 木と比較することで変更を検知します。

対して今回紹介する Signal は、JS の性質を上手く使うことで、VDOM を使わず必要な変更だけを DOM に反映するための面白い手法です。しかも実装が簡単で、簡単な実装ならば暗記できるほどです。今回はそれを作りながら紹介します。

Signal を作ってみよう

Signal の基本的な考え方は Observer パターンです。
状態購読を Observer パターンを適用して書いてみましょう。こんな感じになります。

function state(initialValue) {
  let state = initialValue;
  const listeners = [];

  // 状態を取得する。
  const getter = () => state;

  // 状態を更新し、購読者を呼び出す。
  const setter = (newValue) => {
    state = newValue;
    for (const l of listeners) {
      l();
    }
  };

  // 状態を購読する。
  // 状態が変化すると、引数 f に渡された関数が呼ばれる。
  const onChange = (f) => {
    listeners.push(f);
  };

  return [getter, setter, onChange];
}

const [getCount, setCount, onChangeCount] = state(0);

const incButton = document.createElement("button");
incButton.innerText = "increment";
incButton.onclick = () => setCount(getCount() + 1);

const stateLabel = document.createElement("p");

// 状態が変化したら stateLabel の内容を書き換える。
onChangeCount(() => {
  stateLabel.innerText = `現在の値: ${getCount()}`;
});

Observer パターンは JavaScript の世界ではだいぶ見慣れたものですよね。
さて、今手動で count を購読しています。これを宣言的にしていくにあたって、イベント購読を自動的にしたいです。目標はこんな感じです。

- onChangeCount(() => {
+ effect(() => {
    stateLabel.innerText = `現在の値: ${count.get()}`;
  });

effect 関数に購読者を渡すと、その購読者の依存している状態全てを自動的に購読します。
これができれば、愚直にヘルパー関数を書けば宣言的にできます。

// テキストノードを作る。引数にテキストもしくはテキストの getter を取る。
function text(value) {
  const node = document.createTextNode("");
  // ここで value が依存している状態に購読する
  effect(() => {
    node.nodeValue = typeof value === "function" ? value() : value;
  });
  return node;
}

// `nodeName` ノードを作る。第二引数にそのノードに設定される属性を取る。
function element(nodeName, attrs) {
  const node = document.createElement(nodeName);
  for (const child of attrs.children ?? []) {
    node.appendChild(child);
  }
  for (const k in attrs) {
    if (k !== "children") { // children は上で設定した。
      // ここで各属性が依存している状態に購読する。
      effect(() => void (node[k] = attrs[k]));
    }
  }
  return node;
}

function MyCounter() {
  const [getCount, setCount] = state(0);

  return element("div", {
    children: [
      element("button", {
        onclick: () => setCount(getCount() + 1),
        children: [text("increment")],
      }),
      element("p", {
        // `text` 関数が状態変化に自動で購読してくれるから、
        // 自分で文字列を更新する処理を書かなくていい!
        children: [text(() => `現在の値: ${count.get()}`)],
      }),
    ],
  });
}

JSX 文法を使っていないのでだいぶごちゃついていますが、宣言的っぽくなってきました。

effect 関数を実装していきます。購読者が状態を取得するときには必ず getter を呼びます。言い換えると、「getter の呼び出し元はその状態の購読者(である可能性がある)」です。これが重要な性質となります。

これを踏まえて、まずは getter を書き換えます。

const getter = () => {
  if (caller != null && !listeners.includes(caller)) {
    listeners.push(caller);
  }
  return state;
};
...

caller 変数はグローバルスコープで定義しておき、購読者が入ります。続いて effect 関数がこれです。

function effect(f) {
  caller = f;
  f();
  caller = null;
}

ここで一連の流れをおさらいしましょう。

function App() {
  const [getCount, setCount] = signal(0);
  return element("div", {
    children: [
      // ...
      // ①: ここで text 関数に、テキストノードに設定する値を返却する関数 f を渡す
      // ⑥: text 関数に渡した関数が呼ばれる。状態取得のため getCount を呼ぶ
      element("p", { children: [text(() => `現在の値: ${getCount()}`)] })
    ]
  });
}

function signal(init) {
  let state = init;
  const listeners = [];
  const getter = () => {
    // ⑦: caller にはテキストノードを更新する関数が設定されている。
    //     これが listeners に登録され、この状態の更新された際に再度呼び出される!
    if (typeof caller === "function" && !listeners.includes(caller)) {
      listeners.push(caller);
    }
    return state;
  };
  // setter 省略
  return [getter, setter];
}

function text(value) {
  const node = document.createTextNode("");
  // ②: すると effect 関数が呼ばれる
  effect(() => {
    // ⑤: テキストノードに設定する値を取得するため、value 関数を呼ぶ
    node.nodeValue = typeof value === "function" ? value() : value;
  });
  return node;
}

function effect(f) {
  // ③: effect 関数は f(今回はテキストノードを更新する関数)を caller にセット
  caller = f;
  // ④: f を呼び出す。
  f();
  caller = null;
}

これで完成です!たったこれだけのコードで高速に動作する宣言的 UI が定義できます。
Solid.js はこれにコンパイラなどを加えて、よりユーザーが楽に書けるように工夫されています。

おわりに

Signal の仕組みを知ったとき、「こんなに単純な仕組みで宣言的 UI ができるのか!!!かっけえ!!!」となり、勢い余って久しぶりに記事を書いてみました。

現状エコシステムが弱すぎる感じがあるので、もっと普及して発展していってほしいなあ。

Discussion