はじめに
Immer は Copy-on-write メカニズムと ES6 Proxy 特性を結合し、異常に簡潔な不変データ操作方式を提供します:
const myStructure = {
a: [1, 2, 3],
b: 0
};
const copy = produce(myStructure, () => {
// nothings to do
});
const modified = produce(myStructure, myStructure => {
myStructure.a.push(4);
myStructure.b++;
});
copy === myStructure // true
modified !== myStructure // true
JSON.stringify(modified) === JSON.stringify({ a: [1, 2, 3, 4], b: 1 }) // true
JSON.stringify(myStructure) === JSON.stringify({ a: [1, 2, 3], b: 0 }) // true
これは一体どのように実現されているのでしょうか?
一.目標
Immer には 1 つの コア API しかありません:
produce(currentState, producer: (draftState) => void): nextState
したがって、等価な produce 関数を手動で実装できれば、Immer の秘密を解明できます
二.思路
produce の用法を注意深く観察すると、5 つの特徴を発見するのは難しくありません(注釈を参照):
const myStructure = {
a: [1, 2, 3],
b: 0
};
const copy = produce(myStructure, () => {});
const modified = produce(myStructure, myStructure => {
// 1.producer 関数中で draftState にアクセスするのは、原値 currentState にアクセスするのと同じ
myStructure.a.push(4);
myStructure.b++;
});
// 2.producer 中で draftState を修改しない場合、引用は不変で、すべて原値を指す
copy === myStructure // true
// 3.draftState を改めた場合、引用が変化し、produce() は新値を返す
modified !== myStructure // true
// 4.producer 関数中で draftState への操作はすべて新値に適用される
JSON.stringify(modified) === JSON.stringify({ a: [1, 2, 3, 4], b: 1 }) // true
// 5.producer 関数中で draftState への操作は原値に影響しない
JSON.stringify(myStructure) === JSON.stringify({ a: [1, 2, 3], b: 0 }) // true
つまり:
-
書き込み時のみコピー(注釈 2、注釈 3 を参照)
-
読み取り操作は原値にプロキシされる(注釈 1 を参照)
-
書き込み操作はコピー値にプロキシされる(注釈 4、注釈 5 を参照)
では、シンプルな骨格が水面に浮上しました:
function produce(currentState, producer) {
const copy = null;
const draftState = new Proxy(currentState, {
get(target, key, receiver) {
// todo 読み取り操作を原値にプロキシ
},
set() {
if (!mutated) {
mutated = true;
// todo コピー値を作成
}
// todo 書き込み操作をコピー値にプロキシ
}
});
producer(draftState);
return copy || currentState;
}
さらに、Proxy は現在の層の属性アクセスしか監視できないため、プロキシ関係も必要に応じて作成する必要があります:

