【Vue3.0】- 侦听器
2020-11-26 本文已影响0人
啦啦啦喽啰
监听器
侦听器可以帮助我们去观察某个数据的变化然后去执行一段逻辑
-
Vue.js 2.x
中,通过watch
选项,或者$watch API
去初始化一个侦听器,称作watcher
-
Vue.js 3.0
使用Composition API
中的watch API
可以实现侦听器的效果
watch API 的用法
- 1、
watch API
可以侦听一个getter
函数- 这个
getter
必须返回一个响应式对象 - 当该响应式对象更新后,会执行对应的回调函数
- 这个
import { reactive, watch } from 'vue'
const state = reactive({ count: 0 })
watch(() => state.count, (count, prevCount) => {
// 当 state.count 更新,会触发此回调函数
})
- 2、
watch API
可以直接侦听一个响应式对象,当响应式对象更新后,会执行对应的回调函数
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (count, prevCount) => {
// 当 count.value 更新,会触发此回调函数
})
- 3、
watch API
可以直接侦听多个响应式对象,任意一个响应式对象更新后,就会执行对应的回调函数。
import { ref, watch } from 'vue'
const count = ref(0)
const count2 = ref(1)
watch([count, count2], ([count, count2], [prevCount, prevCount2]) => {
// 当 count.value 或者 count2.value 更新,会触发此回调函数
})
watch API 实现原理
- 与
effect
副作用函数有什么区别?
watch API 的具体实现
function watch(source, cb, options) {
if ((process.env.NODE_ENV !== 'production') && !isFunction(cb)) {
warn(`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`)
}
return doWatch(source, cb, options)
}
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
// 标准化 source
// 构造 applyCb 回调函数
// 创建 scheduler 时序执行函数
// 创建 effect 副作用函数
// 返回侦听器销毁函数
}
-
watch
函数内部调用了doWatch
函数 - 调用前会在非生产环境下判断第二个参数
cb
是不是一个函数 - 如果不是则会报警告以告诉用户应该使用
watchEffect(fn, options) API
1、标准化 source
-
watch
函数的第一个参数source
,可以有三种类型,所以需要先进行标准化source
// source 不合法的时候会报警告
const warnInvalidSource = (s) => {
warn(`Invalid watch source: `, s, `A watch source can only be a getter/effect function, a ref, ` +
`a reactive object, or an array of these types.`)
}
// 当前组件实例
const instance = currentInstance
let getter
if (isArray(source)) {
getter = () => source.map(s => {
if (isRef(s)) {
return s.value
}
else if (isReactive(s)) {
return traverse(s)
}
else if (isFunction(s)) {
return callWithErrorHandling(s, instance, 2 /* WATCH_GETTER */)
}
else {
(process.env.NODE_ENV !== 'production') && warnInvalidSource(s)
}
})
}
else if (isRef(source)) {
getter = () => source.value
}
else if (isReactive(source)) {
getter = () => source
deep = true
}
else if (isFunction(source)) {
if (cb) {
// getter with cb
getter = () => callWithErrorHandling(source, instance, 2 /* WATCH_GETTER */)
}
else {
// watchEffect 的逻辑
}
}
else {
getter = NOOP
(process.env.NODE_ENV !== 'production') && warnInvalidSource(source)
}
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
-
source
标准化主要是根据source
的类型,将其变成 标准成getter
函数 - 1)如果
source
是ref
对象,则创建一个访问source.value
的getter
函数 - 2)如果
source
是reactive
对象,则创建一个访问source
的getter
函数,并设置deep
为true
- 3)如果
source
是一个函数,则会进一步判断第二个参数cb
是否存在- 对于
watch API
来说,cb
是一定存在且是一个回调函数 - 这种情况下,
getter
就是一个简单的对source
函数封装的函数
- 对于
- 最终标准化生成的
getter
函数,返回一个响应式对象- 在后续创建
effect runner
副作用函数需要用到 - 每次执行
runner
就会把getter
函数返回的响应式对象作为watcher
求值的结果
- 在后续创建
-
deep
为true
的情况下- 生成的
getter
函数会被traverse
函数包装一层 -
traverse
函数,通过递归的方式访问value
的每一个子属性 -
reactive API
可以创建复杂对象,为了保证修改任意属性都能触发watcher
,需要使用traverse
包装
- 生成的
- 如果嵌套层级很深,递归 traverse 就会有一定的性能耗时,如何优化使得
traverse
不执行?答案:监听一个getter
函数
watch(() => state.count.a.b, (newVal, oldVal) => {
console.log(newVal)
})
state.count.a.b = 2
2、构造 applyCb 回调函数
- 这里是处理第二个参数
cb
-
cb
是一个回调函数,它有三个参数- 1)
newValue
:代表新值 - 2)
oldValue
:代表旧值。 - 3)onInvalidate:
- 1)
- 构造回调函数的处理逻辑如下:
let cleanup
// 注册无效回调函数
const onInvalidate = (fn) => {
cleanup = runner.options.onStop = () => {
callWithErrorHandling(fn, instance, 4 /* WATCH_CLEANUP */)
}
}
// 旧值初始值
let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE /*{}*/
// 回调函数
const applyCb = cb
? () => {
// 组件销毁,则直接返回
if (instance && instance.isUnmounted) {
return
}
// 求得新值
const newValue = runner()
if (deep || hasChanged(newValue, oldValue)) {
// 执行清理函数
if (cleanup) {
cleanup()
}
callWithAsyncErrorHandling(cb, instance, 3 /* WATCH_CALLBACK */, [
newValue,
// 第一次更改时传递旧值为 undefined
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onInvalidate
])
// 更新旧值
oldValue = newValue
}
}
: void 0
-
applyCb
是对cb
的封装,当侦听的值发生变化时就会执行applyCb
方法 -
watch API
和组件实例相关,当组件销毁后,回调函数cb
不应该被执行而是直接返回 - 执行
runner
求得新值,实际上是执行前面创建的getter
函数求新值 - 判断如果是
deep
的情况或者新旧值发生了变化,则执行回调函数cb
,传入参数newValue
和oldValue
- 执行完回调函数
cb
后,把旧值oldValue
再更新为newValue
创建 scheduler
-
scheduler
的作用是根据某种调度的方式去执行某种函数 - 在
watch API
中,主要影响到的是回调函数的执行方式,实现逻辑如下:
const invoke = (fn) => fn()
let scheduler
if (flush === 'sync') {
// 同步
scheduler = invoke
}
else if (flush === 'pre') {
scheduler = job => {
if (!instance || instance.isMounted) {
// 进入异步队列,组件更新前执行
queueJob(job)
}
else {
// 如果组件还没挂载,则同步执行确保在组件挂载前
job()
}
}
}
else {
// 进入异步队列,组件更新后执行
scheduler = job => queuePostRenderEffect(job, instance && instance.suspense)
}
-
Watch API
第三个参数options
,可以主动设置deep: true
-
scheduler
的创建逻辑受到了第三个参数options
中的flush
属性值的影响,不同的flush
决定了watcher
的执行时机 - 1)当
flush
为sync
的时候,表示它是一个同步watcher
,即当数据变化时同步执行回调函数 - 2)当
flush
为pre
的时候,回调函数通过queueJob
的方式在组件更新之前执行,如果组件还没挂载,则同步执行确保回调函数在组件挂载之前执行 - 3)如果没设置
flush
,那么回调函数通过queuePostRenderEffect
的方式在组件更新之后执行
4、创建 effect
- 计算新值时执行的
runner
函数,是watcher
内部创建的effect
函数,逻辑如下:
const runner = effect(getter, {
// 延时执行
lazy: true,
// computed effect 可以优先于普通的 effect 先运行,比如组件渲染的 effect
computed: true,
onTrack,
onTrigger,
scheduler: applyCb ? () => scheduler(applyCb) : scheduler
})
// 在组件实例中记录这个 effect
recordInstanceBoundEffect(runner)
// 初次执行
if (applyCb) {
if (immediate) {
applyCb()
}
else {
// 求旧值
oldValue = runner()
}
}
else {
// 没有 cb 的情况
runner()
}
- 核心是:通过
effect API
创建一个副作用函数runner
- 1)
runner
是一个computed effect
-
computed effect
可以优先于普通的effect
(比如组件渲染的effect
)先运行 - 可以实现当配置
flush
为pre
的时候,watcher
的执行可以优先于组件更新
-
- 2)
runner
执行的方式-
runner
是lazy
的,不会在创建后立刻执行 - 第一次手动执行
runner
会执行前面的getter
函数,访问响应式数据并做依赖收集,此时activeEffect
就是runner
- 在后面更新响应式数据时,就可以触发
runner
执行scheduler
函数
-
- 3)
runner
的返回结果,手动执行runner
相当于执行了前面标准化的getter
函数-
getter
函数的返回值就是watcher
计算出的值 - 第一次执行
runner
求得的值可以作为oldValue
-
- 4)配置了
immediate
的情况- 当我们配置了
immediate
,创建完watcher
会立刻执行applyCb
函数 - 此时
oldValue
还是初始值 - 在
applyCb
执行时也会执行runner
进而执行前面的getter
函数做依赖收集,求得新值
- 当我们配置了
5、返回销毁函数
- 返回侦听器销毁函数,也就是
watch API
执行后返回的函数。 - 通过调用销毁函数来停止
watcher
对数据的侦听
return () => {
stop(runner)
if (instance) {
// 移除组件 effects 对这个 runner 的引用
remove(instance.effects, runner)
}
}
function stop(effect) {
if (effect.active) {
cleanup(effect)
if (effect.options.onStop) {
effect.options.onStop()
}
effect.active = false
}
}
- 销毁函数内部会执行
stop
方法让runner
失活 - 清理
runner
的相关依赖,可以停止对数据的侦听 - 如果是在组件中注册的
watcher
,也会移除组件effects
对这个runner
的引用
侦听器的实现原理
异步任务队列的设计
- 当侦听器的回调函数的调度方式
flush
不是sync
时,会把回调函数执行的任务推到一个异步队列中执行,为什么会需要异步队列?
异步任务队列的设计
思考:当连续修改响应式数据,会回调多次吗
答案:不会。
因为,在一个Tick
(宏任务执行的生命周期)内,即使多次修改侦听的值,它的回调函数也只执行一次
知识延伸
组件的更新过程是异步的,我们知道修改模板中引用的响应式对象的值时,会触发组件的重新渲染,但是在一个Tick
内,即使你多次修改多个响应式对象的值,组件的重新渲染也只执行一次。这是因为如果每次更新数据都触发组件重新渲染,那么重新渲染的次数和代价都太高
异步任务队列的创建
- 在创建一个
watcher
时,如果配置flush
为pre
或不配置flush
* 那么watcher
的回调函数就会异步执行 - 此时分别是通过
queueJob
和queuePostRenderEffect
把回调函数推入异步队列中的
// 异步任务队列
const queue = []
// 队列任务执行完后执行的回调函数队列
const postFlushCbs = []
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush()
}
}
function queuePostFlushCb(cb) {
if (!isArray(cb)) {
postFlushCbs.push(cb)
}
else {
// 如果是数组,把它拍平成一维
postFlushCbs.push(...cb)
}
queueFlush()
}
-
Vue.js
内部维护了一个queue
数组和一个postFlushCbs
数组 -
queue
数组用作异步任务队列 -
postFlushCbs
数组用作异步任务队列执行完毕后的回调函数队列 - 在添加完毕后都执行了
queueFlush
函数
const p = Promise.resolve()
// 异步任务队列是否正在执行
let isFlushing = false
// 异步任务队列是否等待执行
let isFlushPending = false
function nextTick(fn) {
return fn ? p.then(fn) : p
}
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
nextTick(flushJobs)
}
}
-
Vue.js
内部还维护了isFlushing
和isFlushPending
变量,用来控制异步任务的刷新逻辑 - 首次执行
queueFlush
,设置isFlushPending
为false
,并执行nextTick(flushJobs)
去执行队列里的任务 -
nextTick
在Vue.js 3.0
中的实现也是非常简单,通过Promise.resolve().then
去异步执行flushJobs
·JavaScript· 是单线程执行的,这样的异步设计使你在一个
Tick
内,可以多次执行queueJob
或者queuePostFlushCb
去添加任务,也可以保证在宏任务执行完毕后的微任务阶段执行一次flushJobs
异步任务队列的执行
-
flushJobs
的实现
const getId = (job) => (job.id == null ? Infinity : job.id)
function flushJobs(seen) {
isFlushPending = false
isFlushing = true
let job
if ((process.env.NODE_ENV !== 'production')) {
seen = seen || new Map()
}
// 组件的更新是先父后子
// 如果一个组件在父组件更新过程中卸载,它自身的更新应该被跳过
queue.sort((a, b) => getId(a) - getId(b))
while ((job = queue.shift()) !== undefined) {
if (job === null) {
continue
}
if ((process.env.NODE_ENV !== 'production')) {
checkRecursiveUpdates(seen, job)
}
callWithErrorHandling(job, null, 14 /* SCHEDULER */)
}
flushPostFlushCbs(seen)
isFlushing = false
// 一些 postFlushCb 执行过程中会再次添加异步任务,递归 flushJobs 会把它们都执行完毕
if (queue.length || postFlushCbs.length) {
flushJobs(seen)
}
}
-
flushJobs
函数开始执行的时候,把isFlushPending
重置为false
- 把
isFlushing
设置为 true 来表示正在执行异步任务队列 - 对于异步任务队列
queue
,在遍历执行它们前会先对它们做一次从小到大的排序,这是因为两个主要原因: - 1)创建组件的过程是由父到子,所以创建组件副作用渲染函数也是先父后子
- 父组件的副作用渲染函数的
effect id
是小于子组件的 - 每次更新组件也是通过
queueJob
把effect
推入异步任务队列queue
中的 - 所以为了保证先更新父组再更新子组件,要对
queue
做从小到大的排序
- 父组件的副作用渲染函数的
- 2)如果一个组件在父组件更新过程中被卸载,它自身的更新应该被跳过
- 所以也应该要保证先更新父组件再更新子组件,要对
queue
做从小到大的排序
- 所以也应该要保证先更新父组件再更新子组件,要对
- 然后,遍历这个
queue
,依次执行队列中的任务- 在遍历过程中,
checkRecursiveUpdates
是用来在非生产环境下检测是否有循环更新的
- 在遍历过程中,
- 遍历完
queue
后,又会进一步执行flushPostFlushCbs
方法,去遍历执行所有推入到postFlushCbs
的回调函数
function flushPostFlushCbs(seen) {
if (postFlushCbs.length) {
// 拷贝副本
const cbs = [...new Set(postFlushCbs)]
postFlushCbs.length = 0
if ((process.env.NODE_ENV !== 'production')) {
seen = seen || new Map()
}
for (let i = 0; i < cbs.length; i++) {
if ((process.env.NODE_ENV !== 'production')) {
checkRecursiveUpdates(seen, cbs[i])
}
cbs[i]()
}
}
}
-
const cbs = [...new Set(postFlushCbs)]
拷贝一个postFlushCbs
的副本,避免执行回调函数时修改postFlushCbs
- 遍历完
postFlushCbs
后,会重置isFlushing
为false
- 因为一些
postFlushCb
执行过程中可能会再次添加异步任务,所以需要继续判断如果queue
或者postFlushCbs
队列中还存在任务,则递归执行flushJobs
把它们都执行完毕
- 因为一些
检测循环更新
- 使用
checkRecursiveUpdates
检测是否有循环更新,用以解决在对某个响应式数据的watcher
回调函数中修改这个响应式数据的循环更新问题
const RECURSION_LIMIT = 100
function checkRecursiveUpdates(seen, fn) {
if (!seen.has(fn)) {
seen.set(fn, 1)
}
else {
const count = seen.get(fn)
if (count > RECURSION_LIMIT) {
throw new Error('Maximum recursive updates exceeded. ' +
"You may have code that is mutating state in your component's " +
'render function or updated hook or watcher source function.')
}
else {
seen.set(fn, count + 1)
}
}
}
- 当
flushJobs
开始,创建了seen
-
seen
用于在checkRecursiveUpdates
中,记录某个job
进行重复任务添加次数 - 如果次数超过100,则抛出错误
优化:只用一个变量
-
isFlushPending
用于判断是否在等待nextTick
执行flushJobs
isFlushing
判断是否正在执行任务队列 - 从功能上来看,它们的作用是为了确保以下两点:
- 1)在一个
Tick
内可以多次添加任务到队列中,但是任务队列会在nextTick
后执行 - 2)在执行任务队列的过程中,也可以添加新的任务到队列中,并且在当前
Tick
去执行剩余的任务队列
只使用isFlushing
来控制
- 在执行
queueFlush
的时候,判断isFlushing
为false
,则把它设置为true
- 然后
nextTick
会执行flushJobs
-
flushJobs
函数执行完成的最后,也就是所有的任务(包括后添加的)都执行完毕,再设置isFlushing
为false
watchEffect API
watchEffect API
的作用是注册一个副作用函数,副作用函数内部可以访问到响应式对象,当内部响应式对象变化后再立即执行这个函数
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => console.log(count.value))
count.value++
watchEffect 和 watch API 区别
侦听的源不同
-
watch API
可以侦听一个或多个响应式对象,也可以侦听一个getter
函数 -
watchEffect API
侦听的是一个普通函数,只要内部访问了响应式对象即可,这个函数并不需要返回响应式对象
没有回调函数
-
watchEffect API
没有回调函数,副作用函数的内部响应式对象发生变化后,会再次执行这个副作用函数
立即执行
-
watchEffect API
在创建好watcher
后,会立刻执行它的副作用函数 - 而
watch API
需要配置immediate
为true
,才会立即执行回调函数
watchEffect简易实现
function watchEffect(effect, options) {
return doWatch(effect, null, options);
}
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
instance = currentInstance;
let getter;
if (isFunction(source)) {
getter = () => {
if (instance && instance.isUnmounted) {
return;
}
// 执行清理函数
if (cleanup) {
cleanup();
}
// 执行 source 函数,传入 onInvalidate 作为参数
return callWithErrorHandling(source, instance, 3 /* WATCH_CALLBACK */, [onInvalidate]);
};
}
let cleanup;
const onInvalidate = (fn) => {
cleanup = runner.options.onStop = () => {
callWithErrorHandling(fn, instance, 4 /* WATCH_CLEANUP */);
};
};
let scheduler;
// 创建 scheduler
if (flush === 'sync') {
scheduler = invoke;
}
else if (flush === 'pre') {
scheduler = job => {
if (!instance || instance.isMounted) {
queueJob(job);
}
else {
job();
}
};
}
else {
scheduler = job => queuePostRenderEffect(job, instance && instance.suspense);
}
// 创建 runner
const runner = effect(getter, {
lazy: true,
computed: true,
onTrack,
onTrigger,
scheduler
});
recordInstanceBoundEffect(runner);
// 立即执行 runner
runner();
// 返回销毁函数
return () => {
stop(runner);
if (instance) {
remove(instance.effects, runner);
}
};
}
-
getter
函数就是对source
函数的简单封装- 它会先判断组件实例是否已经销毁
- 然后每次执行
source
函数前执行cleanup
清理函数
-
watchEffect
内部创建的runner
对应的scheduler
对象就是scheduler
函数本身- 再次执行时,就会执行这个
scheduler
函数 - 并且传入
runner
函数作为参数 - 按照一定的调度方式去执行基于
source
封装的getter
函数
- 再次执行时,就会执行这个
- 创建完
runner
后就立刻执行了runner
- 其实就是内部同步执行了基于
source
封装的getter
函数
- 其实就是内部同步执行了基于
- 在执行
source
函数的时候,会传入一个onInvalidate
函数作为参数
注册无效回调函数
- 有些时候,watchEffect 会注册一个副作用函数,在函数内部可以做一些异步操作
- 但是当这个
watcher
停止后,如果我们想去对这个异步操作做一些额外事情(比如取消这个异步操作),我们可以通过onInvalidate
参数注册一个无效函数 - 例
import {ref, watchEffect } from 'vue'
const id = ref(0)
watchEffect(onInvalidate => {
// 执行异步操作
const token = performAsyncOperation(id.value)
onInvalidate(() => {
// 如果 id 发生变化或者 watcher 停止了,则执行逻辑取消前面的异步操作
token.cancel()
})
})
onInvalidate 在 doWatch 中的实现
const onInvalidate = (fn) => {
cleanup = runner.options.onStop = () => {
callWithErrorHandling(fn, instance, 4 /* WATCH_CLEANUP */);
};
};
- 当响应式数据发生变化,会执行
cleanup
方法,注册runner
的onStop
方法 - 当
watcher
被停止,会执行onStop
方法 - 这两者都会执行注册的无效回调函数
fn