从 Immer.js 再看浅拷贝,深拷贝

先回顾一下什么是浅拷贝和深拷贝。

1. 浅拷贝

浅拷贝,仅复制对象的最外层,子对象仍为引用。实现浅拷贝的方式有:

  • ... 拓展运算符
  • Object.assign
  • Array.prototype.slice() , Array.prototype.concat()
  • 遍历实现
js 复制代码
function shallowClone(obj) {
  const newObj = {};
  for(let prop in obj) {
    if(obj.hasOwnProperty(prop)){
      newObj[prop] = obj[prop];
    }
  }
  return newObj;
}

2. 深拷贝

深拷贝不是那么好实现的。(完全独立于副本)

首先需要精确的定义,哪些可以clone?哪些不可以clone?任意对象的深度克隆,edge case 非常多,比如原生 DOM/BOM 对象怎么处理,RegExp 怎么处理,函数怎么处理,原型链怎么处理... 并不是一个简单的问题。复杂对象本身可能有很多约束,这不是一个通用的clone可以搞定的。比如dom元素的复制必须使用cloneNode方法,且它也只处理dom自己的东西。

所以我们讨论的深拷贝,是对于普通对象,或者划定好范围的深拷贝,方式有:

  • lodash.cloneDeep()
  • jQuery.extend()
  • JSON.stringify(JSON.parse(obj))这种方式要求必须是JSON对象,会忽略不是JSON的一些值undefined, symbol、函数
  • 深度递归实现
js 复制代码
function deepClone (obj, hash = new WeakMap()) {
  if (obj === null) return obj
  if (obj instanceof Date) return new Date(obj)
  if (obj instanceof RegExp) return new RegExp(obj)
  // 基本类型:直接返回值
  if (typeof obj !== 'object') return obj
  // 引用类型:进行深拷贝
  if (has.get(obj)) return hash.get(obj)
  let cloneObj = new obj.constructor()
  hash.set(obj, cloneObj)
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], hash)
    }
  }
  return cloneObj
}

3. Immer.js

深拷贝、浅拷贝绝不仅仅只是一个面试题。我们要问知道,为什么要clone? 实际上,clone是为了传递数据对象。我们希望得到一个数据副本,且在修改副本对象时,不会影响到原有对象。

3.1 不可变数据

例如,我们直接复制对象const obj2 = obj1, 当我们修改obj2时,却不小心把obj1也一起修改了。

js 复制代码
const obj1 = {
  name: '金毛1',
  city: '上海'
}
const obj2 = obj1;
obj2.name = '金毛2';
console.log(obj1) // {name:'金毛2', city:'上海'} obj1被修改了

如果不想在修改obj2时,对obj1也产生影响。我们会想到深拷贝一个obj1出来, 然后随便玩耍。

实际的项目中要操作的例子远比这个复杂。如果是一个庞大的对象, 用JSON.parse(JSON.stringify(obj1))实现深拷贝会浪费大量性能在拷贝属性上, 但我们可能只是想要一个name值不同的对象而已。

这个时候,我们会想到拓展运算法...的写法。只修改修改的值,其他值保持原来的引用。

js 复制代码
const obj2 = { ...obj1, name: '金毛2' }

而当obj1是一个多层嵌套的对象,我们需要一个包来帮忙处理层层嵌套的逻辑。这时就需要用到Immer.js。

3.2 基本用法

Immer.js的核心目标, 通过直观的操作修改数据,得到一个拷贝对象(这个副本只新建被改变的变量, 其余变量都复用)

js 复制代码
/*
 * produce方法的第一个参数传入要拷贝的对象
 * produce方法的第二个参数为函数, 里面是draft,对传入对象的修改操作
 * 最后返回一个复制完毕的对象
*/
const obj2 = immer.produce(obj1, (draft) => {
  draft.name.basename['2022'] = '修改name'
})

3.3 实现一个简易的Immer.js

Immer.js 通过递归式的 proxy 代理和浅拷贝,充分复用数据结构中不变的节点,同时满足性能要求 和 不可变数据的要求。

3.3.1 基本的工具方法
js 复制代码
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(target) {
  if (isObject) {
    return Object.assign({}, target)
  } else if (isArray(target)) {
    return [...target]
  } else {
    return target
  }
}
3.3.2 入口方法

传入target需要被拷贝的对象, producer修改操作,最后返回拷贝 + 修改后的对象(还不会出现多层proxy)

js 复制代码
function produce(target, producer) {
  const proxyState = createProxy(target) // 创建proxy
  producer(proxyState) // 修改数据
  return proxyState
}
3.3.3 核心代理方法

不管处于对象的哪一层,Immer.js 给访问到的key都转化为一个proxy对象。每个访问到的key都维护着一个internal对象。比如obj2.name会生成一个自己的internal, obj2.name.nickname也会生成一个自己的internal。internal对象详细的记录着它的原始值、浅拷贝版本、修改后的浅拷贝版本。

