扯皮
最近一直在投简历准备面试,三天时间就把官网 + boss 上的应届前端岗位全投完了,只能说前端校招这块基本上已经烂完了(92✌除外),也后悔自己大学期间没敢跑出去实习积累经验,校招确实是学历不行 + 实习经历不行就寄了。这段几天都在背八股文刷算法,到头来一个面试都没有,只有自己在不断内耗,所以不浪费时间了,后面还是自己学习技术搞项目吧😇
言归正传不扯那么多了,投了几百份简历只有一个小厂面试,正如标题所说,这个问题就是这家公司的面试官提的,个人感觉作为小厂这样延申还是有丶东西的
当时第一时间就想到霍春阳大佬在设计与实现那本书上写到关于数组的代理方式,确实重写了几个方法,但是为什么重写让我给忘完了😑,赶紧下来自己总结一下
正文
关于 Vue2 数组方法重写其实是一道很常见的八股文,如果有去系统背 Vue 相关面试题的话很容易就能了解到,但是自己也调研了一下很少有人提到关于 Vue3 数组的重写问题
开始之前,我们先来看瞟一眼源码打包后关于数组方法重写部分确认一下😇:
接下来针对于 Vue2、Vue3 的数组方法重写我们分开探讨🧐
浅谈 Object.defineProperty
提到 Vue2 数组方法重写的时候就要先提到 Vue2 的响应式原理,提到 Vue2 的响应式原理就又要提到一个 API:Object.defineProperty
关于这个 API 具体使用方法就不再过多介绍了,不清楚的直接查看文档👇:
Object.defineProperty() - JavaScript | MDN (mozilla.org)
我们在这里只讨论该 API 的局限性,根据其描述可以看出它是用来自定义对象上的属性,专业些来讲就是定义属性描述符,所以其实它并没有强调数据劫持的操作,只是在属性描述符中提供了访问器描述符:get、set
而 Vue2 就是借助这两个访问器进行数据劫持实现了响应式数据,我们精简一下核心源码就是这样:
js
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 操作
如果按照我对数据劫持的设想它应该是这样才对🙂:
js
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 上的属性,也就是说要想劫持的话需要这样:
js
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
源码其实很简单没多少行,就是最开始截图的部分,可以明确针对于劫持数组方法的调用会有三个操作:
- 调用原生的数组方法拿到结果,最后将其返回
- 针对于插入操作获取到插入的内容,对插入的内容进行数据劫持
- 通知依赖收集的函数执行
简单画张图来感受一下重写数组的魅力:
当然聪明的你一定能想到关于数组的增删还有一些歪门邪道的做法,比如直接通过索引进行设置来添加元素,以及调用 delete 关键字来删除元素,同样也适用于对象的增删
关于这一点不管是之前实现 defineReactive 还是数组重写是都无法拦截到的,直接进行修改的话由于无法拦截自然就无法触发对应的响应式流程,所以 Vue2 提供了 set、delete 两个全局方法来解决这个问题
同样这两个方法的核心源码也没几行,本质就是调用数组上的 splice 方法做到添加、删除元素,由于 splice 方法已被重写,因此针对于添加的元素会被数据劫持且通知该数组收集的所有依赖函数执行
浅谈 Proxy
Proxy 作为 ES6 新增特性给 JS 提供了强大的代理功能,该 API 的介绍就是针对于对象的基本操作能够进行拦截,而且这里的基本操作并不局限于 get、set,大概有十几种操作,具体可以看文档👇:
Proxy - JavaScript | MDN (mozilla.org)
当然这里我们还是考虑响应式数据这块,依旧先使用 get、set 实现一个数据劫持的效果:
js
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,我们声明一个响应式数组:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">{{data}}</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.js"></script>
<script>
const vm = new Vue({
el: "#app",
data() {
return {
data: [1, 2, 3, 4],
};
},
mounted() {
setTimeout(() => {
this.data[2] = 100;
}, 2000);
},
});
</script>
</body>
</html>
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">{{data}}</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.js"></script>
<script>
Vue.createApp({
data() {
return {
data: [1, 2, 3, 4],
};
},
mounted() {
setTimeout(() => {
this.data[2] = 100;
}, 2000);
},
}).mount("#app");
</script>
</body>
</html>
为了统一风格 Vue3 我也使用了 Options API,当然重点在于当数组元素都是基础数据类型 Vue3 依旧做了劫持,而 Vue2 定时器 2s 后界面上依旧没有变化 🤔
我们打印 Vue2 中响应式数组来看看结果,可以看到定时器后数组元素发生改变,且也有对应的依赖函数更新视图:
归根究底如果你有去研究源码的话可以发现 Vue2 针对于数组从始至终都没有进行 defineReactive,只不过给它增加了一个 observer 对象罢了,当遇到一个 value 是数组时 Vue2 会进行遍历针对于每个元素执行 defineReactive 操作,唯独数组本身没有
然而 Vue3 能够实现这一点要归功于 Proxy API,针对于一个数组代理只需要在 getter 中根据你访问的属性增加额外的判断处理逻辑即可
Vue3 重写数组方法
由最开始的截图可以发现 Vue3 针对于数组方法分了两组重写:
第一组针对于查找相关的方法:includes、indexOf、lastIndexOf
第二组针对于增删相关的方法:push、pop、shift、unshift、splice
我们根据设计与实现中的讲解,分别介绍两组重写的原因🧐
首先来看关于查找相关的方法,书中举了这样的例子:
js
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 值是一个引用值时,会进行递归代理
也就是代理后的对象已经不再是原来的对象了:
js
console.log(arrReactive[0], obj, arrReactive[0] === obj);
而数组的 includes 方法底层也是帮我们遍历数组找到对应的 value,这一点我们在 getter 中打印一下 key 就能发现:
js
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;
},
});
}
它会先访问数组的 includes 属性,接着再访问 length 属性,然后开始遍历访问数组下标进行查找
关于 includes 具体执行流程可以自行查阅 ECMA262 文档👇:
ECMAScript® 2025 Language Specification (tc39.es)
或者看设计与实现这部分的内容,霍春阳大佬已经介绍了这个整个流程
所以我们最终解决问题的方案在 includes 方法上,假如我们数组存储的全是普通对象,那经过 reactive 代理后这里的普通对象会全部变成代理对象,所以 includes 底层进行遍历的时候拿到的都是代理对象进行比对,因此才不符合我们的预期🧐
Vue3 对于这个问题的处理很简单,直接重写 includes 方法,先针对于代理数组中调用 includes 方法查找,如果没有找到再拿到原始数组中调用 includes 方法查找,两次查找就能完美解决这个问题
我们简单来尝试一下,首先改造原来的代理,需要增加一个 raw 字段来保存原始数据,然后只针对于 includes 方法进行重写。具体见注释,没有按照源码封装,精简下来只实现该功能:
js
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 的方法来触发一开始的依赖收集:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="box"></div>
<script>
const boxDom = document.querySelector(".box");
const obj = { name: "test", age: 20 };
const wm = new WeakMap();
// 触发依赖收集
function effect(fn) {
activeEffect = fn;
fn();
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
let res = target[key];
track(target, key); // 依赖收集
return res;
},
set(target, key, value) {
target[key] = value;
trigger(target, key); // 触发依赖
return true;
},
});
}
// weakMap => Map => Set 结构进行依赖收集
function track(target, key) {
if (activeEffect) {
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);
}
}
// 根据 target 找到对应的 deps 取出执行收集的副作用函数
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();
}
}
const objProxy = reactive(obj);
// 手动进行依赖收集
effect(() => {
boxDom.textContent = objProxy.name;
console.log("更改 DOM 内容");
});
</script>
</body>
</html>
稍微了解一些 Vue3 响应式原理源码实现的应该都能看明白,这里只是实现了一个丐版响应式,可以直接复制到 html 里查看效果:
但假如我们去代理一个数组,然后添加一个副作用函数,该副作用函数里进行 push 操作:
js
const arr = [1, 2, 3];
const arrProxy = reactive(arr);
effect(() => {
arrProxy.push(4);
});
这时候会发现直接就爆栈了:
我们来分析一下原因,主要来研究 push 操作的流程,在设计与实现中也根据了 ECMA262 文档分析其过程,这里不再过多展开,需要关注的一点当调用 push 方法时会有这个过程:
- 访问数组的 push 属性(getter)
- 访问数组的 length 属性(getter)
- 修改数组的 length 属性 +1(setter)
问题就出在 length 属性上,当执行副作用函数时 getter 会进行依赖收集,而它的 setter 又会导致该副作用函数重新执行,因此就这样无限循环下去爆栈
所以 Vue3 给到的解决方案就是屏蔽掉 length 属性的依赖收集,实现方式简单粗暴,给个 flag 标志控制是否收集依赖就行,重点在于该 flag 应该在何时改变
其实就在 push 调用上,调用之前我们修改标志禁止收集,调用结束后再解开即可,而重写的过程和上面 includes 思路一样:
js
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);
});
我们来看看源码这部分怎么实现的:
都是一样的控制 shouldTrack 变量实现,至于为什么还用了 stack 存储,个人猜测跟嵌套依赖收集有关,毕竟函数调用是栈结构嘛,这里就不展开深究了
End(总结)
最后我们针对于 Vue2、Vue3 这两种重写数组方法的方式进行一个总结,谈谈我的个人看法🤪
首先两者要解决的问题完全不一样,其根本原因在于 Object.defineProperty 和 Proxy 的特性不同
Vue2 中使用的 Object.defineProperty 操作的最小单元是对象的属性,因此如果数组进行 push 添加新元素时,需要针对于该元素再调用 Object.defineProperty 进行劫持操作,所以需要扩展原有的 push 方法
但了解到 Vue3 的重写方式后我产生了一个疑问🤔, Vue2 也完全可以按照 Vue3 中的模式,针对于每个数组枚举出需要进行重写的方法,然后通过 Object.defineProerty 拦截到对应的方法名,然后返回重写的数组方法,这样就可以不使用以原型继承的方式来重写,且该方式也会避免 Vue3 针对于 length 属性造成爆栈的问题,因为就没有对 length 属性进行劫持操作 👇:
不过很快我就打消了这个念头😑,这样的做法会导致每个数组实例都需要先通过 Object.defineProperty 添加这几个需要重写的数组方法,但Vue2 中重写的方式不管有多少个数组实例,都始终只有一个中间对象来存储重写的方式,所以开销较小🤓
而且在我们的认知中数组方法往往是挂载到原型上的,以这种挂载到实例上方式其实并不合适🧐
Vue3 中使用的 Proxy 操作的最小单元是对象,也就是说无论该对象动态添加多少个属性同样都能劫持到,因此无需考虑 Vue2 上面的问题,但这种方式同样也引出了其他问题:
第一个问题:由于 proxy 返回的是一个新的代理对象,因此如果一个数组中的元素都是引用类型,则通过代理后会发现产生的新代理对象不再是原始的引用值,这就导致数组中查找元素的方式产生问题,Vue3 就针对于这几个查找的方式进行重写,先在代理后的数组对象中查找,再去原始数组中查找,两次查找便能解决上述问题
第二个问题:由于 proxy 是对象级别的代理,那么针对于数组常用方法操作时会产生不必要的劫持属性:length 属性,比如针对于 push 方法的调用底层会进行访问 length、修改 length 两个操作,因此会导致收集的副作用函数无限循环下去造成爆栈,而 Vue3 解决方式就是避免 length 属性的依赖收集操作,通过重写对应的数组方法动态修改 flag 值,其依赖收集的 track 方法会根据该 flag 来判断是否进行收集