一次弄懂 Vue2 和 Vue3 的 nextTick 实现原理

发表于 2年以前  | 总阅读数:1216 次

都会用 nextTick,也都知道 nextTick 作用是在下次 DOM 更新循环结束之后,执行延迟回调,就可以拿到更新后的 DOM 相关信息

那么它到底是怎么实现的呢,在 Vue2 和 Vue3 中又有什么区别呢?本文将结合案例介绍执行原理再深入源码,全部注释,包你一看就会

在进入 nextTick 实现原理之前先稍微回顾一下 JS 的执行机制,因为这与 nextTick 的实现息息相关

JS 执行机制

我们都知道 JS 是单线程的,一次只能干一件事,即同步,就是说所有的任务都需要排队,后面的任务需要等前面的任务执行完才能执行,如果前面的任务耗时过长,后面的任务就需要一直等,这是非常影响用户体验的,所以才出现了异步的概念

同步任务:指排队在主线程上依次执行的任务 异步任务:不进入主线程,而进入任务队列的任务,又分为宏任务和微任务 宏任务:渲染事件、请求、script、setTimeout、setInterval、Node中的setImmediate 等 微任务:Promise.then、MutationObserver(监听DOM)、Node 中的 Process.nextTick等

当执行栈中的同步任务执行完后,就会去任务队列中拿一个宏任务放到执行栈中执行,执行完该宏任务中的所有微任务,再到任务队列中拿宏任务,即一个宏任务、所有微任务、渲染、一个宏任务、所有微任务、渲染...(不是所有微任务之后都会执行渲染),如此形成循环,即事件循环(EventLoop)

nextTick 就是创建一个异步任务,那么它自然要等到同步任务执行完成后才执行

我们先结合例子弄懂执行原理,再深入源码

Vue2

nextTick 用法

看例子,比如当 DOM 内容改变后,我们需要获取最新的高度

<template>
 <div>{{ name }}</div>
</template>
<script>
export default {
 data() {
   return {
     name: ""
  }
},
 mounted() {
   console.log(this.$el.clientHeight) // 0
   this.name = "沐华"
   console.log(this.$el.clientHeight) // 0
   this.$nextTick(() => {
     console.log(this.$el.clientHeight) // 18
  });
}
};
</script>

为什么在 nextTick 里就能拿到最新的 DOM 相关信息?是怎么拿到的,我们来分析一下原理

原理分析

在执行 this.name = '沐华' 的时候,就会触发 Watcher 更新,watcher 会把自己放到一个队列

用队列的原因是比如多个数据变更就更新视图多次的话,性能上就不好了,所以对视图更新做一个异步更新的队列,避免重复计算和不必要的DOM操作,在下一轮事件循环的时候刷新队列,并执行已去重的任务(nextTick的回调函数),更新视图

然后调用 nextTick(),响应式派发更新的源码在这一块是这样的,地址:src/core/observer/scheduler.js - 164行

export function queueWatcher (watcher: Watcher) {
 ...
 // 因为每次派发更新都会引起渲染,所以把所有 watcher 都放到 nextTick 里调用
 nextTick(flushSchedulerQueue)
}

这里参数 flushSchedulerQueue 方法就会被放入事件循环,主线程任务的行完后就会执行这个函数,对 watcher 队列排序、遍历、执行 watcher 对应的 run 方法,然后 render,更新视图

也就是说 this.name = '沐华' 的时候,任务队列可以简单理解成这样 [flushSchedulerQueue]

然后下一行 console.log(...),由于会更新视图的任务 flushSchedulerQueue 在任务队列里没有执行,所以无法拿到更新后的视图

然后执行到 this.$nextTick(fn) 的时候,添加一个异步任务,这时的任务队列可以简单理解成这样 [flushSchedulerQueue, fn]

然后同步任务就执行完了,接着按顺序执行任务队列里的任务,第一个任务执行就会更新视图,后面自然能得到更新后的视图了

nextTick 源码剖析

源码版本:2.6.14,源码地址:src/core/util/next-tick.js

这里整个源码分为两部分,一是判断当前环境能使用的最合适的 API 并保存异步函数,二是调用异步函数 执行回调队列

环境判断

