当前位置:首页 > 技术分析 > 正文内容

Vue2 重写了数组方法,你知道 Vue3 也重写了吗?

ruisui883个月前 (02-03)技术分析14

关于 Vue2 数组方法重写其实是一道很常见的八股文,如果有去系统背 Vue 相关面试题的话很容易就能了解到,但是自己也调研了一下很少有人提到关于 Vue3 数组的重写问题

开始之前,我们先来看瞟一眼源码打包后关于数组方法重写部分确认一下:

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

接下来针对于 Vue2、Vue3 的数组方法重写我们分开探讨 浅谈 Object.defineProperty 提到 Vue2 数组方法重写的时候就要先提到 Vue2 的响应式原理,提到 Vue2 的响应式原理就又要提到一个 API:Object.defineProperty 关于这个 API 具体使用方法就不再过多介绍了,不清楚的直接查看文档: Object.defineProperty() - JavaScript | MDN (mozilla.org) 我们在这里只讨论该 API 的局限性,根据其描述可以看出它是用来自定义对象上的属性,专业些来讲就是定义属性描述符,所以其实它并没有强调数据劫持的操作,只是在属性描述符中提供了访问器描述符:get、set 而 Vue2 就是借助这两个访问器进行数据劫持实现了响应式数据,我们精简一下核心源码就是这样:

function defineReactive(obj, key) {
  let val = obj[key]; //  get它,set它
  Object.defineProperty(obj, key, {
    get() {
      console.log("get 操作"); // 依赖收集
      return val;
    },
    set(newValue) {
      val = newValue;
      console.log("set 操作"); // 触发依赖
    },
  });
}
function walk() {
  const keys = Object.keys(data);
  for (let i = 0; i < keys.length; i++) {
    defineReactive(data, keys[i]);
  }
}

const data = { name: "hello" };
walk();

其实按照我个人的想法来讲这种数据代理劫持并不完美,可以看到 Vue2 主要是通过在外获取了对应的 val,然后针对于该 val 变量以闭包的形式进行 get、set 操作

如果按照我对数据劫持的设想它应该是这样才对:

Object.defineProperty(obj, key, {
  get() {
    console.log("get 操作");
    return obj[key]; // ?
  },
  set(newValue) {
    obj[key] = newValue; // ?
    console.log("set 操作");
  },
});

但毫无疑问这种方式按照访问器的规则肯定是有问题的,比如针对于 get 操作中又进行了一次 get 操作,所以会造成无限递归爆栈,set 操作也是一样的问题 所以后续 ES6 的 Proxy 才是数据劫持的真正解决方案,这点我们放到后续再讲 Vue2 重写数组方法 那问题来了,数组或者其他引用类型也可以通过该 API 劫持吗?答案是可以的,毕竟它们的本质还是对象 拿数组来讲,通过下标访问数组元素的本质也是在访问属性,所以同样能够被 get、set 访问器劫持到 但是我们要考虑数组的方法调用,它的 push、pop 等方法调用的是 Array.prototype 上的属性,也就是说要想劫持的话需要这样:

const arr = [1, 2, 3]
Object.defineProperty(data, "push", {
  get() {
    // do something...
    return Array.prototype.push; 
  },
});

很显然它与一开始封装的数据劫持方法 defineReactive 不兼容,而且这样劫持的意义不大,想象一下我们调用 push 需要关注两个点:push 的内容、push 的结果 然而上面这种方式只能劫持到 push 属性的访问(注意劫持不到调用)其他什么都拿不到,所以自然而然不会使用这种方法,(当然在最后的总结部分有提到也可以使用该方法,但会遇到性能问题,个人认为这就是我们常说的使用 Object.defineProperty 无法劫持数组的原因) 深入研究的话并不是劫持不到数组,而是只使用该 API 无法满足响应式系统的实现,比如 push 一个新的元素它是一个对象,那我们依然需要对该对象进行数据劫持,但现在我们连这个对象都拿不到,更别说劫持了 为了解决上面的问题, Vue2 没有选择对数组进行劫持而是选择了一个巧妙的方式:重写数组方法 首先明确一下需要对哪些方法进行重写,可以发现我们只需要针对于会修改自身数组的方法进行劫持,而像查找遍历的相关的方法正常使用就可以 数组修改自身的方法:push、pop、shift、unshift、splice、sort、reverse 源码其实很简单没多少行,就是最开始截图的部分,可以明确针对于劫持数组方法的调用会有三个操作:

  1. 调用原生的数组方法拿到结果,最后将其返回
  2. 针对于插入操作获取到插入的内容,对插入的内容进行数据劫持
  3. 通知依赖收集的函数执行

