😀

Solid.jsはどのようにDOMを更新しているのか?

に公開

こんにちは。TVer で Web フロントエンジニアを担当している永井(@0906koki)です。

Solid.js について

皆さんは Solid.js をご存知でしょうか?すでに 1.0.0 がリリースされてから 4 年ほど経つので、名前だけ知っている人も多いかと思います。
Solid.js は宣言的な UI を構築するための JavaScript ライブラリです。公式では「高性能な UI を構築するためのモダンな JavaScript フレームワーク」と説明されています。

Solid.js は以下のような特徴があります。

  • Virtual DOM を使わない

    • Solid.js は細かいリアクティブ更新とビルド時の最適化を利用して UI を更新するため、React のような仮想 DOM(Virtual DOM)を使用しません。代わりに、ビルド時に実際の DOM ノードを直接操作するコードに変換され、状態が変わったときは必要な部分だけを直接更新します。
  • JSX をサポート

    • 公式でも JSX を標準的な記述方法として扱っています。Solid のコンポーネントは関数であり、React のように JSX を返すスタイルです。
  • 宣言的 UI とコンポーネント指向

    • Solid.js では UI を宣言的に記述し、状態の変化に応じて UI が自動で更新されます。コンポーネント指向の設計なので、React に慣れている方であれば違和感なく書けます

実際にコードを見た方が分かりやすいと思うので、よくあるカウンターコンポーネントを Solid.js で実装してみます。

import { createSignal } from "solid-js";

export const Counter = () => {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount((count) => count + 1);

  return (
    <div>
      <button type="button" onClick={increment}>
        click
      </button>
      <span>{count()}</span>
    </div>
  );
};

見た目はほぼ React ですが、細かい部分が異なります。React では状態を管理するのにuseStateを使いますが、Solid.js ではcreateSignalを使い、JSX 内でcount()のように関数呼び出しで値を取得します。
Solid.js を理解するために、まずは基本的な API を紹介します。

createSignal

createSignalは React のuseStateとは異なり、実際の「値」ではなく、値を読む関数(count())と値を更新する関数(setCount(...))を返します。Solid.js はcount()の呼び出しを監視しており、特定のスコープ内で呼ばれたときだけ「依存関係」として登録します。

特定のスコープは例えば以下のようなものがあります。

  • JSX の中
  • createEffect(() => { ... })の中
  • createMemo(() => { ... })の中

そのため、以下のようにコンポーネント関数の直下でcount()を実行しても、increment実行後には値が出力されません(初回のみ出力)。

