ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微任务宏任务与Vue3调度:事件循环到nextTick全解析

微任务宏任务与Vue3调度:事件循环到nextTick全解析 面试的时候被问到“微任务和宏任务”好多人能说出个大概轮廓一落到代码执行顺序就懵再一问Vue3里的nextTick、批量更新、异步组件基本就卡壳了。这个组合考察的其实不只是知识点记忆而是你在真实项目里能不能说清楚“数据变了以后Vue到底在哪个时机做了哪些事”。我打算把这套东西从JavaScript事件循环开始一路讲到Vue3源码里的调度逻辑最后配上几道典型的面试实战题争取让你看完之后既懂原理也会答题。这篇文章适合三类人准备前端面试、想搞懂Vue3运行机制、或者已经在用Vue3但遇到“数据变了但DOM没更新”、“nextTick不生效”、“倒计时不准”之类问题的人。文章不绕弯子从底层机制到上层框架再到面试题解法一条线讲完。1. 事件循环微任务和宏任务到底在哪个环节1.1 先搞懂JavaScript为什么需要事件循环JavaScript是单线程语言这一点决定了它在浏览器里的所有执行方式。所谓单线程就是同一时刻只能做一件事哪怕CPU有多核浏览器里的JS引擎对每个标签页也只有一个主线程。这个设计有两个原因一是早期脚本语言定位在“操作DOM的表单校验”如果多线程同时改一个DOM节点浏览器根本没法协调二是单线程模型足够简单不需要考虑锁、死锁、竞争条件这些并发问题。但单线程带来一个问题如果某次操作特别耗时比如发请求等响应、读取文件、等待用户点击难道整个页面就一直卡着于是事件循环应运而生。简单说浏览器把任务分成一个个“任务块”放进队列主线程空闲了就取一个出来执行执行完毕再取下一个不断循环。这个队列机制就是事件循环的核心。这里要记住最基础的一点事件循环不是JS语言本身的特性而是运行环境提供的机制。浏览器和Node.js都有事件循环但实现细节完全不同。在浏览器里事件循环和渲染流程深度绑定在Node里它和libuv库的线程池、I/O回调紧密相关。面试时如果不说清楚“我讲的是浏览器环境”很容易被追问到细节翻车。1.2 宏任务和微任务的划分标准任务队列里其实不是只有一种任务而是分成了宏任务队列和微任务队列。宏任务这个叫法英文里叫MacroTask也被称为Task产生宏任务的常见来源有script标签内的整体代码setTimeout、setInterval、setImmediateNode环境I/O操作回调比如fetch完成、文件读写完成UI交互事件比如click、input、keydownrequestAnimationFrame这个比较特殊后续单独说微任务MicroTask的来源相对少得多但每个都很关键Promise的then、catch、finally回调queueMicrotask函数手动注册的任务MutationObserver的DOM变化回调Node环境下的process.nextTick这里有个很关键的区分点宏任务由宿主环境浏览器或Node派发微任务由JS引擎自己管理。你可以这样理解宏任务像是操作系统给浏览器分发的“外部事件”微任务则是JS代码内部自己产生的“后续动作”。Promise的resolve之后要执行的then本质上是当前代码“欠下来的内部处理”所以它比外部事件更迫切。1.3 一次事件循环迭代的完整顺序搞清楚了任务来源下一步就是真正的核心一次事件循环迭代里宏任务和微任务到底怎么交错执行。标准流程是从宏任务队列里取出第一个宏任务开始执行。宏任务执行过程中如果产生了微任务就往微任务队列里塞。当前宏任务执行完毕开始清空微任务队列一口气把当前所有微任务全部执行完。微任务执行过程中如果又产生了新的微任务依然要追加执行直到微任务队列彻底为空。根据浏览器渲染机制决定是否触发渲染流程Update Rendering。回到第一步取下一个宏任务。这里最反直觉的地方在于微任务清空是“不达目的不罢休”的如果在微任务里递归地添加新微任务就会一直循环执行卡住后续所有宏任务。我见过有人在Promise的then里继续链式调用几百次页面直接假死就是因为在微任务循环里停不下来了。还有一点很多人会忽略渲染流程发生在微任务清空之后、下一个宏任务开始之前。也就是说同步代码改完DOM再执行微任务再统一渲染。这也是为什么Vue的nextTick能恰好拿到更新后的DOM——它把回调排进微任务队列而微任务执行时渲染还没开始但数据更新已经完成了。这个细节是理解Vue3异步更新机制的关键后面会展开讲。2. Vue3源码里的微任务调度nextTick背后到底做了什么2.1 从一次数据修改说起Vue3里改一个ref或reactive数据是不是会立刻触发组件的重新渲染答案是否定的。Vue3和Vue2一样采用异步更新策略。什么意思呢就是说你在同一个事件循环里把数据改了十次Vue可能只会重新渲染一次。原因很现实如果每改一次数据就触发一次渲染那一个函数里连续改多个数据就会造成十几次重复渲染性能完全跟不上。Vue的解决方案是数据变化时不直接更新DOM而是先把需要更新的组件放进一个调度队列然后通过微任务统一执行。这个调度队列由Vue3内部的scheduler模块负责。当你修改reactive数据时会触发依赖收集时注册的副作用函数effect但这个effect不会立即执行而是被包装成一个job塞进队列。真正的更新动作放在一个微任务里统一触发。2.2 nextTick源码核心拆解Vue3里nextTick的实现非常精简核心逻辑就是“把回调塞进微任务队列”。源码中有一个专门给Composition API使用的版本关键代码大致是const resolvedPromise Promise.resolve() as Promiseany let currentFlushPromise: Promisevoid | null null export function nextTickT void( this: T, fn?: (this: T) void ): Promisevoid { const p currentFlushPromise || resolvedPromise return fn ? p.then(fn) : p }这里需要注意一个细节nextTick返回的是一个Promise如果你传了回调函数它就相当于在p.then(fn)里注册回调。而currentFlushPromise是在flushJobs的时候被赋值的。也就是说如果当前已经有组件更新任务在排队nextTick会优先等这批更新执行完再调你的回调如果当前没有更新任务就直接用已resolve的Promise那么回调会在下一个微任务里执行。这个设计回答了一个高频面试问题“调用nextTick时如果没有数据更新回调会怎么执行”答案是它依然是一个微任务会在当前同步代码之后执行但不会等待任何组件渲染。理解了这一步你就知道nextTick并不是无所不能的魔法它做的就是微任务调度。2.3 flushJobs组件批量更新的完整流程真正驱动组件更新的是scheduler模块里的flushJobs函数。它的大致流程是记录当前正在执行的任务currentFlushPromise。对队列里的所有job进行排序。这个排序有讲究因为组件的父子关系决定了渲染顺序父组件要优先于子组件重新渲染否则子组件的props可能拿不到新值。源码里通过组件iduid和effect的id来排序id小的先执行。依次执行队列里的所有job也就是重新执行组件的render函数生成新的虚拟DOM再通过diff算法更新真实DOM。清空队列之后处理一些后续工作比如调用组件的updated生命周期钩子。重置状态把currentFlushPromise设为null。这里的关键点在于flushJobs本身是在微任务里执行的。数据变更时Vue把flushJobs作为一个微任务塞进队列。所以当你连续改十次数据组件只被放进队列一次flushJobs只跑一次渲染只有一次。这也是Vue3性能优于Vue2的一个原因调度器整体设计更干净批量更新粒度更细。实战提示在同一个事件循环里即使你在一个函数里改了一个组件里的几百个响应式数据Vue也只会触发一次渲染。因此不需要为了性能刻意减少数据修改次数但要小心不要在微任务里反复做“无效更新”。2.4 watch回调的触发时机watch和watchEffect的触发机制和组件渲染同源因为它们的底层都是effect都会在数据变化时通过调度器排队。区别在于watchEffect默认是立即执行的watch则默认是懒执行的。它们在事件循环里的表现是数据变化后watch回调不会立刻执行而是等组件更新队列flush的时候一起执行。具体来说watch回调执行在组件更新之后、DOM更新之后吗这里有个微妙的细节。Vue3源码中watch回调所属的job和组件渲染job在同一个队列里。如果仅观察一个简单数据源watch回调会在微任务刷新队列时执行但可能先于组件渲染。如果你在watch回调里操作DOM相关信息建议用nextTick包一层或者使用flush: post选项watch(source, (newVal) { console.log(数据变化了) nextTick(() { // 这里才能保证组件已经完成渲染 }) }, { flush: post })这里最容易踩的坑是在watch回调里读取某个被组件渲染影响的DOM属性比如元素的高度、宽度、scrollTop。如果组件还在更新中读取到的可能就是旧值。我自己就遇到过这种情况数据变了列表变长了想在watch里自动把滚动条拉到底部直接写在回调里是无效的必须用nextTick包一层或者用flush:post。原理就是微任务队列的执行顺序问题明白了事件循环这个坑就再也不会踩了。3. 宏任务在Vue3项目里的实战场景3.1 异步组件加载与Suspense的任务执行流程异步组件的核心是defineAsyncComponent它本质上是一个包装器内部通过import()动态加载组件模块。import()返回的是一个Promise所以异步组件加载完成后后续处理走的是微任务但网络请求本身是宏任务层面的I/O操作。这里要特别注意一个场景页面加载时多个异步组件同时请求浏览器会并行发出请求。当每个请求完成时都会触发对应的Promise resolve进而产生微任务。Vue3会把“组件模块已加载现在用新模块替换并重新渲染”这个动作包装成一个任务。Suspense组件则负责协调这些异步组件的完成时机在还有异步依赖没完成时显示fallback内容全部完成后一次性切换。在实际项目里用Suspense的人常常遇到一个问题fallback内容闪得很快一闪而过视觉上很不友好。原因就是异步组件加载完成后重建vnode、更新组件都是在微任务里快速完成的浏览器可能还没来得及渲染fallback就被替换了。解决办法包括设置最小加载时间、使用transition过度或者干脆不显示fallback而是保持上一帧内容。理解宏任务和微任务的执行流程后你会明白这个“闪烁”本质上是什么时机切换引起的可以从调度层面去想方案而不是盲目调整样式。3.2 定时器、倒计时和轮询的宏任务坑setTimeout和setInterval都是典型的宏任务来源它们的回调不会在设定的时间精确执行。因为当前宏任务如果在执行中新的宏任务回调排入队列只能等当前宏任务执行完、微任务清空后才能轮到它。如果你在主线程里写了一个很耗时的同步循环比如一万次计算setTimeout里的回调时间就会被推迟。这个现象在倒计时场景里非常明显。用setInterval实现倒计时如果页面主线程繁忙或者浏览器切换到后台再切回来倒计时的显示就会跳秒、不准。我见过一个项目倒计时逻辑用setInterval每1000ms减1用户切到别的标签页看视频回来后倒计时居然倒退了十几秒。解决方法很简单倒计时不要用累加或累减而是基于时间戳计算剩余时间。每次执行回调时读取当前时间和目标时间计算差值。这样哪怕回调被延迟了很多毫秒显示的数字也是基于真实流逝时间计算的。但这里还有另一个坑如果回调一直被阻塞页面更新就一帧都不出。所以倒计时回调里如果还涉及修改Vue的响应式数据务必考虑批量更新避免每次都触发全量渲染。轮询场景则是另一个高频问题。用setInterval轮询后端接口时如果上一次请求还没返回下一次回调又触发了就会造成请求堆积。一个稳妥的写法是用递归setTimeout模拟轮询在上一次请求完成的回调里再设置下一次定时器async function poll() { const res await fetch(/api/status) // 处理数据 timer setTimeout(poll, 5000) }这个写法的好处是天然避免了请求堆积因为下一次定时器是在上一次请求结束后才注册的。你还可以在项目里组合宏任务和微任务数据回来后用nextTick在微任务里更新DOM确保渲染流畅。3.3 事件回调与UI更新宏任务里的微任务陷阱事件回调本身就是宏任务。点击一个按钮浏览器把click事件派发给JS引擎形成一个宏任务。在这个宏任务里你可以修改响应式数据触发Vue的批量更新然后该宏任务结束后微任务队列清空DOM完成更新最后浏览器统一渲染。这个流程看起来没什么问题但有一种情况很容易踩坑在事件回调里读取“刚修改后的DOM状态”。比如在一个点击事件里你往列表里加了一项数据想立刻获取列表的总高度。此时DOM其实还没更新因为更新操作排在微任务里当前宏任务还没执行完。解决方案还是nextTick但这背后有一个更本质的道理不要试图在同步代码里读取异步更新的结果。Vue的异步更新机制注定你在同一个事件循环里读不到新DOM。事件回调里所有同步操作都是基于旧DOM的。想读新的状态要么nextTick要么把读取操作放进微任务回调。还有一个和用户输入相关的场景。输入框的input事件每触发一次就是一个宏任务。你在input回调里修改响应式数据Vue批量更新然后在微任务里渲染。如果输入速度极快每一帧可能堆积多个input事件造成渲染压力。Vue3在这块没有什么魔法它依赖浏览器的事件合并机制但你需要知道高频事件如mousemove、scroll、input改造时有时需要引入requestAnimationFrame或节流而节流本身是宏任务层面的操作选错时机也容易出问题。3.4 requestAnimationFrame一个既像宏任务又不像宏任务的特殊存在requestAnimationFrame简称rAF在面试里经常被拿来和setTimeout做对比但它本质上是和渲染帧绑定的回调机制。它既不是标准宏任务也不是微任务而是浏览器在“每帧渲染之前”调用的回调。在事件循环的执行顺序里rAF回调大致位于“微任务清空之后渲染执行之前”。在Vue3项目里rAF主要在动画场景使用。如果你用响应式数据驱动动画每次rAF回调里修改数据Vue会触发调度但因为下一次渲染就在本帧的后面更新其实很快同步发生。这里有一个常见的优化点如果动画只需要改变某个元素的位置不要通过修改Vue响应式数据去驱动而是直接操作DOM的style属性或者在自定义渲染器里跳过响应式系统否则每帧都会触发组件更新性能损耗会翻几倍。Vue官方文档里也建议高频操作应尽量避免走响应式数据更新链路因为响应式系统的开销在这种场景下会被无限放大。4. 面试实战活儿该这么干4.1 概念题怎么答得既透彻又不容易被追问卡住面试官问“什么是微任务和宏任务”最怕你只背定义。合格的回答应该包含三个层次第一层定义。宏任务是由宿主环境发起的独立任务比如setTimeout回调、I/O回调、事件回调微任务是由JS引擎维护的、当前宏任务结束后立即执行的任务比如Promise回调、queueMicrotask回调。第二层执行流程。每个宏任务执行完成后必须清空所有微任务微任务里产生的新微任务也会被继续执行之后才轮到渲染然后才取下一个宏任务。第三层实际影响。因为微任务执行时机优于宏任务所以Promise.then的回调一定比setTimeout回调先执行也因为微任务可以在一个宏任务内连续执行所以在微任务里递归添加微任务会阻塞页面。用这种方式回答面试官通常能感受到你对底层运行机制的理解深度不大容易在基础题上继续纠缠。如果再追问“为什么微任务先执行”你可以补一句浏览器为了保证内部状态的一致性在当前宏任务结束后先处理微任务这些微任务往往和当前运行的环境直接相关比如Promise状态变更如果延迟处理可能导致状态错乱。4.2 执行顺序题从一套标准解法说起面试里必出现的一类题是打印结果console.log(script start) setTimeout(() { console.log(setTimeout) }, 0) Promise.resolve().then(() { console.log(promise1) }).then(() { console.log(promise2) }) console.log(script end)标准答案顺序是script start、script end、promise1、promise2、setTimeout。解题思路是分三步走第一步手动标出同步代码。同步代码按顺序执行输出script start和script end。 第二步识别宏任务和微任务。setTimeout回调是宏任务Promise的then是微任务。 第三步在当前宏任务也就是script整体代码执行完后先清空微任务队列promise1和promise2依次输出然后再取出setTimeout宏任务输出setTimeout。这种题在主流程上基本不会出错真正的坑在嵌套场景setTimeout(() { console.log(timer1) Promise.resolve().then(() { console.log(promise1) }) }, 0) setTimeout(() { console.log(timer2) Promise.resolve().then(() { console.log(promise2) }) }, 0)答案是timer1、promise1、timer2、promise2。核心规则依然是每个宏任务结束后立即清空该宏任务产生的所有微任务然后再取下个宏任务。只要把握住这一条嵌套题也能轻松应对。我在带新人时经常建议这类题拿不准就画图左边画宏任务队列右边画微任务队列每执行一步就更新两个队列的状态。画三次你就能总结出规律以后再也不怕变种题。4.3 Vue3场景题nextTick、批量更新与异步渲染面试环节真正拉开差距的是Vue3场景题。比如“连续修改一个响ref变量三次Vue会渲染几次”答案是只渲染一次。因为Vue3的调度器会把组件更新包装成一个任务塞进微任务队列。同一事件循环内哪怕改了十次队列里也只有一个更新任务。这个设计和事件循环的“微任务批处理”完美契合。再比如“在created钩子里请求数据拿到数据后修改响应式变量页面什么时候更新”答案是数据请求是宏任务I/O操作拿到数据并修改变量时Vue把组件更新排入微任务队列当前宏任务结束后微任务执行页面更新。所以你在then回调里同步读取DOM相关信息拿到的还是旧值。我遇到过印象很深的一个面试题长这样const count ref(0) async function increment() { count.value console.log(count.value) await undefined count.value console.log(count.value) nextTick(() { console.log(nextTick callback) }) }这里涉及到async/await在事件循环里的微任务特征。await undefined相当于把后续代码包装成微任务所以count.value和console.log之间隔了一个微任务。很多人会纠结count.value已经在await之前更新了render是否已经发生答案是没有因为render在微任务队列的某个位置而await后的代码也是微任务它们的先后取决于入队顺序。这类题考察的是你对async/await与Vue调度器交互的理解深度如果能说到“await让出主线程Vue渲染可能还没来得及执行nextTick会把回调排在CDM更新之后”基本就能让面试官满意。4.4 综合应用題网上看来的真实场景面试里还有一种综合题会给你一个完整场景“一个Vue3组件有个按钮点击后请求接口拿到数据后渲染一个超长列表请问你怎么优化”这种题表面考察优化实际考察的是你对宏任务、微任务、渲染的完整理解。如果是我来答我会分四步第一步请求接口是宏任务回调拿到数据后修改变量组件更新被排进微任务队列。长列表渲染如果数据量很大一次渲染大量DOM会导致页面卡顿。这时候可以选择分批渲染比如把数据切块通过requestAnimationFrame逐帧渲染。第二步如果数据本身是可计算的考虑虚拟列表这是另一个话题。第三步在数据更新到DOM之后如果要做计算或获取布局信息务必使用nextTick否则拿到的是旧DOM状态。第四步如果接口返回的数据需要经过复杂处理比如排序、过滤、映射尽量在拿到原始数据后、赋值给响应式变量之前进行处理。因为赋值之后后续每次数据变更都会触发依赖收集和更新调度处理逻辑如果放在渲染过程中会影响性能。这种回答是把宏任务、微任务、渲染调度串成了一个整体面试官一听就知道你平时在项目里做过性能分析和架构设计而不是只会背概念。5. 常见问题与自查指南5.1 为什么我的nextTick没拿到最新DOM这种情况我排查过很多次最常见的三个原因第一数据根本没有发生响应式变化。比如给一个普通对象添加新属性而不是用reactive或ref包裹。如果数据不是响应式的Vue压根不会触发更新nextTick自然等不到任何渲染。第二在mounted钩子里直接读取数据并判断DOM状态。mounted钩子本身是在组件挂载完成后执行的但如果你在这个钩子里修改了响应式数据并希望立刻读取修改后的DOM依然需要nextTick。因为mounted钩子执行时Vue当前渲染流程可能还没完全走完。第三对nextTick的理解错误。nextTick里的回调在“DOM更新完成之后”执行但如果你在微任务里反复修改数据每次修改都会排队nextTick只能保证在“当前批次更新完成”后执行。如果你在nextTick回调里又去修改数据那只能在下一次更新完成后才能拿到最终状态。排查顺序建议是先检查数据是否为响应式再确认更新事件是否触发最后考虑是否嵌套了多个批次的更新。5.2 为什么setTimeout里的回调比预期晚执行因为setTimeout回调是宏任务必须等待当前宏任务执行完成、微任务队列清空之后才能执行。如果你在同步代码里写了一个耗时的while循环即使setTimeout设的延迟是0也要等循环结束后才能执行。实际项目里如果主线程有大量计算、重渲染或长事件处理setTimeout的延迟会变得不可靠。一个相关的排查技巧是如果你发现某个setTimeout回调延迟严重先在代码里找有没有同步的密集计算或者有没有死循环的微任务递归。前文提到过无限递归Promise.then会导致微任务队列永远清空不完后面的宏任务永远拿不到执行机会这种情况下页面会直接卡死。5.3 微任务和宏任务会影响Vue DevTools的显示吗会。我用Vue DevTools调试时经常发现组件状态和DOM状态对不上尤其是在频繁修改数据的场景里。原因是DevTools的视图更新也走Vue的响应式系统它自己也有调度逻辑。有一个很典型的坑在断点调试时Vue的微任务可能已经执行完成但DevTools的组件树面板还停留在旧状态。这时候不要盲目怀疑代码有bug可以先在nextTick回调里设置断点确认最终状态。我个人还发现一个规律如果页面上有大量微任务堆积比如用了很多Promise而不加节流Vue DevTools的刷新频率会明显变慢。优化微任务往往比优化宏任务更影响开发体验。5.4 如何快速判断当前任务环境在代码里快速区分当前是宏任务还是微任务最土的办法是打印执行顺序分别在同步代码、setTimeout回调、Promise.then回调里打console.log通过控制台的输出顺序判断。更实用的是通过queueMicrotask的调用位置来判断如果当前代码是在微任务回调里执行再注册新的微任务也会紧接着执行如果在宏任务回调里执行微任务会在当前宏任务结束后执行。面试时如果问“宏任务和微任务里分别能做什么”可以这样总结宏任务适合做耗时操作和I/O密集型任务微任务适合做需要立即执行的轻量后续处理比如状态更新、回调注册。选错场景轻则性能浪费重则逻辑错乱。6. 一点个人经验我在实际开发中体会最深的一件事Vue3把异步更新调度做得很聪明但很多人没意识到它依赖的是微任务队列的“批处理”特性。当你写业务代码时如果你从“下一个宏任务还是微任务”的角度去思考Vue的更新时机很多诡异问题都能秒懂。比如之前困扰过一个同事的“修改完数据后为什么在watch里读到的DOM信息是旧的”其实就是微任务优先级和渲染时机的顺序问题。搞懂了事件循环这类问题根本不需要查文档。最后再分享一个小技巧面试或排查问题的时候把自己想象成浏览器调度器本身画两条队列标出当前执行位置往下一步一步推演。这个习惯我保留了很多年不管是复杂的事件循环问题还是Vue3源码分析都特别管用。如果觉得文章哪里没说清楚欢迎在评论区交流我看到了会尽量补充。
返回列表