简单画张图来感受一下重写数组的魅力:

添加图片注释,不超过 140 字(可选)

当然聪明的你一定能想到关于数组的增删还有一些歪门邪道的做法,比如直接通过索引进行设置来添加元素,以及调用 delete 关键字来删除元素,同样也适用于对象的增删 关于这一点不管是之前实现 defineReactive 还是数组重写是都无法拦截到的,直接进行修改的话由于无法拦截自然就无法触发对应的响应式流程,所以 Vue2 提供了 $set、$delete 两个全局方法来解决这个问题 同样这两个方法的核心源码也没几行,本质就是调用数组上的 splice 方法做到添加、删除元素,由于 splice 方法已被重写,因此针对于添加的元素会被数据劫持且通知该数组收集的所有依赖函数执行 浅谈 Proxy Proxy 作为 ES6 新增特性给 JS 提供了强大的代理功能,该 API 的介绍就是针对于对象的基本操作能够进行拦截,而且这里的基本操作并不局限于 get、set,大概有十几种操作,具体可以看文档: Proxy - JavaScript | MDN (mozilla.org) 当然这里我们还是考虑响应式数据这块,依旧先使用 get、set 实现一个数据劫持的效果:

const obj = {
  name: "test",
  age: "20",
};

const proxy = new Proxy(obj, {
  get(target, key) {
    console.log("get操作"); // track 依赖收集
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    console.log("set操作"); // trigger 触发依赖
    return true;
  },
});

可以看出这种 Proxy 代理方式要比 Object.defineProperty 省事的多,最主要的区别在于 Proxy 的最小单元是对象,而 Object.defineProperty 最小单元是对象属性 这就导致了 Vue2 需要针对于某个对象还需要进行属性遍历,针对于每个属性进行 Object.defineProperty,也导致了直接添加和删除对象属性无法被劫持到 除此之外再来看这样的例子,下面通过 cdn 分别引入 Vue2 和 Vue3,我们声明一个响应式数组:



  
    
    
    Document
  
  
    
{{data}}




  
    
    
    Document
  
  
    
{{data}}

为了统一风格 Vue3 我也使用了 Options API,当然重点在于当数组元素都是基础数据类型 Vue3 依旧做了劫持,而 Vue2 定时器 2s 后界面上依旧没有变化 我们打印 Vue2 中响应式数组来看看结果,可以看到定时器后数组元素发生改变,且也有对应的依赖函数更新视图:

添加图片注释,不超过 140 字(可选)

归根究底如果你有去研究源码的话可以发现 Vue2 针对于数组从始至终都没有进行 defineReactive,只不过给它增加了一个 observer 对象罢了,当遇到一个 value 是数组时 Vue2 会进行遍历针对于每个元素执行 defineReactive 操作,唯独数组本身没有 然而 Vue3 能够实现这一点要归功于 Proxy API,针对于一个数组代理只需要在 getter 中根据你访问的属性增加额外的判断处理逻辑即可 Vue3 重写数组方法 由最开始的截图可以发现 Vue3 针对于数组方法分了两组重写: 第一组针对于查找相关的方法:includes、indexOf、lastIndexOf 第二组针对于增删相关的方法:push、pop、shift、unshift、splice 我们根据设计与实现中的讲解,分别介绍两组重写的原因 首先来看关于查找相关的方法,书中举了这样的例子:

