likes
comments
collection
share

Vue3源码学习——侦听器watchEffect

作者站长头像
站长
· 阅读数 11

上一节,我们介绍了watch函数的逻辑,watch函数接收三个参数,分别是:

  • 监听对象
  • 回调函数
  • 配置项

当监听对象发生改变的时候,会触发回调函数的执行。

这一节,我们来看一下侦听器章节的另一个API——watchEffect

watchEffect

先看一下watchEffect的用法:

定义: 立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。

watchEffect可以接收两个参数:

  • 第一个参数就是要运行的副作用函数
  • 第二个参数是一个可选的选项,可以用来调整副作用的刷新时机或调试副作用的依赖。(等同于watch的配置项参数)

例如:

const count = ref(0)
watchEffect(() => console.log(count.value))
// -> 输出 0

count.value++
// -> 输出 1

在上面例子中,watchEffect传入一个打印count.value的函数,那么Vue3就会自动监听count.value,当count.value发生改变时,就会在控制台打印count.value的值。

接下来,我们正式进入源码阶段:

首先还是老样子,我们找到最直接的——watchEffect这个函数:

function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  return doWatch(effect, null, options)
}

怎么样,是不是似曾相识呢?跟我们上一节讲到的watch简直一模一样,都是直接返回doWatch函数。只不过这次向doWatch函数中的传递的第二个参数cb传为了null

我们接着看doWatch函数逻辑:

getter

  function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
    const instance = currentInstance
    let getter;
    let forceTrigger = false;
    let isMultiSource = false;
    
    // 这里会根据传入的source类型,为getter赋不同的值
    if (isRef(source)) {
      ...
    } else if (isReactive(source)) {
      ...
    } else if (isArray(source)) {
      ...
    } else if (isFunction(source)) {
      // watchEffect传入的source是function类型,同时cb为null,所以我们只看这里的逻辑即可
      if (cb) {
        ...
      } else {
        getter = () => {
          // 组件是否被卸载
          if (instance && instance.isUnmounted) {
            return;
          }
          
          // 是否存在cleanup函数(通常用于清除上一轮没有执行完的watchEffect)
          if (cleanup) {
            cleanup();
          }
          
          // 返回执行结果,并将onCleanup作为参数传递给source
          return callWithAsyncErrorHandling(
            source,
            instance,
            3,
            [onCleanup]
          );
        };
      }
    } else {
      ...
    }
}

我们在watch函数章节有提到,doWatch函数首先做的就是将不同类型监听对象source处理成一个通用的getter,从而能够在后续阶段将getter包装为一个副作用函数effect,并执行,完成依赖收集,就不再重复这一块内容,有兴趣可以再回顾一下 侦听器watch 这一章节。

而在watchEffect函数中,因为传入doWatch函数sourcefunction类型,cbnull,所以执行的getter赋值逻辑如上。

onCleanup

function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {

    // getter赋值
    ...

    if (cb && deep) {
      const baseGetter = getter;
      getter = () => traverse(baseGetter());
    }
    let cleanup;
    let onCleanup = (fn) => {
      cleanup = effect.onStop = () => {
        callWithErrorHandling(fn, instance, 4 /* WATCH_CLEANUP */);
      };
    };
    ...
 }

getter函数确定之后,接下来定义了onCleanup函数。这里onCleanup函数会接收一个函数作为参数,将该函数的执行控制赋值给cleanup。而我们在上面getter的逻辑中可以看到,在执行source之前,都会判断一次是否存在cleanup,如果存在则优先执行cleanup

这里有点绕,我们举个例子:

const state = ref(1)

function increment() {
  state.value++
}

watchEffect((onCleanup) => {
  let x = state.value
  let timer = setTimeout(() => {
    console.log(state.value)
  }, 2000)
  onCleanup(() => {
    clearTimeout(timer)
  })
})

先说一下这个例子的执行情况:

  • 当我们低频率执行increment时(执行间隔大于2秒):控制台每次会延迟2秒打印出增加后的state.value值
  • 当我们高频率执行increment时(执行间隔小于2秒):控制台只会在最后一次执行完成后延迟2秒打印出state.value的值。

我们来分析一下这个例子:

  • 首先,向watchEffect函数中,传入了一个函数参数fn函数fn的内容是延迟2秒在控制台打印出state.value的值。
  • 这个函数fn可以接收一个onCleanup函数作为参数,这一点我们在源码中对应的是callWithAsyncErrorHandling( source, instance, 3, [onCleanup] );
  • 我们往onCleanup函数中,传入了清空定时器的操作,这样也就是说,当执行完fn之后,会为我们开启一个定时器,同时doWatch函数源码中的cleanup会赋值为清空当前的定时器的函数。
  • 当我们快速执行increment的时候,首先会执行cleanup函数,也就是会清空掉我们上一个watchEffect开启的定时器。所以,最后控制台只会打印出我们最后一次执行完increment之后state.value的值。
  • 这里的逻辑,可以理解为就是用watchEffect实现的防抖

onCleanup函数的作用不止于此,也可以用于取消一些无效请求。

watchEffect(async (onCleanup) => { 
    const { response, cancel } = doAsyncWork(id.value) 
    // `cancel` 会在 `id` 更改时调用 
    // 以便取消之前 
    // 未完成的请求 
    onCleanup(cancel) data.value = await response 
})

job

关于onCleanup函数我们就说到这里,继续往下看,我们这一节只关注watchEffect中关于创建job这一块的逻辑:

  function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
    // getter赋值
    ...
    
    // onCleanup
    ...
    
    // watchEffect中关于创建job不再需要维护新老值,直接执行副作用函数effect就完了
   const job = () => {
      if (!effect.active) {
        return;
      }
        effect.run();
    };
    
    // watchEffect不允许递归调用
    job.allowRecurse = false;
    
    // 确定调度
    let scheduler;
    if (flush === "sync") {
      scheduler = job;
    } else if (flush === "post") {
      scheduler = () => queuePostRenderEffect(job, instance && instance.suspense);
    } else {
      job.pre = true;
      if (instance)
        job.id = instance.uid;
      scheduler = () => queueJob(job);
    }
    
    // 将我们上面包装好的getter处理为副作用函数effect
    const effect = new ReactiveEffect(getter, scheduler);
    
    // 调试侦听器时可用
    if (true) {
      effect.onTrack = onTrack;
      effect.onTrigger = onTrigger;
    }
    
    if (flush === "post") {
      queuePostRenderEffect(
        effect.run.bind(effect),
        instance && instance.suspense
      );
    } else {
      effect.run();
    }
    ...
}

通过隐藏无关逻辑,我们可以发现,doWatch函数cbnull时的逻辑还是比较清晰的:

  • getter处理为副作用函数effect,同时根据调度程序scheduler确定job在任务队列中的执行时机。
  • 创建执行任务job。这里job的逻辑就非常简单,直接执行副作用函数effect。
  • 当传入的配置项中flush不为post时,执行副作用函数effect,执行过程中会涉及到对属性的访问,完成对函数中涉及到的响应式属性的依赖收集
  • 后续,当watchEffect函数中收集过的属性发生改变时,即会触发副作用函数的执行,完成监听。

unwatch

最后同样也是取消监听的函数

function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
...

const unwatch = () => {
    effect.stop()
    if (instance && instance.scope) {
      remove(instance.scope.effects!, effect)
    }
  }
  
  return unwatch
}

定义一个取消监听的函数,通过执行effect.stop方法移除收集到的对应的副作用函数,并将unwatch方法暴露出去,这样我们就可以通过在外界调用从而取消监听了。