跳到主要內容
黯羽輕揚每天積累一點點

new 一個 Immer

免費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 只有一個 核心 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 屬性名和字符串屬性名)

整合

要想把拷貝值與原值整合起來,先要建立兩種關係:

  • 代理與原值、拷貝值的關聯:根節點的代理需要將結果帶出來

  • 下層拷貝值與祖先拷貝值的關聯:拷貝值要能輕鬆對應到結果樹上

對於第一個問題,只需要將代理對象對應的 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;
      }
      //...
    }
  }
}

至於第二個問題,可以通過 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,就叫 imer:

五。對比 Immer

與正版相比,實現方案上有兩點差異:

  • 創建代理的方式不同: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 支持所有的代理操作

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論