尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3 nextTick与事件循环:微任务、宏任务原理及实战解析

Vue3 nextTick与事件循环:微任务、宏任务原理及实战解析 带薪刷题的日子一道nextTick题把我问住了前阵子团队招人我出了一道“送分题”Vue3里调用nextTick后是宏任务还是微任务结果来面试的三个候选人里两个说是宏任务还有一个支支吾吾说“好像跟Promise有关”。更意外的是其中一位高级候选人反过来问我“这个问题跟项目里写业务代码有关系吗我又不写框架。”我当场愣了两秒。说实话这个问题80%的业务场景下确实感知不到但一旦遇到“DOM更新了但数据看起来没变”“子组件onMounted里拿不到父组件传下来的最新值”“异步数据渲染后要滚动到底部但页面还没画完”这类的坑不懂事件循环的人会一直靠setTimeout拍脑袋兜底。而懂的人一句话就能说清楚为什么有时候需要两个nextTick才能拿到结果或者为什么用await等一个循环过后DOM顺带就更新完了。这两年Vue3面试题的问法越来越刁钻已经从“nextTick怎么用”进化到“nextTick为什么这样实现”“响应式更新队列为什么用微任务”“在路由守卫里await一个接口后DOM刷新时机是变的”。表面问的是API实际考的几乎都是事件循环里的微任务与宏任务。我打算把这套东西完整串一遍既讲清楚原理也把我在Vue3项目里遇到过的真实场景摊开来说希望能帮到正在准备面试、或者被这些诡异时序困扰的同行。1. 把浏览器事件循环摆上桌宏任务与微任务的执行真相1.1 一套流水线规则而不是两条平行线很多刚入门的朋友喜欢记这么一句话“微任务先执行宏任务后执行。”这句话不算错但极其容易被理解为“每一轮都是先清空所有微任务再执行一个宏任务”。严格来说事件循环不是两条独立的队列轮流运行而是执行完一个宏任务之后会立刻把当前队列里所有的微任务全部清空然后再从宏任务队列里取下一个。把浏览器的主线程想象成一条流水线宏任务就是流水线上一个个箱子。每拆开一个宏任务箱子里面可能带出一把小纸条这些小纸条就是微任务。规则是这样的拆开一个箱子处理完箱子里的大块工作后先不急着拆下一个箱子而是把箱子里带出来的所有小纸条全部处理干净小纸条处理过程中又产生的新小纸条也必须全部处理完然后才回到流水线上拆下一个箱子。这就能解释很多前端新手经常撞上的诡异现象连续执行多个Promise.then中间没有任务穿插它们的顺序和代码书写顺序一模一样看起来好像“同步”一样。本质就是因为微任务队列清空是连续的中间不会插入任何宏任务。1.2 具体哪些东西属于宏任务和微任务每逢面试我都会让候选人列举两类任务的典型代表这里把最常考的分类表整理出来类别典型代表执行时机宏任务setTimeout / setInterval / setImmediate / MessageChannel / postMessage / 浏览器UI渲染 / 网络IO回调 / 用户事件回调每轮事件循环取一个出来执行微任务Promise.then / catch / finally / MutationObserver / queueMicrotask / Vue3的nextTick当前宏任务结束、下一个宏任务开始前全部清空有一个特别容易记混的点async/await本质上就是Promise的语法糖。await后面的代码会被当作一个微任务排队。也就是说在宏任务内部写await somePromise后面的代码不会立即执行而是要等当前宏任务结束后、在微任务队列里和同级微任务一起按顺序处理。我在项目里反复给团队讲过一个类比宏任务像每顿饭的主食微任务像主食之间夹的甜点。你不可能一口气把所有主食都吃完再吃甜点而是吃一口主食把主食里夹带的所有甜点吃完再吃下一口主食。这个顺序就是事件循环的底层顺序。1.3 为什么宏任务和微任务的区分会影响Vue3Vue3的响应式更新策略里有一个关键行为当你在一个事件处理器里连续修改三次countVue不会同步渲染三次DOM而是把三次修改记录在一个队列里异步统一执行一次更新。这里“异步统一执行”的载体就是微任务——准确说是Promise.resolve().then()。这在感官上接近于“批量合并”但底层依赖的是事件循环的机制。我遇到过不少人把nextTick理解成“等一会儿再执行”把里面的回调跟 setTimeout混为一谈这其实是把微任务和宏任务在时序精度上的差异给忽略了。setTimeout即便设置为0也要等当前同步代码执行完且排在当前微任务队列清空之后而nextTick因为挂的是微任务在当前宏任务结束后的微任务清空阶段就会执行。两者的差距实际测下来经常是几十毫秒在渲染密集场景下甚至更大在动画、测量DOM、抢CSS样式计算等场景里绝对是可感知的差距。2. Vue3响应式更新的幕后为什么调度器死死抱住微任务不放2.1 从“改数据到看到新DOM”之间的完整链路打开Vue3项目在事件处理器里写const count ref(0) count.value 1 count.value 2 console.log(document.querySelector(.num).textContent)这段代码最后打印出来的一定是旧值0不是新值2。原因就是Vue3的set拦截器捕获到变化后没有立即同步操作DOM而是把更新任务标记进了一个调度队列这个队列的刷新动作被包裹在了微任务里。链路是这样的count.value 2触发trigger内部执行queueJob(updateComponent)这个函数把组件更新任务推入队列如果队列处于空闲状态调用queueFlush最终执行Promise.resolve().then(flushJobs)。flushJobs就是真正去跑组件更新、执行DOM差异计算、批量刷新的函数。而nextTick的实现逻辑其实就是const p currentFlushPromise || resolvedPromise然后return p.then(callback)。如果你在修改数据之后立刻调用nextTick(callback)它不会新开一个微任务而是把回调挂在当前已经存在的那个刷新Promise上。所以回调不仅一定在DOM更新之后执行而且比setTimeout(0)早很多——因为后者要在微任务队列清空之后才轮到。2.2 Vue3的queueJob和批量更新的设计逻辑Vue3和Vue2在调度器上有一个明显的区别Vue2的更新队列是用数组模拟的去重方式比较粗放Vue3的调度器引入了SchedulerJob概念用Set做任务去重同一组件同一轮更新即使被触发一百次也只会执行一次。这就产生了一个常见的面试题为什么Vue3要在微任务里做这一层批量合并而不是直接同步更新DOM原因主要有三个第一性能。同步更新DOM意味着一次循环里改十次数据要重绘十次主线程卡顿是必然的。合并成一次更新只对比一次新旧虚拟DOM渲染开销几何级下降。第二稳定执行顺序。Vue内部组件更新的顺序与挂载顺序有关父组件优先于子组件。微任务队列是先进先出的调度器能精确控制父子组件的刷新顺序避免子组件先拿到父组件还没更新完的状态。第三可预测的nextTick时序。因为nextTick挂在这个微任务上开发者能保证回调永远在“同一轮数据修改引起的DOM更新完成之后”执行这个时序是框架契约的一部分。2.3 一个实验console.log、nextTick、setTimeout三者的执行顺序我建议你自己动手在浏览器的控制台里跑一遍这组代码const app createApp({ setup() { const list ref([]) const fetchData () { list.value [1, 2, 3] Promise.resolve().then(() console.log(promise then)) nextTick(() console.log(next tick)) setTimeout(() console.log(set timeout)) console.log(sync log) } return { list, fetchData } } })结果会严格按照sync log - promise then - next tick - set timeout的顺序输出。原因就是同步代码先执行完然后是当前微任务队列里的所有微任务按注册顺序执行Promise的then在nextTick之前注册所以先执行最后才轮到宏任务队列里的setTimeout。这个实验可以直观确认Vue3没有给nextTick搞特殊它就是常规微任务。3. 搞懂Vue3里的加餐玩法onMounted、异步组件和路由守卫的时序坑3.1 onMounted里为什么拿不到最新的props有个Q群里的妹子问过我一个非常典型的实战问题父组件用异步请求拉了一串列表数据v-for渲染了子组件同时把currentItem通过props传给子组件。子组件在onMounted里读取props.currentItem.id去加载关联数据结果发现有时候能拿到有时候是undefined。问题就出在“父子组件onMounted的触发顺序”和“微任务更新时机”上。Vue3里子组件的mount过程是在父组件的render阶段同步触发的子组件的onMounted默认先于父组件的onMounted执行。如果父组件的数据也是异步来的那在父组件完成数据请求并触发更新前子组件可能都已经挂载完了此时读取props自然是旧值。解法通常在两个层面一是用watch加{ immediate: true }取代在onMounted里读props二是如果你必须在mount阶段拿到最终值可以考虑在用异步数据包裹的层外面套一个v-if保证子组件只在数据到达后才会被挂载这时候onMounted里拿到的才是新值。这两个方案的本质都是调整执行时序而不是跟Vue的设计硬刚。3.2 await nextTick连续等两次背后的原理另一个高频场景列表数据更新后想让页面滚动到新列表的底部。很多人一开始写list.value.push(newItem) nextTick(() { nextTick(() { scrollToBottom() }) })看起来有点愚蠢为什么一个nextTick不够要套两层其实原因就是“列表数据更新”和“DOM尺寸计算可用”是两个阶段。第一层nextTick保证的是虚拟DOM已经完成patch、真实DOM节点数量已经改变但如果更新过程中子组件还做了异步渲染或者列表项的图片、异步组件尚未完成内嵌渲染DOM高度还没有达到最终值此时测量就会偏小。加一层nextTick本质是等下一轮微任务里可能发生的子组件更新也跑完。不过我一般不建议无脑套两层更好的做法是用watch配合flush: post或者直接改用requestAnimationFrame测量真实高度。微任务和宏任务的边界在这种细节上体现得尤其明显nextTick拿不到的正确高度往往需要等到浏览器完成一帧渲染后才能拿到这已经超出了微任务的能力范围必须借助宏任务维度的渲染回调。3.3 路由守卫、store异步派发与组件mount执行的次序在Vue3项目里我团队踩过一个线上bug在全局前置守卫router.beforeEach里await store.dispatch(fetchUserInfo)再把用户信息存进store然后放行进入页面。页面的onMounted里读取store里的用户信息发现还是一个空对象。关键就在执行次序上守卫里的await虽然保证了放行发生在请求完成之后但页面组件的mount是下一个宏任务里的事而store里数据的设置、以及页面组件读取的时机都受微任务批次的影响。实际上这个场景下读取空值的原因往往不是await没生效而是组件实例的setup/mounted阶段对store数据的读取发生在一次独立的更新任务里此时同步数据虽已存在但响应式订阅的触发和依赖收集的时序出现了“看起来没更新”的错觉。解决方式也很粗暴不要依赖mounted时机去读这个数据而是用storeToRefs放到computed里消费或者再显式await nextTick()后再读。搞清楚微任务宏任务之后你再遇到这类“数据明明请求完了但页面拿不到”的问题第一反应就是检查读取数据的时机与响应式更新批次是否错位。4. Vue3面试题的七种问法与统一解法4.1 常见题型的分类我梳理了近一年Vue3面试中出现频率最高、跟微任务宏任务相关的题目基本可以分成七类nextTick是宏任务还是微任务底层实现是什么连续修改ref的值DOM渲染几次在onMounted里修改数据再调用nextTick回调能拿到最新DOM吗为什么Vue3的更新调度要用微任务不用setTimeoutawait一个nextTick和await一个宏任务有什么区别父组件onMounted和子组件onMounted谁先执行如何拿到父组件异步数据watchEffect/watch的回调时机和nextTick有什么关系这七类题看起来五花八门其实全部扎在同一个点上你是否理解“当前宏任务 - 微任务清空 - 渲染”这个流水线以及Vue3的调度器在流水线的哪一环插入了自己的更新逻辑。4.2 逐题拆解与标准答法第一题的答法卷起来比较厉害。可以答到源码层面nextTick在Vue3核心包里就是这样实现的——const resolvedPromise /*#__PURE__*/ Promise.resolve() let currentFlushPromise null function nextTick(fn) { const p currentFlushPromise || resolvedPromise return fn ? p.then(fn) : p }关键在于currentFlushPromise。当有组件更新排队时它就是那个内部的flushPromise没有排队时就直接返回一个已经resolve的Promise。所以微任务是跑不掉的。第二题有标准答案只渲染一次。因为三次赋值进入同步代码块Scheduler的队列是带去重的在微任务真正执行前所有重复的更新任务已经被合并为最后一次。这种批量更新行为直接决定了Vue3的性能上限也是面试官想听到的一句关键点。第三题很多人会答“能”。但正确答案要分情况如果修改数据发生在mounted同步代码中后面的nextTick能拿到更新后的DOM如果修改数据发生在异步回调里而且期间触发了其他组件的渲染那nextTick可能只能保证当前组件更新完毕不保证所有异步子组件都完成。讲到这里能加一句“所以我一般在这种模糊场景下推荐用watch(..., { flush: post })”面试官基本会点头。第四题的标准话术是微任务比宏任务更早执行能避免中间插入渲染、减少检查cleanup且同一宿主环境的Promise底成本低于setTimeout。更深一层还可以补充宏任务会产生新的任务ID和任务栈频繁创建setTimeout会打乱浏览器自身的渲染节奏而微任务依附在当前任务尾部天然就是“当前更新批次收尾”的最佳位置。第五题的实验代码可以直接跑在同一个函数里依次await nextTick()和await new Promise(r setTimeout(r, 0))你会发现前者几乎是立即继续的后者要等当前宏任务结束、微任务清空、再重新进入宏任务阶段。这个差距在性能敏感场景下不能忽略。第六、七题的知识点在上一节已经展开过核心是父子挂载顺序、flush时机、以及watch默认是pre还是post这里不再重复。但有一点值得单独强调Vue3面试的深度已经从“API怎么用”全面转向“源码层为什么会这样设计”而源码设计里有一半都在跟事件循环打交道。4.3 一个自我检测的小题目写函数fn要求连续输出0, 1, 2, 3且3要在DOM更新后输出。最容易理解的答案就是let i 0 const timer setInterval(() { count.value i if (i 4) { clearInterval(timer) } }, 0) nextTick(() { console.log(更新完成) })如果你能判断这个nextTick大概率只等到了第一次或第三次更新而不是等到了最后一次说明你已经理解“多次调用nextTick注册回调”和“更新队列合并”之间的差别了。这正是很多人在项目里写错的一个隐性地雷把nextTick当成一个全局大Promise想当然认为注册一次就能等所有更新结束。5. 实战案例用微任务宏任务的时间差解决一个真实的列表滚动问题5.1 场景描述之前接手一个管理后台的工单看板Vue3 Element Plus。需求很简单点击“新增工单”按钮把新工单推入数组然后列表容器滚动到新工单对应的卡片位置。第一版谁都会写问题也出得很典型function addTicket() { tickets.value.unshift(newTicket) nextTick(() { const el document.querySelector(.ticket-card-${newTicket.id}) if (el) { el.scrollIntoView({ behavior: smooth, block: center }) } }) }奇怪的是本地模拟数据一切正常接上真实接口数据之后偶尔滚动位置偏了甚至找不到卡片的DOM。排查到最后发现是两个原因叠在一起一是卡片是另一个异步子组件它内部又await了一次接口拿卡片详情导致整个卡片区域是“二次渲染”后才真正渲染出来的二是因为列表容器有虚拟滚动只有滚动到可视区内的卡片才会真正渲染DOM。第一次nextTick执行时新卡片压根还没被渲染到DOM里自然找不到。5.2 为什么需要听完宏任务的“最终渲染”再滚动第一版失败的关键是虚拟滚动组件的渲染逻辑依赖滚动容器的实测尺寸而这个尺寸要在浏览器完成一帧布局和绘制后才稳定。微任务阶段虽然数据变了但虚拟滚动组件内部还在计算预计渲染位置它自身可能也会调度一个微任务去更新视口内的数据。于是nextTick回调执行时视口内的卡片列表可能还是旧状态下一批微任务才真正渲染新卡片。这时候不能只依赖微任务必须等一个宏任务周期。我最后使用的是requestAnimationFrame套一层async function addTicket() { tickets.value.unshift(newTicket) await nextTick() await new Promise((resolve) requestAnimationFrame(() resolve())) const el document.querySelector(.ticket-card-${newTicket.id}) if (el) { el.scrollIntoView({ behavior: smooth, block: center }) } }有人会问为什么requestAnimationFrame能等到渲染结束因为浏览器渲染帧和事件循环是互相穿插的rAF回调本就被安排在下一帧绘制之前执行而页面一帧已经开始的布局和绘制工作通常要等当前JS任务微任务清空后才能交给渲染线程。等到rAF触发时上一帧的样式计算和虚拟滚动更新已经提交DOM结构也稳定了。这比盲目叠加多个nextTick要靠谱得多。5.3 可复用的封装思路把这个场景做成了通用函数团队后续所有“数据更新后需要测量滚动、定位、聚焦”的需求都直接用这一套async function flushAndRender() { await nextTick() await new Promise((resolve) requestAnimationFrame(resolve)) }注意这个函数不是银弹。如果列表里包含图片等媒体资源光等rAF不一定能拿到图片的完整高度还需要等图片加载事件。如果是文本行数变化导致高度重排那rAF后基本就稳定了。这套组合的精髓在于微任务保证Vue的更新批次完成宏任务rAF保证浏览器布局阶段完成两个阶段都等到DOM测量结果才可信。6. 踩坑实录我在Vue3项目里被“任务时序”坑过的三个现场6.1 首屏白屏宏任务太长微任务更新被活活饿死有段时间我们的报表页面上线后偶尔白屏本地怎么刷都正常线上偶发。查到最后根因是接口请求返回后在一个超大的同步循环里做了十几万条数据清洗这个同步循环占用了主线程将近800毫秒。在这个独占期间浏览器根本无法执行任何微任务Vue的批量更新也就一直被压着。用户看到的画面就是已经拿到了数据但页面还是“空壳”。等同步循环终于结束微任务队列释放才噼里啪啦渲染出来。这个场景很值得展开微任务的优先级很高但高不过当前正在执行的同步代码。如果同步代码把主线程占满微任务也要排队。所谓“微任务比宏任务先执行”是在主线程空闲的前提下。所以遇到类似白屏我第一反应不是去调Vue的更新策略而是拆分长任务让出主线程Vue的调度器才有机会跑。6.2 onSuccess监听不到接口回调注册在错误的批次里另一个坑来自表格组件的上传接口回调。某个自定义组件在onMounted里await了一个初始化接口然后给表格组件注册onSuccess监听结果发现总有一部分场景触发不了。追根溯源是因为初始化接口返回后当前微任务批次触发了表格组件的内部状态更新这个更新任务把表格组件内部一个子组件的mounted重新调度了一次导致我注册监听的时机晚于子组件已经完成挂载。看起来就像“监听不了”。后来改成在watch某个状态变化后再注册问题消失。这个坑和微任务宏任务乍一看没关系但本质上都是“异步时序决定组件生命周期触发的真实顺序”。6.3 死循环微任务无限排队导致页面卡死还有一个案例来自同事写的深层次观察者用watchEffect监控一个ref在回调里又修改了同一个ref而修改动作被包装在一个异步任务里。因为微任务的清空特性每一次修改都会触发watchEffect的回调回调又派发新的更新任务这些任务全部排在微任务队列里浏览器在一轮事件循环内不断清空微任务UI渲染永远插不进去页面就直接“假死”了。这种死循环的本质和Promise递归一样都是微任务无限排队。解决方式也很简单不要在同一个微任务批次里无脑触发另一个同源更新必要时用setTimeout把其中的一环挪到宏任务阶段切断“微任务喂微任务”的链条。这也是为什么很多防抖节流库里仍然坚持使用宏任务。6.4 经验总结什么时候该用微任务什么时候该用宏任务在实际项目里我给团队定的参考标准是同步数据修改后的DOM状态提前获取、Vue内部批次协调、依赖响应式状态的轻量回调优先走微任务nextTick / queueMicrotask需要等浏览器渲染帧完成、测量真实尺寸、处理媒体资源加载、隔断微任务死循环、或者做节流防抖时优先走宏任务rAF / setTimeout / MessageChannel。微任务是“当前这批活干完马上接着干下一批活”的语义适合精细编排宏任务是“让浏览器先喘口气、画完这一帧再干活”的语义适合等待真实环境稳定。理解了这两者的分工Vue3的各种时序问题在你眼里就不再是玄学。我个人在实际操作里的体会是面试答这类题最高级的姿态不是说“我背过源码”而是能随口举出项目里一个真实踩坑的例子讲讲你是怎么从“加个setTimeout试试”进化到“根据微任务宏任务的特征定位问题”的。这份思考过程比标准答案更能看出一个人写前端的天花板在哪里。
返回列表