返回 HackerTubeYouTube2026年11月13日视频时长:26:52

事件循环:浏览器如何为回调腾出执行时机

What the heck is the event loop anyway? | Philip Roberts | JSConf EU

JJSConf

完整字幕

中文
查看原视频
本篇目录

JavaScript 引擎只是浏览器的一部分

查看原文 02:43

JavaScript 执行一段很长的计算时,定时器仍可倒计时,但定时器回调不会打断这段计算。这两件事怎么能同时成立?在 2014 年的 JSConf EU 演讲中,Philip Roberts 用一幅简化图景回答:JavaScript 在调用栈上运行,浏览器则提供能在栈外处理等待的 API;队列和事件循环再让符合条件的回调稍后回到 JavaScript 中执行。从 02:43 开始观看。

Roberts 做这场分享的缘由是,他发现自己虽以 JavaScript 开发为职业,却可以在不了解回调原理的情况下使用它。在他的简化图中,V8 有用于分配内存的堆和记录执行过程的调用栈。DOM、定时器和 XMLHttpRequest(XHR,用于网络请求的 API)等浏览器功能属于外围环境。他也提到,Node 有不同的宿主环境,可以类比为一组原生 API。

这是一套 2014 年的教学模型,并非当今所有浏览器的完整规范。单一队列、线程描述和渲染优先级都简化了更复杂的系统;模型也省略了其他调度机制和工作线程。这些图适合帮助理解示例,不能用来预测每一种执行顺序或画面更新。

调用栈记录同步函数的嵌套关系

查看原文 04:32

栈帧代表一次尚未结束的函数调用。进入函数时把帧压到栈顶,返回时再从栈顶弹出。Roberts 的例子从文件的 main 开始,printSquare(4) 调用 square,后者再调用 multiply(4, 4)。乘法结果返回给 square,再返回给 printSquare,后者输出结果并结束。最后进入的调用最先返回。从 04:32 开始观看。

查看原图

出错时,栈追踪会展示这种嵌套关系:Roberts 展示了 baz 调用 bar,bar 调用 foo,而错误发生在 foo 中。没有终止条件的递归会不断添加栈帧,直至达到栈容量上限。他在 Chrome 中的演示约在 16,000 次调用后停止;这是那次运行的观察值,并非通用上限。

查看原图

耗时的同步工作会让页面看起来冻结

查看原文 07:21

阻塞指占用执行线程的工作妨碍该线程上的其他工作继续。Roberts 用巨大的循环、图像处理和假想的三个同步网络请求说明慢操作:每个请求都必须完成,才能开始下一个;其中一个甚至可能永远不结束。Chrome 演示用长时间运行的同步 while 循环模拟等待,并非真的依靠慢网络响应。从 07:21 开始观看。

查看原图

循环运行期间,连 Run 按钮被点击后的状态都没有及时显示。等循环结束后,输入与画面更新才像是一起赶了上来。实际问题在于页面执行线程被占住:要让界面保持响应,就需要留出处理其他工作和更新画面的机会。

浏览器 API 等待时,调用栈可以继续执行

查看原文 10:26

回调是交给其他函数调用的函数,它可能在稍后运行。在定时器例子里,程序先输出 Hi,然后调用 setTimeout,传入回调和五秒延迟,接着输出 JSConfEU。浏览器负责计时;setTimeout 调用本身可以返回,因此当前代码的其余部分能继续执行。从 12:51 开始观看。

查看原图

在演讲模型中,定时器到期后,回调进入回调队列,即等待运行的工作排队处。事件循环查看队列和栈;栈清空后,它取出队首回调并放到栈上执行,随后输出 there。定时器不会在当前函数执行到一半时插入 JavaScript 代码。

查看原图

零延迟会推迟回调,并不保证执行期限

查看原文 15:03

把五秒改成 setTimeout(..., 0),输出顺序仍是 Hi、JSConfEU、there:当前脚本结束后,排队的回调才运行。零表示请求稍后运行,并不允许它打断当前调用栈。Roberts 的图将定时器画成立即就绪,这是为了便于解释,并不承诺调度会立即发生。从 15:03 开始观看。

XHR 例子遵循同一套教学顺序:发起请求并提供回调,继续执行其他代码;如果结果到达,再在之后处理。请求可能永远不完成,但当前调用栈不必为它等待。

查看原图

实时可视化展示回调如何等待执行

查看原文 17:23

动画幻灯片难以维护后,Roberts 制作了 Loop 来可视化执行过程。演示工具包装了 DOM 事件监听器和 setTimeout;当时尚未为 XHR 编写模拟包装。点击会让处理函数排队,连续多次点击可能使处理函数在执行前等待。这是教学用的可视化,不是浏览器所有内部机制的逐项实录。从 18:12 开始观看。

另一个演示设置了四个延迟均为一秒的定时器。由于前面的工作仍在处理,第四个回调在计时结束后还在等待。指定延迟是获得执行资格之前的最短等待时间,而非保证执行的时刻。

查看原图

回调并不一定是异步的

查看原文 20:25

回调一词描述的是被另一个函数调用的函数,本身并不说明何时调用。数组的 forEach 在当前调用栈中同步调用回调。Roberts 将它与自己写的版本对照:后者为数组每一项安排一个零延迟定时器。他指出,演示代码还应当把当前项的值传进去。从 20:25 开始观看。

查看原图

同步循环会一直占据调用栈,直到所有遍历结束。另一种写法先将回调排队、清空栈,再在稍后逐个执行。console.log 很快,因此收益不明显;如果每项处理都很慢,拆分工作就可能为浏览器在各部分之间处理其他事务创造机会。安排任务本身也需要时间,而单个回调太长仍会造成阻塞。

在工作片段之间让出执行机会,有助于渲染

查看原文 22:05

为了看清差别,Roberts 加入一个模拟渲染队列,对比缓慢的同步处理和通过定时器安排的处理。2014 年的示意以每秒 60 帧、每帧约 16.6 毫秒作为目标。在模拟中,待处理的渲染等待调用栈清空,并获得比回调更高的优先级。这是教学近似,而非通用刷新率或精确的浏览器渲染算法。从 22:05 开始观看。

查看原图

缓慢的同步循环阻塞了模拟渲染,正如前面的忙循环让页面看起来冻结。注册大量定时器时也会占用调用栈,但在演示中这一阶段相对较快;在单个回调之间,渲染器有机会更新。这并不保证每处理一项就一定绘制一次。“不要阻塞事件循环”的要点是:界面需要响应时,不要让慢工作长期独占执行线程。

频繁事件可能让待处理工作不断积压

查看原文 24:42

用户滚动时,执行昂贵操作的滚动处理函数可能被反复触发。即使每次调用最终都能结束,新工作也可能比处理速度更快地积累。Roberts 猜测滚动事件每帧触发一次;他说的是猜测,并非经过测量的规则。从 24:42 开始观看。

他建议减少昂贵工作的执行次数:等用户停止滚动一段时间后再执行(防抖),或限制为按一定间隔执行。这是两种不同的时间安排,目的都是避免不必要的重复工作。

可视化工具本身会故意放慢执行:Roberts 说,它用 Esprima 解析代码,在每条语句前后插入持续半秒的忙循环,再将插桩后的代码送到网页工作线程并展示执行过程。因此画面中的慢速是演示手段,不能当作普通应用速度的测量值。