0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Day 5: 仮想DOMなしでリアクティビティを実現する方法

Last updated at Posted at 2025-12-04

この記事は「構造パスで作るシンプルなフロントエンドフレームワーク(Structive)」Advent Calendarの5日目です。
Structiveについて詳しくはこちらより

前回のおさらい

4日目では、構造パスがボイラープレートを排除する仕組みを学びました。その中で「Proxyによる自動検知」という言葉が出てきましたが、今日はその技術的な詳細を掘り下げます。

仮想DOMとは?

まず、多くの現代的なフレームワークが採用している「仮想DOM」について理解しましょう。

仮想DOMの仕組み

// Reactの例
function Component() {
  const [count, setCount] = useState(0);
  
  return <div>Count: {count}</div>;
}

このコードが実行されるとき、以下のプロセスが発生します:

  1. 仮想DOMの構築: JSXから仮想的なDOMツリーを作成
{
  type: 'div',
  props: {},
  children: ['Count: ', 0]
}
  1. 差分検出: 前回の仮想DOMと比較
// 前回: children: ['Count: ', 0]
// 今回: children: ['Count: ', 1]
// 差分: 2番目の子要素が変更
  1. 実DOMの更新: 差分だけを実DOMに反映

仮想DOMの問題点

この仕組みは効率的ですが、いくつかの課題があります:

1. 計算コスト

  • 毎回仮想DOMツリー全体を構築
  • 差分計算のためのツリー走査
  • 大規模なアプリでは処理が重い

2. メモリオーバーヘッド

  • 仮想DOMツリーをメモリに保持
  • 前回と今回の2つのツリーが必要

3. 複雑性

  • 内部の仕組みが複雑で理解しにくい
  • 最適化のための追加知識が必要(React.memo、useMemoなど)

構造パスの異なるアプローチ

構造パスを使うと、仮想DOMなしでリアクティビティを実現できます。

核心的なアイデア

UIと状態は構造パスで1対1に対応している

<div>Count: {{ count }}</div>

このUIは、状態のcountという単一のプロパティだけに依存します。

つまり:

  • countが変更されたら、このUIだけを更新すればいい
  • 他のどのUIも更新する必要がない
  • 差分計算も不要

Proxyによるリアクティビティ

JavaScriptのProxyを使うと、オブジェクトの変更を自動的に検知できます。

Proxyの基本

const state = {
  count: 0
};

const proxy = new Proxy(state, {
  set(target, property, value) {
    console.log(`${property}${value} に変更されました`);
    target[property] = value;
    return true;
  }
});

proxy.count = 5;
// コンソール: "count が 5 に変更されました"

Proxyのsetトラップを使うと、プロパティへの代入を検知できます。

構造パスとProxyの組み合わせ

構造パスを使うと、ネストしたプロパティの変更も検知できます:

class ReactiveState {
  constructor(data) {
    return new Proxy(data, {
      set: (target, property, value) => {
        target[property] = value;
        
        // 構造パスを使って更新を通知
        this.notify(property);
        return true;
      }
    });
  }
  
  notify(path) {
    // このパスに依存するUIを更新
    console.log(`構造パス "${path}" が更新されました`);
  }
}

const state = new ReactiveState({
  count: 0,
  user: { name: "Alice" }
});

state.count = 5;
// "構造パス "count" が更新されました"

state.user = { name: "Bob" };
// "構造パス "user" が更新されました"

ネストしたプロパティの検知

構造パスの真価は、深くネストしたプロパティでも検知できることです:

export default class {
  product = {
    name: "Laptop",
    price: 999
  };
  
  changeName(newName) {
    // 構造パス記法で直接変更
    this["product.name"] = newName;
    // フレームワークは "product.name" の変更を検知
  }
}

this["product.name"] = newNameと書くことで:

  1. Proxyが"product.name"という文字列キーでの代入を検知
  2. フレームワークは構造パス"product.name"に依存するUIを特定
  3. そのUIだけをピンポイントで更新

UIとの紐付け

では、フレームワークはどうやって「どのUIがどの構造パスに依存しているか」を知るのでしょうか?

依存関係の登録

テンプレートをパースする際、依存関係を記録します:

<div>{{ product.name }}</div>
<p>{{ product.price }}</p>
<span>{{ count }}</span>

フレームワークはこれを以下のように記録:

const dependencies = {
  "product.name": [div要素のテキストノード],
  "product.price": [p要素のテキストノード],
  "count": [span要素のテキストノード]
};

ピンポイント更新

状態が変更されたとき:

this["product.name"] = "Gaming Laptop";
  1. Proxyが"product.name"の変更を検知
  2. dependencies["product.name"]から依存するUIを取得
  3. そのUIだけを更新
