关于javascript:详聊immerjs高效复制与冻结对象的原理于局限性

33次阅读

共计 9856 个字符,预计需要花费 25 分钟才能阅读完成。

故事的开始、

    immer.js应该是 2019 年时候火起来的一个库, 他能够高效的复制一个对象(比方绝对于JSON.parse(JSON.stringify(obj))), 并且解冻对这个对象上的一些值的批改权限。

    然而我发现一些同学推崇在我的项目内大规模应用 immer.js 来操作对象, 但他们并没有给出一个让我认同他这种做法的理由, 所以我决定钻研 immer.js 的原理, 心愿能更好更精确的应用immer.js

    不要为了应用而应用, 学习体会某种技术的思维并使用在失当的中央兴许才是要害, 明天咱们就从原理的角度来剖析 immer.js 适宜的应用场景。

一、拷贝对象有什么问题?

1: 最简略的拷贝

    上面这个对象须要被拷贝进去, 并且将 name 属性改成 金毛 2 :

 const obj1 = {
    name: '金毛 1',
    city: '上海'
 }

    间接复制的话:

const obj2 = obj1;

obj2.name = '金毛 2';

console.log(obj1) // {name:'金毛 2', city:'上海'}

    上述就是一个最简略的例子, 起因大家都懂因为间接 const obj2 = obj1; 属于间接让 obj2 的地址指向了obj1

    所以如果不想每次批改 obj2 都对 obj1 产生影响的话, 那么咱们能够深拷贝一个 obj1 进去, 而后轻易游玩:

const obj2 = JSON.parse(JSON.stringify(obj1));
      obj2.name = '金毛 2'
      console.log(obj1) // {name: '金毛 1', city: '上海'}
      console.log(obj2) // {name: '金毛 2', city: '上海'}
2: 大一点的对象

   在理论的我的项目中要操作的对象可能远比例子中的要简单, 比方 city 如果是一个宏大的对象, 那么当 JSON.parse(JSON.stringify(obj1)) 时就会节约大量性能在拷贝 city 属性上, 但咱们可能只是想要一个 name 不同的对象而已。

   你可能很快就能想到, 间接用扩大运算符进行解构呗:

    const obj1 = {
        name: '金毛 1',
        city: {
            '上海': true,
            '辽宁': false,
            '其余城市': false
        }
    }
    const obj2 = {...obj1}
    obj2.name = '金毛 2'
    console.log(obj1.city === obj2.city)
3: 针对多层的对象

    比方 name 属性是一个多层嵌套的类型, 此时咱们只想扭转它外部的 basename: 2022 在应用的 name 的值:

   const obj1 = {
        name: {
            nickname: {
                2021: 'cc_2021_n',
                2022: 'cc_2022_n'
            },
            basename: {
                2021: 'cc_2021',
                2022: 'cc_2022'
            }
        },
        city: {'上海': true}
    }

    const obj2 = {...obj1}
    obj2.name = {...obj1.name}
    obj2.name.basename = {...obj1.name.basename}
    obj2.name.basename['2022'] = '金毛 2'
    console.log(obj1.name.basename === obj2.name.basename) // false
    console.log(obj1.name.nickname === obj2.name.nickname) // true
    console.log(obj1.city === obj2.city) // true

    下面代码中咱们须要重复进行对象的解构, 能力做到复用 citynickname等对象, 然而如果咱们不光要批改 obj2.name.basename['2022'], 而是要批改一个对象中n 个变量要怎么做? 这个时候就须要有个插件来帮咱们封装这些繁琐的步骤。

二、immer.js 的根本能力

    咱们间接演示 immer.js 的用法吧, 看看他有多优雅:

1: 装置
yarn add immer
2: 应用

   immer提供了 produce 办法, 此办法接管的第二个参数 draft 与第一个参数的值是一样的, 神奇的是在 produce 办法内操作 draft 会被记录下来, 比方 draft.name = 1 则最终导出的 obj2name属性就会变成 1 并且 name 属性变成了obj2 独有的:

