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>要素のテキストノードを新しい値で直接更新します。
更新の流れ
ボタンをクリックしたときの更新の流れを追ってみましょう。
-
increment関数が呼ばれ、setCountでシグナルの値が更新される - Solid.js の内部で、このシグナルを監視している Effect(
insertで登録されたcreateRenderEffect)に変更が通知される -
createRenderEffectのコールバックが再実行され、accessor()(=count())で新しい値を取得 -
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