如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~
作者:前端小王hs
阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主
此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来
书籍:《Vue.js设计与实现》 作者:霍春阳
本篇博文将在书第4.9节至第4.11的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3或想分析Vue3源码的朋友快速阅读
如有帮助,不胜荣幸
前置章节:
- 深入理解Vue3.js响应式系统基础逻辑
 - 深入理解Vue3.js响应式系统设计之栈结构和循环问题
 - 深入理解Vue3.js响应式系统设计之调度执行
 - 深入源码设计!Vue3.js核心API——Computed实现原理
 

watch简单实现
watch的作用是在于侦听(监听)的对象或getter(数据源)发生改变时,重新调用其所给的回调函数,我们直接看书中给出的代码:
watch(obj, () => {  console.log('数据变了')  
})  // 修改响应数据的值,会导致回调函数执行  
obj.foo++
 
其基本的实现逻辑是在watch内部封装了effect——副作用函数,且该effect具有调度器。当obj.foo被读取时会和回调函数相关联(track);而当修改时,触发trigger时执行schedular中的逻辑,进而拿出关联的回调函数进行执行,代码如下:
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {effect(// 触发读取操作,从而建立联系() => source.foo,{scheduler: () => {// 当数据变化时,调用回调函数 cbcb();  }});
} 
 
cb即callback,回调
常量接收参数——避免硬编码
从上述代码可知,source.foo是固定写死的,关于这一点我们曾经在 深入理解Vue3.js响应式系统基础逻辑提到过,也就是硬编码,解决的办法是在函数内定义一个函数,用于遍历接收source,代码如下:
function watch(source, cb) {effect(// 调用 traverse 递归地读取() => traverse(source),{scheduler: () => {cb()}});
}function traverse(value, seen = new Set()) {if (typeof value !== 'object' || value === null || seen.has(value)) return;seen.add(value);// 暂时不考虑数组等其他结构// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理for (const k in value) {traverse(value[k], seen);}return value
}
 
其实用遍历不太详细,我们可以假设传入的是一个对象,那么traverse就可以把它的每一个key都与effectFn建立相关联
接收getter函数作为参数
前面的代码接收的都是obj,那如果是getter,应该如何设计呢?如下所示:
watch(  // getter 函数  () => obj.foo,  // 回调函数  () => {  console.log('obj.foo 的值变了')  }  
)
 
设计方案是在watch内定义了一个常量用于接收传入的getter,并且在effect中的第一个参数传入getter
function watch(source, cb) {// 定义 getterlet getter// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getterif (typeof source === 'function') {getter = source} else {// 否则按照原来的实现调用 traverse 递归地读取getter = () => traverse(source)}effect(// 执行 getter() => getter(),{scheduler: () => {cb()}})
}
 
获取旧值
在watch的回调函数中可以拿到旧值与新值,代码如下所示:
watch(() => obj.foo,(newValue, oldValue) => {console.log(newValue, oldValue)}
);obj.foo++
 
其实现的过程是利用了懒加载,代码如下:
function watch(source, cb) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;const effectFn = effect(() => getter(),{lazy: true,scheduler() {newValue = effectFn();// 将旧值和新值作为回调函数的参数  cb(newValue, oldValue);  oldValue = newValue;  }})oldValue = effectFn();
}
 
变化是从直接执行effect变为了需要手动调用,现在来分析一下整个流程:
- 调用
effectFn()返回了旧值——oldValue,同时在这一过程中,getter和effectFn相关联了 - 如果
getter发生了变化,那么会触发trigger,然后执行scheduler - 那么此时,又一次调用
effectFn获取到的就是newValue - 将
newValue和oldValue传入回调函数 - 将最新的值,变为旧值,这一点很关键,是为下一次变化做的准备
 
那么现在,就可以拿到旧值与新值了
初始化调用——immediate
我们知道在watch中有个重要的属性——immediate,也就是立即的意思,就是说当我们创建watch时,就让他执行,那这里如果我们联系上一节获取旧值的内容,可以知道此时旧值是没有的,也就是undefined,这一点在书中也提到了
让watch立即执行的代码示例如下:
watch(obj, () => {console.log('变化了')
}, {immediate: true
})
 
那这应该怎么实现?
我们要明确一个东西,就是watch的作用是什么?产生这个作用的是function watch(){}的哪一部分?如果这样去想,那么逻辑其实很好理解
watch就是当source改变后会进行触发,执行第二个参数——回调函数,这是作用,注意!是改变后。而改变后触发的就是effect.options.scheduler,也就是产生作用的就是这一部分,那我们可以怎么做?可以直接在创建watch时就执行这一部分,那么就实现了立即调用
也就是说我们在分析时,要明白是在哪发生的这个获取旧值或新值又或是其他逻辑的,进而单独拎出来调用一次就行,在书中拎出来的独立函数为job。我们直接看代码:
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}const effectFn = effect(// 执行 getter() => getter(),{lazy: true,// 使用 job 函数作为调度器函数scheduler: job});if (options.immediate) {    job();  } else {  oldValue = effectFn();}
}
 
闭包解决”竞态问题“
什么是竞态问题?我们直接看书中的例子:
let finalData;watch(obj, async () => {  // 发送并等待网络请求const res = await fetch('/path/to/request');// 将请求结果赋值给 datafinalData = await res.json();
});  
 
假定的情况是,obj在第一次发生改变时,触发了watch,然后执行了回调,发起了请求,但在请求的数据还没返回时,obj就发生了第二次改变,那么同样会触发watch执行回调,在第一次的请求数据还没回来时,第二次的数据就已经回来了。在这样这样的情况下,第一次返回的数据就变成了旧数据,第二次返回的数据就是新数据,而合理的情况是,此时finalData的值应该是新数据,所以,旧数据就报废了
所以设计的思路是,需要判断当前的副作用函数是否已经过期了,如果其过期了,那么理所应当,其返回的数据就是没有用了
看到这里不要混淆,我们执行watch,归根到底还是执行其封装的effect,更为确切的说,是执行其cb
我们先来看初始化的watch是怎么样的:
watch(obj, async (newValue, oldValue, onInvalidate) => {// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期let expired = false;// 调用 onInvalidate() 函数注册一个过期回调onInvalidate(() => {// 当过期时,将 expired 设置为 trueexpired = true})const res = await fetch('/path/to/request');const responseData = await res.json();// 只有当该副作用函数的执行没有过期时,才会执行后续操作  if (!expired) {finalData = responseData;}
});
 
我们在分析的时候,一定要清楚参数async (...) =>{}都是cb
在这段代码中,定义了一个expired,去判断当前的副作用函数是否过期,那么这里可以看到是利用了闭包的操作
我们在学习JavaScript高级内容时都会去了解这个内容,但在初级前端开发中还是比较少见的,所以我们来看一下这个闭包到底带来了什么
从这段代码中,我们可以看到如果expired是过期的,那么是不会执行finalData = responseData;的,也就是说,第一次请求的数据即使返回了,也不会进行赋值操作
现在的关键是onInvalidate,我们来看一下onInvalidate是如何执行的,其实非常简单:
function watch(source, cb, options = {}) {let getterif (typeof source === 'function') {getter = source} else {getter = () => traverse(source)}let oldValue, newValue// cleanup 用来存储用户注册的过期回调let cleanup// 定义 onInvalidate 函数  function onInvalidate(fn) {// 将过期回调存储到 cleanup 中cleanup = fn}const job = () => {newValue = effectFn()// 在调用回调函数 cb 之前,先调用过期回调if (cleanup) {cleanup()} // 将 onInvalidate 作为回调函数的第三个参数,以便用户使用 cb(newValue, oldValue, onInvalidate)oldValue = newValue}const effectFn = effect(// 执行 getter() => getter(),{lazy: true,scheduler: () => {if (options.flush === 'post') {const p = Promise.resolve()p.then(job)} else {job()}}})if (options.immediate) {job()} else {oldValue = effectFn()}
}
 
在这段代码中,定义了一个cleanup去保存传过去的() => { expired = true },并且在关键代码job中进行了调用,我们结合例子来分析一下具体的执行过程,首先是例子:
watch(obj, async (newValue, oldValue, onInvalidate) => {let expired = falseonInvalidate(() => {expired = true})const res = await fetch('/path/to/request')if (!expired) {finalData = await res.json()}  
})// 第一次修改 obj.foo初始值为1
obj.foo++;setTimeout(() => {// 200ms 后做第二次修改obj.foo++;
}, 200);
 
在书中的例子中,是假设第一次修改的结果在1000ms后返回,这个是前提
我们来看两次调用watch发生了什么:
- 执行
watch,由于不是immediate,所以调用了effectFn,建立了obj.foo和effectFn的关联,并且返回了1为``oldValue - 执行
obj.foo++,发生自增操作触发了trigger,会执行effectFn.options.scheduler - 进而执行
job,把自增后的2赋值给newValue,此时cleanup()没有赋值不执行,然后执行cb,在cb中,调用了执行onInvalidate把() => {expired = true}赋值给cleanup,然后发起请求,假设异步请求的结果还没回来,最后oldValue变为了2(异步还在执行,但下一个是同步函数) - 在
setTimeout中执行obj.foo++,触发trigger,执行effectFn.options.scheduler,再次执行job,通过track返回newValue为3,然后现在claenup()存在,所以执行cleanup(),把第三步中的cb中的expired置为了ture - 执行
cb,执行onInvalidate把() => {expired = true}赋值给cleanup,然后发起请求,返回数据,由于此时的expired为false,所以把返回的值赋值给了finalData - 第一次自增触发的回调数据返回了,但由于
expired为true,所以不会进行赋值 
关于expired,这里再说一下变化的过程,在第一次job执行之后,就把其为ture的结果存起来了,然后在第二次job的cb还没执行前,修改第一次job中cb内的expired状态为true
可以分为expiredA和expiredB去理解
我们要清楚两个回调是有先后顺序的
通过闭包,我们可以在某个节点去修改定义在函数外的值,这就是闭包的作用
小记
至此,我们整个第四章就分析完了!
值得一提的是,通过前面的分析我们可以发现整个第四章都是好像连续剧一样,所以在分析时一定要对前面的内容充分理解之后才能继续看下去,我记得我当时看的时候,就是由于看了之后隔了几天没看,加上笔记不清晰,又得重新看,不过这也养成了我看书时专注一本书的习惯,也就是看了就看下去,不停顿
关于这篇,如果认真阅读了,最起码可以达到以下效果
- 了解和学习vue.js团队是如何设计watch的,了解其基本实现原理
 - 理解watch是怎么实现immediate执行的
 - 什么是竞态问题
 - 什么是闭包和闭包在实际开发中的用处
 - 其他…
 
谢谢大家的阅读,如有错误的地方请私信笔者
笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs!