背景
咱们都晓得 vue3
重写了响应式代码,应用 Proxy
来劫持数据操作,分离出来了独自的库@vue/reactivity
,不限于vue
在任何 js 代码都能够应用
然而正因为应用了 Proxy
,Proxy
还无奈用 polyfill
来兼容,就导致了不反对 Proxy
的环境下无奈应用,这也是 vue3
不反对 ie11
的一部分起因
本文内容:重写了 @vue/reactivity
的劫持局部,来兼容不反对 Proxy
的环境
通过本文能够一些内容:
- 响应式原理
@vue/reactivity
和vue2
响应式的区别- 在改写中遇到的问题及解决方案
- 代码实现
- 利用场景及限度
源码地址:reactivity 次要为 defObserver.ts
文件
响应式
在开始之前咱们先对 @vue/reactivity
的响应式有个简略的理解
首先是对一个数据进行了劫持
在 get
获取数据的时候去收集依赖,记录本人是在哪个办法里调用的,假如是被办法 effect1
调用
在 set
设置数据的时候就 拿到 get
时候记录的办法,去触发 effect1
函数,达到监听的目标
而 effect
是一个包装办法,在调用前后将执行栈设置为本人,来收集函数执行期间的依赖
区别
vue3
相比 vue2
的最大区别就是应用了 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 语法灵活性过高也无奈撑持