const obj = { name: "test", age: 100 };
const arr = [obj];

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      const res = target[key];
      if (Object.prototype.toString.call(res) === "[object Object]") {
        return reactive(res);
      }
      return res;
    },
    set(target, key, value) {
      target[key] = value;
    },
  });
}

const arrReactive = reactive(arr);
console.log(arrReactive.includes(obj)); // false ?

我抽离了响应式中代理的核心逻辑代码复现了书中的问题,其主要关键在于最后代理的数组对象通过调用 includes 方法居然返回 false,这其实不是我们想要看到的结果, 首先我们知道 Vue3 数据劫持是惰性的,因为 Proxy 本身的特性,它不需要一开始就遍历对象的属性然后对每个属性进行劫持,而是以一个对象为整体,当访问到该属性时再去进行劫持。因此如果访问该属性其 value 值是一个引用值时,会进行递归代理 也就是代理后的对象已经不再是原来的对象了:

console.log(arrReactive[0], obj, arrReactive[0] === obj);

添加图片注释,不超过 140 字(可选)

而数组的 includes 方法底层也是帮我们遍历数组找到对应的 value,这一点我们在 getter 中打印一下 key 就能发现:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      console.log(key); // ?
      const res = target[key];
      if (Object.prototype.toString.call(res) === "[object Object]") {
        return reactive(res);
      }
      return res;
    },
    set(target, key, value) {
      target[key] = value;
    },
  });
}

添加图片注释,不超过 140 字(可选)

它会先访问数组的 includes 属性,接着再访问 length 属性,然后开始遍历访问数组下标进行查找 关于 includes 具体执行流程可以自行查阅 ECMA262 文档: ECMAScript? 2025 Language Specification (tc39.es) 或者看设计与实现这部分的内容,霍春阳大佬已经介绍了这个整个流程 所以我们最终解决问题的方案在 includes 方法上,假如我们数组存储的全是普通对象,那经过 reactive 代理后这里的普通对象会全部变成代理对象,所以 includes 底层进行遍历的时候拿到的都是代理对象进行比对,因此才不符合我们的预期 Vue3 对于这个问题的处理很简单,直接重写 includes 方法,先针对于代理数组中调用 includes 方法查找,如果没有找到再拿到原始数组中调用 includes 方法查找,两次查找就能完美解决这个问题 我们简单来尝试一下,首先改造原来的代理,需要增加一个 raw 字段来保存原始数据,然后只针对于 includes 方法进行重写。具体见注释,没有按照源码封装,精简下来只实现该功能:

const obj = { name: "test", age: 20 };
const arr = [obj];

function reactive(obj) {
  const proxyData = new Proxy(obj, {
    get(target, key) {
      let res = target[key];
      // 访问 includes 属性拦截使用我们自己重写的返回
      if (key === "includes") res = includes;
      if (Object.prototype.toString.call(res) === "[object Object]") {
        return reactive(res);
      }
      return res;
    },
    set(target, key, value) {
      target[key] = value;
    },
  });
  // 保存原始数据
  proxyData.raw = obj;
  return proxyData;
}
// 原始 includes 方法
const originIncludes = Array.prototype.includes;
// 重写方法
function includes(...args) {
  // 遍历代理对象
  let res = originIncludes.apply(this, args);
  if (res === false) {
    // 代理对象找不到,再去原始数据查找
    res = originIncludes.apply(this.raw, args);
  }
  return res;
}
const arrReactive = reactive(arr);
console.log(arrReactive.includes(obj)); // true 

这样就解决了最开始的问题,而关于数组的查找还有 indexOf、lastIndexOf 这两个 API,统一进行重写即可,都是一样的思路 下面来看第二组重写,是针对于数组的增删方法 为了复现这个问题就需要回顾 Vue3 的响应式数据整体实现了,借这个机会简单复习一下依赖收集和触发依赖的过程,无非就是实现 track、trigger 函数,再提供一个 effect 的方法来触发一开始的依赖收集:



  
    
    
    Document
  
  
    

稍微了解一些 Vue3 响应式原理源码实现的应该都能看明白,这里只是实现了一个丐版响应式,可以直接复制到 html 里查看效果:

