博客 / 詳情

返回

ES6 Proxy 在 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屬性等細節:

// 跳過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:

  • Git repo:ayqy/imer
  • npm package: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 支持所有的代理操作

參考資料

  • immerjs/immer v4.0.1
  • Introducing Immer: Immutability the easy way

有所得、有所惑,真好

關注「前端向後」微信公眾號,你將收穫一系列「用原創」的高質量技術文章,主題包括但不限於前端、Node.js以及服務端技術

本文首發於 ayqy.net ,原文鏈接:http://www.ayqy.net/blog/new%...

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.