4

Vue.js设计与实现之watch属性的实现

 2 years ago
source link: https://www.fly63.com/article/detial/11335
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

1.写在前面

在上篇文章中,我们讨论了compted的实现原理,就是利用effect和options参数进行封装。同样的,watch也是基于此进行封装的,当然watch还可以传递第三参数去清理过期的副作用函数。不仅可以利用副作用函数的调度性,去实现回调函数的立即执行,也可以控制回调函数的执行时机。

2.watch的实现原理

watch本质就是去观测一个响应式数据,当数据变化时通知并执行相应的回调函数。watch的实现本质和computed类似,基于effect函数和options.scheduler选项。

const data = {
  name:"pingping",
  age:18,
  flag:true
};

const state = new Proxy(data,{
  /*...*/
})

watch(state,()=>{
  console.log("数据变化了呀...");
});

//在响应数据的age值被修改了,就会导致回调函数执行
state.age++;

watch函数的实现代码如下所示,副作用函数存在scheduler选项中,当响应数据发生变化时,会触发scheduler调度函数执行,而非直接触发副作用函数执行。

//watch 函数接收source响应数据和回调函数cb
function watch(source, cb){
  effect(
    //触发读取操作,建立联系
    ()=>source.age,
    {
      scheduler(){
        // 当数据发生变化,调用回调函数cb
        cb();
      }
    }
  )
}

上面代码片段中,我们在使用watch对数据进行监听时,只能对soure.age的变化进行观测,不具有通用性,对此需要进行进一步封装。

function watch(source, cb){
  effect(
    //调用traverse函数递归读取操作,建立联系
    ()=>traverse(source),
    {
      scheduler(){
        // 当数据发生变化,调用回调函数cb
        cb();
      }
    }
  )
}
const isObject = (value:any) => typeof value === "object" && value !== null;

function traverse(value, seen = new Set()){
  //如果读取的数据是原始值,或已经读取过响应数据,则什么也不做
  if(!isObject(value) || seen.has(value)) return;
  //将数据添加到seen中,表示遍历读取过数据,避免循环引用导致死循环
  seen.add(value);
  //对数据对象进行遍历递归读取,用于依赖收集
  for(const k in value){
    traverse(value[k],seen);
  }
  return value;
}

在上面代码中,单独封装了一个递归函数traverse可以对响应数据进行遍历递归读取操作,这样就可以读取到对象的上所有属性,从而监听任意属性值发生变化时都能够触发回调函数。

事实上,使用watch进行数据观测时,不仅可以观测响应数据,还可以观测getter函数。那么,我们只需要先对输入的被观测数据判断数据类型是否为function,如果是则赋值给getter,否则还是监听响应式数据。

function watch(source,cb){
    let getter;
    if(typeof source === "function"){ // 如果是函数表示是getter,可以直接赋值
       getter = source;
    }else{
       getter = () => traverse(source)// 包装成effect对应的effectFn, 函数内部进行遍历达到依赖收集的目的
    }
    let oldValue, newValue;
    const effectFn = effect(
      ()=>getter(),
      {
        //开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
        lazy: true,
        scheduler(){
          newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
          cb(newValue,oldValue);
          //更新旧值,不然下次得到的是错误的旧值
          oldValue = newValue;
        }
      }
    )
    //手动调用副作用函数,拿到的值是旧值
    oldValue = effectFn();
}

其实,上面代码中充分了利用了lazy选项的特性,利用其创建一个懒执行的effect。通过手动执行effectFn函数得到的返回值是旧值,当数据变化并触发scheduler调度器执行时,会重新执行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存在且为true时,回调函数在该watch创建时立即执行一次。事实上,回调函数的立即执行和后续执行在本质上区别不大,对此可以将其调度器scheduler进行封装为通用函数,通过options.immediate的存在与否判断是在初始化还是变更时进行执行。

function watch(source, cb, options={}){
  let getter;
  if(type === "function"){
    getter = source;
  }else{
    getter = ()=>traverse(source);
  }
  
  let oldValue, newValue;
  
  // 提取调度函数为独立的函数
  const scheduler = ()=>{
    newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
    cb(newValue,oldValue);
    //更新旧值,不然下次得到的是错误的旧值
    oldValue = newValue;
  }
  
  const effectFn = effect(
    ()=>getter(),
    {
      //开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
      lazy: true,
      scheduler: scheduler
    }
  )
  if(options.immediate){
    //当immediate为true时,立即执行scheduler函数从而触发回调执行
    scheduler()
  }else{
    //手动调用副作用函数,拿到的值是旧值
    oldValue = effectFn();
  }
}

上面代码中,回调函数是立即执行的,在第一次回调函数执行时没有所谓的旧值,此时回调函数的oldValue值是undefined。

回调函数的执行时机