添加图片注释,不超过 140 字(可选)

但假如我们去代理一个数组,然后添加一个副作用函数,该副作用函数里进行 push 操作:

const arr = [1, 2, 3];
const arrProxy = reactive(arr);
effect(() => {
  arrProxy.push(4);
});

这时候会发现直接就爆栈了:

添加图片注释,不超过 140 字(可选)

我们来分析一下原因,主要来研究 push 操作的流程,在设计与实现中也根据了 ECMA262 文档分析其过程,这里不再过多展开,需要关注的一点当调用 push 方法时会有这个过程:

  1. 访问数组的 push 属性(getter)
  2. 访问数组的 length 属性(getter)
  3. 修改数组的 length 属性 +1(setter)

问题就出在 length 属性上,当执行副作用函数时 getter 会进行依赖收集,而它的 setter 又会导致该副作用函数重新执行,因此就这样无限循环下去爆栈 所以 Vue3 给到的解决方案就是屏蔽掉 length 属性的依赖收集,实现方式简单粗暴,给个 flag 标志控制是否收集依赖就行,重点在于该 flag 应该在何时改变 其实就在 push 调用上,调用之前我们修改标志禁止收集,调用结束后再解开即可,而重写的过程和上面 includes 思路一样:

const wm = new WeakMap();
// new: 增加是否进行依赖收集标志
let shouldTrack = true;

function effect(fn) {
  activeEffect = fn;
  fn();
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      let res = target[key];
      // new: 访问 push 属性,返回重写的方法
      if (key === "push") return push;
      track(target, key);
      return res;
    },
    set(target, key, value) {
      target[key] = value;
      trigger(target, key);
      return true;
    },
  });
}

function track(target, key) {
  // new: 补充新的判断是否收集依赖的逻辑
  if (!activeEffect || !shouldTrack) return;

  let map = wm.get(target);
  if (!map) {
    map = new Map();
    wm.set(target, map);
  }

  let deps = map.get(key);
  if (!deps) {
    deps = new Set();
    map.set(key, deps);
  }
  deps.add(activeEffect);
}

function trigger(target, key) {
  const map = wm.get(target);
  if (!map) return;
  const deps = map.get(key);
  if (!deps) return;
  for (const effect of deps) {
    effect();
  }
}

// new: 重写 push 方法
function push(...args) {
  shouldTrack = false;
  const res = Array.prototype.push.apply(this, args);
  shouldTrack = true;
  return res;
}

const arr = [1, 2, 3];
const arrProxy = reactive(arr);
effect(() => {
  arrProxy.push(4);
});

我们来看看源码这部分怎么实现的:

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

都是一样的控制 shouldTrack 变量实现,至于为什么还用了 stack 存储,个人猜测跟嵌套依赖收集有关,毕竟函数调用是栈结构嘛,这里就不展开深究了 End(总结) 最后我们针对于 Vue2、Vue3 这两种重写数组方法的方式进行一个总结,谈谈我的个人看法 首先两者要解决的问题完全不一样,其根本原因在于 Object.defineProperty 和 Proxy 的特性不同 Vue2 中使用的 Object.defineProperty 操作的最小单元是对象的属性,因此如果数组进行 push 添加新元素时,需要针对于该元素再调用 Object.defineProperty 进行劫持操作,所以需要扩展原有的 push 方法 但了解到 Vue3 的重写方式后我产生了一个疑问, Vue2 也完全可以按照 Vue3 中的模式,针对于每个数组枚举出需要进行重写的方法,然后通过 Object.defineProerty 拦截到对应的方法名,然后返回重写的数组方法,这样就可以不使用以原型继承的方式来重写,且该方式也会避免 Vue3 针对于 length 属性造成爆栈的问题,因为就没有对 length 属性进行劫持操作 :

添加图片注释,不超过 140 字(可选)

