本篇为阅读《Vue.js设计与实现》第4章过程总结笔记
- 所谓watch,其本质就是观测一个响应式数据,当响应式数据发生变化时通知并执行相应的回调函数
例如:
js
watch(obj, () => {
console.log('响应式数据变化了');
})
obj.foo++ // 改变响应式数据的值,会导致回调函数的执行
- 上一节我们实现的响应式数据与副作用函数之间联系的各个函数
js
const data = { foo: 1, bar: 2 }; // 只是一个测试使用的数据
let activeEffect;
let effectStack = [];
const bucket = new WeakMap()
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// fn()
const res = fn() // 将fn的执行结果存储到res中
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res // 将res作为effectFn的返回值
}
effectFn.options = options
effectFn.deps = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
function track(target, key) {
// console.log(target, key);
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
function trigger(target, key) {
// console.log(target, key);
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
const obj = new Proxy(data, { // 代理对象
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJob() {
if (isFlushing) return
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
实现原理
-
watch本质上是利用了
effect
以及option.scheduler
选项jseffect(() => { console.log(obj.foo); },{ scheduler() {} })
- 在一个副作用函数effect中访问响应式数据obj.foo,通过前几节知道,这会在副作用函数与响应式数据之间建立联系
- 当响应式数据发生变化时,如果副作用函数存在scheduler选项,会触发scheduler调度函数执行,没有该选项则直接触发副作用函数执行
- 从该角度看,其实scheduler调度函数相当于一个回调函数,watch就是利用该特点
-
实现一个最简单的watch函数
jsfunction watch(source, cb) { effect( () => obj.foo, // 触发读取操作,从而建立联系 { scheduler() { cb() // 当数据发生变化时,执行调度函数 } } ) } watch(obj.foo, () => console.log('foo发生了变化')) // 控制台打印'foo发生了变化' obj.foo++
watch
接收两个 参数source
是响应式数据cb
是回调函数- 但是我们实现的该watch函数硬编码了对source.foo的读取操作,所以只能建观测foo的变化
- 为了让watch函数具有通用性,封装一个通用的读取操作
-
封装读取操作
jsfunction traverse(value, seen = new Set()) { if (typeof value !== 'object' || value === null || seen.has(value)) return value // 如果读取的数据是原始值 或已经读取过 则什么都不做 seen.add(value) // 将数据添加到seen中 代表遍历地读取过了 避免循环引用导致的死循环 for (let k in value) { // 暂时不考虑数组等其他结构 假设value是一个对象 使用 for...in 读取对象中的每一个值,并递归调用traverse进行处理 traverse(value[k], seen) } return value } function watch(source, cb) { effect( () => traverse(source), // 调用traverse递归读取 { scheduler() { cb() } } ) } watch(obj, () => console.log('obj里面有属性值发生了变化')) // 控制台打印了2次 obj.foo++ obj.bar++
- 在watch内部的effect中调用
traverse
函数进行递归****的读取操作,这样子就能读取一个对象上的任意属性,从而当任意属性变化时都能够触发回调函数 - 注意这里watch函数第一个参数应传入obj,传入obj.foo会发生重载错误,要传入obj.foo可以通过以下的getter函数
- 在watch内部的effect中调用
-
watch函数除了可以观测响应式数据,还可以接收一个
getter
函数作为第一个参数在getter函数内部,用户可以指定该watch****依赖哪些响应式数据,只有当这些数据发生变化时,才会触发回调函数执行
js
function watch(source, cb) {
let getter // 定义getter
if (typeof source === 'function') { // 如果source是函数 说明用户传入的是getter 直接将source赋值给getter
getter = source
} else {
getter = () => traverse(source) // 否则按照原来的实现调用 traverse 递归读取
}
effect(
() => getter(),
{
scheduler() {
cb()
}
}
)
}
watch(
() => obj.foo,
() => console.log('obj.foo值发生了变化') // 控制台打印 obj.foo值发生了变化
)
obj.foo++
- 这样子就实现了自定义的getter功能,同时使得watch函数更加强大
- 但是发现新的问题,我们的函数还缺少一个非常重要的功能,通常我们使用在Vue.js中的watch函数时,能够在回调函数中拿到变化前后的值
-
如何获取到新值和旧值,这需要充分利用effect函数的
lazy
选项jsfunction watch(source, cb) { let getter // 定义getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldValue, newValue // 定义旧值与新值 // 使用effect注册副作用函数时 开启lazy选项 并把返回值存储到effectFn中以便后续手动调用 const effectFn = effect( () => getter(), { lazy: true, scheduler() { newValue = effectFn() // 在scheduler中重新执行副作用函数 得到的值为新值 cb(newValue, oldValue) // 将旧值和新值作为回调函数的参数 执行 oldValue = newValue // !! 更新旧值 不然下一次会得到错误的旧值 } } ) oldValue = effectFn() // 手动调用副作用函数 拿到的值为旧值 }
- 最核心的改动是使用
lazy
选项创建了一个懒执行的effect - 注意代码最下面的部分,我们手动调用
effectFn
函数得到的返回值就是旧值,即第一次执行得到的值 - 当变化发生并触发
scheduler
调度函数执行时,会重新调用effectFn
函数并得到新值 - 这样子我们就拿到了旧值和新值,并将它们作为参数传递给回调函数cb就可以
- !!!最后,不要忘记使用新值更新旧值 :
oldValue = newValue
,否则在下一次变更发生时会得到错误的旧值 oldValue = effectFn()只在最开始调用时执行一次
- 最核心的改动是使用
立即执行的watch与回调执行时机
上一节我们完成了watch的基本实现,本质是对effect的二次封装
默认情况下,一个watch的回调只会在响应式数据发生变化时才执行,但是在Vue.js中可以通过选项参数 immediate
来指定回调是否需要立即执行
javascript
watch(obj, () => {
console.log('变化了');
}, {
immediate: true // 回调函数会在watch创建时立即执行一次
})
- 当 immediate 选项存在并且为true时,回调函数会在该watch创建时立刻执行一次。立即执行与后续执行本质上没有任何区别
-
可以把scheduler调度函数封装为一个通用函数,分别在初始化和变更时执行它
jsfunction watch(source, cb, options = {}) { let getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldValue, newValue const job = () => { // 提取scheduler调度函数为一个独立的job函数 newValue = effectFn() cb(newValue, oldValue) oldValue = newValue } const effectFn = effect( () => getter(), { lazy: true, scheduler: job // 使用job函数作为调度器函数 } ) if (options.immediate) { // 当 immediate 为 true 时 立即执行job 从而触发回调执行 job() } else { oldValue = effectFn() } } watch(obj, (newVal, oldVal) => { console.log('watch创建了') console.log(newVal, oldVal); // Proxy(Object){foo: 1,bar: 2} undefined }, { immediate: true })
- 这样子就实现了回调函数的立即执行功能,由于是立即执行的,所以第一次回调执行时没有所谓的旧值,因此 oldValue 为
undefined
也是符合预期的 - 除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机
- 这样子就实现了回调函数的立即执行功能,由于是立即执行的,所以第一次回调执行时没有所谓的旧值,因此 oldValue 为
-
Vue.js3使用
flush
选项来指定jswatch(obj, (oldVal, newVal) => { console.log(oldVal, newVal) }, { // 回调函数会在watch创建时执行一次 flush: 'pre' // 还可以指定为 'post'|'sync' })
- flush本质是在指定调度函数的执行时机,前一节讲解过如何在微任务队列中执行调度函数,这与flush功能相同
- 当flush值为post时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待DOM更新结束后再执行,修改scheduler来模拟该效果
jsscheduler: () => { if (options.flush === 'post') { // 如果flush为 'post' 将其放入微任务队列中执行 const p = Promise.resolve() p.then(job) } else { job() } }
- 在调度器函数内检测potions.flush的值是否为post,是的话将job函数放到微任务队列中,从而实现异步延迟执行,否则直接执行job函数,这本质上相当于'asyn'的实现机制,即同步执行
- 对于'pre'的情况暂时无法模拟,因为设计组件的更新时机('pre'和'post'原本语义指的就是组件更新前和更新后)
过期的副作用函数
竞态问题
-
举一个竞态问题的例子
jslet finalData watch(obj, async () => { const res = await fetch('/path/to/request') // 发生并等待网络请求 finalData = res // 将请求结果赋值给finalData })
- 我们使用watch观测obj对象的变化,每次obj对象发生变化都会发送网络请求,例如请求接口数据,等待数据请求成功之后,将结果赋值给 finalData变量
- 但是假如我们第一次修改obj对象的某个字段,导致回调函数执行,同时发送了第一次请求A,在A返回结果之前,又修改了obj对象的某个字段,导致发送第二次请求B
- 此时A和B同时进行中,如果B先于A返回结果,那么会导致最终finalData中存储的是A请求的结果
- 但是由于B是后发送的,所以A应该被视为过期的,finalData应存储的是B返回的结果
- 我们需要一个让副作用函数过期的手段
-
先看Vue.js中的watch函数复现以上场景,查看它是如何解决该问题的
jslet finalData watch(obj, async (newVal, oldVal, onInvalidate) => { let expired = false // 定义一个标志 代表当前副作用函数是否过期 默认为false 代表没有过期 onInvalidate(() => { // 调用onInvalidate函数注册一个过期回调 expired = true // 当过期是将expired设置为false }) const res = await fetch('') if (!expired) { // 只有当副作用函数的执行没有过期时 才会执行后续操作 finalData = res } })
- 那么 onInvalidate 的实现原理是怎样的呢
- 其实是在watch内部每次检测到变更后,在副作用函数重新执行前,会先调用我们通过onInvalidate函数注册的过期回调
-
在watch函数中添加
onInvalidate
过期回调jsfunction watch(source, cb, options = {}) { let getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldValue, newValue let cleanup // cleanup用来存储用户注册的过期回调 function onInvalidate(fn) { cleanup = fn // 将过期回调存储到 cleanup中 } const job = () => { newValue = effectFn() if (cleanup) { cleanup() } cb(newValue, oldValue, onInvalidate) // 将onInvalidate作为回调函数的第三个参数 以便用户使用 oldValue = newValue } const effectFn = effect( () => getter(), { lazy: true, scheduler: () => { if (options.flush === 'post') { const p = Promise.resolve() p.then(job) } else { job() } } } ) if (options.immediate) { job() } else { oldValue = effectFn() } } // 还是以上面的例子 let finalData watch(obj, async (newVal, oldVal, onInvalidate) => { let expired = false onInvalidate(() => { expired = true }) const res = await fetch('') if (!expired) { finalData = res } }) obj.foo++ setTimeout(() => { obj,foo++ // 200ms后做第二次修改 },200)
- 第一次修改是立即执行的,watch的回调函数执行,由于我们在回调函数中调用了 onInvalidate,所以会注册一个过期回调,发送请求A,假设A需要1000ms后才能返回结果
- 我们在200ms时又修改了foo的值,又会导致watch的回调函数执行,但是在我们代码实现中,每次执行回调函数前要先检查过期回调是否存在,若存在,优先执行过期回调
- 由于watch的函数回调第一次执行的时候,我们已经注册了一个过期回调,在watch的回调函数第二次执行之前,会优先执行之前注册的过期回调,这使得第一次执行的副作用函数内闭包的变量
expired
的值变为true
,即第一次的副作用函数过期了,于是A的结果返回时,其结果会被抛弃
到这里本章节-响应系统 就告一段落~