メインコンテンツへ移動

Immer を 1 つ new する

無料2019-10-19#JS#Immer 内部原理#JavaScript 不可变数据结构#不可变数据结构实现原理#Immer under the hood#dive into Immer

150 行のコードで、手作りの Immer を作成

はじめに

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);
  //...
}

最も重要なのは原値の getset 操作のプロキシです:

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. さらに、他の多くの読み書きメソッドもプロキシする必要があります。例えば hasownKeysdeleteProperty など。処理方式は類似しており、ここでは詳しく述べません

コピー

上記に現れた 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 はすべてのプロキシ操作をサポートします

参考資料

コメント

コメントはまだありません

コメントを書く