const immer = require('immer')

const obj1 = {
    name: {
        nickname: {
            2021: 'cc_2021_n',
            2022: 'cc_2022_n'
        },
        basename: {
            2021: 'cc_2021',
            2022: 'cc_2022'
        }
    },
    city: {'上海': true}
}

const obj2 = immer.produce(obj1, (draft) => {draft.name.basename['2022'] = '批改 name'
})

console.log(obj1.name.basename === obj2.name.basename) // false
console.log(obj1.name.nickname === obj2.name.nickname) // true
console.log(obj1.city === obj2.city) // true
3: 指定 2 个值

    咱们把 city 也进行一个批改, 所以此时只有 nickname 属性是复用的了:

const immer = require('immer')

const obj1 = {
    name: {
        nickname: {
            2021: 'cc_2021_n',
            2022: 'cc_2022_n'
        },
        basename: {
            2021: 'cc_2021',
            2022: 'cc_2022'
        }
    },
    city: {'上海': true}
}

const obj2 = immer.produce(obj1, (draft) => {draft.name.basename['2022'] = '批改 name'
    draft.city['上海'] = false
})

console.log(obj1.name.basename === obj2.name.basename) // false
console.log(obj1.name.nickname === obj2.name.nickname) // true
console.log(obj1.city === obj2.city) // false

三、插件需要剖析

    咱们来简略梳理一下 immer 都做了什么, 看看咱们要攻克什么难点:

  1. produce办法的第一个参数传入要拷贝的对象。
  2. produce 办法的第二个参数为函数, 将其内进行的所有对 draft 进行的 赋值操作 记录下来。
  3. 被赋值的对象会生成新的对象替换掉 obj2 的身上对应的值。
  4. draft 的操作不会影响 produce 办法的第一个参数。
  5. 像是没有解决过的nickname, 则间接复用。

四、外围原理

   produce办法中的 draft 显然是一个被代理的对象, 那么咱们能够利用 new Proxy 的形式生成代理对象, 并利用 get 与 set 办法能够得悉哪些变量被扭转了。

   以咱们例子中的数据结构为例:

const obj1 = {
    name: {
        nickname: {
            2021: 'cc_2021_n',
            2022: 'cc_2022_n'
        },
        basename: {
            2021: 'cc_2021',
            2022: 'cc_2022'
        }
    },
    city: {'上海': true}
}

    如果 obj1.name.basename[2022]产生了扭转, 那么 basename 对象须要调整他的 key(2022)的指向, 所以 basename 自身其实产生了扭转, 他曾经不是本来的 basename 了咱们先叫他basename 改

    basename的父级是 name 属性, 那此时 namebasename属性的值不能持续应用本来的 basename, 而是应该指向basename 改, 所以name 这个属性也变了。

    以此类推其实只有咱们批改了一个值, 则连带这个值的父级也要被批改, 那么父级被批改则父级的父级也要被批改, 造成了一个 批改链 , 所以可能要利用 回溯算法 进行逐级的批改。

   外围指标只有一个, 只新建被扭转的变量, 其余变量都复用!

五、根底的外围代码

    首先我本人摸索写进去的代码有点丑, 所以我这里演示的是看了好多篇文章与视频后写的, 这一版仅真对 Object & Array 两种数据类型, 写明确原理即可:

1: 写一些根底的工具办法
const isObject = (val) => Object.prototype.toString.call(val) === '[object Object]';
const isArray = (val) => Object.prototype.toString.call(val) === '[object Array]';
const isFunction = (val) => typeof val === 'function';

function createDraftstate(targetState) {if (isObject) {return Object.assign({}, targetState)
    } else if (isArray(targetState)) {return [...targetState]
    } else {return targetState}
}
  1. createDraftstate办法是浅复制办法。
