我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情
前言
终于将准备工作做完了,这一章正式进入正题。在此之前我们有必要先搞懂一些基础知识,Vue3 与 Vue2 在响应式系统设计方面最大的不同莫过于:基于 es5+ 的 Proxy 特性来进行了重构。但为什么要这么做呢?这里来说几点原因:
- 初始化时vue2.x是利用Object.defineProperty来拦截对象的getter/setter方法,需要遍历对象所有 key,如果对象层次较深,性能不好;
- 通知更新过程需要维护大量 dep 实例和 watcher 实例,额外占用内存较多;
- 无法监听到数组元素的变化,只能通过劫持重写了几个数组方法;
- 动态新增,删除对象属性无法拦截,只能用特定 set/delete API 代替;
- 不支持 Map、Set 等数据结构;
带着上述的原因,我们一步一步在源码实现过程中寻找答案吧!
基于new Proxy重新定义响应方法
在 packages 目录下新建 reactivity 目录
// packages/reactivity/src/index.ts
import { reactive } from "./reactive";
export { reactive };
// packages/reactivity/src/reactive.ts
import { mutaleBaseHandler } from "./baseHandler";
import { isObject } from "@mini-vue/shared";
export const enum ReactiveFlags {
IS_REACTIVE = '__v_isReactive',
}
// 注意点1
const proxyMap = new WeakMap();
export const isReactive = (value) => {
return !!(value && value[REACTIVE_FLAGS.IS_REACTIVE]);
};
export const reactive = (obj) => {
if (!isObject(obj)) {
return;
}
/*
注意点2
同一个object
const obj = { a: "1" };
const a = vueReactivity.reactive(obj);
const b = vueReactivity.reactive(obj);
console.log(a === b);
*/
const existProxy = proxyMap.get(obj);
if (existProxy) {
return existProxy;
}
/*
注意点3
如果一个对象已经被proxy代理过了,则直接返回
这个地方稍后在定义 getter 的时候还会有体现
const obj = { a: "1" };
const a = vueReactivity.reactive(obj);
const b = vueReactivity.reactive(a);
console.log(a === b);
*/
if (obj[REACTIVE_FLAGS.IS_REACTIVE]) {
return obj;
}
const proxy = new Proxy(obj, mutaleBaseHandler);
proxyMap.set(obj, proxy);
return proxy;
};
这里有一个注意点:
上一个章节我们在 packages 下新建了一个 shared 子包作为共享模块,这里就派上用场了,那么如何安装共享模块呢?
在子包目录下,比如在 reactivity 目录下执行:
pnpm install @mini-vue/shared*
// @mini-vue/shared 必须与 shared 子包的 package.json 中定义的 name 属性保持一致
// * 表示最新版本,这样就不用每次手动安装
接下来我们就可以在 packages/reactivity/package.json 中看到:
"dependencies": {
"@mini-vue/shared": "workspace:*"
}
抽离 proxy 中 get、set 方法
// packages/reactivity/src/baseHandler.ts
import { REACTIVE_FLAGS, reactive } from "./reactive";
import { track, trigger } from "./effect";
import { isObject } from "@mini-vue/shared";
export const mutaleBaseHandler = {
get(target, key, receiver) {
/*
当一个对象已经被proxy代理过了,那么再次读取 REACTIVE_FLAGS.IS_REACTIVE 属性时,
会触发get方法,由此返回true,在这里也可以解释上面在 reactive.js 文件中提到的 注意点3
*/
if (key === REACTIVE_FLAGS.IS_REACTIVE) {
return true;
}
// 依赖收集
track(target, "get", key);
const result = Reflect.get(target, key, receiver);
if (isObject(result)) {
return reactive(result); // 深度代理,取值的时候才代理,性能好
}
return result;
},
set(target, key, newValue, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, newValue, receiver);
if (oldValue !== newValue) {
// 触发更新
trigger(target, "set", key);
}
return result;
},
};
到这里我们需要注意的是 Reflect 这个api。为什么要用这种方式?我们在获取值的时候直接使用:target[key],在设置值的时候使用 target[key] = newValue 这种方式不是更能表达出日常的编码习惯吗?其实这里个人觉得有如下原因:
- 在写法上以一种函数式的方式更显优雅(这好像是废话😁);
- 使用 Reflect.set() 来设置属性会返回布尔值,而正好 proxy 在拦截 setter 时,需要一个返回一个布尔值;
- Reflect.get() 的第三个参数 receiver,可以修正 this 的指向,比如:
const obj = {
a: 1,
get b() {
return this.a;
},
};
const p = new Proxy(obj, {
get: function (target, prop, receiver) {
console.log(prop);
return target[prop];
// return Reflect.get(target, prop, receiver);
},
});
p.b;
这段代码 console.log() 期待要执行两次,第一次正常读取 b 属性,第二次 this.a 也是一次读取操作,但实际只会执行一次,打印”b“;原因是 target[prop] 这种方式读取属性,this 指的原对象,也就是 obj,它并不是代理对象,因为无法触发 getter 方法。
依赖收集与触发更新,抽离到 effect 文件
新建 packages/reactivity/src/effect.ts 文件,要搞清楚这个文件的作用,得先建立起这么几个认知:
- 被操作的代理对象 obj,被操作的字段名,比如 text,副作用函数;三者之间的关系:
类似于 WeakMap{key: target, value: Map{key: key,value: Set}},这是为了解决副作用函数嵌套的问题target └── key └── effect - 每一个副作用函数在初始化执行时都会有一个全局变量 activeEffect ,当触发 getter 时,activeEffect 会被收集起来,执行 setter 时,依次执行 activeEffect。
// packages/reactivity/src/effect.ts
export let activeEffect = undefined;
const cleanupEffect = (context) => {
for (let index = 0; index < context.deps.length; index++) {
const element = context.deps[index];
element.delete(context);
}
context.deps.length = 0;
};
export class ReactiveEffect {
// 保存当前的 activeEffect,当有effect嵌套情况时,方便复位
public parent = null;
// 储存effect对应的依赖 方便effect卸载时 删除对应的依赖
public deps = [];
// 标记当前effect是否被激活 只有在激活状态 才会有依赖收集
public active = true;
constructor(public fn, public scheduler) {}
run() {
if (!this.active) {
// 未激活 只需要执行fn即可
return this.fn();
}
try {
/*
effect嵌套时 activeEffect 与属性对应关系会错乱
比如:
effect(()=>{ effect1
state.name name -> effect1
effect(()=>{ effect2
state.age age -> effect2
})
state.address address -> undefined ( finally 执行的结果 )
})
*/
this.parent = activeEffect;
activeEffect = this;
cleanupEffect(this);
return this.fn();
} finally {
// 让当前的 activeEffect 复位
activeEffect = this.parent;
this.parent = null;
}
}
stop() {
this.active = false;
cleanupEffect(this);
}
}
export const effect = (fn, options: any = {}) => {
const _effect = new ReactiveEffect(fn, options.scheduler);
const runner = _effect.run.bind(_effect);
runner.effect = _effect;
_effect.run();
return runner;
};
const targetMap = new WeakMap();
export const trackEffects = (dep) => {
const shouldTrack = dep.has(activeEffect);
if (!shouldTrack) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
};
/**
* 此处需要注意数据结构
* weakMap{key:target,value:Map{key:key,value:Set}}
* @param target
* @param type
* @param key
* @returns
*/
export const track = (target, type, key) => {
if (!activeEffect) return;
let depMap = targetMap.get(target);
if (!depMap) {
targetMap.set(target, (depMap = new Map()));
}
let dep = depMap.get(key);
if (!dep) {
depMap.set(key, (dep = new Set()));
}
trackEffects(dep);
};
export const triggerEffects = (effects) => {
if (effects) {
/*
逻辑分支切换时清除effect对应的依赖,对于Set数据的删除、新增、循环操作时 造成的死循环
比如:effect(()=>flag?state.name:state.age)
*/
effects = new Set(effects);
effects.forEach((effect) => {
/*
解决循环调用trigger
* 比如:
* effect(() => {
state.a = Math.random();
document.getElementById("app").innerHTML = state.a;
});
*/
if (activeEffect !== effect) {
// 如果用户自定义了更新函数 则执行
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
}
});
}
};
export const trigger = (target, type, key) => {
const depMap = targetMap.get(target);
if (!depMap) return;
let effects = depMap.get(key);
triggerEffects(effects);
};
上述代码看起来很简单,但依然有至少三个点值得我们注意:
-
effect 嵌套的问题;比如:
effect(()=>{ // effect1 effect(()=>{ // effect2 state.age }) state.address })当我们执行 effect1 时,会导致 effect2 执行,此时的 activeEffect 会指向 effect2,当触发 getter 依赖收集时,实际上搜集的是 efffect2,那么我们的 effect1 即使在 setter 触发后依然得不到执行。因此 ReactiveEffect 类中的 parent 属性是用来解决这个问题的;
-
循环调用 trigger;比如:
effect(()=>{ obj.foo++ })首先读取obj.foo,会触发 getter 进行依赖收集,紧接着又将 obj.foo 加1,又触发了 setter 操作,有需要调用 effect 了, 但是此时副作用函数 effect 并没有执行完,因此就会导致无限调用自己。解决办法就是在执行 trigger 函数时,如果当前的 activeEffect 与 调用的 effect 相同时则不执行。
-
逻辑分支的切换;比如:
effect(()=> flag ? state.name : state.age) setTimeout(()=>{ state.flag = false; setTimeout(()=>{ state.name = '新值' },1000) },1000)期望的是flag 置为 false 后,即使改变了 state.name 后,effect 不执行。要解决这个问题,我们需要在 track 依赖收集之前,将之前已经收集的清空,重新收集。因此才有了上面 cleanupEffect 函数。但在执行 cleanupEffect 函数时,仍需注意 new Set() 陷阱。比如:
// 伪代码 会造成无限循环 const set = new Set([1]); set.forEach(item=>{ set.delete(item); set.add(1); })要解决其实也很简单,我们只需要在 trigger 执行时重新复制一份 new Set() 即可, effects = new Set(effects) ,使其不要去执行同一个 Set 。
总结
到这里我们已经基本实现了 Vue3 整个的响应式系统,当然在源码中,其实比这个还要复杂得多。比如在 trigger 时,还判断了 type 来区分是哪种类型的更新操作等等。完整的框架实现是一个复杂的过程,在这里向尤大及其团队致敬😁。麻雀虽小五脏俱全,这个 mini-vue,至少使得我们理清了 getter/setter 的实现流程、一些边界条件以及与 Vue2 版本的巨大差异。感谢各位大佬赏脸阅读,如有不足,请指正,请轻喷😄。