export const Counter = () => {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount((count) => count + 1);

  console.log("Count", count()) // 最初の1回だけ実行されるのみ

  // ...

createEffect

createEffectは、関数内で読み取った signal を自動で追跡し、いずれかが変わるたびに再実行します。先ほどの例ではconsole.log("Count", count())は初回のみ出力されていましたが、createEffectでラップすると、setCountのたびに出力されるようになります。

export const Counter = () => {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount((count) => count + 1);

  createEffect(() => {
    console.log("Count", count()) // setCountでの更新ごとに出力される
  });

  // ...

createMemo

createMemoは計算結果をキャッシュします。依存しているシグナルが変わったときだけ再計算し、それ以外はキャッシュされた値を返します。

例えば、以下のようにitemsシグナルを参照して計算する関数があるとします。JSX 内でexpensiveCalcを呼び出すたびに、毎回計算が実行されます。

import { createSignal, createMemo } from "solid-js";

const [items, setItems] = createSignal([]);

const expensiveCalc = () => {
  console.log("計算実行");
  return items().reduce((sum, item) => sum + item.price, 0);
};

// この関数を参照するたびに毎回計算が走る
<p>{expensiveCalc()}</p>
<p>{expensiveCalc()}</p>  // 再実行
<p>{expensiveCalc()}</p>  // 再実行

createMemoでラップすると、items()が変更されない限りキャッシュされた値を返します。

import { createSignal, createMemo } from "solid-js";

const [items, setItems] = createSignal([]);

const expensiveCalc = createMemo(() => {
  console.log("計算実行");
  return items().reduce((sum, item) => sum + item.price, 0);
});

// キャッシュが効くので、計算は1回だけ
<p>{expensiveCalc()}</p>
<p>{expensiveCalc()}</p>  // キャッシュを利用
<p>{expensiveCalc()}</p>  // キャッシュを利用

Solid.js は差分をどう更新している?

先ほどの Counter コンポーネントでは、ボタンをクリックすると数値がインクリメントされます。

import { createSignal } from "solid-js";

export const Counter = () => {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount((count) => count + 1);

  return (
    <div>
      <button type="button" onClick={increment}>
        click
      </button>
      <span>{count()}</span>
    </div>
  );
};

React の場合、countの状態が更新されると Counter コンポーネント全体が再レンダリングされ、仮想 DOM との差分比較を経て実際の DOM が更新されます。一方、Solid.js は異なるアプローチを取ります。

Solid.js では、ビルド時に JSX が実際の DOM 操作に変換されます。この Counter コンポーネントの場合、{count()}の部分だけが「リアクティブな更新ポイント」として登録され、countシグナルが変更されると<span>要素のテキストノードだけが直接更新されます。コンポーネント関数全体の再実行や仮想 DOM の比較は発生しません。


公式ドキュメントより

上記のコンポーネントを実際にビルドすると、以下のような JavaScript が生成されます。

import {
  c as createSignal,
  t as template,
  i as insert,
  d as delegateEvents,
} from "./index-BtnkU_kE.js";

var _tmpl$ = /* @__PURE__ */ template(
  `<div><button type=button>click</button><span>`
);
function Counter() {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount((count2) => count2 + 1);
  return (() => {
    var _el$ = _tmpl$(),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.nextSibling;
    _el$2.$$click = increment;
    insert(_el$3, count);
    return _el$;
  })();
}
delegateEvents(["click"]);

export { Counter as default };

このビルド後のコードを詳しく見ていきます。

template 関数によるテンプレートの事前生成

var _tmpl$ = /* @__PURE__ */ template(
  `<div><button type=button>click</button><span>`
);

template関数は、JSX で記述した静的な HTML 構造から DOM を生成する関数を返します。

function template(html, isImportNode, isSVG, isMathML) {
  let node;
  const create = () => {
    const t = document.createElement("template");
    t.innerHTML = html;
    return t.content.firstChild;
  };
  const fn = () => (node || (node = create())).cloneNode(true);
  fn.cloneNode = fn;
  return fn;
}

まず内部のcreate関数では、document.createElement("template")<template>要素を作成し、innerHTMLに HTML 文字列を代入してパースします。そしてt.content.firstChildでパース結果の最初の子要素を取得しています。

次にfn関数ですが、これは呼び出すたびに DOM ノードのクローンを返します。node || (node = create()) は「nodeがなければcreate()で作成して保存」という意味で、一度作成したnodeは変数に保持され、2 回目以降は再利用されます。そしてcloneNode(true)でクローンしたノードを返します。
これは、HTML 文字列のパース処理は 1 回だけにして、以降同じノードを使うときはクローンするという戦略にしているようです。

DOM ノードへの直接参照

return (() => {
  var _el$ = _tmpl$(),
    _el$2 = _el$.firstChild,
    _el$3 = _el$2.nextSibling;
  _el$2.$$click = increment;
  insert(_el$3, count);
  return _el$;
})();

ここが Solid.js の特徴的な部分です。コンポーネント関数は一度だけ実行され、その際に実際の DOM ノードへの参照を取得します。

  • _el$: ルートの<div>要素
  • _el$2: <button>要素(firstChildで取得)
  • _el$3: <span>要素(nextSiblingで取得)

イベントハンドラの登録

_el$2.$$click = increment;

ボタン要素に$$clickというプロパティでイベントハンドラを設定しています。これは Solid.js の「イベントデリゲーション」という仕組みです。

通常、クリックイベントを設定するには各要素にaddEventListenerを呼び出します。しかし、ボタンが 100 個あれば 100 回addEventListenerを呼ぶことになり、メモリを消費します。

イベントデリゲーションでは、documentに 1 つだけイベントリスナーを設定します。クリックイベントは DOM ツリーを上に伝播(バブリング)するため、どの要素がクリックされても最終的にdocumentまで届きます。そこで「どの要素がクリックされたか」を判定し、その要素の$$clickプロパティに設定されたハンドラを実行します。

delegateEvents(["click"]);
function delegateEvents(eventNames, document = window.document) {
  const e = document[$$EVENTS] || (document[$$EVENTS] = new Set());
  for (let i = 0, l = eventNames.length; i < l; i++) {
    const name = eventNames[i];
    if (!e.has(name)) {
      e.add(name);
      document.addEventListener(name, eventHandler);
    }
  }
}

このdelegateEvents関数が、documentレベルでの一括管理を設定しています。要素が何個あってもdocumentに設定するリスナーは 1 つだけなので、メモリ効率が良くなります(ここら辺は React も同じ)

insert 関数による動的更新の登録

insert(_el$3, count);

insert関数は、動的な値(countシグナルの getter 関数)を DOM に挿入し、値が変更されたときに自動更新されるよう登録します。

insert関数の実装を見てみましょう。

function insert(parent, accessor, marker, initial) {
  if (marker !== undefined && !initial) initial = [];
  if (typeof accessor !== "function")
    return insertExpression(parent, accessor, initial, marker);
  createRenderEffect(
    (current) => insertExpression(parent, accessor(), current, marker),
    initial
  );
}

accessor(この場合はcount)が関数の場合、createRenderEffectでリアクティブな更新を設定します。createRenderEffectは記事の前半で紹介したcreateEffectに似ていますが、レンダリングに特化した Effect です。

setCountで値が更新されると、この Effect が自動的に再実行され、insertExpression関数が<span>要素のテキストノードを新しい値で直接更新します。

更新の流れ

ボタンをクリックしたときの更新の流れを追ってみましょう。

  1. increment関数が呼ばれ、setCountでシグナルの値が更新される
  2. Solid.js の内部で、このシグナルを監視している Effect(insertで登録されたcreateRenderEffect)に変更が通知される
  3. createRenderEffectのコールバックが再実行され、accessor()(= count())で新しい値を取得
  4. insertExpression関数が<span>要素のテキストノードを直接更新
// insertExpression 内の更新処理(簡略化)
if (t === "string" || t === "number") {
  // ...
  if (current !== "" && typeof current === "string") {
    current = parent.firstChild.data = value; // テキストノードを直接更新
  } else current = parent.textContent = value;
}

このように、Solid.js は仮想 DOM を介さず、シグナルの変更を監視する Effect を通じて、必要な DOM ノードだけを直接更新しています。

なぜ Solid.js は高速なのか

ここまでの内容を踏まえて、Solid.js が高いパフォーマンスを実現できる理由をまとめます。

1. コンポーネント関数は一度しか実行されない

React では状態が変わるたびにコンポーネント関数全体が再実行されますが、Solid.js ではコンポーネント関数は初回マウント時に一度だけ実行されます。状態が変わっても関数の再実行は発生せず、登録された Effect だけが動きます。

2. 仮想 DOM の差分計算がない

React は状態変更のたびに仮想 DOM ツリーを再構築し、前回との差分を計算してから実際の DOM を更新します。Solid.js はこの差分計算をスキップし、変更が必要な DOM ノードを直接更新します。

3. 更新範囲が最小限

シグナルと Effect の依存関係により、更新は本当に必要な箇所だけに限定されます。countが変わっても、影響を受けるのは<span>のテキストノードだけで、<button><div>には何も起きません。

最後に

最後まで読んでいただきありがとうございました。この記事では Solid.js が DOM をどのように更新しているかについて解説しました。
明日の TVer Advent Calendar 2025 は @k0bya4 さんの 「30 分で Spanner の検索とグラフクエリを試す」です。お楽しみに!

Discussion