2: 入口办法
function produce(targetState, producer) {let proxyState = toProxy(targetState)
    producer(proxyState);
    return // 返回最终生成的可用对象
}
  1. targetState是须要被拷贝的对象, 也就是下面例子中的obj1
  2. producer是开发者传入的解决办法。
  3. toProxy是生成一个代理对象的办法, 这个代理对象用来记录用户都为那些属性赋值。
  4. 最终返回一个复制结束的对象即可, 这里具体逻辑有点 ’ 绕 ’ 前面再说。
3: 外围代理办法 toProxy

    这个办法外围能力就是对 操作指标对象的记录, 上面是根本的办法构造演示:

function toProxy(targetState) {
    let internal = {
        targetState,
        keyToProxy: {},
        changed: false,
        draftstate: createDraftstate(targetState),
    }
    return new Proxy(targetState, {get(_, key) { },
        set(_, key, value) {}})
}
  1. internal对象是具体的记录下每个代理对象的各种值, 比方 obj2.name 会生成一个本人的 internal, obj2.name.nickname 也会生成一个本人的internal, 这里有点形象大家加油。
  2. targetState: 记录了原始的值, 也就是传入值。
  3. keyToProxy: 记录了哪些 key 被读取了 (留神不是批改了), 以及key 对应的值。
  4. changed: 以后这一环的 key 值是否被批改。
  5. draftstate: 以后这一环的值的浅拷贝版本。
4: get 与 set 办法

在全局定一个外部应用的key, 不便后续取值:

const INTERNAL = Symbol('internal')

get 与 set 办法

   get(_, key) {if (key === INTERNAL) return internal
        const val = targetState[key];
        if (key in internal.keyToProxy) {return internal.keyToProxy[key]
        } else {internal.keyToProxy[key] = toProxy(val)
        }
        return internal.keyToProxy[key]
    },
    set(_, key, value) {
        internal.changed = true;
        internal.draftstate[key] = value
        return true
    }

get 办法:

  1. if (key === INTERNAL)return internal: 这里是为了后续能够利用这个 key 获取到 internal 实例。
  2. 每次取值会判断这个 key 是否有对应的代理属性, 如果没有则递归应用 toProxy 办法生成代理对象。
  3. 最终返回的是代理对象

set 办法:

  1. 每次应用 set 哪怕是雷同的赋值咱们也认为是产生了扭转, changed属性变成true
  2. draftstate[key]也就是本身的浅拷贝的值, 变成了开发者被动赋予的值。
  3. 最终生成的 obj2 其实就是由所有 draftstate 组成的。
5: 回溯办法, 更改全链路父级

   上述代码只是最根本的批改某一个值, 然而下面也说过, 如果一个值变了那么从他开始向着父级方向会生成 批改链, 那么咱们就来写一下回溯的办法:

最外层会承受一个 backTracking 办法:

