vue3 源码学习,实现一个 mini-vue(五):watch 侦听器
1. 前言
本片文章原文来自 我的个人博客
这是 《vue3 源码学习,实现一个 mini-vue》 系列文章 响应式模块 的最后一章了,在前面几章我们分别介绍了 reactive、ref 以及 computed 这三个方法,阅读了 vue 源码并且实现了它们,那么本章我们最后来实现一下 watch 吧~
2. watch 源码阅读
我们可以点击 这里 来查看 watch 的官方文档。
watch 的实现和 computed 有一些相似的地方,但是作用却与 computed 大有不同。watch 可以监听响应式数据的变化,从而触发指定的函数。
2.1 基础的 watch 实例
我们直接从下面的代码开始 vue 源码调试:
<script>
const { reactive, watch } = Vue
const obj = reactive({
name: '张三'
})
watch(obj, (value, oldValue) => {
console.log('watch 监听被触发')
console.log('value', value)
})
setTimeout(() => {
obj.name = '李四'
}, 2000)
</scri
以上代码分析:
- 首先通过
reactive函数构建了响应性的实例 - 然后触发
watch - 最后触发
proxy的setter
摒弃掉之前熟悉的 reactive,我们从 watch 函数开始源码跟踪:
2.2 watch 函数
- 我们直接来到
packages/runtime-core/src/apiWatch.ts中找到watch函数,开始debugger:

- 可以看到 watch 接受三个参数
sourcecboptions,最后返回并调用了doWatch,我们进入到doWatch:

doWatch方法代码很多,上面有一些警告打印的if,我们直接来到第207行。因为source为reactive类型数据,所以会执行getter = () => source,目前source为proxy实例,即:getter = () => Proxy{name: '张三'}。紧接着,指定deep = true,即:source为reactive时,默认添加options.deep = true。我们继续调试doWatch这个方法:

- 执行
if (cb && deep),条件满足:创建新的常量baseGetter = getter,我们继续调试doWatch这个方法:

-
执行
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE,将INITIAL_WATCHER_VALUE赋值给oldValue,INITIAL_WATCHER_VALUE = {} -
执行
const job: SchedulerJob = () => {...},我们知道Scheduler是一个调度器,SchedulerJob其实就是一个调度器的处理函数,在之前我们接触了一下Scheduler调度器,但是并没有进行深入了解,那么这里将涉及到调度器的比较复杂的一些概念,所以后面我们想要实现watch,还需要 深入的了解下调度器的概念,现在我们暂时先不需要管它。我们继续调试doWatch这个方法:

- 直接执行:
let scheduler: EffectScheduler = () => queuePreFlushCb(job),这里通过执行queuePreFlushCb函数,将上一步的job作为传参,来得到一个完整的调度器函数scheduler。我们继续调试doWatch这个方法:

-
代码继续执行得到一个
ReactiveEffect的实例,注意: 该实例包含一个完善的调度器scheduler,接着调用了effect的run方法,实际上是调用了getter方法,获取到了oldValue,最后返回一个回调函数。 -
至此 watch 函数的逻辑执行完成。
总结:
watch函数的代码很长,但是逻辑还算清晰- 调度器
scheduler在watch中很关键 scheduler、ReactiveEffect两者之间存在互相作用的关系,一旦effect触发了scheduler那么会导致queuePreFlushCb(job)执行- 只要
job()触发,那么就表示watch触发了一次
2.3 reactive 触发 setter
等待两秒,reactive 实例将触发 setter 行为,setter 行为的触发会导致 trigger 函数的触发,所以我们可以直接在 trigger 中进行 debugger
- 我们直接来到
packages/reactivity/src/effect.ts中找到trigger,进行debugger:

- 根据我们之前的经验可知,
trigger最终会触发到triggerEffect,所以我们可以 省略中间 步骤,直接进入到triggerEffect中:

- 我们主要来看
triggerEffect:

- 因为
scheduler存在,所以会直接执行scheduler,即等同于直接执行queuePreFlushCb(job)。所以接下来我们 进入queuePreFlushCb函数,看看queuePreFlushCb做了什么:

- 触发
queueCb(cb, ..., pendingPreFlushCbs, ...)函数,此时cb = job,即:cb()触发一次,意味着watch触发一次,进入queueCb函数:

- 执行
pendingQueue.push(cb),pendingQueue从语义中看表示 队列 ,为一个 数组,接着执行了queueFlush函数,我们进入queueFlush()函数:

