事件循环 Event Loop
第 3 章把界面「摆」好了,这一章回答下一个问题:你点了一下按钮,从手指落下列画面变化,中间那一瞬间到底发生了什么?答案藏在每一款 GUI 框架的心脏里——一个永不停转的事件循环(event loop)。第 1 章的 § 1.5 已经带你看它一眼:存在这么一个循环、事件排队处理。这一节我们把这颗心脏解剖开:为什么全世界的主线程只有一条?任务怎么排队、谁有资格插队?「渲染」在循环里站哪个位置?界面为什么会「卡死」,以及两副解药。学完这一节,「页面卡了」这四个字在你耳朵里会从一个模糊的抱怨,变成一份精确的病历——卡在哪一环、拖了谁的后腿、该开哪一味药,句句有出处。
凌晨两点的小便利店,从头到尾只有一位店员:收银是他,理货是他,加热关东煮还是他。这就是 GUI 世界的主线程——所有跟界面有关的活,只此一人。
顾客进门要排队取号——任务队列:点击、按键、定时器到点、网络数据回来,全都是排队的「号」。店员的原则是办完一单再叫下一号,绝不半途切换(不然单据全乱)。
收银台上贴着一排便利贴:每办完一单,店员必须当场把该单的收尾小事全办完——钉小票、点零钱、记账——才许叫下一个号。这些「当场必须清掉的小事」就是微任务。
墙角的监控摄像头每 16.7 毫秒自动拍一张,拍的是店面当下的样子——这就是渲染:屏幕按刷新率重画,画面必须是「此刻货架的真实状态」。店员得赶在快门之间把货架摆好,照片才不穿帮。
现在,来了一单大活:搬三十箱饮料入库,要三分钟。店员一头扎进货架间——门口的号没人叫了(点击没反应)、快门响了也没人整理货架(画面冻结)、连门口「欢迎光临」的铃声都显得那么遥远。这就是「卡死」的全部真相:不是电脑坏了,是唯一的店员被一件长活占住了,别的什么都轮不上。
术语对照:先把这一节的行话翻译成人话
| 术语 | 翻译成人话 | 便利店里的对应物 |
|---|---|---|
| 主线程(main thread) | 全店唯一的店员,界面上的活只此一人干 | 那位店员本人 |
| 事件循环(event loop) | 店员的工作节奏:取号→办单→清便利贴→赶在快门前理货→再取号 | 店员一整晚的动作循环 |
| 任务 / 宏任务(task) | 排队叫号办的事:点击、到点、网络回来 | 门口叫号机里的号 |
| 微任务(microtask) | 办完当前单必须当场清掉的收尾事 | 收银台上的便利贴 |
| 调用栈(call stack) | 「正在办的事」一层层叠起来的单据 | 店员手里压着的一叠单 |
| 阻塞(blocking) | 一件活占住店员,全店停摆 | 店员进仓库三分钟不出来 |
| 长任务(long task) | 超过 50 毫秒的重活,卡顿的直接元凶 | 搬三十箱饮料 |
| requestAnimationFrame | 「快门响之前」留给理货的固定时段 | 每次拍照前的快速摆货窗口 |
| Web Worker | 隔壁分店:能备货,但不许碰店面 | 后仓帮工(不接触顾客) |
| INP(交互延迟) | 从顾客喊话到店员应答的等待时长 | 按号到拿到东西的时间 |
为什么只有一位店员:单线程的世界观
先回答最反直觉的问题:现代设备动辄八个核十六个线程,凭什么界面只用一条主线程?答案是界面是一份随时可变的公共财产。DOM 树(或原生世界的控件树)是一张所有人共用的画布:你的代码在往里加节点,动画系统在挪位置,浏览器在算布局,屏幕在等着画——如果允许多个线程同时上手改这棵树,就会出现两个画家同时在一张布上落笔的场面:你画到一半的那笔,可能被别人先改了底色。这类问题有个正式名字叫竞态(race condition),它是多线程编程里最难缠的一族 bug——不是每次都犯,是「偶尔犯、还挑演示的时候犯」。
工程界在 GUI 上给出的集体答案是:谁都可以「提议」改界面,但只有一个人可以「动手」。所有提议变成消息排进队列,主线程一条条处理。于是你会看到一个惊人的家族相似:浏览器的渲染主线程、Android 的 main 线程、iOS 的 main thread、Java Swing 的事件分发线程(EDT)、Windows 桌面程序的消息循环——五家平台、三十年历史,全部采用「单线程 UI + 消息队列」这一个配方。好比手术室里只有一位主刀医生:护士、麻醉师、家属人人可以递话、递器械,但动刀的永远只有一个人——不是医生有多重要,是手术台上同时只能有一双手。
说白了,单线程不是性能缺陷,是用「排队」换「不出乱子」的工程决策。代价也明码标价:这位店员要是被一件长活占住,全店失联——这正是本节后半段要解剖的「卡死」。
循环本体:六步一轮,永不停转
把店员一晚上的动作抽象出来,就是事件循环的完整定义。伪代码只有六步:
while (营业中) {
1. 从任务队列取一个任务 // 叫一个号
2. 把这个任务从头干到尾 // 办完这一单,中途不接活
3. 把微任务队列清空(含新产生的) // 便利贴当场全撕完
4. 需要的话,渲染一帧 // 赶在快门前理货
5. 回到第 1 步 // 再叫下一个号
}
六步里藏着三条铁律,后面整节都在给它们作注。铁律一:任务不可中断。一个任务一旦开跑,就要跑完——店员办单办到一半绝不会撂下不管去叫下一个号。这个特性叫「运行至完成」(run-to-completion),它带来一个巨大红利:你的代码永远不会被别人的代码「半路插一刀」,函数里的逻辑天然是一整块。代价则是任何一段代码都可能让后面所有人干等。铁律二:微任务必须当场清空,一张不留,包括清的过程中新贴上来的。铁律三:渲染是「按需插播」——不是每轮都画,浏览器只在它认为需要时(离上一帧过了足够久、且有内容变化)才在第 3 步和第 1 步之间插一次绘制。记住这六步的顺序,你就拿到了本节所有问题的总钥匙:什么先什么后,谁阻塞谁,全部由它裁决。
调用栈:店员手里叠着的那摞单
「把任务从头干到尾」具体长什么样?靠的是调用栈(call stack)。你调用一个函数,就把一张「新单据」压在这摞单的最上面;函数返回,就抽走最上面那张。函数里再调函数,单据就再压一张——栈有多深,说明当前这件事嵌套了多少层。
它有两个日常出口。出口一:报错信息就是这摞单的复印件。你在控制台看到的 stack trace(调用栈回溯),从上到下就是「最里层的单 → 外层的单」,报错发生在第几张单的哪一行,一目了然——读栈是排错的基本功。好比查账时从最内层的小票往回追,一层层追到最初开单的柜台。出口二:栈是有深度的。一个忘记写终止条件的递归函数会把单据一张张往上压,直到超过浏览器允许的上限,报「Maximum call stack size exceeded」(栈溢出)——店员手里的单摞到了天花板,一张都办不动了。
顺带一个重要认知:JavaScript 报「栈溢出」时,界面本身并没有卡——循环还在转,别的号还在叫,只是这一单自己崩了。这与后面的「长任务卡死」是两种完全不同的病:一个是单据摞太高,一个是单张单据太重。
任务队列:门外的叫号机
排队取号的「号」从哪来?四类大来源。用户输入:你点了一下按钮,操作系统把信号交给浏览器,浏览器判断这个点击落在哪个页面的哪个元素上,打包成一条「click 任务」放进队列。定时器到点:setTimeout / setInterval 到了约定的时刻,往队列塞一条任务。异步完成:网络请求的数据回来了、文件读完了,对应的回调排队进场。页面生命周期:页面加载、尺寸变化、进入后台,也都是任务。
需要澄清一个常见的简化:并不是一条笔直的队伍,而是多条队列加优先级——浏览器实现里,用户输入的队列常常会被优先照顾(毕竟「点了没反应」比「动画慢半拍」更伤体验),渲染相关的调度也有自己的档期。但对写代码的人来说,心智模型用一条先进先出的叫号机就够了:你没法控制号什么时候被叫到,只能保证轮到它时它已经排进去了。不妨这样想:叫号机是店员的唯一上游——你的代码再急,也只能先取号,绝无「从后门进店」这回事。这个设定将在「setTimeout(fn, 0) 的真相」一节里坑到无数人。
一次点击的完整旅程:从指尖到队列
既然说号从「顾客上门」来,就跟着一次点击走完全程。你按下鼠标左键:硬件产生电信号,操作系统把它打包成一条输入事件,交给当前有焦点的应用——浏览器。浏览器的总管家进程看一眼:「这个点击落在哪个标签页、哪个坐标」,转交给对应标签页的渲染进程,排进主线程的任务队列。到这一步,店员还没出手,号已经取好了。
接下来是最有含金量的一步:命中测试(hit testing)。浏览器要回答「这个坐标到底落在哪个元素上」。答案不是唯一的候选——页面上同一坐标可能叠着好几个元素(一个按钮压着一张卡片,卡片压着背景)。裁决规则正是 § 3.2 讲过的层叠秩序:从绘制在最上层的开始问起,z-index 高的、后画的优先认领,第一个认领的元素获胜。这解释了一个日常现象:弹窗一弹出来,底下的按钮就「点不动」了——不是按钮坏了,是你的每一次点击在命中测试这关就被弹窗的遮罩层截胡,根本轮不到它。
还有一个容易忽略的真相:你的一次点击其实是一串事件。手指按下触发 pointerdown、mousedown,抬起触发 pointerup、mouseup,最后浏览器综合「按下和抬起落在同一个元素上」这个事实,合成出 click。所以「按下不放拖出按钮再松开」不会触发 click——抬起的地方不是它,合成的条件不满足。这一串事件的每一站,将来在 § 4.2 的三个阶段里还会再走一遍,那是下一节的主角。好比公司前台转接电话:总机(操作系统)接到线路,查分机表(哪个标签页),转给部门(渲染进程),秘书记录来电(任务入队)——而「这通电话找谁」(命中测试)要到店员拿起听筒的那一刻才见分晓。
微任务:办完这单立刻清账的便利贴
任务队列之外还有一条更小的通道,叫微任务队列,它的常客是三兄弟:Promise 的 then / catch / finally 回调、queueMicrotask() 直接投递的函数、MutationObserver(监听 DOM 变化的观察者)。它们的特权写在铁律二里:当前任务一结束、下一个任务还没开始前,全部清空——包括清空过程中新产生的微任务。也就是说便利贴有繁殖能力:撕一张的过程中又贴了一张新便利贴,新这张也必须当场撕掉,直到收银台干干净净,才轮到叫下一个号。
为什么这么设计?为了时序的确定性。Promise 的本意是「这件事办完之后,紧接着做那件事」——如果允许微任务排到下个任务后面,中间就可能插进别人的活,你读到的状态就可能被别人改过。微任务的存在保证了「同一轮里的后续逻辑」永远在渲染和下一个任务之前跑完,逻辑链不断档、状态不打架。好比结账的收尾动作:小票必须当场钉、零钱必须当场点——如果留到下一位顾客结账时再补,账面就会串门。而「下一位顾客」可能是一位要改同一个 DOM 的别的代码。
说白了,宏任务和微任务的区别一句话记牢:宏任务是「下一个号」,微任务是「本单收尾」——前者排外队,后者是当前任务的一部分尾巴。
顺序翻车现场:一道题看穿两条队列
两队列的规则用一道经典小题现场验收。下面四行代码,打印顺序是什么?
console.log(1); // 同步代码
setTimeout(() => console.log(2), 0); // 定时器:新任务
Promise.resolve().then(() => console.log(3)); // Promise:微任务
console.log(4); // 同步代码
答案是 1、4、3、2。逐个对号入座:1 和 4 是当前任务的本体——这一整段同步代码就是一个任务,从头跑到尾,所以 1 先、4 紧随其后。3 是微任务:当前任务(本体)刚结束,便利贴当场撕,所以它插在 4 后面、抢在一切新号之前。2 是新任务:哪怕定时器写了 0 毫秒,它也只是「最早可以在下一轮被叫到」——排在微任务之后,最后打印。
这道题的价值不在答案本身,在于它把「循环六步」变成了肌肉记忆:同步代码是单据正文,微任务是收尾便利贴,定时器回调是下一个号。以后再看到「为什么我的 Promise 比 setTimeout 先执行」这类问题,心里直接放电影:店员办完这一单,先撕便利贴(微任务),再叫下一个号(宏任务)——顺序从来没有任何悬念。打个比方,这就好比问「结账的零钱先找,还是下一个顾客先来」——收尾当然在下一单之前,这是店规,不是巧合。
async / await 的循环视角:语法糖的成分表
现代代码里到处是 async / await,很多人以为它是「让代码在后台运行」的新机制——把它放回循环里看,成分表一目了然:它就是 Promise 加微任务的语法糖,没有任何新引擎。规则只有一条:await 是一个挂起点——遇到它,当前函数立刻返回,「await 后面的代码」被登记成一张便利贴(微任务),等 await 的那个结果就绪时再撕。
async function 结账() {
console.log('接单');
await 库存查询(); // 挂起点:函数在此返回,后文变成便利贴
console.log('出票'); // 以微任务身份回来
}
结账();
console.log('下一位');
打印顺序是「接单 → 下一位 → 出票」:await 一挂起,主线程立刻空出来去跑后续同步代码(下一位);库存结果一到,「出票」作为微任务被当场撕掉。说白了,await 那一行的含义翻译成人话是:「先返回,结果到了用便利贴续上」——店员把单子递进后厨,不站在窗口干等,转身去接待下一位顾客;出餐铃一响,他回头用便利贴记着「3 号顾客的餐好了,端出去」。理解了这一层,async 函数里「为什么后面的代码晚执行」就再也不是玄学——它不是被推迟了,是换了个身份(微任务)排队回来。
渲染是插播,不是任务
第六个关键角色登场:渲染。第 2 章讲过屏幕的重画节奏——60Hz 的屏幕每 16.7 毫秒一次快门(§ 2.5 的帧预算),这一节补上它在循环里的位置:渲染不是队列里的一个号,而是两轮任务之间的插播。时序是:任务清完 → 微任务清完 → (如果到了该画的时间)执行 rAF 回调 → 样式计算 → 布局 → 绘制 → 合成 → 才轮到下一个任务。
requestAnimationFrame(简称 rAF)因此在动画界地位特殊:它是「快门响之前」的固定窗口——你把「挪动元素位置」的代码放进 rAF 回调,浏览器保证它在下一次绘制之前执行,帧与帧之间严丝合缝。写在别处(比如 setInterval 里)就没这个保证:你的代码和快门各跑各的,容易出现「这一帧挪了两次、下一帧一次没挪」的抖动。
这个「渲染插播」设定还解释了一个著名反模式:改了 DOM 立刻读尺寸,会强制同步布局。你在任务里改完一个元素的样式,马上又读它的 offsetHeight——浏览器为了给你一个「当下真实」的答案,被迫中断手头的活、当场把布局算一遍;循环里反复「写→读→写→读」,布局就一遍遍地被迫重算,业内戏称 layout thrashing(布局抖动)。§ 2.4 的渲染管线说过布局是最贵的工序之一,这就是它最冤的浪费方式——好比摄影师还没喊完口令你就反复要求重拍,每喊一次全场重来一遍。药方也顺口:一轮任务里「先集中写、后集中读」,读写分开打包。
主线程的同事们:其实浏览器不止一条线程
说「全店只有一位店员」,需要把账算精确一点——「单线程」说的是「能碰界面的一条」,不是整个浏览器只有一条。真实的浏览器是一个分工细密的作坊:渲染进程里有本节的主角主线程,还有合成线程和若干光栅线程;进程之外还有专门管网络的服务、和 GPU 对话的 GPU 进程、统管标签页和地址栏的浏览器进程。它们各干各的,互不排队。
这个设定解释了一个让无数人困惑的现场:JavaScript 卡了三秒,页面居然还能滚动。因为现代浏览器把「纯滚动」交给了合成线程——只要滚动的层不需要重新布局重绘(§ 2.5 讲过的合成层),合成线程自己就能把画面挪上去,根本不用等主线程点头。同理,transform 动画(只改合成层的位移)也归它管。但是——点击的处理必须回主线程。于是长任务的典型症状是一句口诀:「能滚,点不动」:滚动顺滑(合成线程在值班),点击无响应(店员还在搬箱子)。下次遇到这个组合,连 Performance 面板都不用开,病根已经锁定。好比商场里店员临时离岗,自动门和扶梯照常运转(不归他管),但收银台前的顾客只能干等——哪些活停了、哪些活照转,恰好暴露了值班表的结构。
长任务解剖:卡死的验尸报告
万事俱备,正式验尸「卡死」。行业给「占住主线程超过 50 毫秒」的任务起了名字:长任务(long task)。50ms 这个门槛不是拍脑袋——如果每个任务都不超过 50ms,任务之间的空隙就够浏览器及时插播渲染、及时叫到新号,用户体验流畅;一旦某个任务超了 50ms,三件事同时发生:新事件进不来(点击、按键在队列里干等)、渲染插不上(快门空转,画面停在上上帧)、动画掉帧(该画的帧被挤掉)。用户看到的就是:点了没反应、滚动白屏、动画卡成幻灯片。
量化这件事的官方尺子叫 INP(Interaction to Next Paint,交互到下次绘制):从用户一次交互发生,到画面实际更新出新内容,中间隔多久。它是谷歌网页体验三大指标之一(2024 年正式接替老指标 FID),及格线 200 毫秒、良好 200 毫秒以内。说白了,INP 量的就是便利店里「顾客按号」到「拿到东西」的全过程:不仅包括店员办你这单的时间,还包括他先把手头那单办完的时间——所以哪怕你的点击处理器只有 5 毫秒,如果它前面排着一个 300 毫秒的长任务,用户照样觉得卡了 305 毫秒。卡顿从来不是「你的代码慢」这么简单,是「队伍里有人慢」。
施工案例:亲手冻住一个页面
理论听得再多,不如亲手冻一次。一个按钮,点下去让主线程忙三秒:
document.querySelector('#btn').addEventListener('click', () => {
const start = Date.now();
while (Date.now() - start < 3000) { /* 空转:死等 3 秒 */ }
document.querySelector('#status').textContent = '完成';
});
点击之后的三秒里,亲眼见证「全店失联」:文字不变、别的按钮点了没反应、CSS 动画和 GIF 全部定格——连页面上那个转圈圈的 loading 动画都停了。最后一种最颠覆认知:CSS 动画不是 GPU 画的吗?部分是的,但动画的「调度」和页面的响应都离不开主线程;主线程被 while 循环焊死,快门节奏再准也没人供货。
注意那个细节:卡了三秒后「完成」两个字才出现。这说明改文字的那行代码一直在等着 while 跑完——同一张单据上,后面的行必须等前面的行。想让它先变再忙?很多人顺手把「改文字」挪到 while 前面——结果文字依然最后才显示。为什么?因为改 DOM 只是改了数据,画面更新要等渲染插播,而渲染插播得等这单任务办完。病根清楚了,药方也就呼之欲出:让这个任务「办一半、让一让」——正是下一节的内容。
解药一:切片——把三十箱拆成三十趟
第一个解药不改工作量,只改结算方式:把一个三秒的长任务切成许多个 50ms 以内的小段,每段干完就主动「让位」——setTimeout(下一步, 0) 把剩余工作排回队尾,自己先退出。于是循环在两段之间有了喘息:能叫新号、能插渲染。用户那边的体验从「冻三秒」变成「页面还活着,进度在一点点走」——配一个进度条,体验天差地别,尽管总耗时几乎没变。好比搬家:一口气搬完三小时,电梯口的人全堵死;一趟搬五箱、中间让一让,路通了,活也没耽误。
async function 搬完三十箱() {
for (let i = 0; i < 30; i++) {
搬一箱(i); // 一箱 ≈ 几十毫秒
await 让位(); // 空出主线程,事件和渲染有机会插队
}
}
function 让位() {
return new Promise(r => setTimeout(r, 0)); // 老写法;新 API scheduler.yield() 正在各浏览器铺开
}
切片的适用面:处理大列表、解析大文件、批量改 DOM——凡是「一个循环干到天荒地老」的活。它也有边界:如果单步本身就超过 50ms(比如一次就要算 500ms 的数学),切了也没用——那是第二副解药的战场。说白了,切片不是让活变少,是让活「插队有缝」——缝,就是流畅本身。
案例回访:给冻结的页面灌进空气
回到「亲手冻住一个页面」的施工案例,用切片把它救活。改造要点只有两处:把三秒的死循环改成「分段计数」的循环,每段之间插入一次让位:
btn.addEventListener('click', async () => {
for (let done = 0; done < 3000; done += 50) {
忙五十毫秒(); // 干一小段
progress.textContent = Math.round(done / 30) + '%'; // 汇报进度
await 让位(); // 空出主线程:渲染插播、事件进场
}
status.textContent = '完成';
});
再点一次按钮,体感完全换了一个世界:进度条的数字一格一格往上走(每段任务结束后,渲染插播终于有机会把这行字画上屏幕)、页面其他地方照常可点(队列的两段之间有缝,新号叫得进来)、CSS 动画继续转(快门有人供货了)。最耐人寻味的是那个对比:总耗时几乎没变,还是三秒左右——变的是「这三秒里世界是否还活着」。
这就是流畅的本质,也是本节最想留下的一句感悟:流畅不是「快」,是「随时有回应的机会」。用户能容忍一个进度条走三秒,不能容忍一个假死的界面三秒——前者是等待,后者是失联。好比外卖平台:骑手晚十分钟到没关系,地图上那个小箭头一直在动就行——真正让人恐慌的从来不是慢,是「不知道还活着吗」。
解药二:Web Worker——隔壁开一家分店
第二副解药更釜底抽薪:再开一条线程。Web Worker 让你在主线程之外雇一个帮工,把纯计算的脏活累活整个外包:
const worker = new Worker('crunch.js'); // 雇个帮工,脚本独立
worker.postMessage(大数组); // 把原料递过去(复印件)
worker.onmessage = (e) => { // 帮工干完喊你
render(e.data); // 主线程拿到结果,负责上界面
};
帮工有两条铁律。铁律一:不许碰店面——Worker 里没有 document、没有 window、碰不了任何 DOM。理由回到本节开头:能让改界面的手只有一双。帮工算得再欢,想把结果「摆上货架」,必须把数据传回主线程,由店员亲手上架。铁律二:传过去的是复印件——postMessage 的数据经过「结构化克隆」,是一份深拷贝:主线程改了原件,帮工手里的副本不会跟着变,反之亦然。传超大对象时这份复印成本本身就不便宜,量着传。
好比中央厨房和门店的关系:中央厨房(Worker)负责洗菜切配一切重加工,门店(主线程)只做最后的装盘出品——分工的前提恰恰是「只有门店能见客人」。简单说,判断该用哪副解药只需一问:这活要碰界面吗?要碰(改 DOM、读尺寸)只能切片;不碰(算数、解析、压缩、图像处理)尽管丢给 Worker,主线程乐得清闲。
setTimeout(fn, 0) 的真相:零毫秒不等于立刻
切片代码里那个 setTimeout(下一步, 0) 值得单独审问。第二个参数写 0,很多人的直觉是「立刻执行」——错。这个 0 的真实含义是「最早也得从下一轮开始」:定时器到点后干的事,是把回调作为一个新任务放进队列,然后就没有然后了——要等当前任务跑完、微任务清完、可能还有一次渲染插播,队头才轮得到它。说白了,setTimeout 是「取号」,不是「插队」——号叫到之前,谁也不知道前面排着什么。
还有一层更细的钳制:规范规定嵌套的定时器(回调里再设定时器、连环套)最小间隔被钳到 4 毫秒——防的是有人拿 setTimeout(fn, 0) 写死循环空转,把队列塞爆。另外实测里 setTimeout 的实际延迟常常是几个毫秒到十几毫秒,从来没有真正的 0。这一小节的全部结论可以浓缩成一句:定时器给的是「下限承诺」,不是「准点承诺」——它保证至少等这么久,从不保证刚好这么久。
后台标签页的节能模式:店员放慢巡场
定时器还有一个暗坑:你的网页被切到后台(用户切到别的标签页)之后,浏览器会大幅节流——后台标签页的定时器通常被压到每秒最多一次,连续套娃的定时器链条甚至被压到每分钟一次,rAF 则干脆停发(画面反正看不见,画了也白画)。这是浏览器替用户省电的善意,但对「靠数 tick 计时」的代码是灭顶之灾:一个每 100 毫秒加一的计时器,切后台十分钟回来,你以为它加了 6000,实际可能只加了 600——倒计时、轮询、动画时间轴全部失真。
药方是把「数拍子」改成「看表」:不累积 tick 次数,记录时间戳、算时间差。每次回调里用 Date.now() 或 performance.now() 减去开始时刻得出真实经过的时间——无论中间被节流了多少,回来一算账,一秒不差。好比打烊后的便利店:店员不必每分钟巡一次场(省电),但墙上的钟照走——早上开店对表,该几点就是几点,巡场次数少了,时间却不会骗人。判断口诀:tick 数次数的都会被节流坑,时间戳算差值的都不怕。
事件处理器的军规:快进快出
把视角收回到你自己写的代码。点击处理器、键盘监听器、滚动回调——它们每一个都是一个「任务」,你的每一行都在占用全店唯一的店员。于是本节立下军规:处理器快进快出,重活外包让路。具体到数字:单个处理器争取控制在 50 毫秒内办完(长任务门槛),滚动和输入类回调更要狠——它们触发频率极高,一次 20 毫秒的浪费乘上每秒几十次的触发,就是灾难。
执行层面三条纪律。其一,只做「记账 + 调度」:处理器里改状态、发指令(「开始加载!」「标记选中!」),重活排给切片或 Worker——处理器是前台接待,不是后厨。其二,高频事件必须节流防抖——scroll / resize / mousemove 这类每秒几十上百次的事件,直接裸写处理器等于让店员一直被同一件事打断;防抖(等一阵子没动静再办)和节流(固定间隔最多办一次)这对工具在 § 1.5 已经讲透,此处只重申一句:它们的本质就是给任务队列减号。其三,别在处理器里同步等网络、等文件——同步等待等于店员站在原地数秒,这是「卡死案例」的又一种穿法。打个比方,前台接待的原则是「接单、传菜、转身」——亲自下厨的接待,再勤快也是灾难。
requestIdleCallback:打烊前的擦桌子时间
预算表上还有最后一格:两帧之间的空闲时间。忙完一帧的任务、微任务和渲染,离下一次快门若还剩几毫秒,这几毫秒不能浪费——requestIdleCallback(简称 rIC)就是浏览器递来的抹布:「现在没客人,这几毫秒你擦擦桌子」。它的回调会收到一份截止时间表(deadline),上面写着「这一轮空闲还剩几毫秒」——活干到一半时间到了,就该收拾摊子、把没干完的排回 rIC,把主线程还回去。
适合丢给空闲时间的活有共同气质:不着急、可中断、丢了也不心疼——埋点上报、日志整理、预取下一页数据、缓存预热。它们不配占用「营业时间」(任务),却又不该永远不干。而需要精确对齐画面的事(动画)用 rAF,需要尽快的事用普通任务,三档时间档期正好凑齐:rAF 对齐快门、任务尽快办、rIC 填缝隙。一个兼容性提醒:rIC 至今没进 Safari,用之前探测一下,兜底方案就是 setTimeout——闲活嘛,晚一点无妨。好比便利店打烊前的那半小时:没什么客人了,店员擦货架、盘库存、给关东煮换汤——但门铃一响,抹布立刻放下,客人永远比杂活重要。
原生那边:同一家族的三种方言
「单线程 UI + 消息队列」不是 Web 专利,去原生世界认认亲。Android:每个线程可以有一个 Looper(循环器)管家,App 启动时主线程的 Looper 就开始转,Handler 负责把消息投进它的 MessageQueue——你在子线程里 runOnUiThread 或 handler.post(...),本质就是「往主线程的叫号机里塞一张号」。主线程被占住超过约 5 秒,系统直接弹出大名鼎鼎的 ANR(Application Not Responding,应用无响应)对话框——相当于商场管理处冲进店里:「店员失联五分钟,顾客可以报警了」。iOS:主线程的 RunLoop 是同一个角色的苹果方言,GCD 的 dispatch_get_main_queue() 把任务派回主队列,机制不同、语义一致。桌面老家:Windows 的 GetMessage / DispatchMessage 消息循环是这套思想的祖师爷(1985 年就转起来了),Java Swing 的事件分发线程 EDT 是它的 Java 翻译。
说白了,五家平台连词汇表都能对上:浏览器的主线程 ≈ Android 主线程 ≈ iOS main ≈ Windows 消息循环 ≈ Swing EDT;任务队列 ≈ MessageQueue ≈ main queue ≈ 消息泵;setTimeout ≈ Handler.postDelayed ≈ dispatch_after。学一次事件循环,等于拿到了全平台 GUI 的通行证——这不是巧合,是同一道工程题(一棵树只能一双手改)在全世界的标准答案。
测量:谁卡了,卡在哪一步
最后一段是侦查纪律:卡顿面前,先量再猜,永远别倒过来。工具三件套。第一件,DevTools Performance 面板:录制一段卡顿现场,时间轴上每个任务一条横杠,超过 50ms 的长任务顶着一个红色小三角,一眼锁定元凶;点开横杠,调用栈瀑布把「这三百毫秒到底花在哪几层函数里」列得明明白白。第二件,Long Tasks API:代码里挂一个 new PerformanceObserver 监听 longtask,线上环境的每个长任务都会带着时长和发生时刻自动上报——把「用户说卡」变成「周三下午三点有 47 个 300ms 长任务」。第三件,console.time / performance.now():给可疑代码段掐表,怀疑谁就给谁戴上秒表。
就像医院查病:先做心电图(Performance 时间轴)看哪里心律不齐,再抽血化验(Long Tasks 上报)锁定指标,最后针对性拍片(掐表)确认病灶——拍脑袋诊断的医生,治不好任何病人。
排错手册:卡死三案
- 案情一:点按钮后全页冻结几秒,然后一口气恢复。同步长任务作祟。Performance 面板找红色长任务,确认元凶函数:能切片的切片,纯计算的丢 Worker。
- 案情二:动画整体还算流畅,但偶尔抽风掉几帧。间歇性长任务挤掉了渲染帧。用 Long Tasks API 收集线上数据,常见嫌犯:超大的同步循环、频繁触发的滚动处理器没节流、布局抖动(读写 DOM 交替)。
- 案情三:后台切一会儿回来,计时/轮询全乱了。后台节流。检查代码里有没有「数 tick」逻辑,全部换成时间戳算差值。
- 附案:报错 Maximum call stack size exceeded。栈溢出,不是卡死。找没有终止条件的递归。
全店只有一位店员(主线程)——这不是抠门,是规矩:货架(界面)只能让一双手整理,多一双手就多一分画花的风险。店员一整晚只重复六步:叫号(取任务)→ 办单(执行到底,绝不半途换单)→ 撕便利贴(清微任务,含新贴的)→ 赶快门理货(渲染插播)→ 叫下一个号。
号从哪来?叫号机(任务队列):顾客上门(点击)、闹钟到点(定时器)、快递送达(网络回调),全在门外排队——再急也只能取号,没有后门。
便利贴为什么必须当场撕完?因为它是「这一单的收尾」——零钱不点清就叫下一位,账会串门。Promise 的 then 就是便利贴,setTimeout 的回调就是下一个号,所以那道经典题的答案永远是「同步 → 微任务 → 宏任务」。
卡死的定义也全在这家店里:店员搬三十箱饮料(长任务>50ms),门外的号没人叫、快门响了没人理、连转圈的招牌都停了。解药两副——把三十箱拆成三十趟搬(切片,让队伍有缝),或者在隔壁开家分店专职备货(Web Worker,不许碰店面,传货只传复印件)。
别忘了这家店还有店规细则:零毫秒的号也得排队(setTimeout 的下限承诺)、打烊后店员放慢巡场但钟照走(后台节流,计时要看表别数拍子)、接待的原则是接单传菜转身(处理器快进快出)。
最后你会发现:Android 的 Looper、iOS 的 RunLoop、Windows 的消息泵,全城每一家「店」都是这套排班——一条主线程、一台叫号机、一沓便利贴、一扇快门。学会了这一节的夜班,你就懂了所有界面的心跳。
写交互代码前过一遍:
① 想到「界面」就想到「只有一位店员」——同步代码每一行都在占用他;
② 事件处理器单次执行压在 50ms 内了吗?重活切片或 Worker 了吗?
③ 高频事件(scroll / resize / mousemove)加防抖节流了吗?
④ 一轮任务里 DOM 的读和写分开打包了吗(防布局抖动)?
⑤ 动画的位置更新放进 rAF 了吗?
⑥ 计时逻辑用的是时间戳差值,还是裸数 tick(后台节流会坑后者)?
⑦ Worker 里有没有偷摸碰 DOM 的代码(那是编译都过不了的幻觉,检查设计)?
⑧ 上线前用 Performance 面板录一段真实操作,红色长任务清零了吗?
事件循环一句话:全世界的 GUI 都是一家只有一位店员的店——所有跟界面有关的活排着队等他,他六步一轮永不停转:取任务、办到底、清微任务、按需渲染、再取号。单线程不是缺陷,是「一双手改一棵树」的工程纪律;任务不可中断(运行至完成)换来代码的完整性,也埋下卡死的种子;微任务是本单收尾、宏任务是下一个号,顺序铁打不动(同步 → 微任务 → 宏任务);渲染是两轮之间的插播,rAF 是快门前的固定窗口;占住主线程超过 50ms 就是长任务,INP 量的是「从交谈到应答」的全程;两副解药——切片让队伍有缝、Worker 让重活出家门(不碰 DOM、只传复印件);定时器只有下限承诺,后台节流逼你「看表别数拍子」;处理器军规快进快出;Android 的 Looper、iOS 的 RunLoop、Windows 的消息泵,全是这家店的连锁分号。下一节钻进那张最常用的「号」——点击本身:从你按下鼠标到处理器收到通知,消息在元素的祖孙三代间怎么层层传递——冒泡与捕获。