// 擬似コード
notify(path) {
  const uiElements = this.dependencies[path];
  for (const element of uiElements) {
    element.textContent = this[path];
  }
}

仮想DOMは不要です。 変更されたプロパティに対応するDOM要素を直接更新します。

配列の変更検知

配列の場合はどうでしょうか?

配列メソッドの問題

this.items.push(newItem);  // 検知できない!

pushは配列の内部を変更しますが、配列そのものへの代入ではないため、Proxyのsetトラップが発火しません。

解決策:配列全体の置き換え

構造パスでは、配列を変更する際は新しい配列を代入します:

// ❌ 検知できない
this.items.push(newItem);

// ✅ 検知できる
this.items = [...this.items, newItem];

// または
this.items = this.items.concat(newItem);

これにより:

  • Proxyのsetトラップが発火
  • "items"という構造パスの変更として検知
  • 依存するUIが更新される

配列メソッドの代替

ES2023のtoSplicedなどを使うと、より直感的です:

// 削除
this.items = this.items.toSpliced(index, 1);

// 追加
this.items = this.items.toSpliced(index, 0, newItem);

// 置換
this.items = this.items.toSpliced(index, 1, newItem);

パフォーマンスの比較

仮想DOMと構造パスのパフォーマンスを比較してみましょう。

仮想DOMの場合

状態変更
  ↓
コンポーネント全体を再レンダリング
  ↓
新しい仮想DOMツリーを構築(100ノード)
  ↓
前回の仮想DOMと比較(100ノード走査)
  ↓
差分を検出(1ノードだけ変更)
  ↓
実DOMを更新(1ノードだけ)

1つのプロパティ変更で、100ノード以上の処理が発生

構造パスの場合

状態変更(this["product.name"] = "...")
  ↓
Proxyが "product.name" の変更を検知
  ↓
dependencies["product.name"] からUIを取得
  ↓
実DOMを直接更新(1ノードだけ)

変更されたプロパティに対応するノードだけを更新

ベンチマーク(概念的な比較)

操作 仮想DOM 構造パス
1プロパティ変更 全ツリー走査 1ノード更新
10プロパティ変更 全ツリー走査×10 10ノード更新
100ノードのリスト 差分計算が重い ピンポイント更新

実装例

実際のコードでどう動くか見てみましょう。

状態クラス

export default class {
  count = 0;
  user = {
    name: "Alice",
    email: "alice@example.com"
  };
  items = [
    { id: 1, name: "Item 1" },
    { id: 2, name: "Item 2" }
  ];
  
  increment() {
    // Proxyが検知 → {{ count }} を更新
    this.count += 1;
  }
  
  updateUserName(newName) {
    // Proxyが検知 → {{ user.name }} を更新
    this["user.name"] = newName;
  }
  
  addItem(newItem) {
    // Proxyが検知 → {{ for:items }} を更新
    this.items = this.items.concat(newItem);
  }
}

テンプレート

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>User: {{ user.name }} ({{ user.email }})</p>
    <ul>
      {{ for:items }}
        <li>{{ items.*.name }}</li>
      {{ endfor: }}
    </ul>
  </div>
</template>

何が起きるか

  1. increment()を呼ぶ

    • this.count += 1が実行される
    • Proxyが"count"の変更を検知
    • {{ count }}のテキストノードだけが更新される
    • {{ user.name }}{{ items.*.name }}は無視される
  2. updateUserName("Bob")を呼ぶ

    • this["user.name"] = "Bob"が実行される
    • Proxyが"user.name"の変更を検知
    • {{ user.name }}のテキストノードだけが更新される

必要最小限の更新だけが行われます。

まとめ

今日は、仮想DOMなしでリアクティビティを実現する仕組みを学びました:

核心的なメカニズム:

  • Proxyで状態の変更を自動検知
  • 構造パスで変更箇所を特定
  • UIと状態の1対1対応
  • 依存関係に基づくピンポイント更新

仮想DOMとの違い:

  • ツリー全体の再構築が不要
  • 差分計算が不要
  • メモリオーバーヘッドが少ない
  • シンプルで理解しやすい

パフォーマンスの利点:

  • 変更されたプロパティに対応するDOMだけを更新
  • 大規模なUIでも効率的
  • 最適化の知識が不要

次回予告:
明日は、「実践:シンプルなカウンターアプリを作る」として、これまで学んだ概念を実際のコードで体験します。構造パスを使った開発の流れを、手を動かしながら理解しましょう。


次回: Day 6「実践:シンプルなカウンターアプリを作る」

Proxyやリアクティビティについて質問があれば、コメントでぜひ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?