Skip to content

仮想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つの木を比べ、実ノードに当てるパッチ関数を返す

順に見ていく。

  1. 宣言的UI = 差分: 「今あるべき木」を毎回宣言する。差分の算出はこちらの仕事ではない
  2. diff/patch: 木を再帰的に突き合わせる。種類が違えば差し替え、同じタグなら props と子を再帰。テキストは内容が変わっても作り直さず nodeValue をその場更新
  3. diff は純粋関数: 比較の段階では実DOMを一切触らず、パッチ関数を返すだけ。実DOMを変えるのはそれを当てる瞬間

前提: 実DOMは「重い」のか

よくある誤解を先に片付けておく。仮想DOMは「実DOMより速い」からあるのではない。手で書いた最小のDOM操作より速くなることはない(むしろ diff のぶん遅い)。仮想DOMの価値は速度でなく書きやすさにある。最小のDOM操作を人間が導く代わりに、宣言から機械的に導いてくれる。この点は章の最後にもう一度戻る。

実DOMのノード(document.createElement で作るもの)は、属性・子・イベント等を持つそれなりに重いオブジェクト。だから「作り直す」より「使い回して一部だけ変える」方が軽い。diff の目的はまさに作り直しを最小化することにある。

仮想DOMノードを組み立てる: h()

仮想DOMノードは、要素かテキストのどちらか。要素はタグ名・props・子を持つただのオブジェクト:

ts
// 仮想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が無いので、木をたどって丸ごと作る:

ts
// 仮想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 して appendChildon*(例 onClick)で始まる props はイベントリスナに、それ以外は属性にする。値が false/null の属性は付けない。これで disabled: cond のような条件付き属性がそのまま書ける。

差分を取る: diff() は「パッチ関数」を返す

ここが心臓部。diff(old, new) は実DOMを触らず、「実ノードを受け取って更新する関数(パッチ)」を返す。比較(何をどう変えるかの決定)と、適用(実際にDOMをいじる)を分けてあるので、順に追いやすい:

ts
// 仮想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
diff の4分岐。上から順に判定し、最初に当たった処理でパッチを決める。『同じタグの要素』のときだけ再帰的に props と子へ降りていく。それ以外は差し替え・その場更新・何もしない、のいずれかで確定する

分岐②が「使い回せないから作り直す」、分岐③④が「使い回して一部だけ直す」。同じタグかどうかが使い回しの境界になる。だから divspan に変えると中身が同じでも丸ごと作り直される。

props の差分

同じタグなら、消えた属性を外し、増えた/変わった属性を当てる。イベントは古いリスナを外してから新しいのを付ける:

ts
// 仮想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 が長ければ末尾から削除:

ts
// 仮想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 と件数テキストの更新だけ。宣言のまるごとさと、適用の最小さのギャップを見てほしい。

デモ仮想DOM(diff / patch)宣言 11ノード / 実DOM操作 11
実DOMプレビュー(宣言した木の描画結果)
Todos×2
  • 牛乳を買う
  • 本を返す
この更新で実際に走った最小DOM操作
追加初回 mount: 11 ノードを生成

同じ木を毎回まるごと宣言しても、diff が最小の実DOM操作だけを取り出す。ボタンで試す

宣言し直した木は毎回 11 ノード。だが diff が取り出す実DOM操作は上のぶんだけ「毎回まるごと宣言 → 差分だけ適用」が仮想DOMの働き。手で最小のDOM操作を書くより速くはならない

宣言的更新のループ

mount で初回描画し、状態が変わるたびに新しい木を作って diff を当てる。これを繰り返すのが宣言的UIの本体:

ts
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