前言
书接上节,我们已经把环境准备工作做完了,这节就来到了 Vue 中比较核心的功能之一:响应式,下面我们写下它如何实现的。
分解实现
本节主要是在 reactivity 目录里面实现所有的逻辑。
vue3-core
├─ packages
│ ├─ reactivity
│ │ ├─ package.json
│ │ └─ src
│ │ ├─ baseHandlers.ts
│ │ ├─ effect.ts
│ │ ├─ index.ts
│ │ └─ reactive.ts
│ └─ shared
│ ├─ package.json
│ └─ src
│ └─ index.ts
├─ scripts
│ └─ dev.js
└─ tsconfig.json
实现 reactive
我们先引用下官方 vue 的 reactivity 模块中的 reactive 方法,我就直接在 html 里面操作演示了,它里面暴露出来的是一个对象,名叫 VueReactivity。
const { reactive } = VueReactivity;
const state = reactive({
name: "lisi",
age: 12,
hobby: ["rap"],
});
console.log(state);
上面代码运行效果如下:经过 reactive 方法调用,它返回的是一个 proxy 的代理对象。
下面我们实现自己的逻辑。至于为什么要使用 Reflect,可以看下我之前写的一篇文章
// packages/reactivity/src/index.ts
import { reactive } from "./reactive";
export { reactive };
// packages/reactivity/src/reactive.ts
import { isObject } from "@vue/shared";
import { baseHandlers } from "./baseHandlers";
// 将数据转化为响应式数据,只能代理对象类型
export function reactive(target: any) {
if (!isObject(target)) return;
let proxy = new Proxy(target, baseHandlers);
return proxy
}
// packages/reactivity/src/baseHandlers.ts
export const baseHandlers = {
get(target: object, key: PropertyKey, receiver: any) {
return Reflect.get(target, key, receiver);
},
set(target: object, key: PropertyKey, value: any, receiver: any) {
return Reflect.set(target, key, value, receiver);
},
};
// packages/shared/src/index.ts
export const isObject = (val: unknown): val is Record<any, any> =>
val !== null && typeof val === "object";
这样就实现了最基础的对象代理,那现在我们思考这样一个场景:在设置了代理后得到一个代理对象 state1,我们又将原对象代理了一遍 或者将代理对象又代理了一遍 得到 state2,那么它们应该是同一个对象,即 state1 === state2。好,下面我们完善下这个逻辑,原理是使用 weakMap 将 proxy 寸一份,等到下次再调用 如果有的话直接返回,这种处理方式只能解决咱们的第一个问题:也就是将原对象代理。那将代理对象又代理如何处理呢,在这里其实是 添加了一个标识 IS_REACTIVE,在第二次代理的时候,判断 target 上有没有这个标识,
那么就会走 get,里面有判断逻辑,返回 true,最后直接返回的是 target,也就是代理对象。
const data = {
name: "lisi",
age: 12,
hobby: ["rap"],
}
const state1 = reactive(data);
const state2 = reactive(data 或者 state1);
console.log(state1 === state2);
// packages/reactivity/src/reactive.ts
import { isObject } from "@vue/shared";
+ import { baseHandlers, ReactiveFlags } from "./baseHandlers";
+ const reactiveMap = new WeakMap<any, any>();
// 将数据转化为响应式数据,只能代理对象类型
export function reactive(target: any) {
if (!isObject(target)) return;
+ if (target[ReactiveFlags.IS_REACTIVE]) return target;
+ let observed = reactiveMap.get(target);
+ if (observed) return observed;
let proxy = new Proxy(target, baseHandlers);
+ reactiveMap.set(target, proxy);
return proxy
}
// packages/reactivity/src/baseHandlers.ts
+ export const enum ReactiveFlags {
+ IS_REACTIVE = "__v_isReactive",
+ }
export const baseHandlers = {
get(target: object, key: PropertyKey, receiver: any) {
+ if (key === ReactiveFlags.IS_REACTIVE) return true;
return Reflect.get(target, key, receiver);
},
set(target: object, key: PropertyKey, value: any, receiver: any) {
return Reflect.set(target, key, value, receiver);
},
};
实现 Effect
经过前面 reactive 方法的实现,我们已经能够拿到一个响应式的数据对象了,我们进行 get和 set 操作都能够被拦截。 Effect 是 实现响应式重要的组成部分之一,主要负责收集依赖(核心就是将当前的 effect 和 所要渲染的属性关联起来,也就是 track 方法),更新依赖(对应的是 trigger 方法)。初始化时会默认执行一次,当依赖数据发生变化时,会再次更新视图。
我们先引用官方的 effect 方法来演示下:初始化时页面读取 state中的值 进行视图渲染,当数据变化后会再次触发视图更新。
const { reactive, effect } = VueReactivity;
const state = reactive({
name: "lisi",
age: 12,
hobby: ["rap"],
});
effect(() => {
document.getElementById("app").innerHTML = `${state.name} ${state.age} 岁`;
});
setTimeout(() => {
state.age++;
}, 1000);
下面我们实现自己的逻辑。看看如何和响应式数据关联起来。
即然要实现 effect,那么我们先 创建一个 effect 方法,里面的逻辑是:创建响应式的 effect,便于后续依赖数据变化了,要自动做更新。
在这里我们使用类的形式创建 ReactiveEffect 构造函数,在 run 方法里面我们执行 fn 函数,并使用 activeEffect 保存了当前正在执行的 effect,目的就是 在稍后进行取值操作时,也就是在 track 逻辑里能够获取到这个全局的 activeEffect。
// packages/reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined;
class ReactiveEffect<T = any> {
constructor(public fn: () => T) {}
// 执行 effect
run() {
try {
activeEffect = this;
const result = this.fn();
return result;
} finally {
activeEffect = undefined;
}
}
}
export function effect<T = any>(fn: () => T) {
// 创建 响应式 effect
const _effect = new ReactiveEffect(fn);
_effect.run();
}
那如果有嵌套的 effect 呢?上面逻辑就有问题了,因为在 finally 里面,把 activeEffect 置为了 undefined,导致后续的逻辑不能执行,如下代码:state.c = 3 将不会被收集。
effect(() => {
state.a = 1;
effect(() => {
state.b = 2;
})
state.c = 3;
})
那如何解决呢:框架老版本使用的栈结构记录了 effect的先后顺序,当前执行的永远是指向最后一个,每执行完一个,就会弹出栈顶元素, 在新版本的 core 里面,使用树形结构记录了 parent 是谁。
// packages/reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined;
class ReactiveEffect<T = any> {
+ parent: ReactiveEffect | undefined = undefined;
constructor(public fn: () => T) {}
// 执行 effect
run() {
try {
+ this.parent = activeEffect;
activeEffect = this;
const result = this.fn();
return result;
} finally {
- activeEffect = undefined;
+ activeEffect = this.parent;
+ this.parent = undefined;
}
}
}
export function effect<T = any>(fn: () => T) {
// 创建 响应式 effect
const _effect = new ReactiveEffect(fn);
_effect.run();
}
实现 track
如何把 effect 和 依赖数据关联起来呢,下面我们来实现 track 逻辑。
// packages/reactivity/src/baseHandlers.ts
+ import { track } from "./effect";
export const enum ReactiveFlags {
IS_REACTIVE = "__v_isReactive",
}
export const baseHandlers = {
get(target: object, key: PropertyKey, receiver: any) {
if (key === ReactiveFlags.IS_REACTIVE) return true;
+ track(target, "get" as const, key);
return Reflect.get(target, key, receiver);
},
set(target: object, key: PropertyKey, value: any, receiver: any) {
const result = Reflect.set(target, key, value, receiver);
return result;
},
};
我们使用 WeakMap 来存储 若干个响应式对象 && 它们的若干个属性 对应哪些 effect,若干个响应式对象用一个 Map 来存储,那么它里面的属性使用 Set 结构存储的依赖的哪些 effect。 大致的结构是这样的:
{
state1: {
key: [effect, effect]
},
state2: {
key: [effect, effect]
}
}
当修改响应式数据不在 effect 中,也就是没有 activeEffect 的时候,这个时候不应该被收集,所以先做了一次判断,另一个处理点就是判断 dep 里面是否已经包含了 activeEffect 来决定是否被收集。
// packages/reactivity/src/effect.ts
const targetMap = new WeakMap();
export function track(target: object, type: string, key: PropertyKey) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
let shouldTrack = !dep.has(activeEffect);
if (shouldTrack) {
dep.add(activeEffect);
}
}
写到这里,我们只是实现了单向收集:也就是 属性记录了哪些 effect, 我们试想另一个问题:在 effect 里面遇到 三目运算符,当判断条件变化的时候,我们要删除之前记录的属性,所以我们还要让 effect 记录它被哪些属性收集过,这样做的好处是为了清理。这里先记录下,具体实现逻辑在下面 实现 cleanupEffect。
一个属性对应多个
effect, 一个effect对应多个属性
effect(() => {
isBoy ? state.value1++ : state.value2++
})
// packages/reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined;
class ReactiveEffect<T = any> {
parent: ReactiveEffect | undefined = undefined;
+ deps: any = [];
constructor(public fn: () => T) {}
// 执行effect
run() {
try {
this.parent = activeEffect;
activeEffect = this;
const result = this.fn();
return result;
} finally {
activeEffect = this.parent;
this.parent = undefined;
}
}
}
const targetMap = new WeakMap();
export function track(target: object, type: string, key: PropertyKey) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
let shouldTrack = !dep.has(activeEffect);
if (shouldTrack) {
dep.add(activeEffect);
+ activeEffect.deps.push(dep);
}
}
export function effect<T = any>(fn: () => T) {
// 创建 响应式 effect
const _effect = new ReactiveEffect(fn);
_effect.run();
}
实现 trigger
上面实现了依赖收集,接下来就是触发更新了,也就是 trigger 逻辑。
// packages/reactivity/src/baseHandlers.ts
+ import { track, trigger } from "./effect";
export const enum ReactiveFlags {
IS_REACTIVE = "__v_isReactive",
}
export const baseHandlers = {
get(target: object, key: PropertyKey, receiver: any) {
if (key === ReactiveFlags.IS_REACTIVE) return true;
track(target, "get" as const, key);
return Reflect.get(target, key, receiver);
},
set(target: object, key: PropertyKey, value: any, receiver: any) {
+ // 判断新值和老值是否一致,不一致,执行 trigger
+ let oldValue = (target as any)[key];
const result = Reflect.set(target, key, value, receiver);
+ if (oldValue !== value) {
+ trigger(target, 'set', key, value, oldValue)
+ }
return result;
},
};
// packages/reactivity/src/effect.ts
export function trigger(
target: object,
type: string,
key?: unknown,
newValue?: unknown,
oldValue?: unknown
) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach((e: ReactiveEffect) => {
e.run()
});
}
上面的实现是有点小问题的,我们看如下例子:
const { reactive, effect } = VueReactivity;
const state = reactive({
name: "lisi",
age: 12,
hobby: ["rap"],
});
effect(() => {
state.age = Math.random();
document.getElementById(
"app"
).innerHTML = `${state.name} ${state.age} 岁`;
});
setTimeout(() => {
state.age++;
}, 1000);
运行代码后:导致死循环,原因是 state.age 会频繁改变,触发 set,进而触发 trigger,又执行 该 effect。所以我们加下判断条件。
// packages/reactivity/src/effect.ts
export function trigger(
target: object,
type: string,
key?: unknown,
newValue?: unknown,
oldValue?: unknown
) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach((e: ReactiveEffect) => {
+ if (e !== activeEffect) e.run()
});
}
好,上面我们只考虑了 对象的情况,那数组的情况呢,我们看下面的例子:
const state = reactive([1, 2, 3]);
effect(() => {
document.getElementById("app").innerHTML = `${state}`;
});
setTimeout(() => {
state.length = 1;
}, 1000);
数据变化是直接更新数组的长度,而在 effect 中没有使用 length 属性,所以在更新 length 属性时不会触发 effects 的依次执行,这样 length 改变 effect 回调函数不会执行,视图也不会被更新。这时就需要对属性是 length 的数组进行验证,如果直接更新的是数组的长度就需要单独处理:
// packages/reactivity/src/effect.ts
export function trigger(
target: object,
type: string,
key?: unknown,
newValue?: unknown,
oldValue?: unknown
) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
- const effects = depsMap.get(key);
- effects && effects.forEach((e: ReactiveEffect) => {
- if (e !== activeEffect) e.run()
- });
+ const run = (effects: ReactiveEffect<any>[]) => {
+ effects &&
+ effects.forEach((e: ReactiveEffect) => {
+ if (e !== activeEffect) e.run();
+ });
+ };
+ if (key === "length" && Array.isArray(target)) {
+ depsMap.forEach((deps: any, key: string) => {
+ key === "length" && run(deps);
+ });
+ } else run(depsMap.get(key));
}
实现 cleanupEffect
上面我们已经实现了比较基本的 track 和 trigger 了,下面我们看下面的例子:
const { reactive, effect } = VueReactivity;
const state = reactive({
flag: true,
name: "lisi",
age: 12,
hobby: ["rap"],
});
effect(() => {
console.log("重新执行");
document.getElementById("app").innerHTML = state.flag
? state.name
: state.age;
});
setTimeout(() => {
state.flag = false;
setTimeout(() => {
state.name = "zhangsan";
}, 1000);
}, 1000);
上面代码运行效果如下:在定时器里我们修改了 state.flag 值后, effect 里面将不会再对 state.name收集,这是我们期望的,所以打印 两次 log是正常的,但实际上却打印了三次,原因是我们 分支控制的功能还没有实现。
// packages/reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined;
class ReactiveEffect<T = any> {
parent: ReactiveEffect | undefined = undefined;
deps: any = [];
constructor(public fn: () => T) {}
// 执行effect
run() {
try {
this.parent = activeEffect;
activeEffect = this;
+ cleanupEffect(this);
const result = this.fn();
return result;
} finally {
activeEffect = this.parent;
this.parent = undefined;
}
}
}
+ function cleanupEffect(effect: ReactiveEffect) {
+ const { deps } = effect;
+ if (deps.length) {
+ for (let i = 0; i < deps.length; i++) {
+ deps[i].delete(effect);
+ }
+ effect.deps.length = 0;
+ }
+ }
好家伙,又死循环了,我们分析下原因:执行完 cleanupEffect 逻辑后,我们执行 fn,会触发依赖收集,改值会触发 trigger,里面又执行了 run,我们可以拷贝一份,生成一个新的引用。
// packages/reactivity/src/effect.ts
export function trigger(
target: object,
type: string,
key?: unknown,
newValue?: unknown,
oldValue?: unknown
) {
....
- } else run(depsMap.get(key));
+ } else run([...depsMap.get(key)]);
}
总结
本文我们实现了基础版的 reactive 和 effect,并对里面的细节做了些处理,相比于 core 来说,处理边缘问题和覆盖更多 case 还是差的比较多呢。
最后希望各位大佬积极指出错误。