この記事は「構造パスで作るシンプルなフロントエンドフレームワーク(Structive)」Advent Calendarの5日目です。
Structiveについて詳しくはこちらより
前回のおさらい
4日目では、構造パスがボイラープレートを排除する仕組みを学びました。その中で「Proxyによる自動検知」という言葉が出てきましたが、今日はその技術的な詳細を掘り下げます。
仮想DOMとは?
まず、多くの現代的なフレームワークが採用している「仮想DOM」について理解しましょう。
仮想DOMの仕組み
// Reactの例
function Component() {
const [count, setCount] = useState(0);
return <div>Count: {count}</div>;
}
このコードが実行されるとき、以下のプロセスが発生します:
- 仮想DOMの構築: JSXから仮想的なDOMツリーを作成
{
type: 'div',
props: {},
children: ['Count: ', 0]
}
- 差分検出: 前回の仮想DOMと比較
// 前回: children: ['Count: ', 0]
// 今回: children: ['Count: ', 1]
// 差分: 2番目の子要素が変更
- 実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と書くことで:
- Proxyが
"product.name"という文字列キーでの代入を検知 - フレームワークは構造パス
"product.name"に依存するUIを特定 - その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";
- Proxyが
"product.name"の変更を検知 -
dependencies["product.name"]から依存するUIを取得 - その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>
何が起きるか
-
increment()を呼ぶ-
this.count += 1が実行される - Proxyが
"count"の変更を検知 -
{{ count }}のテキストノードだけが更新される -
{{ user.name }}や{{ items.*.name }}は無視される
-
-
updateUserName("Bob")を呼ぶ-
this["user.name"] = "Bob"が実行される - Proxyが
"user.name"の変更を検知 -
{{ user.name }}のテキストノードだけが更新される
-
必要最小限の更新だけが行われます。
まとめ
今日は、仮想DOMなしでリアクティビティを実現する仕組みを学びました:
核心的なメカニズム:
- Proxyで状態の変更を自動検知
- 構造パスで変更箇所を特定
- UIと状態の1対1対応
- 依存関係に基づくピンポイント更新
仮想DOMとの違い:
- ツリー全体の再構築が不要
- 差分計算が不要
- メモリオーバーヘッドが少ない
- シンプルで理解しやすい
パフォーマンスの利点:
- 変更されたプロパティに対応するDOMだけを更新
- 大規模なUIでも効率的
- 最適化の知識が不要
次回予告:
明日は、「実践:シンプルなカウンターアプリを作る」として、これまで学んだ概念を実際のコードで体験します。構造パスを使った開発の流れを、手を動かしながら理解しましょう。
次回: Day 6「実践:シンプルなカウンターアプリを作る」
Proxyやリアクティビティについて質問があれば、コメントでぜひ!