背景
我们都知道 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
方法来赋值数组元素下标的变化,可以直接使用下标来操作数组,直接修改数组
length
const 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;
},
});
}
<!-- 此示例在 codepen 中尝试 -->
这样我们做到了,原始数据和响应数据隔离,并且不管数据层级有多深都可以
现在我们还面临一个问题,数组怎么办?
数组通过下标来获取,跟对象的属性还不太一样,这要怎么来做隔离
那就是跟对象一样的方式来劫持数组下标
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 语法灵活性过高也无法支撑
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。