function toProxy(targetState, backTracking = () => {}) {

外部会应用与定义方法:

get(_, key) {if (key === INTERNAL) {return internal}
    const val = targetState[key];
    if (key in internal.keyToProxy) {return internal.keyToProxy[key]
    } else {internal.keyToProxy[key] = toProxy(val, () => {
            internal.changed = true;
            const proxyChild = internal.keyToProxy[key];
            internal.draftstate[key] = proxyChild[INTERNAL].draftstate;
            backTracking()})
    }
    return internal.keyToProxy[key]
},
set(_, key, value) {
    internal.changed = true;
    internal.draftstate[key] = value
    backTracking()
    return true
}

get 外部:

  1. 每次调用 toProxy 生成代理对象都传递一个办法, 这个办法如果被触发则 changed 被改为true, 也就是记录本身为被批改过的状态。
  2. proxyChild: 获取到产生扭转的子集。
  3. internal.draftstate[key] = proxyChild[INTERNAL].draftstate;: 将子集的批改后的值赋予给本人。
  4. backTracking(): 因为本人的值扭转了, 所以让本人的父级执行雷同操作。

set 外部:

  1. backTracking(): 执行父级传递进来的办法, 迫使父级扭转并将 key 指向本人的新值也就是draftstate
6: 原理梳理

    脱离代码总的来说一下原理吧, 比方咱们取 obj.name.nickname = 1, 则会先触发obj 身上的 get 办法, 将 obj.name 的值生成一个代理对象挂载到 keyToProxy 上, 而后触发 obj.nameget办法, 为 obj.name.nickname 生成代理的对象挂载到 keyToProxy 上, 最初 obj.name.nickname = 1 触发 obj.name.nicknameset办法。

    set办法触发 backTracking 开始自下而上触发父级的办法, 父级将子元素的值赋值给本身 draftstate 对应的key

    所有代理对象都在 keyToProxy 被, 但最初返回的是 draftstate 所以不会呈现多层 Proxy 的状况(‘ 套娃代理 ’)。

7: 残缺代码
const INTERNAL = Symbol('internal')

function produce(targetState, producer) {let proxyState = toProxy(targetState)
    producer(proxyState);
    const internal = proxyState[INTERNAL];
    return internal.changed ? internal.draftstate : internal.targetState
}

function toProxy(targetState, backTracking = () => {}) {
    let internal = {
        targetState,
        keyToProxy: {},
        changed: false,
        draftstate: createDraftstate(targetState),
    }
    return new Proxy(targetState, {get(_, key) {if (key === INTERNAL) {return internal}
            const val = targetState[key];
            if (key in internal.keyToProxy) {return internal.keyToProxy[key]
            } else {internal.keyToProxy[key] = toProxy(val, () => {
                    internal.changed = true;
                    const proxyChild = internal.keyToProxy[key];
                    internal.draftstate[key] = proxyChild[INTERNAL].draftstate;
                    backTracking()})
            }
            return internal.keyToProxy[key]
        },
        set(_, key, value) {
            internal.changed = true;
            internal.draftstate[key] = value
            backTracking()
            return true
        }
    })
}

function createDraftstate(targetState) {if (isObject) {return Object.assign({}, targetState)
    } else if (isArray(targetState)) {return [...targetState]
    } else {
        // 还有很多类型, 缓缓写
        return targetState
    }
}

module.exports = {produce}

六、immer 的代码有些不标准

    原本想以 immer.js 为源码进行展现的, 然而源码外面做了很多兼容 es5 的代码, 可读性较差, 并且代码标准方面也不太符合要求, 容易给大家谬误示范, 上面一起看看几处写的不标准的点:

1: 变量信息不语义化

这种数字的传参着实让人看不懂, 其实他对应的是错误码:

那其实易读性上思考至多应该写enum:

2: 超多三元 ’ 骑虎难下 ’

   这就不多说了, 看的太 ’ 顶了 ’。

3: 全是 any, 这 ts 还有意义吗 …

七、秀翻在地的面试体验

    深拷贝 浅拷贝 属于高级的 八股文 , 然而如果你现场给面试官旋一个immer.js 级别的拷贝, 预计你写完了就剩下面试官的缄默了, 间接将这题拔高了 2 个级别你来教他吧, 新的风暴曾经呈现!

八、解冻数据能力: setAutoFreeze办法

    immer.js还有一个重要的能力, 就是解冻属性禁止批改。

    图里咱们能够看进去, 批改 obj2 的值是无奈失效的, 除非应用 immer 实例身上的 setAutoFreeze 办法:

    当然啦, 持续应用 immer 办法是能够批改值的:

九、非凡状况 ’ 大作战 ’

    咱们本人写的例子只有外围性能, 但咱们能够一起试一下 immer.js 自身做的够不够谨严, 所以上面应用的 immer 是源码的不是咱们本人写的。

1: 值不变

    咱们某个值等于本身, 看看他是否有变动:

    尽管触发了 set 办法但依然返回传入的对象。

2: 函数函数再函数

    执行函数后则返回值扭转

    不扭转值就不会返回新的对象:

3: pop 这种无感批改

    pop()能够让数组变动, 然而没有触发 set 办法, 这种会是什么成果:

    尽管没有触发 set, 然而会触发get 外面对函数的解决。

十、immer.js局限性

    咱们根本理解了 immer.js 的工作原理了, 那么其实你也能够感触到日常一般的业务开发中其实没必要应用 immer.js, 毕竟创立各种Proxy 也是耗费性能的。

    应用前你能够顺着你要扭转的量向上找一下, 看看复制对象后不变的量有多少, 可能是省下来的性能真的不多。

    尽管 immer.js 的应用体验曾经十分不错了, 但还是有一些学习老本。

    但如果你面对的场景是 大 & 简单 那么 immer.js 确实是个不错的抉择, 比方 react 源码的性能问题, 地图的渲染问题等。

十一、react 中的使用

   本篇是以 immer.js 的原理为主所以把 react 相干放在了这里, 比方咱们 useState 申明了一个比拟深的对象:

function App() {const [todos, setTodos] = useState({
    user: {
      name: {
        nickname: {
          2021: 'cc_2021',
          2022: 'cc_2022'
        }
      },
      age: 9
    }
  });

  return (<div className="App" onClick={() => {// 此处编写, 更改 nickname[2022] = '新 name'
    }}>
      {todos.user.name.nickname[2022]
      }
    </div>
  );
}
形式一: 齐全复制
  const _todos = JSON.parse(JSON.stringify(todos));
  _todos.user.name.nickname[2022] = '新的';
  setTodos(_todos)

    大家当初看到 JSON.parse(JSON.stringify(todos)) 这种模式是不是就想到了咱们的 immer.js 了。

形式二: 解构赋值
  const _todos = {
    user: {
      ...todos.user,
      name: {
        ...todos.user.name,
        nickname: {
          ...todos.user.name.nickname,
          2022: '新的'
        }
      }
    }
  };
  setTodos(_todos)
形式三: 新变量触发更新

    新申明一个变量, 这个变量是负责触发 react 的刷新机制的:

  const [_, setReload] = useState({})

    每次扭转 todos 都不会触发 react 刷新, 并且 setTodos 时 react 的判断机制认为值没变导致也不会刷新, 所以须要其余 hooks 来触发刷新:

  todos.user.name.nickname[2022] = '新的';
  setTodos(todos)
  setReload({})
形式四: immer.js触发刷新

装置:

yarn add immer

引入:

import produce from "immer";

应用:

setTodos(produce((draft) => {draft.user.name.nickname[2022] = '新的';
    })
  );

    setTodos办法接管函数, 则执行函数并且参数就是 todos 变量, immer.js源码内对第一个参数为函数做了相干的转换解决:

    但我还是感觉独自搞一个入口办法比拟好, 逻辑都放在 produce 里感觉有点乱, 并且间接读源码的时候会感觉莫名其妙!

形式五: immer.js提供的 hooks

装置:

yarn add use-immer

引入:

import {useImmer} from "use-immer";

应用:

// 这里留神用 useImmer 代替 useState
 const [todos, setTodos] = useImmer({
    user: {
      name: {
        nickname: {
          2021: 'cc_2021',
          2022: 'cc_2022'
        }
      },
      age: 9
    }
  });

// 应用时:
 setTodos((draft) => {draft.user.name.nickname[2022] = '新的';
 }

十三、启发

    最近写的两篇文章都是对于如何将优化做到极致的技术, 上一篇文章 Qwik.js 框架是如何谋求极致性能的?! 粗浅感觉到其实本人习认为然的一些代码的写法上都存在能够极致优化的点, 有时候写代码也像温水煮青蛙, 写着写着就习惯了, 而咱们是不是能够想一些方法让本人经常性的跳出思维定式从新扫视本人的能力?

end

     这次就是这样, 心愿与你一起提高。

正文完
 0