主要是判断用哪个宏任务或微任务,因为宏任务耗费的时间是大于微任务的,所以成先使用微任务,判断顺序如下

  • Promise
  • MutationObserver
  • setImmediate
  • setTimeout
export let isUsingMicroTask = false // 是否启用微任务开关
const callbacks = [] // 回调队列
let pending = false // 异步控制开关,标记是否正在执行回调函数

// 该方法负责执行队列中的全部回调
function flushCallbacks () {
 // 重置异步开关
 pending = false
 // 防止nextTick里有nextTick出现的问题
 // 所以执行之前先备份并清空回调队列
 const copies = callbacks.slice(0)
 callbacks.length = 0
 // 执行任务队列
 for (let i = 0; i < copies.length; i++) {
   copies[i]()
}
}
let timerFunc // 用来保存调用异步任务方法
// 判断当前环境是否支持原生 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
 // 保存一个异步任务
 const p = Promise.resolve()
 timerFunc = () => {
   // 执行回调函数
   p.then(flushCallbacks)
   // ios 中可能会出现一个回调被推入微任务队列,但是队列没有刷新的情况
   // 所以用一个空的计时器来强制刷新任务队列
   if (isIOS) setTimeout(noop)
}
 isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
 isNative(MutationObserver) ||
 MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
 // 不支持 Promise 的话,在支持MutationObserver的非 IE 环境下
 // 如 PhantomJS, iOS7, Android 4.4
 let counter = 1
 const observer = new MutationObserver(flushCallbacks)
 const textNode = document.createTextNode(String(counter))
 observer.observe(textNode, {
   characterData: true
})
 timerFunc = () => {
   counter = (counter + 1) % 2
   textNode.data = String(counter)
}
 isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
 // 使用setImmediate,虽然也是宏任务,但是比setTimeout更好
 timerFunc = () => {
   setImmediate(flushCallbacks)
}
} else {
 // 以上都不支持的情况下,使用 setTimeout
 timerFunc = () => {
   setTimeout(flushCallbacks, 0)
}
}

环境判断结束就会得到一个延迟回调函数 timerFunc

然后进入核心的 nextTick

nextTick()

我们用 Vue.nextTick() 或者 this.$nextTick() 都是调用 nextTick() 这个方法

这里代码不多,主要逻辑就是:

  • 把传入的回调函数放进回调队列 callbacks
  • 执行保存的异步任务 timeFunc,就会遍历 callbacks 执行相应的回调函数了
export function nextTick (cb?: Function, ctx?: Object) {
 let _resolve
 // 把回调函数放入回调队列
 callbacks.push(() => {
   if (cb) {
     try {
       cb.call(ctx)
    } catch (e) {
       handleError(e, ctx, 'nextTick')
    }
  } else if (_resolve) {
     _resolve(ctx)
  }
})
 if (!pending) {
   // 如果异步开关是开的,就关上,表示正在执行回调函数,然后执行回调函数
   pending = true
   timerFunc()
}
 // 如果没有提供回调,并且支持 Promise,就返回一个 Promise
 if (!cb && typeof Promise !== 'undefined') {
   return new Promise(resolve => {
     _resolve = resolve
  })
}
}

可以看到最后有返回一个 Promise 是可以让我们在不传参的时候用的,如下

this.$nextTick().then(()=>{ ... })

Vue3

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("沐华")
    const test = ref(null)
    async function handleClick(){
        name.value = '掘金'
        console.log(test.value.innerText) // 沐华
        await nextTick()
        console.log(test.value.innerText) // 掘金
    }
    return { name, test, handleClick }
</script>

Vue3 里这一块有大改,不过事件循环的原理还是一样,只是加了几个专门维护队列的方法,以及关联到 effect,不过好在这里源码的代码不多,所以不如直接看源码会更容易理解

nextTick 源码剖析

源码版本:3.2.11,源码地址: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
}

就一个 Promise,没了

就这!!!

好吧,认真点

可以看出 nextTick 接受一个函数为参数,同时会创建一个微任务

在我们页面调用 nextTick 的时候,会执行该函数,把我们的参数 fn 赋值给 p.then(fn),在队列的任务完成后,fn 就执行了

由于加了几个维护队列的方法,所以执行顺序是这样的:

queueJob -> queueFlush -> flushJobs -> nextTick参数的 fn

