本文涉及源码部分以 Vue2.x 作为分析模板,推荐了解浏览器事件循环、有一定Vue使用基础、对双向数据绑定、发布订阅和观察者模式有一定理解的同学进行阅读
前言
调度,这个词在各行各业都有很多涉及以及对应的设计体现。比如十字路口交通调度、厨房后厨备菜调度、日常工作的任务日程管理。那么我们首先可以根据上述事物建立一个抽象联系,那就是,调度可以看做是
将当前不急于实践的行为集中放到下一次执行,也就是异步操作
Part1: nextTick
说起 nextTick,大家可能会联想到 $nextTick, 他们区别是 nextTick 在框架内部导出,而 $nextTick 挂载在了Vue的原型上,让每个通过Vue构造函数生成的实例都能调用。
我们看看官方文档描述
将回调推迟到下一个 DOM 更新周期之后执行。在更改了一些数据以等待 DOM 更新后立即使用它。
1-1.使用
简单来说,next-tick 在代码中的调用方式,下面代码来自Vue官方文档
// 你可以这么用
import { nextTick } from 'vue'
const app = createApp({
setup() {
const message = ref('Hello!')
const changeMessage = async newMessage => {
message.value = newMessage
await nextTick()
console.log('Now DOM is updated')
}
}
})
// $nextTick也可以
createApp({
// ...
methods: {
// ...
example() {
// 修改数据
this.message = 'changed'
// DOM 尚未更新
this.$nextTick(function() {
// DOM 现在更新了
// `this` 被绑定到当前实例
this.doSomethingElse()
})
}
}
})
那么这两种调用有什么区别呢?
Vue 在渲染阶段,将 nextTick 挂到了框架实例上,所以我们可以在首次渲染之后去调用 $nextTick 方法处理。除了在引用方式上的些差别之外,两者在使用上并没有直接区别。Ps: 不要在nextTick的回调中写任何引发DOM改变的操作...容易陷入死循环
// 我们能够用 $nextTick 原因是这段代码
export function renderMixin (Vue: Class<Component>) {
//...
Vue.prototype.$nextTick = function (fn: Function) { // 这里的 Vue 是 Vue 构造函数
return nextTick(fn, this)
}
//...
}
1-2.nextTick原理
讲完具体的使用我们从源码层看next-tick.
抛去对于各种边界情况的判断,我们直接把整个 next-tick.js 简化下面这段代码。
//next-tick.js
// ...省略引入的部分
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let timerFunc
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
}
// 省略对宿主环境如移动端Webview、Native的一些特殊事件循环pollyfill
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
}//...
})
if (!pending) {
pending = true
timerFunc()
}
// ...
}
它的原理是将$nextTick中的回调收集在一个异步调度栈中,在当前页面渲染完成后将调度栈中的方法依次执行。
next-tick.js 内部会通过一系列的边界条件来进行 timerFunc 回调函数的设置。flushCallbacks 最后会在进入到微任务阶段依次的执行callbacks中的函数。至于任务队列出栈时执行的是宏任务还是微任务,取决于入栈状态本身。
Part2: 从框架整体看调度
在看完 Part1 后我们知道了 nextTick 的使用方式和原理。那可能会产生疑问:
- 为什么要
DOM创建完成后调用nextTick? - 整个Vue的调度就靠
nextTick实现么?
在回答上面我问题之前,我们可以看看Vue的渲染过程是怎样的。
2-1.Vue的更新过程
Vue 的更新过程官图
在这个挂载过程中可以看做是发生了这三步:
- data changes
- Virtual DOM re-render and patch
- updated
中间还经历了下面的过程。我们结合一个简单的 MVVM 模型来看
- 被劫持的对象触发
setter中的notify() - 观察者收到通知,执行观察者中的
update()触发render - 触发
vm.__patch__()执行,进行【Virtual DOM re-render and patch】 createElm()执行完毕后完成挂载。
大家在建立对调度初步理解的时候,可以看做,在 update() 这个阶段放置调度栈。
2-2. 调度流程
调度可以看做是 Vue 框架中一个非常重要的渲染优化方式,触发调度是在双向绑定派发通知notify()的阶段触发派发更新update()调用 queueWatcher方法,他不会在每个节点更新的时候立即更新,他会把需要更新的若干更新放到队列里,是vue中一个很厉害的优化点。
接下来我们从源码层面看一下。
2-2-1. Watcher
update() 中,this.sync 是当下要去执行的观察者方法,queueWatcher(this) 则是触发调度栈。
// watcher.js
/**
* Subscriber interface.
* Will be called when a dependency changes.
*/
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
/**
* Scheduler job interface.
* Will be called by the scheduler.
*/
run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
const info = `callback for watcher "${this.expression}"`
invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
2-2-2. 调度核心 scheduler.js
在上一节queueWatcher(this)执行后,我们来看看 scheduler.js 中几个关键源码逻辑
/**
* 清空两个队列并执行观察者.
*/
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
/*
在队列清空前排序.
确保一下几点:
1. 组件从父到子更新 (因为父组件总是在子组件前创建)
2. 一个组件的使用者的watcher运行在这个组件渲染它的watchers之后。(因为使用者的watcher在组件渲染watcher之前创建))
3. 如果组件在父组件的watcher执行期间销毁,那么这个watcher可以跳过
*/
queue.sort((a, b) => a.id - b.id)
// do not cache length because more watchers might be pushed
// as we run existing watchers
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run() // 执行更新
// in dev build, check and stop circular updates.
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
// keep copies of post queues before resetting state
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()// 重置调度状态
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit('flush')
}
}
function callUpdatedHooks (queue) {
let i = queue.length
while (i--) {
const watcher = queue[i]
const vm = watcher.vm
if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'updated') // 通知生命周期,让在updated注册的方法执行。
}
}
}
/**
* Push a watcher into the watcher queue.
* Jobs with duplicate IDs will be skipped unless it's
* pushed when the queue is being flushed.
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher) // flushing 还是初始值,继续往调度里推 watcher
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
调用queueWatcher方法后,如果没有正在清空的调用栈,那就
如果是开发环境,而且vue.config.js 中的配置项没有配置异步,直接调用flushSchedulerQueue(),如果是正常生产环境,看... nextTick(flushSchedulerQueue) 这就和 nextTick串到一起了。
自此我们可以回答开头的两个问题:
1. 为什么要 DOM 创建完成后调用 nextTick?
A:因为nextTick的回调函数在微任务中执行
2. 整个Vue的调度就靠 nextTick 实现么?
A: 整个Vue的调度主要依靠scheduler.js 中的 queueWatcher 结合 nextTick 实现。nextTick 在整个设计中体现的是一个工具,既能在框架中作为私有函数使用,也通过模块化暴露和放置在原型链的方式暴露给框架使用者。
结语
在我们经过 part2 分析源码之后可以发现,Vue在调度层上有两个
queueWatcher(对观察者的运行调度)nextTick(基于事件循环的异步方法层)
整个框架调度模型如下:
参考链接
【1】Vue3 官方文档
【2】Vue2.x 官方文档
【3】Vue 源码
最后,如果本文能给你起到帮助请点赞支持一下~转载请标注出处