当然,除了上面的可以指定回调函数为立即执行外,还可以通过options参数来指定回调函数的执行时机。在Vue.js3中可以通过flush选项来指定调度函数的执行时机,当flush的值为"post"时,表示调度函数需要将副作用函数放在微任务队列中,等待dom更新结束后执行。

function watch(source, cb, options={}){
  let getter;
  if(type === "function"){
    getter = source;
  }else{
    getter = ()=>traverse(source);
  }
  
  let oldValue, newValue;
  
  // 提取调度函数为独立的函数
  const obj = ()=>{
    newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
    cb(newValue,oldValue);
    //更新旧值,不然下次得到的是错误的旧值
    oldValue = newValue;
  }
  
  const effectFn = effect(
    ()=>getter(),
    {
      //开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
      lazy: true,
      scheduler(){
        if(options.flush === "post"){
          const p = Promise.resolve();
          p.then(obj);
        }else{
          obj();
        }
      }
    }
  )
  if(options.immediate){
    //当immediate为true时,立即执行scheduler函数从而触发回调执行
    scheduler()
  }else{
    //手动调用副作用函数,拿到的值是旧值
    oldValue = effectFn();
  }
}

其实就是根据options.flush是否等于"post",来实现是否需要将obj函数进行异步处理。

4.过期的副作用函数和cleanup

在讲到watch过期的副作用函数,就要提到在多进程或多线程编程中经常被提及的竞态问题。在下面代码片段中,使用watch观测state对象的变化,每次state对象发生变化时都会发送网络请求。

let finalData;

watch(state, async ()=>{
  //发送等待网络请求
  const res = await fetch("/user/info");
  finalData = res;
})

在上面代码看起来是没啥问题,但其实会发生竞态问题,在第一次修改state对象的字段值后,会导致回调执行,同时发送第一次请求A;在A请求返回结果之前,我们继续修改state的字段值,同时发送第二次请求B。但是请求A和请求B谁的结果先返回,我们并不知道?

6254dac1ac346.jpg

将A的请求结果覆盖B的请求结果

在理论分析下,我们先后发送A、B请求,按道理应该是先返回A,再返回B请求的结果。这是因为请求A是副作用函数第一次执行产生的副作用,而请求B是副作用函数第二次执行产生的副作用。请求B在请求A后发生,请求A应当在这之前就过期了,返回的结果应该是无效的。

但是,在前面没有对watch的执行时机进行调度的情况下,就会发生请求A的值后返回覆盖B请求返回值的错误。

要想解决这种问题,我们只需要提供一个副作用过期的手段即可。事实上,watch函数的回调函数可以传入第三个参数onInvalidate函数,让其注册一个回调在当前副作用函数过期时执行:

function watch(source, cb, options={}){
  let getter;
  if(type === "function"){
    getter = source;
  }else{
    getter = ()=>traverse(source);
  }
  
  let oldValue, newValue;
  
  let cleanupFn;//用于存储用户注册的过期回调
  //定义onInvalidate函数
  const onInvalidate = (fn)=>{
    //将过期的回调函数存储到cleanupFn中
    fn();
  }
  
  // 提取调度函数为独立的函数
  const obj = ()=>{
    newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
    
    //在调用回调函数cb之前,先调用过期的回调函数
    if(cleanupFn){
      cleanupFn();
    }
    cb(newValue,oldValue,onInvalidate);
    //更新旧值,不然下次得到的是错误的旧值
    oldValue = newValue;
  }
  
  const effectFn = effect(
    ()=>getter(),
    {
      //开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
      lazy: true,
      scheduler(){
        if(options.flush === "post"){
          const p = Promise.resolve();
          p.then(obj);
        }else{
          obj();
        }
      }
    }
  )
  if(options.immediate){
    //当immediate为true时,立即执行scheduler函数从而触发回调执行
    scheduler()
  }else{
    //手动调用副作用函数,拿到的值是旧值
    oldValue = effectFn();
  }
}

在上面代码片段中,定义变量存储用户通过onInvalidate函数注册的回调函数,将过期的回调赋值给cleanupFn,在job函数中每次执行回调函数cb前都会检查是否存在过期回调。存在过期回调则执行cleanupFn函数清理,最后将onInvalidate返回给用户使用。

写个demo实践下:

watch(state, async (newValue, oldValue, onInvalidate)=>{
  let expired = false;
  onInvalidate(()=>{
    expired = true;
  })
  
  const res = await fetch("/user/info");
  
  if(!expired){
    finaleData = res;
  }
});

//第一次修改
state.age++;
setTimeout(()=>{
  state.age++;
},200)

示意图如下:

6254dac8d93f5.jpg

5.写在最后

本文中,讨论了watch函数是如何利用副作用函数和options进行封装实现的,也通过调度函数去控制回调函数的立即执行和执行时机,还可以解决竞态问题。

来源: 前端一码平川

链接: https://www.fly63.com/article/detial/11335


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK