1.在上一篇文章中,我们讨论了compted的实现原理,即利用effect和options参数进行封装。同样,watch也是基于此封装的。当然watch也可以传第三个参数来清理过期的副作用函数。不仅可以通过副作用函数的调度来实现回调函数的立即执行,还可以控制回调函数的执行时机。2、watch的实现原理watch的本质是观察一个响应式数据,当数据发生变化时通知并执行相应的回调函数。watch的实现本质和computed类似,都是基于effect函数和options.scheduler选项。constdata={name:"pingping",age:18,flag:true};conststate=newProxy(data,{/*...*/})watch(state,()=>{console.log("Thedatahaschanged...");});//当响应数据的age值被修改时,会导致回调函数执行state.age++;watch函数的实现代码如下,sideeffectfunction有一个scheduleroption中,当response数据发生变化时,会触发scheduler调度函数的执行,而不是直接触发sideeffectfunction的执行。//watch函数接收source响应数据和回调函数cbfunctionwatch(source,cb){effect(//触发读取操作,建立连接()=>source.age,{scheduler(){//当数据变化时,调用callbackfunctioncbcb();}})}在上面的代码片段中,我们在使用watch监控数据时,只能观察到soure.age的变化,不具有通用性,需要进一步封装。functionwatch(source,cb){effect(//调用遍历函数递归读取并建立连接()=>traverse(source),{scheduler(){//数据变化时,调用回调函数cbcb();}})}constisObject=(value:any)=>typeofvalue==="object"&&value!==null;functiontraverse(value,seen=newSet()){//如果读取data为原始值,或者已经读取响应数据,则什么都不做if(!isObject(value)||seen.has(value))return;//向seen添加数据,说明遍历已经读取到数据,避免循环引用造成的死循环seen.add(value);//遍历并递归读取数据对象进行依赖收集for(constkinvalue){traverse(value[k],seen);}returnvalue;}上面代码中单独封装了一个递归函数traverse来遍历并递归读取响应数据,这样就可以读取到对象上的所有属性,从而实现任意属性值变化时触发回调函数.实际上,使用watch进行数据观察时,不仅可以观察响应数据,还可以观察getter函数。那么,我们只需要判断输入的观察数据的数据类型是否为函数,如果是就赋值给getter,否则就监听响应式数据。functionwatch(source,cb){letgetter;if(typeofsource==="function"){//如果function是getter的意思,可以直接赋值getter=source;}else{getter=()=>traverse(source)//封装到effect对应的effectFn中,在函数内部遍历,达到依赖收集的目的}letoldValue,newValue;consteffectFn=effect(()=>getter(),{//启用惰性选项并存储返回值转到effectFn手动调用lazy:true,scheduler(){newValue=effectFn();//运行effect值变化时再次函数获取新值cb(newValue,oldValue);//更新旧值,否则下载错误的旧值oldValue=newValue;}})//手动调用副作用函数,和值得到的是旧值oldValue=effectFn();}其实上面的代码充分利用了lazy选项的特性,用它来创建一个延迟执行的效果。手动执行effectFn函数得到的返回值是旧值。当数据发生变化,触发调度器执行时,会重新执行effectFn函数,获取新的值。这样我们就得到了数据变化前后的新值和旧值,可以作为参数传递给回调函数cb。变更执行副作用函数后,需要将新值赋值给oldValue,方便后续计算,否则后续变更会得到错误的旧值。写个demo使用:watch(()=>state.age,(newValue,oldValue)=>{console.log(newValue,oldValue);})state.age++3.立即执行的watch和回调执行定时watch是本质上是对effect的二次封装,有两个特点:立即执行的回调函数,以及回调函数的执行时机。立即执行的回调函数立即执行的回调函数,默认情况下,watch回调函数只会在响应数据发生变化时执行,但在Vue.js中,可以使用options.immediate来指定是否立即执行回调。当options.immediate存在且为真时,回调函数在watch创建时立即执行一次。其实回调函数的立即执行和后续执行本质上没有太大区别。为此,可以将scheduler封装成一个通用的函数,options.immediate的存在可以在初始化或者改变的时候判断是否执行。functionwatch(source,cb,options={}){让getter;如果(类型===“函数”){getter=source;}else{getter=()=>traverse(source);}让旧值,新值;//将调度函数提取为一个独立的函数constscheduler=()=>{newValue=effectFn();//值变化时再次运行效果函数,得到新值cb(newValue,oldValue);//更新旧值,否则下次会得到错误的旧值oldValue=newValue;}consteffectFn=effect(()=>getter(),{//开启lazy选项,将返回值存入effectFn,方便后面手动调用lazy:true,scheduler:scheduler})if(options.immediate){//当immediate为true时,立即执行scheduler函数触发回调执行scheduler()}else{//手动调用sideeffect函数,获取的值为旧值oldValue=effectFn();}}在上面的代码中,回调函数被立即执行。第一次执行回调函数时没有所谓的旧值。此时回调函数的oldValue值未定义。回调函数的执行时机当然,除了指定回调函数立即执行外,还可以通过options参数指定回调函数的执行时机。在Vue.js3中,flush选项可以用来指定调度函数的执行时机。当flush的值为“post”时,表示调度函数需要将副作用函数放入microtask队列,等待DOM更新执行。functionwatch(source,cb,options={}){让getter;如果(类型===“函数”){getter=source;}else{getter=()=>traverse(source);}让旧值,新值;//将调度函数提取为独立函数constobj=()=>{newValue=effectFn();//值变化时再次运行效果函数,得到新值cb(newValue,oldValue);//更新旧值,否则下次会得到错误的旧值oldValue=newValue;}consteffectFn=effect(()=>getter(),{//开启lazy选项,将返回值存入effectFn,方便后面手动调用lazy:true,scheduler(){if(options.flush==="post"){constp=Promise.resolve();p.then(obj);}else{obj();}}})if(options.immediate){//当immediate为真时,执行调度器function立即触发回调执行scheduler()}else{//手动调用sideeffectfunction,获取的值为旧值oldValue=effectFn();}}其实就是根据options.flush是否等于“post”来实现obj函数是否需要异步处理。4.Expiredsideeffectfunctions和清理说到watchexpiredsideeffectfunctions,就不得不提到多进程或者多线程编程中经常提到的race问题。在下面的代码片段中,watch用于观察状态对象的变化,每当状态对象发生变化时,都会发送一个网络请求。letfinalData;watch(state,async()=>{//发送并等待网络请求constres=awaitfetch("/user/info");finalData=res;})好像没什么问题以上代码,但实际上会出现racecondition问题。状态对象的字段值第一次被修改后,会执行回调,同时发送第一个请求A;在A请求返回结果之前,我们继续修改state的字段值,同时发送第二个请求。第二个请求B。但是我们不知道请求A和请求B会先返回谁的结果?A的请求结果会覆盖B的请求结果,理论上分析下,我们是A和B先后发送请求。合理的做法是先返回A,再返回B的请求结果。这是因为请求A是第一次执行副作用函数的副作用,请求B是第二次执行副作用函数的副作用。请求B发生在请求A之后,请求A应该在此之前过期,返回的结果应该是无效的。但是如果之前没有调度watch的执行时机,就会出现请求A的值,然后覆盖B请求返回的值的错误。要解决这个问题,我们只需要提供一个让副??作用过期的手段即可。其实watch函数的回调函数可以传入第三个参数onInvalidate函数,让它注册一个当前副作用函数过期时执行的回调:functionwatch(source,cb,options={}){letgetter;如果(类型===“函数”){getter=source;}else{getter=()=>traverse(source);}让旧值,新值;letcleanupFn;//用于存储用户注册的过期回调函数//定义onInvalidate函数constonInvalidate=(fn)=>{//将过期回调函数存储在cleanupFn中fn();}//将调度函数提取为独立函数constobj=()=>{newValue=effectFn();//值变化时再次运行效果函数,得到新值//在调用回调函数cb之前,先调用过期的回调函数if(cleanupFn){cleanupFn();}cb(newValue,oldValue,onInvalidate);//更新旧值,否则下次会得到错误的旧值oldValue=newValue;}consteffectFn=effect(()=>getter(),{//开启lazy选项,将返回值存入effectFn,方便后面手动调用lazy:true,scheduler(){if(options.flush==="post"){constp=Promise.resolve();p.then(obj);}else{obj();}}})if(options.immediate){//当immediate为真时,执行调度器立即发挥作用,以便触发回调执行scheduler()}else{//手动调用副作用函数,得到的值是旧值oldValue=effectFn();}}在上面的代码片段中,定义了一个变量来存放用户通过onInvalidate函数注册的回调函数,并将过期的回调赋值给cleanupFn。在jobfunction中执行回调函数cb之前,会检查是否有过期的回调。如果有过期回调,会执行cleanupFn函数进行清理,最后返回onInvalidate给用户使用。写一个demo练习:watch(state,async(newValue,oldValue,onInvalidate)=>{letexpired=false;onInvalidate(()=>{expired=true;})constres=awaitfetch("/user/info");if(!expired){finaleData=res;}});//第一次修改state.age++;setTimeout(()=>{state.age++;},200)原理图如下:requestexpires5.写在上一篇文章中,我们讨论了watch函数是如何使用side-effectfunctions和options进行封装的。调度函数也用来控制回调函数的立即执行和执行时机,也可以解决racecondition问题。