ルートノードは事前に Proxy を作成し、オブジェクトツリー上でアクセスされたすべての中間ノード(または新規追加サブツリーのルートノード)も対応する Proxy を作成する必要があります
各 Proxy は書き込み操作(直接代入、ネイティブデータ操作 API など)を監視した時のみコピー値を作成し(いわゆる Copy-on-write)、その後の書き込み操作をすべてコピー値にプロキシします
最後に、これらのコピー値と原値を統合して、データ操作結果を取得します
したがって、Immer = Copy-on-write + Proxy
三.具体的実装
上記の分析に従い、実装上は主に 3 つの部分に分かれます:
-
プロキシ:必要に応じて作成、読み書き操作をプロキシ
-
コピー:必要に応じてコピー(Copy-on-write)
-
統合:コピー値と原値の関連を建立、原値とコピー値を深度 merge
プロキシ
原値を取得した後、まずルートノードに Proxy を作成し、producer が操作する draftState を取得:
function produce(original, producer) {
const draft = proxy(original);
//...
}
最も重要なのは原値の get、set 操作のプロキシです:
function proxy(original, onWrite) {
// プロキシ関係及びコピー値を格納
let draftState = {
originalValue: original,
draftValue: Array.isArray(original) ? [] : Object.create(Object.getPrototypeOf(original)),
mutated: false,
onWrite
};
// ルートノードプロキシを作成
const draft = new Proxy(original, {
// 読み取り操作(属性アクセスをプロキシ)
get(target, key, receiver) {
if (typeof original[key] === 'object' && original[key] !== null) {
// 基本値タイプでない既存属性、次の層のプロキシを作成
return proxyProp(original[key], key, draftState, onWrite);
}
else {
// 改めた場合は直接 draft から最新状態を取得
if (draftState.mutated) {
return draftValue[key];
}
// 存在しない、または値が基本値の既存属性、原値にプロキシ
return Reflect.get(target, key, receiver);
}
},
// 書き込み操作(データ修改をプロキシ)
set(target, key, value) {
// 新値が基本値タイプでない場合、次の層のプロキシを作成
if (typeof value === 'object') {
proxyProp(value, key, draftState, onWrite);
}
// 最初の書き込み時にコピー
copyOnWrite(draftState);
// コピー済み、直接書き込み
draftValue[key] = value;
return true;
}
});
return draft;
}
P.S. さらに、他の多くの読み書きメソッドもプロキシする必要があります。例えば has、ownKeys、deleteProperty など。処理方式は類似しており、ここでは詳しく述べません
コピー
上記に現れた copyOnWrite 関数です:
function copyOnWrite(draftState) {
const { originalValue, draftValue, mutated, onWrite } = draftState;
if (!mutated) {
draftState.mutated = true;
// 次の層に修改がある時のみ親レベル draftValue に掛ける
if (onWrite) {
onWrite(draftValue);
}
// 最初の書き込み時にコピー
copyProps(draftValue, originalValue);
}
}
最初の書き込み時(!mutated)のみ原値上の他の属性を draftValue にコピーします
特殊的、浅コピー時には属性記述子、[Symbol](/articles/symbol-es6 ノート 7/) 属性などの詳細に注意する必要があります:
// target 身上にすでに存在する属性をスキップ
function copyProps(target, source) {
if (Array.isArray(target)) {
for (let i = 0; i < source.length; i++) {
// より深い層ですでに改められた属性をスキップ
if (!(i in target)) {
target[i] = source[i];
}
}
}
else {
Reflect.ownKeys(source).forEach(key => {
const desc = Object.getOwnPropertyDescriptor(source, key);
// 既存属性をスキップ
if (!(key in target)) {
Object.defineProperty(target, key, desc);
}
});
}
}
P.S.Reflect.ownKeys はオブジェクトのすべての属性名(Symbol 属性名と文字列属性名を含む)を返すことができます
統合
コピー値と原値を統合するには、まず 2 つの関係を建立する必要があります:
-
プロキシと原値、コピー値の関連:ルートノードのプロキシは結果を持ち出す必要がある
-
下層コピー値と祖先コピー値の関連:コピー値は結果ツリーに簡単に対応できる必要がある
1 つ目の問題については、プロキシオブジェクトに対応する draftState を露出させるだけでよいです:
const INTERNAL_STATE_KEY = Symbol('state');
function proxy(original, onWrite) {
let draftState = {
originalValue: original,
draftValue,
mutated: false,
onWrite
};
const draft = new Proxy(original, {
get(target, key, receiver) {
// proxy から draft 値への関連を建立
if (key === INTERNAL_STATE_KEY) {
return draftState;
}
//...
}
}
}
2 つ目の問題については、onWrite フックを通じて下層コピー値と祖先コピー値の関連を建立できます:
// 次の層のプロキシを作成
function proxyProp(propValue, propKey, hostDraftState) {
const { originalValue, draftValue, onWrite } = hostDraftState;
// 次の層属性が書き込み操作を発生した時
const onPropWrite = (value) => {
// 必要に応じて親レベルコピー値を作成
if (!draftValue.mutated) {
hostDraftState.mutated = true;
// host のすべての属性をコピー
copyProps(draftValue, originalValue);
}
// 子レベルコピー値を掛ける(コピー値の親子関係を建立)
draftValue[propKey] = value;
// 祖先に通知、上に完全なコピー値ツリーを建立
if (onWrite) {
onWrite(draftValue);
}
};
return proxy(propValue, onPropWrite);
}
つまり、深層属性が初めて書き込み操作を発生した時、上に必要に応じてコピーし、コピー値ツリーを構築
至此、大功告成:
function produce(original, producer) {
const draft = proxy(original);
// draft を修改
producer(draft);
// draft 内部状態を取り出す
const { originalValue, draftValue, mutated } = draft[INTERNAL_STATE_KEY];
// 改めた新値を patch する
const next = mutated ? draftValue : originalValue;
return next;
}
四.オンライン Demo
手作りのバージョンは原版より少しシンプルなので、m を 1 つ少なくして、imer と呼びます:
五.Immer と比較
正版と比較して、実装方案上に 2 つの差異があります:
-
プロキシを作成する方式が異なる:imer は
new Proxyを使用し、immer はProxy.revocable()を採用 -
統合方案が異なる:imer はコピー値ツリーを反向構築し、immer はプロキシオブジェクトツリーを正向走査
Proxy.revocable() で作成した Proxy はプロキシ関係を解除でき、より安全です
Immer がプロキシオブジェクトツリーを正向走査するのも非常に賢明なやり方です:
When the producer finally ends, it will just walk through the proxy tree, and, if a proxy is modified, take the copy; or, if not modified, simply return the original node. This process results in a tree that is structurally shared with the previous state. And that is basically all there is to it.
onWrite がコピー値ツリーを反向構築するより直観的で、借鉴する価値があります
P.S. さらに、Immer は Object.defineProperty()、Object.setPrototypeOf() 操作をサポートしていませんが、手作りの imer はすべてのプロキシ操作をサポートします
コメントはまだありません