Vue3源码学习 6 | watch
从上文中我们了解到了computed,有没有发现似乎Vue中的很多东西都是跟响应式有关的。嗷嗷嗷!你发现得没错,在这一节中,咱即将学习的 watch 也是基于响应式实现的。
6.1 watch 的实现原理
所谓watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:
watch(obj,() => {
console.log('数据变了')
})
// 修改响应式数据的值,会导致回调函数执行
obj.foo++
假设obj是一个响应式数据,使用watch函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。
实际上,watch的实现本质上就是利用了effect以及options.scheduler选项,*如以下代码所示: *
effect(() => {
console.log(obj.foo)
},{
scheduler() {
// 当 obj.foo 的值发生变化时,会执行 scheduler 调度函数
}
}
)
在一个副作用函数中访问响应式数据 obj.foo,通过前面的知识,咱可以知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。
但是有一个例外: 如果副作用函数存在scheduler选项,当响应式数据发生变化时,会触发scheduler调度函数执行,而非直接触发副作用函数执行。
从这个角度来看,其实scheduler调度函数就相当于一个回调函数,而watch的实现就是利用了这个特点。下面是最简单的 watch 函数的实现:
// watch 函数接收两个参数,sourse 是响应式数据,cb是回调函数
function watch(source,cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
这样就可以使用 watch 函数:
const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })
watch(obj,() => {
console.log('数据变化了')
})
obj.foo++
上面这段代码能正常工作,但是咱注意到在watch函数的实现中,硬编码了对source.foo的读取操作。换句话说,现在只能观测obj.foo的改变。为了让watch函数具有通用性,需要封装一个通用的读取操作:
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 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数据等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for(const k in value) {
traverse(value[k],seen)
}
return value
}
如上面的代码所示,在watch内部的effect中调用traverse函数进行递归的读取,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能触发回调函数执行。
watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
以上代码所示,传递给watch函数的第一个参数不再是一个响应式数据,而是一个getter函数。在getter函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:
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()
}
}
)
上面的代码:
- 首先判断 source 的类型,如果是函数类型,说明用户直接传递 getter 函数,这时直接使用用户的 getter 函数。
- 如果不是函数类型,那么保留之前的做法,即调用 traverse 函数递归地读取。
这样就实现了自定义getter的功能,同时使得watch函数更加强大。
使用过Vue中watch的大家可能都意识到了一个问题,咱平时使用watch是可以拿到新数据和旧数据的,这是watch一个非常重要的能力。
通常咱在使用Vue中的watch函数时,能够在回调函数中得到变化前后的值:
watch(
() => obj.foo,
(newValue,oldValue) => {
console.log(newValue,oldValue)
}
)
obj.foo++
可以充分利用effect函数的lazy选项,获得新值与旧值:
function watch(source,cb) {
let getter
if(typeof source === 'function') {
getter = source
}else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue,newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effeFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy:true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue,oldValue)
// 更新旧值,不然下次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
在这段代码中,最核心的改动是使用lazy选项创建了一个懒执行的effect。
注意上面代码中最下面的部分,手动调用effectFn函数得到的返回值就是旧值,即第一次执行得到的值。当变化并触发schuduler调度函数执行时,会重新调用effectFn函数并得到新值,这样就拿到了旧值与新值,接着它们作为参数传递给回调函数cb就可以了。最后一件非常重要的事情,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更变化时会得到错误的旧值。
6.2 立即执行的 watch 与 回调执行时机
在上面的内容中,咱知道了watch的本质其实是对effect的二次封装。接下来让咱继续学习watch的两个特性:
- 立即执行的回调函数
- 回调函数的执行时机
立即执行的回调函数
默认情况下,一个 watch 的回调只会在响应式数据发生变化时才执行:
// 回调函数只有在响应式数据 obj 后续发生变化时才执行
watch(obj, () => {
console.log('变化了')
})
在Vue.js中可以通过选项参数 immediate 来指定回调是否需要立即执行:
watch(obj,() => {
console.log('变化了')
},{
// 回调函数会在 watch 创建时立即执行一次
immediate:true
}
)
当immediate选项存在并且为true时,回调函数会在该watch创建时立刻执行一次。
其实,回调函数的立即执行与后续执行的本质上没有任何差别,可以把scheduler调度函数封装为一个通用函数,分别在初始化和变更时执行它,如一下代码所示:
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,
// 使用 jib 函数作为调度器函数
scheduler:job
}
)
if(options.immediate) {
// 当 immediate 为true时立即执行 job ,从而触发回调执行
job()
}else {
oldValue = effectFn()
}
}
这样就实现了回调函数的立即执行功能。由于回调函数是立即执行的,所以第一次回调执行时没有所谓的旧值,因此此时回调函数的oldValue值为undefined,这也是符合期望的
回调执行的时机
通过其他选项参数来执行回调函数的执行时机,使用 flush 选项来指定:
watch(obj,() => {
console.log('变化了')
},{
// 回调函数会在 watch 创建时立即执行一次
flush:'pre' // 还可以指定为 'post' | 'sync'
}
)
flush 本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数scheduler,这与flush的功能相同。当flush的值为'post'时,代表调度函数需要将副作用函数放在一个微任务队列中,并等待DOM更新结束后再执行, 可以用如下代码进行模拟:
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,
scheduler:() => {
// 在调度函数中判断 flush 是否为'post',如果是,将其放到微任务队列中执行
if(options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
}else {
job()
}
}
}
)
if(options.immediate) {
// 当 immediate 为true时立即执行 job ,从而触发回调执行
job()
}else {
oldValue = effectFn()
}
}
上面的代码中:
- 修改了调度器函数scheduler的实现方式,在调度器函数内检测options.flush的值
是否为post - 如果是,则将job函数放到微任务队列中,从而实现异步延迟执行
- 如果不是,直接执行 job 函数,本质上相当于'sync'的实现机制,即同步执行
对于options.flush的值为'pre'的情况,我不知道怎么模拟了,因为这是涉及到了组件的更新时机。但是上面的两个分别是组件更新前和更新后,相信大家也可以通过这两个理解到了控制回调函数的更新时机。
总结
久违的总结,怎么说呢,如果真的有仔细阅读我这几篇有关响应式的东西的话,你会发现响应式也不过是一个响应式数据+若干个副作用函数+调度器,并没有我们想象中的那么复杂,最值得学习的还是框架的设计思路和各种巧妙的代码书写。