深入源码设计!Vue3.js核心API——watch实现原理

如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~

作者:前端小王hs

阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主

此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来

书籍:《Vue.js设计与实现》 作者:霍春阳

本篇博文将在书第4.9节第4.11的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3想分析Vue3源码的朋友快速阅读

如有帮助,不胜荣幸

前置章节:

  1. 深入理解Vue3.js响应式系统基础逻辑
  2. 深入理解Vue3.js响应式系统设计之栈结构和循环问题
  3. 深入理解Vue3.js响应式系统设计之调度执行
  4. 深入源码设计!Vue3.js核心API——Computed实现原理

核心watch

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: () => {
        // 当数据变化时,调用回调函数 cb
        cb();  
      }
    }
  );
} 

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) {
  // 定义 getter
  let getter
  // 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
  if (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变为了需要手动调用,现在来分析一下整个流程:

  1. 调用effectFn()返回了旧值——oldValue,同时在这一过程中,gettereffectFn相关联了
  2. 如果getter发生了变化,那么会触发trigger,然后执行scheduler
  3. 那么此时,又一次调用effectFn获取到的就是newValue
  4. newValueoldValue传入回调函数
  5. 将最新的值,变为旧值,这一点很关键,是为下一次变化做的准备

那么现在,就可以拿到旧值新值

初始化调用——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');
  // 将请求结果赋值给 data
  finalData = 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 设置为 true
    expired = 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 getter
    if (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 = false
    onInvalidate(() => {
        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发生了什么:

  1. 执行watch,由于不是immediate,所以调用了effectFn,建立了obj.fooeffectFn的关联,并且返回了1为``oldValue
  2. 执行obj.foo++,发生自增操作触发了trigger,会执行effectFn.options.scheduler
  3. 进而执行job,把自增后的2赋值给newValue,此时cleanup()没有赋值不执行,然后执行cb,在cb中,调用了执行onInvalidate() => {expired = true}赋值给cleanup,然后发起请求,假设异步请求的结果还没回来,最后oldValue变为了2(异步还在执行,但下一个是同步函数
  4. setTimeout中执行obj.foo++,触发trigger,执行effectFn.options.scheduler,再次执行job,通过track返回newValue3,然后现在claenup()存在,所以执行cleanup(),把第三步中的cb中的expired置为了ture
  5. 执行cb,执行onInvalidate() => {expired = true}赋值给cleanup,然后发起请求,返回数据,由于此时的expiredfalse,所以把返回的值赋值给了finalData
  6. 第一次自增触发的回调数据返回了,但由于expiredtrue,所以不会进行赋值

关于expired,这里再说一下变化的过程,在第一次job执行之后,就把其为ture的结果存起来了,然后在第二次jobcb还没执行,修改第一次jobcb内的expired状态为true

可以分为expiredAexpiredB去理解

我们要清楚两个回调是有先后顺序

通过闭包,我们可以在某个节点去修改定义在函数外的值,这就是闭包的作用

小记

至此,我们整个第四章就分析完了!

值得一提的是,通过前面的分析我们可以发现整个第四章都是好像连续剧一样,所以在分析时一定要对前面的内容充分理解之后才能继续看下去,我记得我当时看的时候,就是由于看了之后隔了几天没看,加上笔记不清晰,又得重新看,不过这也养成了我看书时专注一本书的习惯,也就是看了就看下去,不停顿

关于这篇,如果认真阅读了,最起码可以达到以下效果

  1. 了解和学习vue.js团队是如何设计watch的,了解其基本实现原理
  2. 理解watch是怎么实现immediate执行的
  3. 什么是竞态问题
  4. 什么是闭包和闭包在实际开发中的用处
  5. 其他…

谢谢大家的阅读,如有错误的地方请私信笔者

笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端小王hs

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值