不过很快我就打消了这个念头,这样的做法会导致每个数组实例都需要先通过 Object.defineProperty 添加这几个需要重写的数组方法,但Vue2 中重写的方式不管有多少个数组实例,都始终只有一个中间对象来存储重写的方式,所以开销较小 而且在我们的认知中数组方法往往是挂载到原型上的,以这种挂载到实例上方式其实并不合适 Vue3 中使用的 Proxy 操作的最小单元是对象,也就是说无论该对象动态添加多少个属性同样都能劫持到,因此无需考虑 Vue2 上面的问题,但这种方式同样也引出了其他问题: 第一个问题:由于 proxy 返回的是一个新的代理对象,因此如果一个数组中的元素都是引用类型,则通过代理后会发现产生的新代理对象不再是原始的引用值,这就导致数组中查找元素的方式产生问题,Vue3 就针对于这几个查找的方式进行重写,先在代理后的数组对象中查找,再去原始数组中查找,两次查找便能解决上述问题 第二个问题:由于 proxy 是对象级别的代理,那么针对于数组常用方法操作时会产生不必要的劫持属性:length 属性,比如针对于 push 方法的调用底层会进行访问 length、修改 length 两个操作,因此会导致收集的副作用函数无限循环下去造成爆栈,而 Vue3 解决方式就是避免 length 属性的依赖收集操作,通过重写对应的数组方法动态修改 flag 值,其依赖收集的 track 方法会根据该 flag 来判断是否进行收集



扫描二维码推送至手机访问。

版权声明:本文由ruisui88发布,如需转载请注明出处。

本文链接:http://www.ruisui88.com/post/670.html

分享给朋友:

“Vue2 重写了数组方法,你知道 Vue3 也重写了吗?” 的相关文章

Excel VBA 收费结算模块/一步一步带你设计【收费管理系统】11

本文于2023年6月9日首发于本人同名公众号:Excel活学活用,更多文章案例请搜索关注!☆本期内容概要☆用户窗体设置:收费结算模块设置(6)增加合计金额增加收款方式选择输入大家好,我是冷水泡茶,前几期我们分享了【收费管理系统】的设计,最近一期是(Excel VBA 收费结算模块/一步一步带你设计【...

基于gitlab的PR操作教程

基于gitlab的PR操作教程注:该教程主要基于git命令行操作,其他图形化工具也可完成以下所有操作步骤,顺手即可。推荐工具:Source Tree ,TortoiseGit参考:gitflow一 . 基于分支的PR操作1. 本地切换到master分支1. 拉取最新代码2. 基于master创建ho...

「干货」FPGA设计中深度约束技巧及调试经验总结

今天跟大家分享的内容很重要,也是我们调试FPGA经验的总结。随着FPGA对时序和性能的要求越来越高,高频率、大位宽的设计越来越多。在调试这些FPGA样机时,需要从写代码时就要小心谨慎,否则写出来的代码可能无法满足时序要求。另外,最近跟网友聊天时,有谈到公众号寿命的问题,我觉得网络交换FPGA公众号应...

一文让你彻底搞懂 vue-Router

路由是网络工程里面的专业术语,就是通过互联把信息从源地址传输到目的地址的活动。本质上就是一种对应关系。分为前端路由和后端路由。后端路由:URL 的请求地址与服务器上的资源对应,根据不同的请求地址返回不同的资源。前端路由:在单页面应用中,根据用户触发的事件,改变URL在不刷新页面的前提下,改变显示内容...

前端路由简介以及vue-router实现原理

作者:muwoo 来源:https://zhuanlan.zhihu.com/p/37730038后端路由简介路由这个概念最先是后端出现的。在以前用模板引擎开发页面时,经常会看到这样http://www.xxx.com/login 大致流程可以看成这样:浏览器发出请求服务器监听到80 端口(或443...

vue打开新窗口并且实现传参,有图有真相

我要实现的功能是打开一个新窗口用来展示新页面,而且需要传参数,并且参数不能显示在地址栏里面,而且当我刷新页面的时候,传过来的参数不能丢失,要一直存在,除非我手动关闭这个新窗口,即浏览器的标签页。通过面向百度编程,发现网上的根本达不到这个效果,而且还都是坑,明明实现不了,还若有其事的写出来,于是我在标...