• 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:

     

    五.对比 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 支持所有的代理操作

     

    参考资料

     

  • 相关阅读:
    利用 AlwaysInstallElevated 提权
    一批内网文章分享
    关于DLL劫持提权
    Xposed+JustTrustMe关闭ssl证书验证
    关于windows组策略首选项提权
    关于代替Procdump dump lsass的两种方法
    与ServletContext相关的监听器
    java EE 监听器
    ServletContext
    GenericServlet
  • 原文地址:https://www.cnblogs.com/ayqy/p/es6-proxy-in-immer.html
Copyright © 2020-2023  润新知