仮想DOM(diff / patch)
「画面をどう変えるか」でなく「画面はどうあるべきか」を毎回まるごと宣言し、前回との差分だけを実 DOM に当てる。差分の算出はライブラリに任せるのが宣言的 UI で、その仕組みを作る。diff は 2 つの木を再帰的に突き合わせ、種類が違えば差し替え、同じタグなら属性(props)と子だけを再帰する。実 DOM を触るのはパッチを当てる瞬間だけで、diff 自体は純粋関数だ。
この章で作るもの
DOM を手で書き換えるコードは、すぐに破綻する。「カウントが増えたらこのテキストを、項目が消えたらあの <li> を…」と、状態のあらゆる変化に対応する DOM 操作を人間が漏れなく書くのは無理がある。
仮想DOM の発想はこれを裏返す。画面のあるべき姿を軽量なオブジェクトの木(仮想DOM)として毎回まるごと作り、前回の木と比較して、変わった所だけを実DOMに反映する。人間は「どうあるべきか」だけを宣言し、「どう変えるか(最小のDOM操作)」はライブラリが導く。
作るのは3つの関数だけ:
h(type, props, ...children)— 仮想DOMノードを組み立てる(JSX が変換される先)mount(vnode)— 仮想DOM木から実DOMを作る(初回描画)diff(oldTree, newTree)— 2つの木を比べ、実ノードに当てるパッチ関数を返す
順に見ていく。
- 宣言的UI = 差分: 「今あるべき木」を毎回宣言する。差分の算出はこちらの仕事ではない
- diff/patch: 木を再帰的に突き合わせる。種類が違えば差し替え、同じタグなら props と子を再帰。テキストは内容が変わっても作り直さず
nodeValueをその場更新 diffは純粋関数: 比較の段階では実DOMを一切触らず、パッチ関数を返すだけ。実DOMを変えるのはそれを当てる瞬間
前提: 実DOMは「重い」のか
よくある誤解を先に片付けておく。仮想DOMは「実DOMより速い」からあるのではない。手で書いた最小のDOM操作より速くなることはない(むしろ diff のぶん遅い)。仮想DOMの価値は速度でなく書きやすさにある。最小のDOM操作を人間が導く代わりに、宣言から機械的に導いてくれる。この点は章の最後にもう一度戻る。
実DOMのノード(document.createElement で作るもの)は、属性・子・イベント等を持つそれなりに重いオブジェクト。だから「作り直す」より「使い回して一部だけ変える」方が軽い。diff の目的はまさに作り直しを最小化することにある。
仮想DOMノードを組み立てる: h()
仮想DOMノードは、要素かテキストのどちらか。要素はタグ名・props・子を持つただのオブジェクト:
// 仮想DOM(Virtual DOM)を最小構成でフルスクラッチする。
//
// 肝は3つ:
// 1. 宣言的UI = 「あるべき木」を毎回まるごと作り、前回との差分だけを実DOMに当てる
// 2. diff/patch = 2つの木を再帰比較し、最小の DOM 操作を導く
// 3. diff は「パッチ関数」を返す純粋関数。実DOMを触るのは patch を当てる瞬間だけ
// #region vnode{ts}
export interface VElement {
kind: "element";
type: string; // タグ名 "div" など
props: Props;
children: VNode[];
}
export interface VText {
kind: "text";
text: string;
}
export type VNode = VElement | VText;
export type Props = Record<string, unknown>;
// h() が受け取れる子。false/null/undefined は「描かない」を表す(条件付きレンダリング)
type Child = VNode | string | number | boolean | null | undefined;
// h(type, props, ...children): 仮想DOMノードを組み立てる。
// JSX の <div id="a">hi</div> は h("div", {id:"a"}, "hi") に変換される、その h。
export function h(type: string, props: Props | null, ...children: Array<Child | Child[]>): VElement {
return {
kind: "element",
type,
props: props ?? {},
children: normalize(children),
};
}
// 子を1次元の VNode 配列に均す:
// - 配列は平坦化(items.map(...) をそのまま渡せる)
// - false/null/undefined は捨てる(show && h(...) が書ける)
// - 文字列・数値はテキストノードにする
function normalize(children: Array<Child | Child[]>): VNode[] {
const out: VNode[] = [];
for (const c of children.flat()) {
if (c === null || c === undefined || c === false || c === true) continue;
out.push(typeof c === "object" ? c : { kind: "text", text: String(c) });
}
return out;
}
// #endregion vnode{ts}
// #region mount{ts}
// mount: 仮想DOM木から実DOMノードを作る(初回描画)。
export function mount(vnode: VNode): Node {
if (vnode.kind === "text") {
return document.createTextNode(vnode.text);
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(mount(child)); // 子を再帰的に mount
}
return el;
}
// props を実DOMに反映する。onClick 等は addEventListener、それ以外は属性。
function setProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.addEventListener(eventName(key), value as EventListener);
} else if (value === false || value === null || value === undefined) {
el.removeAttribute(key);
} else {
el.setAttribute(key, String(value));
}
}
// on* を判定/変換するヘルパ。次章 mini-next のハイドレーションでも使うので公開する。
export const isEventProp = (key: string): boolean => key.startsWith("on") && key.length > 2;
export const eventName = (key: string): string => key.slice(2).toLowerCase(); // onClick → click
// #endregion mount{ts}
// #region diff{ts}
// patch は「実ノードを受け取り、更新後のノードを返す」関数。
// diff は木を比べてこの patch を組み立てる(この時点では実DOMを触らない)。
export type Patch = (node: Node) => Node | undefined;
const noop: Patch = (node) => node;
export function diff(oldV: VNode, newV: VNode | undefined): Patch {
// 1. 消えた: ノードを外す
if (newV === undefined) {
return (node) => {
node.parentNode?.removeChild(node);
return undefined;
};
}
// 2. 種類が違う(テキスト ⇔ 要素)/ タグが違う: 丸ごと差し替え
if (oldV.kind !== newV.kind || !sameType(oldV, newV)) {
return (node) => {
const created = mount(newV);
node.parentNode?.replaceChild(created, node);
return created;
};
}
// 3. どちらもテキスト: 内容が違えば nodeValue をその場更新(作り直さない)
if (oldV.kind === "text" && newV.kind === "text") {
if (oldV.text === newV.text) return noop;
return (node) => {
node.nodeValue = newV.text;
return node;
};
}
// 4. 同じタグの要素: props と子だけを差分更新する
// ここに来る時点で 2・3 により両方 element 確定(型を明示的に絞る)
if (oldV.kind === "element" && newV.kind === "element") {
const patchProps = diffProps(oldV.props, newV.props);
const patchChildren = diffChildren(oldV.children, newV.children);
return (node) => {
patchProps(node as HTMLElement);
patchChildren(node as HTMLElement);
return node;
};
}
return noop; // 到達しない(網羅性のための保険)
}
// 「同じ枠」として使い回せるか。要素はタグが同じなら使い回す(props/子は後で差分)。
// テキストは常に使い回す(内容が違っても nodeValue 更新で済む)。
function sameType(a: VNode, b: VNode): boolean {
if (a.kind === "text" && b.kind === "text") return true;
if (a.kind === "element" && b.kind === "element") return a.type === b.type;
return false;
}
// #endregion diff{ts}
// #region props{ts}
// props の差分: 消えた属性を外し、増えた/変わった属性を当てる。
function diffProps(oldProps: Props, newProps: Props): (el: HTMLElement) => void {
const patches: Array<(el: HTMLElement) => void> = [];
// 消えた or 変わった: 古いのを撤去(イベントは removeEventListener)
for (const [key, oldValue] of Object.entries(oldProps)) {
if (!(key in newProps) || newProps[key] !== oldValue) {
patches.push((el) => unsetProp(el, key, oldValue));
}
}
// 増えた or 変わった: 新しいのを適用
for (const [key, newValue] of Object.entries(newProps)) {
if (oldProps[key] !== newValue) {
patches.push((el) => setProp(el, key, newValue));
}
}
return (el) => patches.forEach((p) => p(el));
}
function unsetProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.removeEventListener(eventName(key), value as EventListener);
} else {
el.removeAttribute(key);
}
}
// #endregion props{ts}
// #region children{ts}
// 子の差分: 同じ位置(index)どうしを付き合わせる素朴な方式。
// - 共通の範囲は再帰 diff で更新(差し替えても index は動かない)
// - new の方が長い → 末尾に追加
// - old の方が長い → 末尾から削除(末尾から消せば index がずれない)
function diffChildren(oldCh: VNode[], newCh: VNode[]): (parent: HTMLElement) => void {
const common = Math.min(oldCh.length, newCh.length);
const pairPatches: Patch[] = [];
for (let i = 0; i < common; i++) {
// ↑ で common に絞っているので oldCh[i]/newCh[i] は必ず存在する(非null確定)
pairPatches.push(diff(oldCh[i] as VNode, newCh[i] as VNode));
}
const added = newCh.slice(common); // 追加ぶん
return (parent) => {
// 1. 共通範囲を更新(childNodes[i] は在る。差し替えは同じ位置に入る)
pairPatches.forEach((p, i) => {
const target = parent.childNodes[i];
if (target) p(target);
});
// 2. 余った古い子を末尾から削除
while (parent.childNodes.length > newCh.length) {
parent.removeChild(parent.lastChild as Node);
}
// 3. 足りない新しい子を末尾に追加
for (const child of added) {
parent.appendChild(mount(child));
}
};
}
// #endregion children{ts}h("div", {id:"a"}, "hello") が {kind:"element", type:"div", props:{id:"a"}, children:[{kind:"text", text:"hello"}]} になる。JSX の <div id="a">hello</div> はトランスパイラによってこの h(...) 呼び出しに変換される。JSX は h() の糖衣にすぎない。
normalize が地味に効く。子のうち false/null/undefined を捨てるので show && h("li", …) という条件付きレンダリングが書け、配列を平坦化するので items.map(...) の結果をそのまま子として渡せる。
仮想DOM → 実DOM: mount()
初回はまだ実DOMが無いので、木をたどって丸ごと作る:
// 仮想DOM(Virtual DOM)を最小構成でフルスクラッチする。
//
// 肝は3つ:
// 1. 宣言的UI = 「あるべき木」を毎回まるごと作り、前回との差分だけを実DOMに当てる
// 2. diff/patch = 2つの木を再帰比較し、最小の DOM 操作を導く
// 3. diff は「パッチ関数」を返す純粋関数。実DOMを触るのは patch を当てる瞬間だけ
// #region vnode{ts}
export interface VElement {
kind: "element";
type: string; // タグ名 "div" など
props: Props;
children: VNode[];
}
export interface VText {
kind: "text";
text: string;
}
export type VNode = VElement | VText;
export type Props = Record<string, unknown>;
// h() が受け取れる子。false/null/undefined は「描かない」を表す(条件付きレンダリング)
type Child = VNode | string | number | boolean | null | undefined;
// h(type, props, ...children): 仮想DOMノードを組み立てる。
// JSX の <div id="a">hi</div> は h("div", {id:"a"}, "hi") に変換される、その h。
export function h(type: string, props: Props | null, ...children: Array<Child | Child[]>): VElement {
return {
kind: "element",
type,
props: props ?? {},
children: normalize(children),
};
}
// 子を1次元の VNode 配列に均す:
// - 配列は平坦化(items.map(...) をそのまま渡せる)
// - false/null/undefined は捨てる(show && h(...) が書ける)
// - 文字列・数値はテキストノードにする
function normalize(children: Array<Child | Child[]>): VNode[] {
const out: VNode[] = [];
for (const c of children.flat()) {
if (c === null || c === undefined || c === false || c === true) continue;
out.push(typeof c === "object" ? c : { kind: "text", text: String(c) });
}
return out;
}
// #endregion vnode{ts}
// #region mount{ts}
// mount: 仮想DOM木から実DOMノードを作る(初回描画)。
export function mount(vnode: VNode): Node {
if (vnode.kind === "text") {
return document.createTextNode(vnode.text);
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(mount(child)); // 子を再帰的に mount
}
return el;
}
// props を実DOMに反映する。onClick 等は addEventListener、それ以外は属性。
function setProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.addEventListener(eventName(key), value as EventListener);
} else if (value === false || value === null || value === undefined) {
el.removeAttribute(key);
} else {
el.setAttribute(key, String(value));
}
}
// on* を判定/変換するヘルパ。次章 mini-next のハイドレーションでも使うので公開する。
export const isEventProp = (key: string): boolean => key.startsWith("on") && key.length > 2;
export const eventName = (key: string): string => key.slice(2).toLowerCase(); // onClick → click
// #endregion mount{ts}
// #region diff{ts}
// patch は「実ノードを受け取り、更新後のノードを返す」関数。
// diff は木を比べてこの patch を組み立てる(この時点では実DOMを触らない)。
export type Patch = (node: Node) => Node | undefined;
const noop: Patch = (node) => node;
export function diff(oldV: VNode, newV: VNode | undefined): Patch {
// 1. 消えた: ノードを外す
if (newV === undefined) {
return (node) => {
node.parentNode?.removeChild(node);
return undefined;
};
}
// 2. 種類が違う(テキスト ⇔ 要素)/ タグが違う: 丸ごと差し替え
if (oldV.kind !== newV.kind || !sameType(oldV, newV)) {
return (node) => {
const created = mount(newV);
node.parentNode?.replaceChild(created, node);
return created;
};
}
// 3. どちらもテキスト: 内容が違えば nodeValue をその場更新(作り直さない)
if (oldV.kind === "text" && newV.kind === "text") {
if (oldV.text === newV.text) return noop;
return (node) => {
node.nodeValue = newV.text;
return node;
};
}
// 4. 同じタグの要素: props と子だけを差分更新する
// ここに来る時点で 2・3 により両方 element 確定(型を明示的に絞る)
if (oldV.kind === "element" && newV.kind === "element") {
const patchProps = diffProps(oldV.props, newV.props);
const patchChildren = diffChildren(oldV.children, newV.children);
return (node) => {
patchProps(node as HTMLElement);
patchChildren(node as HTMLElement);
return node;
};
}
return noop; // 到達しない(網羅性のための保険)
}
// 「同じ枠」として使い回せるか。要素はタグが同じなら使い回す(props/子は後で差分)。
// テキストは常に使い回す(内容が違っても nodeValue 更新で済む)。
function sameType(a: VNode, b: VNode): boolean {
if (a.kind === "text" && b.kind === "text") return true;
if (a.kind === "element" && b.kind === "element") return a.type === b.type;
return false;
}
// #endregion diff{ts}
// #region props{ts}
// props の差分: 消えた属性を外し、増えた/変わった属性を当てる。
function diffProps(oldProps: Props, newProps: Props): (el: HTMLElement) => void {
const patches: Array<(el: HTMLElement) => void> = [];
// 消えた or 変わった: 古いのを撤去(イベントは removeEventListener)
for (const [key, oldValue] of Object.entries(oldProps)) {
if (!(key in newProps) || newProps[key] !== oldValue) {
patches.push((el) => unsetProp(el, key, oldValue));
}
}
// 増えた or 変わった: 新しいのを適用
for (const [key, newValue] of Object.entries(newProps)) {
if (oldProps[key] !== newValue) {
patches.push((el) => setProp(el, key, newValue));
}
}
return (el) => patches.forEach((p) => p(el));
}
function unsetProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.removeEventListener(eventName(key), value as EventListener);
} else {
el.removeAttribute(key);
}
}
// #endregion props{ts}
// #region children{ts}
// 子の差分: 同じ位置(index)どうしを付き合わせる素朴な方式。
// - 共通の範囲は再帰 diff で更新(差し替えても index は動かない)
// - new の方が長い → 末尾に追加
// - old の方が長い → 末尾から削除(末尾から消せば index がずれない)
function diffChildren(oldCh: VNode[], newCh: VNode[]): (parent: HTMLElement) => void {
const common = Math.min(oldCh.length, newCh.length);
const pairPatches: Patch[] = [];
for (let i = 0; i < common; i++) {
// ↑ で common に絞っているので oldCh[i]/newCh[i] は必ず存在する(非null確定)
pairPatches.push(diff(oldCh[i] as VNode, newCh[i] as VNode));
}
const added = newCh.slice(common); // 追加ぶん
return (parent) => {
// 1. 共通範囲を更新(childNodes[i] は在る。差し替えは同じ位置に入る)
pairPatches.forEach((p, i) => {
const target = parent.childNodes[i];
if (target) p(target);
});
// 2. 余った古い子を末尾から削除
while (parent.childNodes.length > newCh.length) {
parent.removeChild(parent.lastChild as Node);
}
// 3. 足りない新しい子を末尾に追加
for (const child of added) {
parent.appendChild(mount(child));
}
};
}
// #endregion children{ts}テキストなら createTextNode、要素なら createElement して props を当て、子を再帰的に mount して appendChild。on*(例 onClick)で始まる props はイベントリスナに、それ以外は属性にする。値が false/null の属性は付けない。これで disabled: cond のような条件付き属性がそのまま書ける。
差分を取る: diff() は「パッチ関数」を返す
ここが心臓部。diff(old, new) は実DOMを触らず、「実ノードを受け取って更新する関数(パッチ)」を返す。比較(何をどう変えるかの決定)と、適用(実際にDOMをいじる)を分けてあるので、順に追いやすい:
// 仮想DOM(Virtual DOM)を最小構成でフルスクラッチする。
//
// 肝は3つ:
// 1. 宣言的UI = 「あるべき木」を毎回まるごと作り、前回との差分だけを実DOMに当てる
// 2. diff/patch = 2つの木を再帰比較し、最小の DOM 操作を導く
// 3. diff は「パッチ関数」を返す純粋関数。実DOMを触るのは patch を当てる瞬間だけ
// #region vnode{ts}
export interface VElement {
kind: "element";
type: string; // タグ名 "div" など
props: Props;
children: VNode[];
}
export interface VText {
kind: "text";
text: string;
}
export type VNode = VElement | VText;
export type Props = Record<string, unknown>;
// h() が受け取れる子。false/null/undefined は「描かない」を表す(条件付きレンダリング)
type Child = VNode | string | number | boolean | null | undefined;
// h(type, props, ...children): 仮想DOMノードを組み立てる。
// JSX の <div id="a">hi</div> は h("div", {id:"a"}, "hi") に変換される、その h。
export function h(type: string, props: Props | null, ...children: Array<Child | Child[]>): VElement {
return {
kind: "element",
type,
props: props ?? {},
children: normalize(children),
};
}
// 子を1次元の VNode 配列に均す:
// - 配列は平坦化(items.map(...) をそのまま渡せる)
// - false/null/undefined は捨てる(show && h(...) が書ける)
// - 文字列・数値はテキストノードにする
function normalize(children: Array<Child | Child[]>): VNode[] {
const out: VNode[] = [];
for (const c of children.flat()) {
if (c === null || c === undefined || c === false || c === true) continue;
out.push(typeof c === "object" ? c : { kind: "text", text: String(c) });
}
return out;
}
// #endregion vnode{ts}
// #region mount{ts}
// mount: 仮想DOM木から実DOMノードを作る(初回描画)。
export function mount(vnode: VNode): Node {
if (vnode.kind === "text") {
return document.createTextNode(vnode.text);
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(mount(child)); // 子を再帰的に mount
}
return el;
}
// props を実DOMに反映する。onClick 等は addEventListener、それ以外は属性。
function setProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.addEventListener(eventName(key), value as EventListener);
} else if (value === false || value === null || value === undefined) {
el.removeAttribute(key);
} else {
el.setAttribute(key, String(value));
}
}
// on* を判定/変換するヘルパ。次章 mini-next のハイドレーションでも使うので公開する。
export const isEventProp = (key: string): boolean => key.startsWith("on") && key.length > 2;
export const eventName = (key: string): string => key.slice(2).toLowerCase(); // onClick → click
// #endregion mount{ts}
// #region diff{ts}
// patch は「実ノードを受け取り、更新後のノードを返す」関数。
// diff は木を比べてこの patch を組み立てる(この時点では実DOMを触らない)。
export type Patch = (node: Node) => Node | undefined;
const noop: Patch = (node) => node;
export function diff(oldV: VNode, newV: VNode | undefined): Patch {
// 1. 消えた: ノードを外す
if (newV === undefined) {
return (node) => {
node.parentNode?.removeChild(node);
return undefined;
};
}
// 2. 種類が違う(テキスト ⇔ 要素)/ タグが違う: 丸ごと差し替え
if (oldV.kind !== newV.kind || !sameType(oldV, newV)) {
return (node) => {
const created = mount(newV);
node.parentNode?.replaceChild(created, node);
return created;
};
}
// 3. どちらもテキスト: 内容が違えば nodeValue をその場更新(作り直さない)
if (oldV.kind === "text" && newV.kind === "text") {
if (oldV.text === newV.text) return noop;
return (node) => {
node.nodeValue = newV.text;
return node;
};
}
// 4. 同じタグの要素: props と子だけを差分更新する
// ここに来る時点で 2・3 により両方 element 確定(型を明示的に絞る)
if (oldV.kind === "element" && newV.kind === "element") {
const patchProps = diffProps(oldV.props, newV.props);
const patchChildren = diffChildren(oldV.children, newV.children);
return (node) => {
patchProps(node as HTMLElement);
patchChildren(node as HTMLElement);
return node;
};
}
return noop; // 到達しない(網羅性のための保険)
}
// 「同じ枠」として使い回せるか。要素はタグが同じなら使い回す(props/子は後で差分)。
// テキストは常に使い回す(内容が違っても nodeValue 更新で済む)。
function sameType(a: VNode, b: VNode): boolean {
if (a.kind === "text" && b.kind === "text") return true;
if (a.kind === "element" && b.kind === "element") return a.type === b.type;
return false;
}
// #endregion diff{ts}
// #region props{ts}
// props の差分: 消えた属性を外し、増えた/変わった属性を当てる。
function diffProps(oldProps: Props, newProps: Props): (el: HTMLElement) => void {
const patches: Array<(el: HTMLElement) => void> = [];
// 消えた or 変わった: 古いのを撤去(イベントは removeEventListener)
for (const [key, oldValue] of Object.entries(oldProps)) {
if (!(key in newProps) || newProps[key] !== oldValue) {
patches.push((el) => unsetProp(el, key, oldValue));
}
}
// 増えた or 変わった: 新しいのを適用
for (const [key, newValue] of Object.entries(newProps)) {
if (oldProps[key] !== newValue) {
patches.push((el) => setProp(el, key, newValue));
}
}
return (el) => patches.forEach((p) => p(el));
}
function unsetProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.removeEventListener(eventName(key), value as EventListener);
} else {
el.removeAttribute(key);
}
}
// #endregion props{ts}
// #region children{ts}
// 子の差分: 同じ位置(index)どうしを付き合わせる素朴な方式。
// - 共通の範囲は再帰 diff で更新(差し替えても index は動かない)
// - new の方が長い → 末尾に追加
// - old の方が長い → 末尾から削除(末尾から消せば index がずれない)
function diffChildren(oldCh: VNode[], newCh: VNode[]): (parent: HTMLElement) => void {
const common = Math.min(oldCh.length, newCh.length);
const pairPatches: Patch[] = [];
for (let i = 0; i < common; i++) {
// ↑ で common に絞っているので oldCh[i]/newCh[i] は必ず存在する(非null確定)
pairPatches.push(diff(oldCh[i] as VNode, newCh[i] as VNode));
}
const added = newCh.slice(common); // 追加ぶん
return (parent) => {
// 1. 共通範囲を更新(childNodes[i] は在る。差し替えは同じ位置に入る)
pairPatches.forEach((p, i) => {
const target = parent.childNodes[i];
if (target) p(target);
});
// 2. 余った古い子を末尾から削除
while (parent.childNodes.length > newCh.length) {
parent.removeChild(parent.lastChild as Node);
}
// 3. 足りない新しい子を末尾に追加
for (const child of added) {
parent.appendChild(mount(child));
}
};
}
// #endregion children{ts}判定は上から順に4通り:
old, new を比べる
│
├─ new が無い ──────────────▶ ① ノードを外す(removeChild)
│
├─ 種類/タグが違う ─────────▶ ② 丸ごと差し替え(replaceChild + mount)
│ (div→span, text→element)
│
├─ どちらもテキスト ────────▶ ③ 違えば nodeValue をその場更新
│ (テキストノードは作り直さない)
│
└─ 同じタグの要素 ──────────▶ ④ props と子だけを再帰的に差分
diffProps + diffChildren分岐②が「使い回せないから作り直す」、分岐③④が「使い回して一部だけ直す」。同じタグかどうかが使い回しの境界になる。だから div を span に変えると中身が同じでも丸ごと作り直される。
props の差分
同じタグなら、消えた属性を外し、増えた/変わった属性を当てる。イベントは古いリスナを外してから新しいのを付ける:
// 仮想DOM(Virtual DOM)を最小構成でフルスクラッチする。
//
// 肝は3つ:
// 1. 宣言的UI = 「あるべき木」を毎回まるごと作り、前回との差分だけを実DOMに当てる
// 2. diff/patch = 2つの木を再帰比較し、最小の DOM 操作を導く
// 3. diff は「パッチ関数」を返す純粋関数。実DOMを触るのは patch を当てる瞬間だけ
// #region vnode{ts}
export interface VElement {
kind: "element";
type: string; // タグ名 "div" など
props: Props;
children: VNode[];
}
export interface VText {
kind: "text";
text: string;
}
export type VNode = VElement | VText;
export type Props = Record<string, unknown>;
// h() が受け取れる子。false/null/undefined は「描かない」を表す(条件付きレンダリング)
type Child = VNode | string | number | boolean | null | undefined;
// h(type, props, ...children): 仮想DOMノードを組み立てる。
// JSX の <div id="a">hi</div> は h("div", {id:"a"}, "hi") に変換される、その h。
export function h(type: string, props: Props | null, ...children: Array<Child | Child[]>): VElement {
return {
kind: "element",
type,
props: props ?? {},
children: normalize(children),
};
}
// 子を1次元の VNode 配列に均す:
// - 配列は平坦化(items.map(...) をそのまま渡せる)
// - false/null/undefined は捨てる(show && h(...) が書ける)
// - 文字列・数値はテキストノードにする
function normalize(children: Array<Child | Child[]>): VNode[] {
const out: VNode[] = [];
for (const c of children.flat()) {
if (c === null || c === undefined || c === false || c === true) continue;
out.push(typeof c === "object" ? c : { kind: "text", text: String(c) });
}
return out;
}
// #endregion vnode{ts}
// #region mount{ts}
// mount: 仮想DOM木から実DOMノードを作る(初回描画)。
export function mount(vnode: VNode): Node {
if (vnode.kind === "text") {
return document.createTextNode(vnode.text);
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(mount(child)); // 子を再帰的に mount
}
return el;
}
// props を実DOMに反映する。onClick 等は addEventListener、それ以外は属性。
function setProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.addEventListener(eventName(key), value as EventListener);
} else if (value === false || value === null || value === undefined) {
el.removeAttribute(key);
} else {
el.setAttribute(key, String(value));
}
}
// on* を判定/変換するヘルパ。次章 mini-next のハイドレーションでも使うので公開する。
export const isEventProp = (key: string): boolean => key.startsWith("on") && key.length > 2;
export const eventName = (key: string): string => key.slice(2).toLowerCase(); // onClick → click
// #endregion mount{ts}
// #region diff{ts}
// patch は「実ノードを受け取り、更新後のノードを返す」関数。
// diff は木を比べてこの patch を組み立てる(この時点では実DOMを触らない)。
export type Patch = (node: Node) => Node | undefined;
const noop: Patch = (node) => node;
export function diff(oldV: VNode, newV: VNode | undefined): Patch {
// 1. 消えた: ノードを外す
if (newV === undefined) {
return (node) => {
node.parentNode?.removeChild(node);
return undefined;
};
}
// 2. 種類が違う(テキスト ⇔ 要素)/ タグが違う: 丸ごと差し替え
if (oldV.kind !== newV.kind || !sameType(oldV, newV)) {
return (node) => {
const created = mount(newV);
node.parentNode?.replaceChild(created, node);
return created;
};
}
// 3. どちらもテキスト: 内容が違えば nodeValue をその場更新(作り直さない)
if (oldV.kind === "text" && newV.kind === "text") {
if (oldV.text === newV.text) return noop;
return (node) => {
node.nodeValue = newV.text;
return node;
};
}
// 4. 同じタグの要素: props と子だけを差分更新する
// ここに来る時点で 2・3 により両方 element 確定(型を明示的に絞る)
if (oldV.kind === "element" && newV.kind === "element") {
const patchProps = diffProps(oldV.props, newV.props);
const patchChildren = diffChildren(oldV.children, newV.children);
return (node) => {
patchProps(node as HTMLElement);
patchChildren(node as HTMLElement);
return node;
};
}
return noop; // 到達しない(網羅性のための保険)
}
// 「同じ枠」として使い回せるか。要素はタグが同じなら使い回す(props/子は後で差分)。
// テキストは常に使い回す(内容が違っても nodeValue 更新で済む)。
function sameType(a: VNode, b: VNode): boolean {
if (a.kind === "text" && b.kind === "text") return true;
if (a.kind === "element" && b.kind === "element") return a.type === b.type;
return false;
}
// #endregion diff{ts}
// #region props{ts}
// props の差分: 消えた属性を外し、増えた/変わった属性を当てる。
function diffProps(oldProps: Props, newProps: Props): (el: HTMLElement) => void {
const patches: Array<(el: HTMLElement) => void> = [];
// 消えた or 変わった: 古いのを撤去(イベントは removeEventListener)
for (const [key, oldValue] of Object.entries(oldProps)) {
if (!(key in newProps) || newProps[key] !== oldValue) {
patches.push((el) => unsetProp(el, key, oldValue));
}
}
// 増えた or 変わった: 新しいのを適用
for (const [key, newValue] of Object.entries(newProps)) {
if (oldProps[key] !== newValue) {
patches.push((el) => setProp(el, key, newValue));
}
}
return (el) => patches.forEach((p) => p(el));
}
function unsetProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.removeEventListener(eventName(key), value as EventListener);
} else {
el.removeAttribute(key);
}
}
// #endregion props{ts}
// #region children{ts}
// 子の差分: 同じ位置(index)どうしを付き合わせる素朴な方式。
// - 共通の範囲は再帰 diff で更新(差し替えても index は動かない)
// - new の方が長い → 末尾に追加
// - old の方が長い → 末尾から削除(末尾から消せば index がずれない)
function diffChildren(oldCh: VNode[], newCh: VNode[]): (parent: HTMLElement) => void {
const common = Math.min(oldCh.length, newCh.length);
const pairPatches: Patch[] = [];
for (let i = 0; i < common; i++) {
// ↑ で common に絞っているので oldCh[i]/newCh[i] は必ず存在する(非null確定)
pairPatches.push(diff(oldCh[i] as VNode, newCh[i] as VNode));
}
const added = newCh.slice(common); // 追加ぶん
return (parent) => {
// 1. 共通範囲を更新(childNodes[i] は在る。差し替えは同じ位置に入る)
pairPatches.forEach((p, i) => {
const target = parent.childNodes[i];
if (target) p(target);
});
// 2. 余った古い子を末尾から削除
while (parent.childNodes.length > newCh.length) {
parent.removeChild(parent.lastChild as Node);
}
// 3. 足りない新しい子を末尾に追加
for (const child of added) {
parent.appendChild(mount(child));
}
};
}
// #endregion children{ts}子の差分: 素朴な index 対応
子どうしは**同じ位置(index)**で突き合わせる。共通範囲は再帰 diff、new が長ければ末尾に追加、old が長ければ末尾から削除:
// 仮想DOM(Virtual DOM)を最小構成でフルスクラッチする。
//
// 肝は3つ:
// 1. 宣言的UI = 「あるべき木」を毎回まるごと作り、前回との差分だけを実DOMに当てる
// 2. diff/patch = 2つの木を再帰比較し、最小の DOM 操作を導く
// 3. diff は「パッチ関数」を返す純粋関数。実DOMを触るのは patch を当てる瞬間だけ
// #region vnode{ts}
export interface VElement {
kind: "element";
type: string; // タグ名 "div" など
props: Props;
children: VNode[];
}
export interface VText {
kind: "text";
text: string;
}
export type VNode = VElement | VText;
export type Props = Record<string, unknown>;
// h() が受け取れる子。false/null/undefined は「描かない」を表す(条件付きレンダリング)
type Child = VNode | string | number | boolean | null | undefined;
// h(type, props, ...children): 仮想DOMノードを組み立てる。
// JSX の <div id="a">hi</div> は h("div", {id:"a"}, "hi") に変換される、その h。
export function h(type: string, props: Props | null, ...children: Array<Child | Child[]>): VElement {
return {
kind: "element",
type,
props: props ?? {},
children: normalize(children),
};
}
// 子を1次元の VNode 配列に均す:
// - 配列は平坦化(items.map(...) をそのまま渡せる)
// - false/null/undefined は捨てる(show && h(...) が書ける)
// - 文字列・数値はテキストノードにする
function normalize(children: Array<Child | Child[]>): VNode[] {
const out: VNode[] = [];
for (const c of children.flat()) {
if (c === null || c === undefined || c === false || c === true) continue;
out.push(typeof c === "object" ? c : { kind: "text", text: String(c) });
}
return out;
}
// #endregion vnode{ts}
// #region mount{ts}
// mount: 仮想DOM木から実DOMノードを作る(初回描画)。
export function mount(vnode: VNode): Node {
if (vnode.kind === "text") {
return document.createTextNode(vnode.text);
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(mount(child)); // 子を再帰的に mount
}
return el;
}
// props を実DOMに反映する。onClick 等は addEventListener、それ以外は属性。
function setProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.addEventListener(eventName(key), value as EventListener);
} else if (value === false || value === null || value === undefined) {
el.removeAttribute(key);
} else {
el.setAttribute(key, String(value));
}
}
// on* を判定/変換するヘルパ。次章 mini-next のハイドレーションでも使うので公開する。
export const isEventProp = (key: string): boolean => key.startsWith("on") && key.length > 2;
export const eventName = (key: string): string => key.slice(2).toLowerCase(); // onClick → click
// #endregion mount{ts}
// #region diff{ts}
// patch は「実ノードを受け取り、更新後のノードを返す」関数。
// diff は木を比べてこの patch を組み立てる(この時点では実DOMを触らない)。
export type Patch = (node: Node) => Node | undefined;
const noop: Patch = (node) => node;
export function diff(oldV: VNode, newV: VNode | undefined): Patch {
// 1. 消えた: ノードを外す
if (newV === undefined) {
return (node) => {
node.parentNode?.removeChild(node);
return undefined;
};
}
// 2. 種類が違う(テキスト ⇔ 要素)/ タグが違う: 丸ごと差し替え
if (oldV.kind !== newV.kind || !sameType(oldV, newV)) {
return (node) => {
const created = mount(newV);
node.parentNode?.replaceChild(created, node);
return created;
};
}
// 3. どちらもテキスト: 内容が違えば nodeValue をその場更新(作り直さない)
if (oldV.kind === "text" && newV.kind === "text") {
if (oldV.text === newV.text) return noop;
return (node) => {
node.nodeValue = newV.text;
return node;
};
}
// 4. 同じタグの要素: props と子だけを差分更新する
// ここに来る時点で 2・3 により両方 element 確定(型を明示的に絞る)
if (oldV.kind === "element" && newV.kind === "element") {
const patchProps = diffProps(oldV.props, newV.props);
const patchChildren = diffChildren(oldV.children, newV.children);
return (node) => {
patchProps(node as HTMLElement);
patchChildren(node as HTMLElement);
return node;
};
}
return noop; // 到達しない(網羅性のための保険)
}
// 「同じ枠」として使い回せるか。要素はタグが同じなら使い回す(props/子は後で差分)。
// テキストは常に使い回す(内容が違っても nodeValue 更新で済む)。
function sameType(a: VNode, b: VNode): boolean {
if (a.kind === "text" && b.kind === "text") return true;
if (a.kind === "element" && b.kind === "element") return a.type === b.type;
return false;
}
// #endregion diff{ts}
// #region props{ts}
// props の差分: 消えた属性を外し、増えた/変わった属性を当てる。
function diffProps(oldProps: Props, newProps: Props): (el: HTMLElement) => void {
const patches: Array<(el: HTMLElement) => void> = [];
// 消えた or 変わった: 古いのを撤去(イベントは removeEventListener)
for (const [key, oldValue] of Object.entries(oldProps)) {
if (!(key in newProps) || newProps[key] !== oldValue) {
patches.push((el) => unsetProp(el, key, oldValue));
}
}
// 増えた or 変わった: 新しいのを適用
for (const [key, newValue] of Object.entries(newProps)) {
if (oldProps[key] !== newValue) {
patches.push((el) => setProp(el, key, newValue));
}
}
return (el) => patches.forEach((p) => p(el));
}
function unsetProp(el: HTMLElement, key: string, value: unknown): void {
if (isEventProp(key)) {
el.removeEventListener(eventName(key), value as EventListener);
} else {
el.removeAttribute(key);
}
}
// #endregion props{ts}
// #region children{ts}
// 子の差分: 同じ位置(index)どうしを付き合わせる素朴な方式。
// - 共通の範囲は再帰 diff で更新(差し替えても index は動かない)
// - new の方が長い → 末尾に追加
// - old の方が長い → 末尾から削除(末尾から消せば index がずれない)
function diffChildren(oldCh: VNode[], newCh: VNode[]): (parent: HTMLElement) => void {
const common = Math.min(oldCh.length, newCh.length);
const pairPatches: Patch[] = [];
for (let i = 0; i < common; i++) {
// ↑ で common に絞っているので oldCh[i]/newCh[i] は必ず存在する(非null確定)
pairPatches.push(diff(oldCh[i] as VNode, newCh[i] as VNode));
}
const added = newCh.slice(common); // 追加ぶん
return (parent) => {
// 1. 共通範囲を更新(childNodes[i] は在る。差し替えは同じ位置に入る)
pairPatches.forEach((p, i) => {
const target = parent.childNodes[i];
if (target) p(target);
});
// 2. 余った古い子を末尾から削除
while (parent.childNodes.length > newCh.length) {
parent.removeChild(parent.lastChild as Node);
}
// 3. 足りない新しい子を末尾に追加
for (const child of added) {
parent.appendChild(mount(child));
}
};
}
// #endregion children{ts}末尾から削除するのがポイント。先頭から消すと以降の index がずれて、パッチが別のノードを指してしまう。この素朴な index 対応の弱点は後述する(key の話)。
動かす
下のデモは、build(state) で「あるべき木」を毎回まるごと作り直し、前回の木と diff した結果の最小DOM操作を右に出す。「カウント+1」を押すと宣言し直すノードは全部(数個)なのに、実DOMで走る操作はテキスト1つの更新だけ。「項目を追加」なら <li> の append と件数テキストの更新だけ。宣言のまるごとさと、適用の最小さのギャップを見てほしい。
- 牛乳を買う
- 本を返す
同じ木を毎回まるごと宣言しても、diff が最小の実DOM操作だけを取り出す。ボタンで試す
宣言的更新のループ
mount で初回描画し、状態が変わるたびに新しい木を作って diff を当てる。これを繰り返すのが宣言的UIの本体:
let tree = h("div", null, h("span", null, String(count)));
let node = mount(tree);
container.appendChild(node);
// 状態が変わるたびに:
function update() {
const next = h("div", null, h("span", null, String(++count)));
diff(tree, next)(node); // 差分だけが実DOMに当たる
tree = next;
}呼ぶ側は「今の状態から木を作る」ことだけ考えればよく、どのDOMをどう変えるかは一切書かない。この「状態 → 木 → 差分適用」のループの上に、コンポーネントやフックが乗る(次章以降)。
設計の観点: 仮想DOMは何を速くするのか
「仮想DOMって実DOMより速いんですよね?」に正しく答えられるかが試される:
- 速さの正体: 仮想DOMは実DOM操作を速くしない。手で書いた最適な最小DOM操作が理論上いつでも最速で、diff はそのオーバーヘッドぶんだけ遅い。仮想DOMが最適化するのは開発者の手数で、最小操作を人間が導く必要がなくなる
- いつ diff が損か: 巨大リストの全置換など「ほぼ全部変わる」場面では diff のコストが丸損。仮想化(可視分だけ描く)や、そもそも DOM を直接叩く方が速いことがある
- key の重要性: この章の index 対応は、リストの先頭挿入を「以降全部が別物」と誤認して大量に作り直す。実装は各子に
keyを振り、同じ key の実ノードを位置に関わらず使い回す(keyed reconciliation)。リストを描くとき key を付けろ、の理由がこれ - バッチと非同期: 実務のフレームワークは複数の状態変更を1回の再描画にまとめ(バッチング)、描画をマイクロタスク/次フレームに遅延させる。この章は同期・即時で、そこは踏み込まない
メリット・デメリットと実例
| 方式 | 更新の考え方 | 強み | 弱み | 実例 |
|---|---|---|---|---|
| 手続き的DOM操作 | 変化ごとに DOM を直接いじる | 最小操作を書けば最速 | 変化の組合せが増えると破綻 | jQuery 時代、素の DOM API |
| 仮想DOM + diff | 毎回まるごと宣言→差分適用 | 宣言的で書きやすい | diff のオーバーヘッド | React、Preact、(初期の)Vue |
| コンパイル時解析 | 何が変わりうるかを事前に静的解析 | 実行時 diff がほぼ不要で速い | ビルド前提・表現の制約 | Svelte、Solid(細粒度リアクティブ) |
裏どり:
- React: 仮想DOM + reconciliation の代表。
keyによるリスト差分、Fiber による中断可能な描画、バッチングを持つ。この章はその最小核だけ - Preact: React 互換で 3KB 級。この章と同じ「index 対応 + key」の素直な diff に近く、実装が読みやすい
- Vue: 仮想DOM を使うが、テンプレートをコンパイルして「変わりうる部分」にヒントを付け、diff を軽くする(patch flags)
- Svelte / Solid: そもそも仮想DOM を使わない方向。Svelte はコンパイル時に更新コードを生成、Solid は細粒度リアクティブで変わる箇所だけを直接更新する。仮想DOM は「唯一の正解」ではない
簡略化したこと
- key 付き差分なし: リストの並べ替え・途中挿入を最小手数で扱う keyed reconciliation は未実装。index 対応なので先頭挿入で余分に作り直す(設計の観点で触れた通り)
- コンポーネント・状態管理なし: 関数コンポーネント/フック/再描画スケジューリングは無し。ここは描画エンジン(vdom)だけ。状態→再描画のループは次章以降
- バッチング・非同期描画なし:
diff(...)()は同期・即時。実務の「複数変更をまとめて1回」は無し - 属性の特別扱いは最小: プロパティ vs 属性(
value/checked等)、styleオブジェクト、SVG 名前空間などは扱わない。on*イベントと通常属性のみ - フラグメント・ポータルなし
参考資料
- React: Reconciliation と key の意味
- Preact のソース(
src/diff/)。読める規模の実プロダクト実装 - Rodrigo Pombo, "Build your own React"(didact)。mount/diff を段階的に作る定番
- Svelte / Solid のドキュメント。「仮想DOMを使わない」側の視点
- 実装: frontend/vdom