-
queueFlush函数内部做了两件事:1. 执行了isFlushPending = trueisFlushPending是一个 标记,表示promise进入pending状态。2. 通过Promise.resolve().then()这样一种 异步微任务的方式 执行了flushJobs函数,flushJobs是一个 异步函数,它会等到 同步任务执行完成之后 被触发,我们可以 给flushJobs函数内部增加一个断点 -
至此整个
trigger就执行完成
总结:
- 整个
trigger的执行核心是触发了scheduler调度器,从而触发queuePreFlushCb函数 queuePreFlushCb函数主要做了以下几点事情:- 构建了任务队列
pendingQueue - 通过
Promise.resolve().then把flushJobs函数扔到了微任务队列中
- 构建了任务队列
同时因为接下来 同步任务已经执行完成,所以 异步的微任务 马上就要开始执行,即接下来我们将会进入 flushJobs 中。
2.4 flushJobs 函数
- 进入
flushJobs函数代码:

- 执行
flushPreFlushCbs(seen)函数,这个函数非常关键,我们来看一下:

-
通过截图代码可知,
pendingPreFlushCbs为一个数组,其中第一个元素就是job函数(通过2.2 watch 函数第 4 步下面的截图可以看到传参) -
执行
for循环,执行activePreFlushCbs[preFlushIndex](),即从activePreFlushCbs这个数组中,取出一个函数,并执行(就是 job 函数!) -
到这里,
job** 函数被成功执行**,我们知道job执行意味着watch执行,即当前watch的回调 即将被执行
总结:
flushJobs的主要作用就是触发job,即:触发watch
2.5 job 函数
- 进入
job的执行函数,执行const newValue = effect.run(),此时effect为 :

