背景
我们都知道 vue3 重写了响应式代码,使用 Proxy 来劫持数据操作,分离出来了单独的库@vue/reactivity,不限于vue 在任何 js 代码都可以使用
但是正因为使用了Proxy,Proxy还无法用polyfill来兼容,就导致了不支持Proxy的环境下无法使用,这也是 vue3 不支持 ie11 的一部分原因
本文内容:重写了 @vue/reactivity 的劫持部分,来兼容不支持 Proxy 的环境
通过本文可以一些内容:
- 响应式原理
@vue/reactivity和vue2响应式的区别- 在使用
Object.defineProperty改写中遇到的问题及解决方案 - 代码实现
- 应用场景及限制
源码地址:reactivity 主要为 defObserver.ts 文件
响应式
在开始之前我们先对 @vue/reactivity 的响应式有个简单的了解
首先是对一个数据进行了劫持
在 get 获取数据的时候去收集依赖,记录自己是在哪个方法里调用的,假设是被方法 effect1 调用
在 set 设置数据的时候就拿到 get 时候记录的方法,去触发 effect1 函数,达到监听的目的
而 effect 是一个包装方法,在调用前后将执行栈设置为自己,来收集函数执行期间的依赖
区别
vue3 相比 vue2 的最大区别就是使用了 Proxy
Proxy可以比Object.defineProperty有更全面的代理拦截:
(Proxy 虽然带来了更加全面的功能,但是也带来了性能,Proxy 实际上比 Object.defineProperty 慢得多)
-
未知属性的
get/set劫持const obj = reactive({}); effect(() => { console.log(obj.name); }); obj.name = 111;这一点在
Vue2中就必须使用set方法来赋值 -
数组元素下标的变化,可以直接使用下标来操作数组,直接修改数组
lengthconst arr = reactive([]); effect(() => { console.log(arr[0]); }); arr[0] = 111; -
对
delete obj[key]属性删除的支持const obj = reactive({ name: 111, }); effect(() => { console.log(obj.name); }); delete obj.name; -
对
key in obj属性是否存在has的支持const obj = reactive({}); effect(() => { console.log("name" in obj); }); obj.name = 111; -
对
for(let key in obj){}属性被遍历ownKeys的支持const obj = reactive({}); effect(() => { for (const key in obj) { console.log(key); } }); obj.name = 111; -
对
Map、Set、WeakMap、WeakSet的支持
这些是Proxy带来的功能,还有一些新的概念或使用方式上的变化
- 独立的分包,不止可以在
vue里使用 - 函数式的方法
reactive/effect/computed等方法,更加灵活 - 原始数据与响应数据隔离,也可以通过
toRaw来获取原始数据,在vue2中是直接在原始数据中进行劫持操作 - 功能更加全面
reactive/readonly/shallowReactive/shallowReadonly/ref/effectScope,只读、浅层、基础类型的劫持、作用域
那么如果我们要使用Object.defineProperty,能完成上面的功能吗?会遇到哪些问题?
问题及解决
我们先忽略Proxy和Object.defineProperty功能上的差异
因为我们要写的是@vue/reactivity而不是基于vue2,所以要先解决一些新概念差异的问题,如原始数据和响应数据隔离
@vue/reactivity 的做法,原始数据和响应数据之间有一个弱类型的引用(WeakMap),在 get 一个object类型数据的时候拿的还是原始数据,只是判断一下如果存在对应的响应数据就去取,不存在就生成一个对应的响应式数据保存并获取
这样在 get 层面控制,通过响应式数据拿到的永远是响应式,通过原始对象拿到的永远是原始数据(除非直接将一个响应式直接赋值给一个原始对象里属性)
那么 vue2 的源码就不能直接拿来用了
按照上面所说的逻辑,写一个最小实现的代码来验证逻辑:
const proxyMap = new WeakMap();
function reactive(target) {
// 如果当前原始对象已经存在对应响应对象,则返回缓存
const existingProxy = proxyMap.get(target);
if (existingProxy) {
return existingProxy;
}
const proxy = {};
for (const key in target) {
proxyKey(proxy, target, key);
}
proxyMap.set(target, proxy);
return proxy;
}
function proxyKey(proxy, target, key) {
Object.defineProperty(proxy, key, {
enumerable: true,
configurable: true,
get: function () {
console.log("get", key);
const res = target[key];
if (typeof res === "object") {
return reactive(res);
}
return res;
},
set: function (value) {
console.log("set", key, value);
target[key] = value;
},
});
}
这样我们做到了,原始数据和响应数据隔离,并且不管数据层级有多深都可以
现在我们还面临一个问题,数组怎么办?
数组通过下标来获取,跟对象的属性还不太一样,这要怎么来做隔离
那就是跟对象一样的方式来劫持数组下标
const target = [{ deep: { name: 1 } }];
const proxy = [];
for (let key in target) {
proxyKey(proxy, target, key);
}
就是在上面的代码里加个isArray的判断
而这样也决定了我们后面要一直维护这个数组映射,其实也简单,在数组push/unshift/pop/shift/splice等长度变化的时候给新增或删除的下标重新建立映射
const instrumentations = {}; // 存放重写的方法
["push", "pop", "shift", "unshift", "splice"].forEach((key) => {
instrumentations[key] = function (...args) {
const oldLen = target.length;
const res = target[key](...args);
const newLen = target.length;
// 新增/删除了元素
if (oldLen !== newLen) {
if (oldLen < newLen) {
for (let i = oldLen; i < newLen; i++) {
proxyKey(this, target, i);
}
} else if (oldLen > newLen) {
for (let i = newLen; i < oldLen; i++) {
delete this[i];
}
}
this.length = newLen;
}
return res;
};
});
老的映射无需改变,只用映射新的下标和删除已被删除的下标
这样做的缺点就是,如果你重写了数组的方法,并在里面设置了一些属性并不能成为响应式
例如:
class SubArray extends Array {
lastPushed: undefined;
push(item: T) {
this.lastPushed = item;
return super.push(item);
}
}
const subArray = new SubArray(4, 5, 6);
const observed = reactive(subArray);
observed.push(7);
这里的 lastPushed 无法被监听,因为 this 是原始对象
有个解决方案就是在 push 之前将响应数据记录,在 set 修改元数据的时候判断并触发,还在考虑是否这样使用
// 在劫持push方法的时候
enableTriggering()
const res = target[key](...args);
resetTriggering()
// 声明的时候
{
push(item: T) {
set(this, 'lastPushed', item)
return super.push(item);
}
}
实现
在 get 劫持里调用 track 去收集依赖
在 set 或 push 等操作的时候去 触发 trigger
用过 vue2 的都应该知道defineProperty的缺陷,无法监听属性删除和未知属性的设置,所以有一个已有属性和未知属性的区别
其实上面的示例稍微完善一下就可以了,就已经支持了已有属性的劫持
const obj = reactive({
name: 1,
});
effect(() => {
console.log(obj.name);
});
obj.name = 2;
接下来在实现上我们要修复 defineProperty 和 Proxy 的差异
下面几点差异:
- 数组下标变动
- 未知元素的劫持
- 元素的
hash操作 - 元素的
delete操作 - 元素的
ownKeys操作
数组的下标变化
数组有点特殊就是当我们调用 unshift 在数组最开始插入元素的时候,要 trigger 去通知数组每一项变化了,这个在Proxy中完全支持不需要写多余代码,但是使用defineProperty就需要我们去兼容去计算哪些下标变动
在splice、shift、pop、push等操作的时候也同样需要去计算出变动了哪些下标然后去通知
另外有个缺点:数组改变 length 也不会被监听,因为无法重新length属性
未来可能考虑换成对象来代替数组,不过这样就不能用Array.isArray来判断了:
const target = [1, 2];
const proxy = Object.create(target);
for (const k in target) {
proxyKey(proxy, target, k);
}
proxyKey(proxy, target, "length");
其他操作
剩下的这些属于defineProperty的硬伤,我们只能通过新增额外的方法来支持
所以我们新增了 set、get、has、del、ownKeys 方法
(可点击方法查看源码实现)
使用
const obj = reactive({});
effect(() => {
console.log(has(obj, "name")); // 判断未知属性
});
effect(() => {
console.log(get(obj, "name")); // 获取未知属性
});
effect(() => {
for (const k in ownKeys(obj)) {
// 遍历未知属性
console.log("key:", k);
}
});
set(obj, "name", 11111); // 设置未知属性
del(obj, "name"); // 删除属性
obj 本来是一个空对象,并不知道未来会添加什么属性
像 set 和 del 都是 vue2 中存在的,用来兼容defineProperty的缺陷
set 替代了未知属性的设置
get 替代了未知属性的获取
del 替代了delete obj.name 删除语法
has 替代了 'name' in obj 判断是否存在
ownKeys 替代了 for(const k in obj) {}等遍历操作,在将要遍历对象/数组的时候要用ownKeys包裹
应用场景及限制
目前来说此功能主要定位为:非vue环境并且不支持 Proxy
其他的语法使用 polyfill 兼容
因为老版的 vue2 语法也不用改,如果要在 vue2 使用新语法也可以使用 composition-api 来兼容
为什么要做这个事情,原因还是我们的应用(小程序)其实还是有一部分用户的环境是不支持 Proxy ,但还想用 @vue/reactivity 这种语法
至于通过上面使用的例子我们应该也知道了,限制是挺大的,灵活性的代价也很高
如果想要灵活一点必须使用方法包装一下,如果不灵活的话,用法就跟 vue2 差不太多,所有的属性先初始化的时候定义一下
const data = reactive({
list: [],
form: {
title: "",
},
});
这种方法带来了一种心智上的损耗,在使用和设置的时候都要考虑这个属性是否是未知的属性,是否要使用方法来包装
粗暴点的给所有设置都用方法包裹,这样的代码也好看不到哪里去
而且根据木桶效应,一旦使用了包装方法,那么在高版本的时候自动切换到Proxy劫持好像也就没有必要了
另一种方案是在编译时处理,给所有获取的时候套上 get 方法,给所有的设置语法套上 set 方法,但这种带来的成本无疑是非常大的,并且一些 js 语法灵活性过高也无法支撑