nextTick
?定义: 在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM
看完这个定义不免心生疑问:
基于以上问题和平时的使用经验可以基本解答疑问:
那么nextTick
是怎么实现的呢?既然是异步更新,这涉及到了 js
的执行机制,下面一起复习一下js执行机制。
我们都知道 JS 是单线程语言,即指某一时间内只能干一件事,即为同步。
而JS为什么是单线程的呢?这就要提及JS的主要用途了。JS自诞生之日起,其主要用途是与用户互动和DOM操作,如果同一时间,一个添加了 DOM
,一个删除了 DOM
, 这个时候语言就不知道是该添还是该删了,所以从应用场景来看 JS
只能是单线程,否则会带来复杂的同步问题。
单线程就意味着所有的任务都需要排队,后面的任务需要等前面的任务执行完才能执行,如果前面的任务耗时过长,后面的任务就需要一直等,一些从用户角度上不需要等待的任务就会一直等待,这个从体验角度上来讲是不可接受的,所以JS
中就出现了异步的概念。
概念
同步任务
:指排队在主线程上依次执行的任务
异步任务
:不进入主线程,而进入任务队列的任务,又分为宏任务和微任务
宏任务
: 渲染事件、请求、script、setTimeout、setInterval、Node中的setImmediate 等
微任务
: Promise.then、MutationObserver(监听DOM)、Node 中的 Process.nextTick等
执行机制
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。
当执行栈中的同步任务执行完后,就会去任务队列中拿一个宏任务放到执行栈中执行,执行完该宏任务中的所有微任务,再到任务队列中拿宏任务,即一个宏任务、所有微任务、渲染、一个宏任务、所有微任务、渲染…(不是所有微任务之后都会执行渲染),如此形成循环,即事件循环(EventLoop)
。
nextTick
就是创建一个异步任务,那么它自然要等到同步任务执行完成后才执行。
先看个例子,点击按钮更新 DOM 内容,并获取最新的 DOM 内容
<template>
<div ref="test">{{name}}</div>
<el-button @click="handleClick">按钮</el-button>
</template>
<script setup>
import { ref, nextTick } from 'vue'
const name = ref("initName")
const test = ref(null)
async function handleClick(){
name.value = 'newName'
console.log(test.value.innerText) // initName
await nextTick()
console.log(test.value.innerText) // newName
}
return { name, test, handleClick }
</script>
nextTick
实现完全基于语言执行机制实现,直接创建一个异步任务,那么nextTick自然就达到在同步任务后执行的目的
源码版本:3.2.22
,源码地址:packages/runtime-core/src/sheduler.ts
const resolvedPromise: Promise<any> = Promise.resolve()
let currentFlushPromise: Promise<void> | null = null
export function nextTick<T = void>(this: T, fn?: (this: T) => void): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
可以看出 nextTick
接受一个函数为参数,同时会创建一个Promise
微任务。所以,页面调用 nextTick
的时候,会把的参数 fn
赋值给 p.then(fn)
,在队列currentFlushPromise || resolvedPromise
的任务完成后,执行fn
。
vue3
中nextTick
的队列由几个方法维护,基本执行顺序是这样的:queueJob
-> queueFlush
-> flushJobs
-> nextTick参数的 fn
先有个印象即可,后面按照执行顺序依次分析。
入口函数 queueJob
是在renderer
函数中调用:
// packages/runtime-core/src/renderer.ts - 1555行
function baseCreateRenderer(){
const setupRenderEffect: SetupRenderEffectFn = (...) => {
const effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(instance.update), // 当作参数传入
instance.scope
)
}
}
这里先看一下ReactiveEffect
类的构造函数:
// packages/reactivity/src/effect.ts - 53行
export class ReactiveEffect<T = any> {
// ...
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope | null
) {
recordEffectScope(this, scope)
}
// ...
}
在 ReactiveEffect
这边接收过来的第二个形参就是 scheduler
,最终被用到响应式源码的派发更新。
当响应式对象发生改变后,如果执行 effect
有 scheduler
这个参数,会执行这个 scheduler
函数,并且把 effect
当做参数传入
// packages/reactivity/src/effect.ts - 330行
export function triggerEffects(
// ...
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
然后看 queueJob
具体做了什么。
该方法负责维护主任务队列,接受一个函数作为参数,为待入队任务,会将参数 push
到 queue
队列中,有唯一性判断。会在当前宏任务执行结束后,清空队列
const queue: SchedulerJob[] = []
let isFlushing = false // 是否正在执行
let isFlushPending = false // 是否正在等待执行
export function queueJob(job: SchedulerJob) {
// 判断条件:主任务队列为空 或者 有正在执行的任务且没有在主任务队列中 && job 不能和当前正在执行任务及后面待执行任务相同
// 重复数据删除:
// - 使用Array.includes(Obj, startIndex) 的 起始索引参数:startIndex
// - startIndex默认为包含当前正在运行job的index,此时,它不能再次递归触发自身
// - 如果job是一个watch()回调函数或者当前job允许递归触发,则搜索索引将+1,以允许他递归触发自身-用户需要确保回调函数不会死循环
if (
(!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)) &&
job !== currentPreFlushParentJob
) {
// 判断当前job id 是否存在 不存在则添加到主任务队列
if (job.id == null) {
queue.push(job)
} else {
// 存在则从当前任务队列中查到位置并删除替换
queue.splice(findInsertionIndex(job.id), 0, job)
}
// 创建微任务
queueFlush()
}
}
该方法负责尝试创建微任务,等待任务队列执行
let isFlushing = false // 是否正在执行
let isFlushPending = false // 是否正在等待执行
const resolvedPromise: Promise<any> = Promise.resolve() // 微任务创建器
let currentFlushPromise: Promise<void> | null = null // 当前任务
function queueFlush() {
// 当前没有微任务
if (!isFlushing && !isFlushPending) {
// 避免在事件循环周期内多次创建新的微任务
isFlushPending = true
// 创建微任务,把 flushJobs 推入任务队列等待执行
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
该方法负责处理队列任务,主要逻辑如下:
Id
进行队列排序,并遍历执行队列任务,执行完毕后清空并重置队列flushJobs
清空队列function flushJobs(seen?: CountMap) {
isFlushPending = false // 是否正在等待执行
isFlushing = true // 正在执行
if (__DEV__) {
seen = seen || new Map() // 开发环境下
}
flushPreFlushCbs(seen) // 执行前置任务队列
// 根据 id 排序队列,这是为了一下两点:
// 1. 组件更新顺序为:父到子,因为父级总是在子级前面先创建,它的渲染效果具有较小的优先级数
// 2. 如果父组件更新期间卸载了子组件,则改子组件更新将跳过
queue.sort((a, b) => getId(a) - getId(b))
// checkRecursiveUpdate 的条件使用必须在 try ... catch 块外中确定,因为 Rollup 默认会在 try-catch 中取消优化 treeshaking。
const check = __DEV__
? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
: NOOP
try {
// 遍历主任务队列,批量执行更新任务
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
if (__DEV__ && check(job)) {
continue
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0 // 队列任务执行完,重置队列索引
queue.length = 0 // 清空队列
flushPostFlushCbs(seen) // 执行后置队列任务
isFlushing = false // 重置队列执行状态
currentFlushPromise = null // 重置当前微任务为 Null
// some postFlushCb queued jobs!
// keep flushing until it drains.
// 如果主任务队列、前置和后置任务队列还有没被清空,就继续递归执行
if (
queue.length ||
pendingPreFlushCbs.length ||
pendingPostFlushCbs.length
) {
flushJobs(seen)
}
}
}
该方法负责执行前置任务队列,说明都写在注释里了
flushJobs
清空队列const pendingPreFlushCbs: SchedulerJob[] = []
let activePreFlushCbs: SchedulerJob[] | null = null
let preFlushIndex = 0
export function flushPreFlushCbs(
seen?: CountMap,
parentJob: SchedulerJob | null = null
) {
// 待处理前置任务队列不为空
if (pendingPreFlushCbs.length) {
currentPreFlushParentJob = parentJob
activePreFlushCbs = [...new Set(pendingPreFlushCbs)] // 待处理前置任务队列去重备份为activePreFlushCbs
pendingPreFlushCbs.length = 0 // 待处理前置任务队列重置
if (__DEV__) {
seen = seen || new Map()
}
// 遍历执行队列里的任务
for (
preFlushIndex = 0;
preFlushIndex < activePreFlushCbs.length;
preFlushIndex++
) {
if (
__DEV__ &&
checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])
) {
continue
}
// 任务执行
activePreFlushCbs[preFlushIndex]()
}
// 清空当前活动的任务队列
activePreFlushCbs = null
preFlushIndex = 0
currentPreFlushParentJob = null
// 递归执行,直到清空前置任务队列
flushPreFlushCbs(seen, parentJob)
}
}
该方法负责执行后置任务队列,说明都写在注释里了
const pendingPostFlushCbs: SchedulerJob[] = []
let activePostFlushCbs: SchedulerJob[] | null = null
let postFlushIndex = 0
export function flushPostFlushCbs(seen?: CountMap) {
// 待处理后置任务队列队列不为空
if (pendingPostFlushCbs.length) {
// 待处理后置任务队列去重备份为deduped
const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0 // 待处理后置任务队列重置
// #1947 already has active queue, nested flushPostFlushCbs call
// 如果当前已经有活动的队列,就添加到执行队列的末尾,并返回
if (activePostFlushCbs) {
activePostFlushCbs.push(...deduped)
return
}
// 赋值为当前活动队列
activePostFlushCbs = deduped
if (__DEV__) {
seen = seen || new Map()
}
// 队列排序
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
// 遍历执行队列里的任务
for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
if (
__DEV__ &&
checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])
) {
continue
}
activePostFlushCbs[postFlushIndex]()
}
// 清空当前活动的任务队列
activePostFlushCbs = null
postFlushIndex = 0
}
}
整个 nextTick 的源码到这就解析完啦
一个例子让大家明白,如果没有 nextTick
更新机制,那么 num
每次更新值都会触发视图更新,有了nextTick
机制,只需要更新一次。
{{num}}
for(let i=0; i<100000; i++){
num = i
}
nextTick
是 vue
中的更新策略,也是性能优化手段,基于JS执行机制实现
vue
中我们改变数据时不会立即触发视图,如果需要实时获取到最新的DOM
,可以手动调用 nextTick
。