现在不知道都是干嘛的不要紧,几分钟后你就会清楚了

我们按顺序来,先看一下入口函数 queueJob 是在哪里调用的,看代码

// packages/runtime-core/src/renderer.ts - 1555行
function baseCreateRenderer(){
 const setupRenderEffect: SetupRenderEffectFn = (...) => {
   const effect = new ReactiveEffect(
     componentUpdateFn,
    () => queueJob(instance.update), // 当作参数传入
     instance.scope
  )
}
}

ReactiveEffect 这边接收过来的形参就是 scheduler,最终被用到了下面这里,看过响应式源码的这里就熟悉了,就是派发更新的地方

// packages/reactivity/src/effect.ts - 330行
export function triggerEffects(
 ...
 if (effect.scheduler) {
   effect.scheduler()
} else {
   effect.run()
}
}

然后是 queueJob 里面干了什么?我们一个一个的来

queueJob()

该方法负责维护主任务队列,接受一个函数作为参数,为待入队任务,会将参数 pushqueue 队列中,有唯一性判断。会在当前宏任务执行结束后,清空队列

const queue: SchedulerJob[] = []

export function queueJob(job: SchedulerJob) {
 // 主任务队列为空 或者 有正在执行的任务且没有在主任务队列中 && job 不能和当前正在执行任务及后面待执行任务相同
 if ((!queue.length ||
     !queue.includes( job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex )
    ) && job !== currentPreFlushParentJob
) {
   // 可以入队就添加到主任务队列
   if (job.id == null) {
     queue.push(job)
  } else {
     // 否则就删除
     queue.splice(findInsertionIndex(job.id), 0, job)
  }
   // 创建微任务
   queueFlush()
}
}

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)
}
}

flushJobs()

该方法负责处理队列任务,主要逻辑如下:

  • 先处理前置任务队列
  • 根据 Id 排队队列
  • 遍历执行队列任务
  • 执行完毕后清空并重置队列
  • 执行后置队列任务
  • 如果还有就递归继续执行
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))
 try {
   // 遍历主任务队列,批量执行更新任务
   for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
     const job = queue[flushIndex]
     if (job && job.active !== false) {
       if (__DEV__ && checkRecursiveUpdates(seen!, job)) {
         continue
      }
       callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
    }
  }
} finally {
   flushIndex = 0 // 队列任务执行完,重置队列索引
   queue.length = 0 // 清空队列
   flushPostFlushCbs(seen) // 执行后置队列任务
   isFlushing = false  // 重置队列执行状态
   currentFlushPromise = null // 重置当前微任务为 Null
   // 如果主任务队列、前置和后置任务队列还有没被清空,就继续递归执行
   if ( queue.length || pendingPreFlushCbs.length || pendingPostFlushCbs.length ) {
     flushJobs(seen)
  }
}
}

flushPreFlushCbs()

该方法负责执行前置任务队列,说明都写在注释里了

export function flushPreFlushCbs( seen?: CountMap, parentJob: SchedulerJob | null = null) {
 // 如果待处理的队列不为空
 if (pendingPreFlushCbs.length) {
   currentPreFlushParentJob = parentJob
   // 保存队列中去重后的任务为当前活动的队列
   activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
   // 清空队列
   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)
}
}

flushPostFlushCbs()

该方法负责执行后置任务队列,说明都写在注释里了

let activePostFlushCbs: SchedulerJob[] | null = null

export function flushPostFlushCbs(seen?: CountMap) {
 // 如果待处理的队列不为空
 if (pendingPostFlushCbs.length) {
   // 保存队列中去重后的任务
   const deduped = [...new Set(pendingPostFlushCbs)]
   // 清空队列
   pendingPostFlushCbs.length = 0
   // 如果当前已经有活动的队列,就添加到执行队列的末尾,并返回
   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 的源码到这就解析完啦

结语

如果本文对你有一丁点帮助,点个赞支持一下吧,感谢感谢

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/Kb2H4U2XsTUUY_gLI-HhRQ

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:8月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:8月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:8月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:8月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:8月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:8月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:8月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:8月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:8月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:8月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:8月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:8月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:8月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:8月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:8月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:8月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:8月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:8月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:8月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:8月以前  |  398次阅读  |  详细内容 »
 目录