大致流程:

  • 先给最外层对象转换为一个proxy对象
  • 如果对象中某个的key被读取,触发 get 方法,immerjs会把读取到每个的key转化为一个proxy。
  • 每个proxy会维护当前这个key的值的浅拷贝版本draftState
  • 当set的时候,会返回draftState[key] = value
  • 读取拷贝对象时,会返回 draftState[key]
js 复制代码
function toProxy(targetState) {
  let internal = {
    targetState, // 原始对象
    keyToProxy: {}, // 记录了哪些key被读取了(注意不是修改了),以及key对应的proxy
    changed: false, // 标记是否被修改
    draftstate: createDraftstate(targetState), // 当前这一环的key的浅拷贝版本
  }
  return new Proxy(targetState, {
    get(_, key) {
      if (key === INTERNAL) return internal
      const val = targetState[key];
      // 只要key被读取了,就把它替换为一个proxy对象
      if (!(key in internal.keyToProxy)) {
        internal.keyToProxy[key] = toProxy(val)
      }
      return internal.keyToProxy[key]
  },
  set(_, key, value) {
    internal.changed = true;
    // 最终的拷贝对象其实是由所有draftstate组成
    internal.draftstate[key] = value
    return true
  }
})}
3.3.4 完整代码(加入回溯)

上面的代码只是修改第一层的情况,但是我们修改了一个值如obj1.name.basename[2022], 则连带这个值的父级也要被修改, 父级被修改则父级的父级也要被修改, 形成了一个修改链, 所以要加入回溯算法进行逐级的修改。

完整代码如下:(可以直接放入浏览器试试效果)

js 复制代码
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(target) {
  if (isObject) {
    return Object.assign({}, target)
  } else if (isArray(target)) {
    return [...target]
  } else {
    return target
  }
}

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: {}, // 记录了哪些key被读取了(注意不是修改了),以及key对应的proxy
    changed: false, // 标记是否被修改
    draftstate: createDraftstate(targetState), // 当前这一环的key的浅拷贝版本
  }
  return new Proxy(targetState, {
    get(_, key) {
      if (key === INTERNAL) {
        return internal
      }
      const val = targetState[key];
      // 只要key被读取了,就把它替换为一个proxy对象
      if (!(key in internal.keyToProxy)) {
        internal.keyToProxy[key] = toProxy(val, () => {
          internal.changed = true;
          const proxyObj = internal.keyToProxy[key];
          // 将修改后的值赋给自己
          internal.draftstate[key] = proxyObj[INTERNAL].draftstate;
          backTracking()
        })
      }
      return internal.keyToProxy[key]
    },
    set(_, key, value) {
      internal.changed = true;
      // 最终的拷贝对象其实是由所有draftstate组成
      internal.draftstate[key] = value
      backTracking()
      return true
    }
  })
}


const originalState = {
  name: 'John',
  age: 30,
  locate: {
    address: 1,
    arr: [0, 1]
  }
};

const newState = produce(originalState, function (draft) {
  draft.age = 31; // 注意set不会触发get
  draft.locate.address = 2;
  draft.locate.arr[0] = 1;
  // delete draft.name; 这个还没实现
});


console.log('Original State:', originalState);
console.log('New State:', newState);
3.3.5 如何理解回溯代码?

假设我们只做这个操作

js 复制代码
const newState = produce(originalState, function (draft) {
  draft.locate.address = 2;
});

会经过这么几个步骤

  • 将 draft 转换为 proxy
  • 触发了 draft.locate 的 get
  • 将 draft.locate 转换为 proxy
  • 触发了 address 的 set
  • address 的 set 执行了 key 为 locate 的 backTracking
  • backTracking 触发了 draft.locate 的 get, return 的是 internal
  • draft.locate 的 get 触发了 draft 的get, return 的是 internal

参考资料

相关推荐
大聪明了1 分钟前
Nuxt3 使用 ElementUI Plus报错问题
前端
Ama_tor7 分钟前
网页制作16-Javascipt时间特效の设置D-DAY倒计时
前端·javascript·html
几何心凉18 分钟前
两款好用的工具,大模型训练事半功倍.....
前端
Dontla43 分钟前
黑马node.js教程(nodejs教程)——AJAX-Day01-04.案例_地区查询——查询某个省某个城市所有地区(代码示例)
前端·ajax·node.js
威哥爱编程44 分钟前
vue2和vue3的响应式原理有何不同?
前端·vue.js
呆呆的猫1 小时前
【前端】Vue3 + AntdVue + Ts + Vite4 + pnpm + Pinia 实战
前端
qq_456001651 小时前
30、Vuex 为啥可以进行缓存处理
前端
浪裡遊1 小时前
Nginx快速上手
运维·前端·后端·nginx
天生我材必有用_吴用1 小时前
Vue3后台管理项目封装一个功能完善的图标icon选择器Vue组件动态加载icon文件下的svg图标文件
前端
小p1 小时前
初探typescript装饰器在一些场景中的应用
前端·typescript·nestjs