简介
今天我们来看看 watch
的实现。 watch
本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch
的实现本质上就是利用了 effect
以及 options.scheduler
选项。
代码地址: https://github.com/SuYxh/share-vue3
代码并没有按照源码的方式去进行组织,目的是学习、实现 vue3 响应式系统的核心,用最少的代码去实现最核心的能力,减少我们的学习负担,并且所有的流程都会有配套的图片,图文 + 代码,让我们学习更加轻松、快乐。
每一个功能都会提交一个 commit
,大家可以切换查看,也顺变练习练习 git 的使用。
watch 实现
在一个副作用函数中访问响应式数据 obj.foo
,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler
选项,当响应式数据发生变化时,会触发 scheduler
调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler
调度函数就相当于一个回调函数,而 watch
的实现就是利用了这个特点。
编写单测
假设obj
是一个响应数据,使用 watch
函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。
it("base watch", () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100 });watch(obj, () => {mockFn()});obj.foo ++expect(mockFn).toHaveBeenCalledTimes(1);
});
代码实现
下面是最简单的 watch 函数的实现:
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {effect(// 触发读取操作,从而建立联系() => source.foo,{scheduler() {// 当数据变化时,调用回调函数 cbcb();}});
}
运行单测
是不是很简单!
支持所有属性监听
在来看一个 case
it("watch 多个属性", () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });watch(obj, () => {mockFn()});obj.bar ++obj.age ++expect(mockFn).toHaveBeenCalledTimes(2);});
执行一下
修改了 2 个属性值,回调函数应该执行 2 次,但是回调函数并没有执行,这是为什么呢?
前面的watch
函数中写死了 source.foo,
source.bar
没有进行依赖收集,自然回调函数就不会执行了。
那么就需要封装一个通用的读取操作:
function traverse(value, seen = new Set()) {// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做if (typeof value !== 'object' || value === null || seen.has(value)) return;// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环seen.add(value);// 暂时不考虑数组等其他结构// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理for (const k in value) {traverse(value[k], seen);}return value;
}
修改 watch
如下:
function watch(source, cb) {effect(// 触发读取操作,从而建立联系() => traverse(source),{scheduler() {// 当数据变化时,调用回调函数 cbcb();}});
}
traverse
方法的作用,读取传入对象的所有属性,然后构建依赖关系,任何一个属性值发生变化,都会执行回调函数。
再次执行单测:
相关代码在 commit: (5063b6b)watch 基础实现 ,
git checkout 5063b6b 即可查看。
支持函数参数
看一个 case
it('支持 getter 函数', () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });watch(() => obj.age, () => {mockFn()});obj.age ++expect(mockFn).toHaveBeenCalledTimes(1);
})
运行一下
发现没有通过,因为我们之前也没有实现对函数的支持,肯定不会通过。
在 watch 中增加一个对第一个参数的判断就好:
export function watch(source, cb) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}effect(// 触发读取操作,从而建立联系() => getter(),{scheduler() {// 当数据变化时,调用回调函数 cbcb();},});
}
再次运行单测
这样就通过了。
相关代码在 commit: (0acd398)watch 支持函数参数 ,
git checkout 0acd398 即可查看。
获取新值与旧值
看这个 case
it('get newVal and oldVal', () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });let newValue = nulllet oldValue = nullwatch(() => obj.age, (newVal, oldVal) => {newValue = newValoldValue = oldVal});obj.age ++expect(newValue).toBe(11);expect(oldValue).toBe(10);
})
不用运行,肯定跑不过,因为我们都没有去实现。
那么如何获得新值与旧值呢?这需要充分利用 effect
函数的 lazy
选项,如以下代码所示:
function watch(source, cb) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(),{lazy: true,scheduler() {// 在 scheduler 中重新执行副作用函数,得到的是新值newValue = effectFn();// 将旧值和新值作为回调函数的参数cb(newValue, oldValue);// 更新旧值,不然下一次会得到错误的旧值oldValue = newValue;}});// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn();
}
其中最核心的改动是使用 lazy
选项创建了一个懒执行的 effect
。注意上面代码中最下面的部分,我们手动调用 effectFn
函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler
调度函数执行时,会重新调用 effectFn
函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数 cb
就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue
,否则在下一次变更发生时会得到错误的旧值。
运行单测
相关代码在 commit: (5ac39a6)watch 获取新值与旧值 ,
git checkout 5ac39a6 即可查看。
支持 immediate
看看这个 case
it('支持 immediate', () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });let newValue = undefinedlet oldValue = undefinedwatch(() => obj.age, (newVal, oldVal) => {mockFn()newValue = newValoldValue = oldVal}, {immediate: true});expect(mockFn).toHaveBeenCalledTimes(1);expect(newValue).toBe(10);expect(oldValue).toBe(undefined);obj.age ++expect(mockFn).toHaveBeenCalledTimes(2);expect(newValue).toBe(11);expect(oldValue).toBe(10);
})
又是熟悉的老套路,增加一个 options
,代码如下:
export function watch(source, cb, options) {let getter;if (typeof source === "function") {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(), {lazy: true,scheduler() {// 在 scheduler 中重新执行副作用函数,得到的是新值newValue = effectFn();// 将旧值和新值作为回调函数的参数cb(newValue, oldValue);// 更新旧值,不然下一次会得到错误的旧值oldValue = newValue;},});if (options.immediate) {// 当 immediate 为 true 时立即执行 effectFn,从而触发回调执行newValue = effectFn();cb(newValue, oldValue);oldValue = newValue;} else {// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn();}
}
再次运行单测:
相关代码在 commit: (fd0e845)watch 支持 immediate ,
git checkout fd0e845 即可查看。
重构
我们可以发现 scheduler
方法中的逻辑和 options.immediate
为 true
时执行的逻辑一样,那么就可以进行封装:
export function watch(source, cb, options) {let getter;if (typeof source === "function") {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;// 提取 scheduler 调度函数为一个独立的 job 函数const job = () => {newValue = effectFn();cb(newValue, oldValue);oldValue = newValue;}// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(), {lazy: true,scheduler: job,});if (options.immediate) {// 当 immediate 为 true 时立即执行 job,从而触发回调执行job()} else {// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn();}
}
执行测试命令
pnpm test
我们可以看到,我们修改了代码,之前的 case 出了问题
原因是当我们没有传 options
的时候,options
相当于是 undefined
, 取值自然会出错,我们添加一个默认值就好。
可以看到就全部通过了,单测为我们的代码保驾护航!
相关代码在 commit: (c0721bd)watch 代码优化 ,
git checkout c0721bd 即可查看。
流程图
整体流程图如下:
一个前端小学生的学习之路,如果你喜欢前端,我们可以一起进行学习、交流、共建。可以添加好友,结伴学习,成长的路上不孤单!