从零开始的手写vue3响应式 —— effect(3)
前言
前面我们实现了调度器,那么接下来我们会利用这个调度器实现几个常见的api,computed
和 watch
。
1、lazy和computed
1.1 lazy
在讲计算属性之前,我们需要先了解下懒执行
的 effect
。
我们目前设计的 effect
是立即执行的,但是有些时候我们并不希望他立即执行,而是希望它在我们需要的时候执行,就比如 computed
。 因此我们可以通过在 options
添加一个懒执行的标识 lazy
来控制。
除了实现让副作用函数不立即执行的功能,我们还有一个问题需要去解决:
副作用函数什么时候执行?
之前实现 scheduler
的时候,我们就实现了 effect
调用完之后的返回值,就能拿到对应的副作用函数。这样我们就能手动调用副作用函数。
但是,如果仅仅只是能手动调用副作用函数,这个意义并不是很大。但是如果我们把传递给 effect
的函数看作一个getter,那么这个getter能返回任何值。
比如:
const effectFn = effect(() => obj.foo + obj.bar, { lazy: true });
// value就是getter的返回值
const value = effectFn();
接着,我们来看测试用例:
it("lazy", () => {
const obj = reactive({ foo: 1 });
let dummy;
const runner = effect(() => (dummy = obj.foo), { lazy: true });
expect(dummy).toBe(undefined);
expect(runner()).toBe(1);
expect(dummy).toBe(1);
obj.foo = 2;
expect(dummy).toBe(2);
});
实现:
class ReactiveEffect<T = any> {
// ...
run() {
cleanupEffect(this);
activeEffect = this;
effectStack.push(this);
// 新增
const value = this.fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
// 新增
return value;
}
}
export interface ReactiveEffectOptions {
// 新增
lazy?: boolean;
scheduler?: EffectScheduler;
}
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
const _effect = new ReactiveEffect(fn);
if (options) {
Object.assign(_effect, options);
}
// 新增:
if (!options || !options.lazy) {
_effect.run();
}
// _effect.run();
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner;
runner.effect = _effect;
return runner;
}
1.2 computed
基于上面实现的实现的可以懒执行的effect,我们可以初步实现 computed
的功能。另外,通过 vue3
官方文档知道,我们需要通过 .value
才能拿到其值。
测试用例:
describe("reactivity/computed", () => {
it("should return updated value", () => {
const value = reactive({});
const cValue = computed(() => value.foo);
expect(cValue.value).toBe(undefined);
value.foo = 1;
expect(cValue.value).toBe(1);
});
});
实现:
export type ComputedGetter<T> = (...args: any[]) => T;
export type ComputedSetter<T> = (v: T) => void;
class ComputedRefImpl<T> {
private _value!: T;
public readonly effect: ReactiveEffect<T>;
constructor(getter: ComputedGetter<T>) {
this.effect = new ReactiveEffect(getter);
}
get value() {
this._value = this.effect.run();
return this._value;
}
}
export function computed<T>(getter: ComputedGetter<T>) {
return new ComputedRefImpl(getter);
}
可以看到我们顺利的通过了最基础的测试,即懒执行。每次读取 .value
的时候才会进行计算并得到值。
但是只完成了这点可远远称不上是 computed
,因为它还有另外一个特点是 缓存
。为了实现这点,我们需要加个标识 dirty
来标识是否需要重新计算。
测试用例:
describe("reactivity/computed", () => {
it("should return updated value", () => {
const value = reactive({});
const cValue = computed(() => value.foo);
expect(cValue.value).toBe(undefined);
value.foo = 1;
expect(cValue.value).toBe(1);
});
it("should compute lazily", () => {
const value = reactive({});
const getter = jest.fn(() => value.foo);
const cValue = computed(getter);
// lazy
expect(getter).not.toHaveBeenCalled();
expect(cValue.value).toBe(undefined);
expect(getter).toHaveBeenCalledTimes(1);
// should not compute again
cValue.value;
expect(getter).toHaveBeenCalledTimes(1);
// should not compute until needed
value.foo = 1;
expect(getter).toHaveBeenCalledTimes(1);
// now it should compute
expect(cValue.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(2);
// should not compute again
cValue.value;
expect(getter).toHaveBeenCalledTimes(2);
});
});
实现:
class ComputedRefImpl<T> {
private _value!: T;
// 用来标识是否需要重新计算,true为需要
public _dirty = true;
public readonly effect: ReactiveEffect<T>;
constructor(getter: ComputedGetter<T>) {
// 新增一个scheduler,用于后面触发时能够重新计算
this.effect = new ReactiveEffect(getter, () => {
this._dirty = true;
});
}
get value() {
// 新增
if (this._dirty) {
this._value = this.effect.run();
this._dirty = false;
}
return this._value;
}
}
除此之外,我们还有一个问题需要解决:
it("should trigger effect", () => {
const value = reactive({});
const cValue = computed(() => value.foo);
let dummy;
effect(() => {
dummy = cValue.value;
});
expect(dummy).toBe(undefined);
value.foo = 1;
expect(dummy).toBe(1);
});
通过上面的测试用例发现,如果我们试图在另一个副作用函数中去读取cValue.value,并且计算属性改变期望能够触发副作用函数的执行。但事实上这并没有触发。
通过分析,我们不难看出,computed
只收集了内部的 effect
,外层的effect
不会被内层的响应式数据收集。
所以,我们可以通过手动调用的方式,当读取计算属性的值时可以触发track函数
收集,当计算属性依赖的响应式数据发生变化的时候,可以通过trigger函数
触发响应。
class ComputedRefImpl<T> {
private _value!: T;
public _dirty = true;
public readonly effect: ReactiveEffect<T>;
constructor(getter: ComputedGetter<T>) {
this.effect = new ReactiveEffect(getter, () => {
// 新增
if (!this._dirty) {
this._dirty = true;
trigger(this, "value");
}
});
}
get value() {
if (this._dirty) {
this._value = this.effect.run();
this._dirty = false;
}
// 新增
track(this, "value");
return this._value;
}
}
2. watch
watch
相信大家都并不陌生,它主要是用于检测响应式数据,并数据发生变化的时候可以执行响应的回调。
另外,回调的时候还可以获取到新值和旧值。
2.1 观测对象
先看下用法,注意到 watch
要接收两个参数,一个观测的对象,另一个是回调函数。
describe("watch", () => {
it("for bar", () => {
const data = reactive({ foo: 1 });
const cb = jest.fn(() => {
});
watch(data, cb);
// 一开始不会执行
expect(cb).not.toHaveBeenCalled();
data.foo++;
// 触发之后会调用cb
expect(cb).toHaveBeenCalledTimes(1);
});
});
实现的思路是把观测对象和副作用函数建立联系,然后 callback
通过effect
的scheduler
执行,之后响应式数据发生变化的时候就会执行callback
。
为了快速的通过这段用例,我们先对foo
这个属性做一个硬编码。
export function watch(source, cb) {
// 先对foo做硬编码
const getter = () => source.foo;
const scheduler = cb;
const effect = new ReactiveEffect(getter, scheduler);
effect.run();
}
接下来的话,就要针对目标对象的不同属性也要做到同样的事情。而且还要包含嵌套的情况。不过目前我们的reactive
还没实现嵌套的功能,所以先暂且搁置。
it("for different property", () => {
const data = reactive({
foo: 1,
bar: 2,
});
const cb = jest.fn(() => {});
watch(data, cb);
expect(cb).not.toHaveBeenCalled();
data.foo++;
expect(cb).toHaveBeenCalledTimes(1);
data.bar++;
expect(cb).toHaveBeenCalledTimes(2);
});
然后实现的思路是,我们需要一个函数去遍历读取对象的每个值,触发依赖收集。
function traverse(value, seen = new Set()) {
// 如果是非对象,null以及被读取过的就不需要再继续了
if (typeof value !== "object" || value === null || seen.has(value)) return;
// 为什么需要记录读取过的值,是因为如果对象是循环引用的,就会造成死循环
seen.add(value);
for (const key in value) {
traverse(value[key], seen);
}
return value;
}
export function watch(source, cb) {
// const getter = () => source.foo;
// 遍历去读取值
const getter = () => traverse(source);
const scheduler = cb;
const effect = new ReactiveEffect(getter, scheduler);
effect.run();
}
2.2 观测getter
watch的用法即使有好几种,就比如
const a = reactive({foo: 1});
const b = reactive({foo: 2});
// 默认深度监听
watch(a, () => {});
// 监听getter
watch(() => a.foo, () => {});
// 监听多个响应式对象
watch([a, b], () => {});
仔细观测的话就会发现,这跟我一开始硬编码的情况一样嘛。只不过做个兼容就好了。
export function watch(source, cb) {
// const getter = () => source.foo;
// const getter = () => traverse(source);
let getter;
if (typeof source === "function") {
getter = source;
} else if (typeof source === "object") {
getter = () => traverse(source);
} else if (Array.isArray(source)) {
// ...
}
const scheduler = cb;
const effect = new ReactiveEffect(getter, scheduler);
effect.run();
}
2.3 回调当中获取新值与旧值
先看用例
it("should get newVal and oldVal in cb", () => {
const data = reactive({ foo: 1 });
let n;
let o;
const cb = jest.fn((newVal, oldVal) => {
n = newVal;
o = oldVal;
});
watch(() => data.foo, cb);
expect(cb).not.toHaveBeenCalled();
data.foo++;
// 触发之后获取新旧值
expect(cb).toHaveBeenCalledTimes(1);
expect(n).toBe(2);
expect(o).toBe(1);
});
这里的话,我们需要对之前的watch
稍微改一下,因为我们需要手动调用effect
。
然后一开始利用返回的 runner
手动调用取得旧值。然后后续在 scheduler
中继续调用 runner
获取新值,然后在回调中塞进去。
export function watch(source, cb) {
// const getter = () => source.foo;
// const getter = () => traverse(source);
let getter;
if (typeof source === "function") {
getter = source;
} else if (typeof source === "object") {
getter = () => traverse(source);
} else if (Array.isArray(source)) {
// ...
}
// const scheduler = cb;
// const effect = new ReactiveEffect(getter, scheduler);
// effect.run();
let newVal, oldVal;
const runner = effect(getter, {
lazy: true,
scheduler() {
newVal = runner();
cb(newVal, oldVal);
oldVal = newVal;
},
});
oldVal = runner();
}
2.4 立即执行
立即执行的话其实就是把 scheduler
先执行一次,除此之外没啥特点。
不过要注意的是,立即执行之后,oldVal
是 undefined
,只有 newVal
才有值。这点要稍微留意一下。
it("should get newVal when immediate", () => {
const data = reactive({ foo: 1 });
let n;
let o;
const cb = jest.fn((newVal, oldVal) => {
n = newVal;
o = oldVal;
});
watch(() => data.foo, cb, { immediate: true });
expect(cb).toHaveBeenCalledTimes(1);
expect(n).toBe(1);
expect(o).toBe(undefined);
data.foo++;
expect(cb).toHaveBeenCalledTimes(2);
expect(n).toBe(2);
expect(o).toBe(1);
});
interface WatchOptions {
immediate?: boolean;
}
export function watch(source: any, cb: Function, options: WatchOptions = {}) {
// const getter = () => source.foo;
// const getter = () => traverse(source);
let getter;
if (typeof source === "function") {
getter = source;
} else if (typeof source === "object") {
getter = () => traverse(source);
} else if (Array.isArray(source)) {
// ...
}
let newVal, oldVal;
const job = () => {
newVal = runner();
cb(newVal, oldVal);
oldVal = newVal;
};
const runner = effect(getter, {
lazy: true,
scheduler: job,
});
if (options.immediate) {
job();
} else {
oldVal = runner();
}
}
2.5 副作用清除
在vue的官方文档中有这么一段描述:
第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。
这个出现的背景我们来稍微解释一下,如果你通过 watch
发起了两次网络请求,第一次请求返回了数据A,第二次返回了数据B,但是B比A先返回了。实际上你需要的是最新的数据B,这时候A就要被抛弃,不然会先出现竞态问题。
然后我们来做一个稍微复杂点的单测:
it("should cleanup", () => {
jest.useFakeTimers();
let finalData;
const data = reactive({ foo: 1 });
const fn = jest.fn((res) => {
finalData = res;
});
// 模拟请求
function mockRequest(time = 100) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(time);
}, time);
});
}
watch(
() => data.foo,
async (newVal, oldVal, onInvalidate) => {
let expired = false;
onInvalidate(() => {
expired = true;
});
const res = await mockRequest((4 - newVal) * 100);
// 如果没有过期的会,就会调用
if (!expired) {
fn(res);
expect(fn).toBeCalledTimes(1);
expect(finalData).toEqual(0);
}
}
);
// 总共调用三次,最后一次调用是最快返回的
data.foo++;
setTimeout(() => {
data.foo++;
}, 50);
setTimeout(() => {
data.foo++;
}, 100);
jest.runAllTimers();
});
预期的流程是这样的:
请求A -> expiredA = false;
请求B -> expiredB = false; expiredA = true;
请求C -> expiredC = false; expiredB = true; expiredA = true;
响应C -> expiredC = false -> 赋值
响应B -> expiredB = true -> 抛弃
响应A -> expiredA = true -> 抛弃
我们需要做的就是要把用户传进来的第三个回调函数存起来。然后在每次调用cb之前再调用一次。用这边的例子来举例就是,第N次调用的时候,它会先把第N-1次的onInvalidate触发,然后再执行watch的callback,这样就不会影响到自己。
export function watch(source: any, cb: Function, options: WatchOptions = {}) {
// const getter = () => source.foo;
// const getter = () => traverse(source);
let getter;
if (typeof source === "function") {
getter = source;
} else if (typeof source === "object") {
getter = () => traverse(source);
} else if (Array.isArray(source)) {
// ...
}
let newVal, oldVal;
// 用来存储用户注册的过期回调
let cleanup;
function onInvalidate(fn) {
cleanup = fn;
}
const job = () => {
newVal = runner();
// 第二次开始才会触发
if (cleanup) {
cleanup();
}
// 第一次触发的时候只进行赋值
cb(newVal, oldVal, onInvalidate);
oldVal = newVal;
};
const runner = effect(getter, {
lazy: true,
scheduler: job,
});
if (options.immediate) {
job();
} else {
oldVal = runner();
}
}
3. 总结
至此,effect
这部分已经算结束了。可以看到 effect
函数与响应式数据结合十分的巧妙,另外它的可调度性对 computed
和 watch
十分的重要,两者都依赖于它来实现。
转载自:https://juejin.cn/post/7151440894472749070