vue3 的响应式
前言
Vue2 里的响应式像是半成品,对于对象上新增的属性无能为力,对于数组则需要拦截它的原型方法来实现响应式,为了解决这个问题,Vue 提供了this.$set这个 API,使得新增的属性也拥有响应式的效果
响应式仓库
Vue3 不同于 Vue2 也体现在源码结构上,Vue3 把耦合性较低的包分散在 package 目录下,单独发布成 npm 包,这也是目前很流行的一种大型项目管理方式:Monorepo
负责响应式部分的仓库是 @vue/rectivity,它不涉及 Vue 的其他任何部分,甚至可以集成进 React
区别
// Vue2
// 必须预先知道要拦截的 key 是什么,需要根据具体的 key 去拦截对应的get和set操作
Object.defineProperty(data, 'count', {
get() {},
set() {},
});
// Vue3
// 无需关心具体 key,拦截的是整个 data 对象的 get 和 set 操作
new Proxy(data, {
get(key) {},
set(key, value) {},
});
简单的例子
Vue3 的 effect 相当于去掉了手动声明依赖的进化版的 React 中的 useEffect
// 响应式数据
const data = reactive({
count: 1,
});
// 观测变化
effect(() => console.log('count changed', data.count));
// 触发上面的打印重新执行
data.count = 2;
可以把 effect 中的回调函数联想到视图的重新渲染、watch 的回调函数等等,因为它们同样基于这套响应式机制
先讲原理
在 Proxy 第二个参数 handler 中,拦截各种取值、赋值操作,依托 track 和 trigger 两个函数进行依赖收集和派发更新
- track:在读取时收集依赖
- trigger:在更新时触发依赖
track
function track(target: object, type: TrackOpTypes, key: unknown) {
const depsMap = targetMap.get(target);
// 收集依赖时,通过 key 建立一个 set
let dep = new Set();
targetMap.set(ITERATE_KEY, dep);
// 这个 effect 可以理解为更新函数,存放在 dep 里
dep.add(effect);
}
- target:原对象
- type:本次收集的类型,也就是收集依赖的时候用来标记是什么类型的操作,如 get
- key:本次访问的是数据中的哪个 key,如 count
- 首先全局会存在一个 targetMap,它用来建立
数据->依赖的映射,是一个 WeakMap 数据结构 - targetMap 通过数据 target,可以获取到 depsMap,它用来存放这个数据对应的所有响应式依赖
- depsMap 的每一项是一个 Set 数据结构,这个 Set 就存放着对应 key 的更新函数
举例
const target = { count: 1 };
const data = reactive(target);
const effection = effect(() => console.log(data.count));
这个例子的依赖关系为
- 全局的 targetMap 是
targetMap: { { count: 1 }: dep } - dep 是
dep: { count: Set{ effection } }
这样一层层下去,就可以通过 target 找到 count 对应的更新函数 effection 了
trigger
这里是最小化的实现,仅仅为了便于理解原理,实际上要复杂很多
type 的作用很关键,见后文
export function trigger(target: object, type: TriggerOpTypes, key?: unknown) {
// 简单来讲,就是通过 key 找到所有更新函数,依次执行
const dep = targetMap.get(target);
dep.get(key).forEach(effect => effect());
}
新增属性
讲一讲数组调用原生方法,以 push 为例
const data = reactive([]);
effect(() => console.log('c', data[1]));
// 没反应
data.push(1);
// 触发响应,因为修改了下标为 1 的值
data.push(2);
因为 push 会触发两对 get 和 set 操作
- 读取 push 方法
- 读取 arr 原有的 length 属性
- 对数组第 i 项赋值
- 对数组 length 属性赋值
在例子中,读取 data[1],一定会把对于 1 这个下标的依赖收集起来,所以 push 的时候也能精准的触发响应式依赖的执行
遍历后新增
const data = reactive([]);
effect(() =>
console.log(
'data map +1',
data.map(item => item + 1)
)
);
// 触发响应,打印出 [2]
data.push(1);
因为在 map 的时候,会触发 get length,而在触发更新的时候,Vue3 内部会对新增 key 的操作进行特殊处理,这里是新增了 0 这个下标的值,会走到 trigger 中这样的一段逻辑里去
// 简化版
if (isAddOrDelete) {
add(depsMap.get('length'));
}
把之前读取 length 时收集到的依赖拿到,然后触发函数
总结,我们在 effect 里的 map 操作读取了 length,收集了 length 的依赖,在新增 key 的时候,触发 length 收集到的依赖,触发回调函数,对于 for...of 操作,也是一样
遍历后删除或者清空
注意上面的源码里的判断条件是 isAddOrDelete,所以删除也是同理
获取 keys
const obj = reactive({ a: 1 });
effect(() => console.log('keys', Reflect.ownKeys(obj)));
effect(() => console.log('keys', Object.keys(obj)));
effect(() => {
for (let key in obj) {
console.log(key);
}
});
// 触发所有响应
obj.b = 2;
以上几种获取 key 的方式都能成功的拦截,因为 Vue 内部拦截了 ownKeys 操作符
const ITERATE_KEY = Symbol('iterate');
function ownKeys(target) {
track(target, 'iterate', ITERATE_KEY);
return Reflect.ownKeys(target);
}
ITERATE_KEY 作为一个特殊的标识符,表示这是读取 key 的时候收集到的依赖,它会被作为依赖收集的 key,那么在触发更新时,其实就对应这段源码
if (isAddOrDelete) {
add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}
删除对象属性
const obj = reactive({ a: 1, b: 2 });
effect(() => console.log(Object.keys(obj)));
// 触发响应
delete obj['b'];
这是因为 Vue 内部拦截了 deleteProperty 操作符
function deleteProperty(target: object, key: string | symbol): boolean {
const result = Reflect.deleteProperty(target, key);
trigger(target, TriggerOpTypes.DELETE, key);
return result;
}
const isAddOrDelete =
type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE;
if (isAddOrDelete) {
add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}
这里的 target 不是数组,所以还是会触发 ITERATE_KEY 收集的依赖,也就是上面提到的对于 key 的读取收集到的依赖
判断属性是否存在
const obj = reactive({});
effect(() => console.log('has', Reflect.has(obj, 'a')));
effect(() => console.log('has', 'a' in obj));
effect(() => console.log('has', Object.hasOwnProperty(obj, 'a')));
// 触发两次响应
obj.a = 1;
利用了 has 操作符的拦截
function has(target, key) {
const result = Reflect.has(target, key);
track(target, 'has', key);
return result;
}
Map 和 Set
Vue3 对于这两种数据类型完全支持响应,详见另一篇文章