- 我们知道执行
run,本质上是执行fn,而traverse(baseGetter())即为traverse(() => Proxy{name: 'xx'}),结合代码获取到的是newValue,所以我们可以大胆猜测,测试fn的结果等同于:`fn: () => ({name: '李四'})。 接下来执行:callWithAsyncErrorHandling(cb ......):

- 函数接收的第一个参数
fn的值为watch的第二个参数cb。接下来执行callWithErrorHandling(fn ......)。这里的代码就比较简单了,其实就是触发了fn(...args),即:watch的回调被触发,此时args的值为:

- 截止到此时
watch的回调终于 被触发了。
总结:
job函数的主要作用其实就是有两个:- 拿到
newValue和oldValue - 触发
fn函数执行
- 拿到
2.6 总结
到目前为止,整个 watch 的逻辑就已经全部理完了。整体氛围了四大块:
watch函数本身reactive的setterflushJobsjob
整个 watch 还是比较复杂的,主要是因为 vue 在内部进行了很多的 兼容性处理,使代码的复杂度上升了好几个台阶,我们自己去实现的时候 会简单很多 的。
3. 代码实现
3.1 scheduler 调度系统机制实现
经过了 computed 的代码和 watch 的代码之后,其实我们可以发现,在这两块代码中都包含了同样的一个概念那就是:调度器 scheduler。完整的来说,我们应该叫它:调度系统
整个调度系统其实包含两部分实现:
lazy:懒执行scheduler:调度器
3.1.1 懒执行
懒执行相对比较简单,我们来看 packages/reactivity/src/effect.ts 中第 183 - 185 行的代码:
if (!options || !options.lazy) {
_effect.run()
}
这段代码比较简单,其实就是如果存在 options.lazy 则 不立即 执行 run 函数。
我们可以直接对这段代码进行实现:
export interface ReactiveEffectOptions {
lazy?: boolean
scheduler?: EffectScheduler
}
/**
* effect 函数
* @param fn 执行方法
* @returns 以 ReactiveEffect 实例为 this 的执行函数
*/
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
// 生成 ReactiveEffect 实例
const _effect = new ReactiveEffect(fn)
// !options.lazy 时
if (!options || !options.lazy) {
// 执行 run 函数
_effect.run()
}
}
那么此时,我们就可以新建一个测试案例来测试下 lazy,创建 packages/vue/examples/reactivity/lazy.html:
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(
() => {
console.log(obj.count)
},
{
lazy: true
}
)
obj.count = 2
console.log('代码结束')
</script>
当不存在 lazy 时,打印结果为:
1
2
代码结束
当 lazy 为 true 时,因为不在触发 run,所以不会进行依赖收集,打印结果为:
代码结束
3.1.2 scheduler:调度器
调度器比懒执行要稍微复杂一些,整体的作用分成两块:
- 控制执行顺序
- 控制执行规则
1. 控制执行顺序
- 在
packages/reactivity/src/effect.ts中:
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
// 生成 ReactiveEffect 实例
const _effect = new ReactiveEffect(fn)
// 存在 options,则合并配置对象
+ if (options) {
+ extend(_effect, options)
+ }
// !options.lazy 时
if (!options || !options.lazy) {
// 执行 run 函数
_effect.run()
}
}
- 在
packages/shared/src/index.ts中,增加extend函数:
/**
* Object.assign
*/
export const extend = Object.assign
- 创建测试案例
packages/vue/examples/reactivity/scheduler.html:
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(
() => {
console.log(obj.count)
},
{
scheduler() {
setTimeout(() => {
console.log(obj.count)
})
}
}
)
obj.count = 2
console.log('代码结束')
</script>
最后执行结果为:
1
代码结束
2
说明我们实现了 控制执行顺序
2. 控制执行规则
- 创建
packages/runtime-core/src/scheduler.ts:
// 对应 promise 的 pending 状态
let isFlushPending = false
/**
* promise.resolve()
*/
const resolvedPromise = Promise.resolve() as Promise<any>
/**
* 当前的执行任务
*/
let currentFlushPromise: Promise<void> | null = null
/**
* 待执行的任务队列
*/
const pendingPreFlushCbs: Function[] = []
/**
* 队列预处理函数
*/
export function queuePreFlushCb(cb: Function) {
queueCb(cb, pendingPreFlushCbs)
}
/**
* 队列处理函数
*/
function queueCb(cb: Function, pendingQueue: Function[]) {
// 将所有的回调函数,放入队列中
pendingQueue.push(cb)
queueFlush()
}
/**
* 依次处理队列中执行函数
*/
function queueFlush() {
if (!isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
/**
* 处理队列
*/
function flushJobs() {
isFlushPending = false
flushPreFlushCbs()
}
/**
* 依次处理队列中的任务
*/
export function flushPreFlushCbs() {
if (pendingPreFlushCbs.length) {
let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0
for (let i = 0; i < activePreFlushCbs.length; i++) {
activePreFlushCbs[i]()
}
}
}
- 创建
packages/runtime-core/src/index.ts,导出queuePreFlushCb函数:
export { queuePreFlushCb } from './scheduler'
- 在
packages/vue/src/index.ts中,新增导出函数:
export { queuePreFlushCb } from '@vue/runtime-core'
- 创建测试案例
packages/vue/examples/reactivity/scheduler-2.html:
<script>
const { reactive, effect, queuePreFlushCb } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(
() => {
console.log(obj.count)
},
{
scheduler() {
queuePreFlushCb(() => {
console.log(obj.count)
})
}
}
)
obj.count = 2
obj.count = 3
</script>
最后执行结果为:
1
3
3
说明我们实现了 控制执行规则
3.2.3 总结
懒执行相对比较简单,所以我们的总结主要针对调度器来说明。
调度器是一个相对比较复杂的概念,但是它本身并不具备控制 执行顺序 和 执行规则 的能力。
想要完成这两个能力,我们需要借助一些其他的东西来实现,这整个的一套系统,我们把它叫做 调度系统
那么到目前,我们调度系统的代码就已经实现完成了,这个代码可以在我们将来实现 watch 的时候直接使用。
3.2 初步实现 watch 数据监听器
- 创建
packages/runtime-core/src/apiWatch.ts模块,创建watch与doWatch函数:
/**
* watch 配置项属性
*/
export interface WatchOptions<Immediate = boolean> {
immediate?: Immediate
deep?: boolean
}
/**
* 指定的 watch 函数
* @param source 监听的响应性数据
* @param cb 回调函数
* @param options 配置对象
* @returns
*/
export function watch(source, cb: Function, options?: WatchOptions) {
return doWatch(source as any, cb, options)
}
function doWatch(
source,
cb: Function,
{ immediate, deep }: WatchOptions = EMPTY_OBJ
) {
// 触发 getter 的指定函数
let getter: () => any
// 判断 source 的数据类型
if (isReactive(source)) {
// 指定 getter
getter = () => source
// 深度
deep = true
} else {
getter = () => {}
}
// 存在回调函数和deep
if (cb && deep) {
// TODO
const baseGetter = getter
getter = () => baseGetter()
}
// 旧值
let oldValue = {}
// job 执行方法
const job = () => {
if (cb) {
// watch(source, cb)
const newValue = effect.run()
if (deep || hasChanged(newValue, oldValue)) {
cb(newValue, oldValue)
oldValue = newValue
}
}
}
// 调度器
let scheduler = () => queuePreFlushCb(job)
const effect = new ReactiveEffect(getter, scheduler)
if (cb) {
if (immediate) {
job()
} else {
oldValue = effect.run()
}
} else {
effect.run()
}
return () => {
effect.stop()
}
}
- 在
packages/reactivity/src/reactive.ts为reactive类型的数据,创建 标记:
export const enum ReactiveFlags {
IS_REACTIVE = '__v_isReactive'
}
function createReactiveObject(
...
) {
...
// 未被代理则生成 proxy 实例
const proxy = new Proxy(target, baseHandlers)
// 为 Reactive 增加标记
proxy[ReactiveFlags.IS_REACTIVE] = true
...
}
/**
* 判断一个数据是否为 Reactive
*/
export function isReactive(value): boolean {
return !!(value && value[ReactiveFlags.IS_REACTIVE])
}
- 在
packages/shared/src/index.ts中创建EMPTY_OBJ:
/**
* 只读的空对象
*/
export const EMPTY_OBJ: { readonly [key: string]: any } = {}
-
在
packages/runtime-core/src/index.ts和packages/vue/src/index.ts中导出watch函数 -
创建测试实例
packages/vue/examples/reactivity/watch.html:
<script>
const { reactive, watch } = Vue
const obj = reactive({
name: '张三'
})
watch(
obj,
(value, oldValue) => {
console.log('watch 监听被触发')
console.log('value', value)
}
)
setTimeout(() => {
obj.name = '李四'
}, 2000)
</script>
此时运行项目,却发现,当前存在一个问题,那就是 watch 监听不到 reactive 的变化。
这个问题的原因是 我们在 setTimeout 中,触发了 触发依赖 操作。但是我们并没有做 依赖收集 的操作导致的。
不知道大家还记不记得,我们之前在看源码的时候,看到过一个 traverse 方法。
之前的时候,我们一直没有看过该方法,那么现在我们可以来说一下它了。
它的源码在 packages/runtime-core/src/apiWatch.ts 中:
查看源代码可以发现,这里面的代码其实有些 莫名其妙,他好像什么都没有做,只是在 循环的进行 xxx.value 的形式,我们知道 xxx.value 这个行为,我们把它叫做 getter 行为。并且这样会产生 副作用,那就是 依赖收集!。
所以我们知道了,对于 traverse 方法而言,它就是一个不断在触发响应式数据 依赖收集 的方法。
我们可以通过该方法来触发依赖收集,然后在两秒之后,触发依赖,完成 scheduler 的回调。
3.3 完成 watch 数据监听器的依赖收集
- 在
packages/runtime-core/src/apiWatch.ts中,创建traverse方法:
/**
* 依次执行 getter,从而触发依赖收集
*/
export function traverse(value: unknown) {
if (!isObject(value)) {
return value
}
for (const key in value as object) {
traverse((value as any)[key])
}
return value
}
- 在 doWatch 中通过 traverse 方法,构建 getter:
// 存在回调函数和deep
if (cb && deep) {
// TODO
const baseGetter = getter
getter = () => traverse(baseGetter())
}
此时再次运行测试实例, watch 成功监听。
同时因为我们已经处理了 immediate 的场景:
if (cb) {
if (immediate) {
job()
} else {
oldValue = effect.run()
}
} else {
effect.run()
}
所以,目前 watch 也支持 immediate 的配置选项。
3.4 总结
对于 watch 而言本质上还是依赖于 ReactiveEffect 来进行的实现。
本质上依然是一个 依赖收集、触发依赖 的过程。只不过区别在于此时的依赖收集是被 “被动触发” 的。
除此之外,还有一个调度器的概念,对于调度器而言,它起到的的主要作用就是 控制执行顺序、控制执行规则 ,但是大家也需要注意调度器本身只是一个函数,想要完成调度功能,还需要其他的东西来配合才可以。
4. 最后总结
到这里,mini-vue 的整个 响应系统 就完成了,响应系统分成了:
reactiverefcomputedwatch
四大块来进行分别的实现。
通过之前的学习可以知道,响应式的核心 API 为 Proxy。整个 reactive 都是基于此来进行实现。
但是 Porxy 只能代理 复杂数据类型,所以延伸除了 get value 和 set value 这样 以属性形式调用的方法, ref 和 computed 之所以需要 .value 就是因为这样的方法。
响应系统 终于结束,接下来可以开始学习新的模块 渲染系统 喽~
转载自:https://juejin.cn/post/7184088829014310971