网络日志

详聊immer.js高效复制与冻结"对象"的原理于局限性

故事的开始、

    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

     这次就是这样, 希望与你一起进步。