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 接受三个参数
source
cb
options
,最后返回并调用了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 = true
isFlushPending
是一个 标记,表示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
的setter
flushJobs
job
整个 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 的整个 响应系统 就完成了,响应系统分成了:
reactive
ref
computed
watch
四大块来进行分别的实现。
通过之前的学习可以知道,响应式的核心 API
为 Proxy
。整个 reactive
都是基于此来进行实现。
但是 Porxy
只能代理 复杂数据类型,所以延伸除了 get value
和 set value
这样 以属性形式调用的方法, ref
和 computed
之所以需要 .value
就是因为这样的方法。
响应系统 终于结束,接下来可以开始学习新的模块 渲染系统 喽~