[译] The Browser's Main Thread Is Expensive

原文:https://kciter.so/posts/the-expensive-main-thread/en/

What comes to mind when you hear “frontend optimization”? For most of us it’s things like reducing network requests, shrinking the bundle, or making good use of the cache. Beyond that, maybe cutting down on re-renders or tuning when resources get loaded. The main thread doesn’t usually come up, and there’s a reason for that: on most screens it never becomes a problem. But on screens with a lot of interaction, where data streams in live and scrolling, animation, and input all get tangled together, the picture changes. However much you save on network and bundle size, the screen freezes the moment the main thread gets blocked.

听到“前端优化”,你会想到什么?对大多数人来说,无非是减少网络请求、缩小打包体积,或充分利用缓存。再进一步,也许是减少重复渲染,或调整资源加载时机。我们通常不会想到主线程,这是有原因的:在大多数页面上,它根本不会成为问题。但在交互密集的页面上,实时流入的数据与滚动、动画和输入全都纠缠在一起,情况就变了。无论你在网络和打包体积上省下多少,只要主线程一阻塞,页面就会冻结。

You’ve probably come across a website where scrolling stutters now and then, a button responds slightly late, or the letters you type into a search box show up half a beat behind. It isn’t bad enough to be annoying, but it gets on your nerves in a subtle way. That kind of jank is what a blocked main thread looks like.

你大概遇到过这样的网站:滚动时偶尔卡顿,按钮响应稍有延迟,或是在搜索框里输入文字后,字符总要慢半拍才出现。问题还没严重到让人恼火,却会不知不觉地让人心烦。这种不流畅,就是主线程受阻时的样子。

When we run into jank like this as developers, the usual reaction is to wonder “is my code slow?” and start picking apart algorithms or looking for wasted computation. In most cases, though, the speed of the code is not the problem. The code isn’t slow. It just happens to be the code that’s holding the main thread.

作为开发者,遇到这种卡顿时,我们通常会先怀疑“是不是我的代码太慢了?”,接着开始逐一排查算法,或寻找无谓的计算。但大多数时候,问题并不在代码的执行速度。代码并不慢,只是恰好由它占住了主线程。

The browser has a number of threads, but almost everything we can touch from code is concentrated on the main thread. Computation, rendering, event handling, network response handling, and your framework’s internals are all processed there. One resource, a mountain of work.

浏览器拥有多个线程,但几乎所有能通过代码触及的工作都集中在主线程上。计算、渲染、事件处理、网络响应处理,以及框架的内部逻辑,全都在那里执行。资源只有一份,工作却堆积如山。

The browser’s main thread is expensive. Most of the time it doesn’t cause trouble, but once you try to do something ambitious, dealing with the main thread becomes the important part. This article is about how to handle that expensive resource.

浏览器的主线程是一种昂贵的资源。大多数时候它不会带来麻烦,但一旦你想实现更复杂、更有野心的功能,如何应对主线程就成了关键。本文要讨论的,正是如何管理这项昂贵的资源。

What Does the Main Thread Do?

主线程负责什么?

Let’s start with what the main thread actually does. Its work falls into two broad categories.

我们先从主线程实际负责的工作说起。大体上,这些工作可分为两类。

The first is running JavaScript. The code we write, along with event handlers, timers, network response callbacks, and the framework’s internals, all run here. These tasks execute in the order they enter the queue, whenever there is a gap, with no relation to the screen refresh cycle.

第一类是运行 JavaScript。我们编写的代码,以及事件处理器、定时器、网络响应回调和框架内部逻辑,都在这里运行。这些任务按照进入队列的顺序,一有空档便依次执行,与屏幕刷新周期无关。

The second is drawing the screen. When the DOM or styles change and the screen needs updating, the browser goes through roughly these steps, in order, to produce a frame.

第二类是绘制屏幕。当 DOM 或样式发生变化、屏幕需要更新时,浏览器大致会按以下顺序生成一帧画面。

  • Run requestAnimationFrame callbacks - JavaScript registered to run just before the frame is drawn
  • 执行 requestAnimationFrame 回调——运行那些注册为在绘制一帧之前执行的 JavaScript
  • Style calculation - compute the final CSS values for each element
  • 样式计算——计算每个元素最终采用的 CSS 值
  • Layout - compute each element’s position and size (also called reflow)
  • 布局——计算每个元素的位置和尺寸(也称为重排)
  • Paint - generate paint commands describing what to draw in which colors
  • 绘制——生成绘制指令,描述要绘制什么以及使用什么颜色

If nothing changed, these steps are skipped entirely, so they don’t necessarily run every frame. Only the final compositing step, which takes the produced output and assembles it on screen, is handed off to the compositor thread [1]. In other words, most of the front half of the pipeline that draws the screen is the main thread’s responsibility.

如果没有发生任何变化,这些步骤会被全部跳过,因此并非每一帧都会执行。只有最后的合成步骤会交给合成线程 [1:1]:它负责接收前面生成的结果,并将其组合到屏幕上。换句话说,屏幕绘制流水线前半段的大部分工作,都由主线程承担。

image

For the screen to look smooth, frames have to be drawn at the display’s refresh rate. On the most common 60Hz display, that means 60 frames per second, or about 16.6 milliseconds per frame. And you don’t get to use all of it. Once the browser’s own processing cost is subtracted, the practical budget is usually considered to be around 10 milliseconds [2], and on a 120Hz device the budget itself is cut in half.

要让画面看起来流畅,就必须按照显示器的刷新率绘制帧。在最常见的 60Hz 显示器上,这意味着每秒绘制 60 帧,即每帧约 16.6 毫秒。而且这段时间并不能全部由你支配。扣除浏览器自身的处理开销后,实际可用的帧预算通常被认为只有大约 10 毫秒 [2:1];在 120Hz 设备上,这份预算还会减半。

The problem is that the two kinds of work above stand in a single line on the same thread. JavaScript was designed around a single-threaded event loop model. The main thread processes one task at a time, and while that task is running, nothing else can happen. If one JavaScript function runs for 200 milliseconds, then for those 200 milliseconds the browser can’t repaint the screen or receive a click from the user. Against a frame budget of around 10 milliseconds, that is a fatal amount of time. A task that runs this long and holds the main thread is called a long task, and anything over 50 milliseconds is generally considered a problem.

问题在于,上述两类工作都在同一个线程里排成一条队。JavaScript 采用的是单线程事件循环模型。主线程一次只能处理一个任务,而且只要这个任务还在运行,其他事情就都无法进行。如果某个 JavaScript 函数运行了 200 毫秒,那么在这 200 毫秒里,浏览器既无法重新绘制屏幕,也无法接收用户的点击。对仅有约 10 毫秒的帧预算来说,这是致命的超时。像这样长时间运行并占据主线程的任务称为长任务,通常只要超过 50 毫秒,就会被视为问题。

Words only go so far, so let’s feel it. In the demo below, pressing the button makes JavaScript grab the main thread for a moment.

光说不练很难体会,不妨亲自感受一下。在下面的演示中,按下按钮会让 JavaScript 短暂占住主线程。

在原文中查看交互动画

When you press the button, the JS animation stops and typing into the input field does nothing. The CSS animation, on the other hand, keeps running. We’ll come back to where that difference comes from later. What to remember for now is that holding the main thread for a long time is the same thing as freezing the screen.

按下按钮后,JS 动画会停止,在输入框中键入内容也不会有任何反应;而 CSS 动画却会继续运行。稍后我们会再解释这种差异从何而来。现在只需记住:长时间占用主线程,就等同于冻结屏幕。

This connects directly to web performance metrics. INP (Interaction to Next Paint), which measures how long it takes for the screen to respond after the user does something, and TBT (Total Blocking Time), which measures the total time the main thread was blocked during page load, are both essentially ways of expressing how long the main thread was blocked. A large part of performance optimization is a matter of how carefully you spend this one thread.

这与 Web 性能指标直接相关。INP(Interaction to Next Paint,交互到下一次绘制)衡量用户操作后,屏幕需要多长时间才能作出响应;TBT(Total Blocking Time,总阻塞时间)衡量页面加载期间主线程被阻塞的总时长。两者本质上都是对主线程阻塞时长的不同表达。性能优化在很大程度上,就是要精打细算地使用这一个线程。

The ways of spending it carefully fall into two broad families. One is to divide the main thread’s time well from within. The other is to send the work outside the main thread altogether. Let’s take them in order.

精打细算的方法大致分为两类:一类是从内部合理划分主线程的时间,另一类则是干脆把工作移到主线程之外。下面依次来看。

Using the Expensive Resource Wisely

明智地使用昂贵的资源

The first family is about staying on the main thread but spending its time intelligently. There are four core moves.

第一类方法仍然在主线程上工作,但要聪明地分配它的时间。其中有四种核心手段。

  • How do you split up work that runs too long?
  • 如何拆分运行时间过长的工作?
  • How do you group work that runs too often?
  • 如何合并执行得过于频繁的工作?
  • Among several tasks, which goes first?
  • 多个任务之间,哪个应该先执行?
  • How do you postpone work that doesn’t need to happen now?
  • 如何推迟那些不必立即完成的工作?

We’ll call these splitting, batching, prioritizing, and deferring. The first two shape the size of tasks, and the last two decide their timing. Of the four, splitting is the foundation for the rest. Tasks need boundaries before you can decide what to slot in between them and what to push back. So we start with splitting.

我们把这四种手段称为拆分、批处理、优先级排序和延后执行。前两种塑造任务的大小,后两种决定任务的时机。在这四者中,拆分是其余方法的基础。只有先划定任务边界,才能决定在任务之间插入什么、又该把什么往后推。因此,我们先从拆分说起。

Splitting

拆分

Picture the chat pane of a live stream. On a popular stream, chat can burst to hundreds of messages per second. In that environment, messages don’t arrive politely one at a time. When traffic spikes, the server sends them in clumps of dozens, and the moment you enter a room, hundreds of backlogged messages come down at once. What happens if you render that whole clump in one go right when it arrives? Every message you draw brings DOM creation, style calculation, layout, and paint along with it, and those hundreds of iterations run back to back inside a single task. Meanwhile, the user trying to type their own message gets a stuttering input field, and every other animation on screen hitches too. Other people’s chat is monopolizing the main thread and getting in the way of yours.

想象一下直播间的聊天面板。热门直播间的聊天消息可能会瞬间飙升到每秒数百条。在这种环境下,消息不会规规矩矩地一条一条到达。流量激增时,服务器会一次发来几十条;而你刚进入房间时,更会有数百条积压消息同时涌来。如果在这批消息到达时一口气全部渲染,会发生什么?每绘制一条消息,都会伴随 DOM 创建、样式计算、布局和绘制;数百次这样的迭代会在同一个任务中连续执行。与此同时,正想输入消息的用户会发现输入框断断续续,屏幕上的其他动画也都会卡顿。别人的聊天消息独占了主线程,妨碍了你发送自己的消息。

The fix is what we said above. Cut the clump into small pieces, and between the pieces, hand control of the main thread back for a moment. In those gaps the browser can catch up on the screen updates and input handling it had queued.

解决方法就是前面提到的:把整批工作切成小块,并在各块之间短暂交还主线程的控制权。在这些间隙里,浏览器可以赶上已经排队的屏幕更新和输入处理。

The demo below simulates a streaming chat pane. Press “Flood the chat” and messages start pouring in. Try typing in the input field while watching the smoothness gauge and fps at the top, and compare the “Immediate render” and “Yielding render” modes.

下面的演示模拟了一个直播聊天面板。按下“Flood the chat”,消息就会开始大量涌入。试着在输入框中打字,同时观察顶部的流畅度仪表和 fps,并比较“Immediate render”和“Yielding render”两种模式。

在原文中查看交互动画

In “Immediate render” mode, the DOM is touched as each message arrives, so while chat is flooding in, fps drops sharply, the gauge stutters, and the input field lags. If you look closely, the chat messages themselves start appearing noticeably more slowly as well, because the callback that receives and processes them is also a task waiting in the main thread’s line, so it gets delayed with everything else. Now switch to “Yielding render”. Messages are still drawn one at a time, just as before, yet input comes back to life and the screen moves again. The only thing that changed is that after every 20 messages, the main thread is released for a moment.

在“Immediate render”模式下,每条消息到达时都会操作 DOM。因此,当聊天消息大量涌入时,fps 会急剧下降,仪表出现卡顿,输入框也会延迟。仔细观察还会发现,聊天消息本身的出现速度也明显变慢了,因为接收并处理消息的回调同样是一个等待主线程执行的任务,所以它会和其他任务一起延迟。现在切换到“Yielding render”。消息仍像之前一样逐条绘制,但输入恢复了响应,画面也重新动了起来。唯一的变化是:每处理 20 条消息,就短暂让出一次主线程。

One thing not to misread here is that yielding does not make the work faster. The total amount of work is unchanged, and the few milliseconds spent waiting at each yield are added overhead, so in wall-clock terms it actually takes longer. So why did rendering recover along with input?

这里有一点不能误解:让出主线程并不会让工作完成得更快。 工作总量并没有变化,而且每次让出时等待的几毫秒还会形成额外开销,因此按实际经过时间计算,完成工作反而需要更久。那么,为什么渲染会和输入一起恢复呢?

As we saw earlier, the main thread can do nothing while a task is running. The rendering pipeline that produces frames can’t cut into the middle of a task either. It can only run between tasks. Yielding is the act of creating those gaps. The backlogged input and frame production get their turn in the gaps, and to the user it feels as though performance improved.

正如前面看到的,只要一个任务仍在运行,主线程就无法做其他事情。负责生成帧的渲染流水线也不能插进任务中途,只能在任务之间运行。让出主线程,就是主动制造这些间隙。积压的输入处理和帧生成会在间隙中获得执行机会,于是用户便会感觉性能有所提升。

At the code level, the classic way to yield is setTimeout, which pushes the continuation into the next task. Take a look at the following code.

从代码层面看,经典的让出方式是使用 setTimeout,它会把后续执行推入下一个任务。来看下面这段代码。

// A batch of chat messages arrives at once / 一批聊天消息同时到达
socket.on('messages', (chats) => {
  renderChats(chats);
});

// Draw the messages, yielding the main thread after every 20 / 绘制消息,每处理 20 条就让出主线程
async function renderChats(chats) {
  let count = 0;
  for (const chat of chats) {
    appendChatNode(chat); // draw one message / 绘制一条消息

    if (++count % 20 === 0) {
      await new Promise((resolve) => setTimeout(resolve, 0)); // yield here / 在此让出主线程
    }
  }
}

With this in place, no matter how hard chat floods in, the DOM work never occupies the main thread in one piece, and between the pieces there is room for the user’s input and animations to be processed.

采用这种方式后,无论聊天消息涌入得多猛烈,DOM 工作都不会整块占用主线程;各块工作之间会留出空间,让用户输入和动画得以处理。

The star of this code is setTimeout. When it schedules the resumption of the remaining work as a new task, the current task ends right there, and in that gap the backlogged input and rendering get processed. await pauses the function until the scheduled task comes back around, then picks up where it left off.

这段代码的主角是 setTimeout。当它把剩余工作的继续执行安排为一个新任务时,当前任务就会在此结束,而积压的输入和渲染会在这个间隙中得到处理。await 会暂停函数,等轮到预定的任务执行时,再从中断的位置继续。

How yielding changes the timeline

让出主线程如何改变时间线

The example above split the incoming work by count. But if an animation is already running, or the user is in the middle of scrolling, splitting by time is safer than splitting by count. An animation uses a little of the main thread every frame, so a heavy job has to keep checking the clock and cut itself off before it swallows what’s left of the frame’s budget.

上面的示例按数量拆分传入的工作。但如果动画已经在运行,或者用户正在滚动页面,那么按时间拆分会比按数量拆分更稳妥。动画每一帧都会占用一小段主线程时间,因此繁重任务必须不断查看时间,并在吞掉帧预算的剩余部分之前主动停下来。

async function processDuringAnimation(items) {
  let i = 0;
  let frameStart = performance.now();
  while (i < items.length) {
    // Work only until 5ms have passed since the frame started / 只工作到本帧开始后的 5ms 为止
    while (i < items.length && performance.now() - frameStart < 5) {
      doWork(items[i++]);
    }
    frameStart = await new Promise(requestAnimationFrame); // resume with the next frame's start time / 在下一帧开始时恢复,并取得该帧的开始时间
  }
}

Here performance.now() acts as the stopwatch that checks whether we’ve gone over budget, and requestAnimationFrame acts as the alarm that says “wake me just before the next frame is drawn.” This is also why we yield with rAF rather than setTimeout when splitting by time. The resumption lands in step with the frame cycle.

这里,performance.now() 就像秒表,用来检查是否超出预算;requestAnimationFrame 则像一个闹钟,告诉浏览器“在绘制下一帧之前叫醒我”。这也是按时间拆分工作时使用 rAF 而不是 setTimeout 来让出执行权的原因:恢复执行的时机会与帧周期保持同步。

Note that rAF passes the frame’s start timestamp to its callback, and the code above uses that as the reference point for the budget. The reason is that the function doesn’t have the frame to itself. If other animation callbacks ran earlier in the same frame, our share has to shrink by however much time they used, or the frame budget is broken. Anchoring to the frame’s start time turns “use 5ms” into “use until 5ms after the frame started,” which makes the code cooperate naturally when several animations share one frame.

请注意,rAF 会把该帧的开始时间戳传给回调,而上面的代码将它作为预算的基准点。原因在于,这个函数并不能独占一帧。如果同一帧中已有其他动画回调先运行,那么它们用了多少时间,我们可用的份额就必须相应减少,否则就会突破帧预算。以一帧的开始时间为基准,会把“使用 5ms”变成“执行到该帧开始后的第 5ms 为止”。这样,当多个动画共享同一帧时,代码便能自然协作。

Why 5 milliseconds? There’s nothing special about the number. We said the practical budget is around 10 milliseconds, so handing roughly half to background work and leaving the rest for animation callbacks, style, layout, and paint is a reasonable heuristic. If your animations are heavy, shrink it.

为什么是 5 毫秒?这个数字并没有什么特殊之处。前面说过,实际可用的预算约为 10 毫秒,因此把大约一半交给后台工作,剩余部分留给动画回调、样式、布局和绘制,是一种合理的经验做法。如果动画本身很繁重,就把这个数值再调小一些。

With this approach, even while heavy work is in progress, there is room to draw the screen every frame, and the work and the animation run smoothly side by side. Try the demo below. Moving the mouse scatters 4,000 particles away from the cursor, and nearby particles also push each other apart, so deciding one particle’s direction means checking its distance to every other particle. That comes to roughly 16 million distance calculations per pass, and recomputing all of it every frame blows through the frame budget on its own. Compare the “Compute all at once” and “5ms per frame” modes.

采用这种方式后,即使繁重工作仍在进行,每一帧也有时间绘制屏幕,工作与动画可以流畅地并行推进。试试下面的演示。移动鼠标时,4,000 个粒子会从光标周围散开,附近的粒子也会相互排斥。因此,要确定一个粒子的移动方向,就必须检查它与其他每一个粒子的距离。每轮大约要进行 1,600 万次距离计算;如果每一帧都重新完成全部计算,单是这项工作就会耗尽帧预算。比较一下“Compute all at once”和“5ms per frame”两种模式。

在原文中查看交互动画

Splitting is the most basic way to use the main thread’s time sparingly. It is what gives users the perceived performance they care about: fast responses and a smooth screen.

拆分是节省主线程时间最基本的方法。它能带来用户真正关心的感知性能:快速响应和流畅画面。

Finally, a few points to be careful about. First, splitting too finely backfires. Yielding and coming back has a cost of its own, so if the pieces are too small, that overhead can end up larger than the work you’re trying to do.

最后,还有几点需要注意。第一,拆分得过细会适得其反。 让出执行权再恢复本身也有成本,因此如果工作块太小,这些额外开销最终可能比真正要做的工作还大。

Second, yielding with setTimeout involves a minimum delay [3], so each piece can end up waiting a few milliseconds for nothing. Usually this doesn’t matter, but in situations that demand a very high level of responsiveness, the delay can become a problem.

第二,使用 setTimeout 让出执行权时会受到最小延迟限制 [3:1],因此每块工作都可能平白等待几毫秒。通常这并不重要,但在对响应速度要求极高的场景中,这段延迟可能会成为问题。

That’s why some code schedules the next task by posting a message through a MessageChannel instead. React’s scheduler uses this method. More recently, a standard API called scheduler.yield() has also appeared to address this problem. Its advantage is that after yielding, the original work resumes ahead of other queued tasks instead of being pushed to the back. Browser support is still uneven, though.

因此,有些代码会改用 MessageChannel 发送消息来调度下一个任务。React 的调度器就采用了这种方法。近来还出现了一个名为 scheduler.yield() 的标准 API,专门用于解决这个问题。它的优势在于:让出执行权后,原来的工作会优先于其他已排队任务恢复,而不会被推到队尾。不过,目前各浏览器的支持程度仍不一致。

Third, different yielding tools come back at different times. setTimeout and scheduler.yield() resume without regard to the rendering cycle, while requestAnimationFrame resumes just before a frame is drawn, so for work that needs to keep in rhythm with screen updates, requestAnimationFrame is the better fit. If you want finer control over priorities, you can also build your own queue on MessageChannel and manage the yielding and resuming yourself.

第三,不同的让出工具会在不同的时机恢复执行。 setTimeoutscheduler.yield() 恢复执行时并不考虑渲染周期,而 requestAnimationFrame 会在绘制一帧之前恢复。因此,对于需要与屏幕更新保持同步的工作,requestAnimationFrame 更合适。如果希望更精细地控制优先级,也可以基于 MessageChannel 构建自己的队列,自行管理让出和恢复。

Lastly, splitting isn’t always possible. Parsing a multi-megabyte response with JSON.parse, for example, is a single atomic synchronous call, and there is no way to stop halfway and yield. Until the parse finishes, the main thread is stuck. Heavy work that can’t be split like this is the clear limit of “using it wisely.” In that case you have to change the premise and not do the work on the main thread at all. We’ll get to that in “Not Using the Expensive Resource.”

最后,并非所有工作都能拆分。例如,使用 JSON.parse 解析一个数兆字节的响应,是一次不可分割的同步调用,无法在中途停下来让出执行权。在解析完成之前,主线程会一直被占用。此类无法拆分的繁重工作,正是“聪明地使用主线程”这一思路的明确边界。遇到这种情况,就必须改变前提,彻底不在主线程上执行这项工作。我们会在“Not Using the Expensive Resource”一节中讨论这一点。

Batching

批处理

Splitting on its own doesn’t solve every problem, though. Think back to the streaming chat example. Yielding rescued input and rendering, but it did nothing to make chat draw faster. If anything, throughput, meaning the number of messages drawn per unit of time, went down by the overhead of yielding. So what happens if chat pours in faster than the throughput? Arrivals outpace processing, the backlog keeps growing, and the messages reaching the screen get older and older. This situation is called backpressure.

不过,单靠拆分并不能解决所有问题。回想一下直播聊天的例子。让出主线程挽救了输入和渲染,却没有让聊天消息绘制得更快。事实上,吞吐量——也就是单位时间内绘制的消息数量——还会因为让出的额外开销而下降。那么,如果聊天消息涌入的速度超过吞吐量,会发生什么?消息到达速度快于处理速度,积压不断增加,最终显示到屏幕上的消息也会越来越滞后。这种情况称为背压

Raising throughput takes a different tool than splitting. For example, instead of drawing messages one by one, you can draw the accumulated batch in one go. The fixed per-message cost folds together, and the same amount of time renders more chat. Being told to split and then told to batch may sound like a contradiction, but the point of both is to trim tasks to an appropriate size. Splitting deals with tasks so long that rendering can’t squeeze in, and batching deals with tasks so frequent that the pipeline’s fixed cost is paid over and over.

提高吞吐量需要使用不同于拆分的手段。例如,不再逐条绘制消息,而是把积累起来的一批消息一次性绘制。这样,每条消息的固定成本会合并起来,相同时间内就能渲染更多聊天消息。先说要拆分,接着又说要批处理,听起来似乎自相矛盾,但二者的目标都是把任务调整到合适的大小。拆分用于处理持续时间过长、让渲染无从插入的任务;批处理则用于处理发生过于频繁、导致流水线固定成本反复支出的任务。

The best batching targets are events. Scroll, resize, and input events can fire dozens or hundreds of times in a short span. If you run a heavy handler on every one of them, there’s nothing left of the main thread. So we collapse many events into one execution, either by “running once after things quiet down” or by “running at most once per interval.” These are called debounce and throttle, respectively.

最适合批处理的对象是事件。滚动、调整窗口大小和输入事件可能会在短时间内触发几十次甚至几百次。如果每次都运行一个繁重的处理函数,主线程便没有余力做其他事情。因此,我们会把许多事件合并为一次执行:要么“等事件平息后执行一次”,要么“每个时间间隔最多执行一次”。这两种方式分别称为防抖和节流。

The demo below is a markdown editor with a long CHANGELOG open. Building the preview means parsing the entire document (about 2,000 lines) and rebuilding its DOM from scratch, which is far too expensive to run on every keystroke. Type quickly into the left editor with “No debounce” selected. The preview is rebuilt once per character and your input falls behind. Switch to “Debounce 300ms” and the render happens just once, after you stop typing, and the typing becomes smooth.

下面的演示是一个打开了长篇 CHANGELOG 的 Markdown 编辑器。生成预览意味着解析整份文档(约 2,000 行),并从头重建其 DOM;每次按键都执行这项工作,成本高得难以承受。选择“No debounce”后,在左侧编辑器中快速输入。预览会每输入一个字符就重建一次,你的输入很快就会跟不上。切换到“Debounce 300ms”后,渲染只会在你停止输入时执行一次,打字也会变得流畅。

在原文中查看交互动画

For visual updates, you can use requestAnimationFrame. The screen only gets drawn once per frame anyway, so no matter how many update requests pile up, drawing once per frame is enough.

对于视觉更新,可以使用 requestAnimationFrame。屏幕本来每一帧就只绘制一次,因此无论积累了多少更新请求,每帧绘制一次就足够了。

let scheduled = false;

socket.on('tick', (tick) => {
  chart.push(tick); // keep every data point — nothing is thrown away / 保留每个数据点,不丢弃任何数据
  if (scheduled) return; // this frame's draw is already booked / 本帧的绘制已经安排好了
  scheduled = true;
  requestAnimationFrame(() => {
    renderBoard(); // draw once per frame / 每帧绘制一次
    scheduled = false;
  });
});

The demo below updates a board of 60 tickers with over 1,000 messages per second. “Render every tick” mode redraws the whole board on every message. Calling a chart library’s update() on every message is a common mistake, and this is exactly what it looks like. Switch to “Once per frame” and every arriving data point is still reflected, but the fps comes back.

下面的演示以每秒超过 1,000 条消息的速度更新一个包含 60 个行情代码的看板。“Render every tick”模式会在每条消息到达时重绘整个看板。每收到一条消息就调用图表库的 update() 是一种常见错误,而这正是它实际呈现的效果。切换到“Once per frame”后,每个到达的数据点仍会反映在画面上,但 fps 会恢复正常。

在原文中查看交互动画

DOM writes can be batched as well. Appending a hundred nodes in one operation instead of one at a time, or toggling a single class instead of changing style properties individually, turns many changes into one and helps performance. The old technique of assembling an HTML string and assigning it to innerHTML in one shot has the same essence. You gather the writes so the rendering pipeline’s fixed cost is paid once.

DOM 写入也可以批量处理。一次性追加一百个节点,而不是逐个追加;或者切换一个 class,而不是分别修改各个样式属性,都能把许多变更合并为一次,从而提升性能。过去先拼装 HTML 字符串,再一次性赋给 innerHTML 的技巧,本质上也是如此。把写入集中起来,渲染流水线的固定成本就只需支付一次。

This kind of optimization is a familiar pattern to frontend developers, and React’s virtual DOM is itself a device for it. However many times state changes, the changes accumulate in the virtual tree, get compared first, and only the actual differences are applied to the real DOM in one pass. Merging several state updates inside one event handler into a single re-render, or queueing analytics events and sending them in one request instead of individually, is the same idea. A fixed cost that would repeat once per item gets paid once per batch.

这类优化模式对前端开发者并不陌生,React 的虚拟 DOM 本身就是实现这种优化的一种机制。无论状态变化多少次,变更都会先积累在虚拟树中并进行比较,最后只把真正的差异一次性应用到真实 DOM。把一个事件处理函数中的多次状态更新合并为一次重新渲染,或者先将分析事件放入队列,再用一个请求统一发送,而不是逐条发送,都是同样的思路:原本会对每个项目重复支付的固定成本,改为每批只支付一次。

Prioritizing

确定优先级

If splitting and batching shape the size of work, prioritizing decides its order. Reacting to the button the user just pressed needs to happen quickly, while precomputing statistics for content that’s off screen can wait. Prioritizing means ordering the urgent work ahead of the work that isn’t urgent.

如果说拆分与批处理决定了工作的粒度,那么确定优先级决定的就是工作顺序。用户刚刚按下按钮时,页面需要迅速响应;而为屏幕外的内容预先计算统计数据,则可以等等再做。确定优先级,就是把紧急工作排在不紧急的工作之前。

Order matters because on a main thread that nothing can interrupt, order is the responsiveness the user feels. To control order, you usually build a queue that work is pushed into and pulled out of. Jobs in the queue are processed FIFO, but when something urgent comes in, it gets pulled to the front of the line.

顺序之所以重要,是因为在无法被任何事情中断的主线程上,执行顺序直接决定了用户感受到的响应速度。为了控制顺序,通常会构建一个队列,让工作从一端进入、从另一端取出。队列中的任务按先进先出(FIFO)处理;但当紧急任务到来时,就把它提到队首。

const queue = [];
const channel = new MessageChannel();

// One message = one task. Process a piece, then book the next one / 一条消息对应一个任务。处理一项,再安排下一项
channel.port1.onmessage = () => {
  const job = queue.shift(); // take whatever is at the front right now / 取出当前位于队首的任务
  if (!job) return; // guard against duplicate bookings / 防止重复调度
  job();
  if (queue.length > 0) channel.port2.postMessage(null);
};

function postJob(job, urgent = false) {
  if (urgent) queue.unshift(job); // urgent jobs cut to the front / 紧急任务插到队首
  else queue.push(job);
  if (queue.length === 1) channel.port2.postMessage(null);
}

This structure is useful because priority isn’t a fixed value. Work that wasn’t urgent to begin with can suddenly become urgent because of something the user does. Say the user attaches a few dozen photos to a post. To save on costs, the client sometimes resizes images before uploading them to the server, and that resizing is unhurried work that can be processed in order.

这种结构很有用,因为优先级并不是固定不变的。原本不紧急的工作,可能会因为用户的某个操作突然变得紧急。比如,用户给一篇帖子附加了几十张照片。为了节省成本,客户端有时会在上传到服务器之前调整图片尺寸;这类工作并不着急,可以按顺序处理。

React works along similar lines, with more sophisticated machinery on top, such as starvation protection, batching, and continuations. Use startTransition or useDeferredValue and a scheduler spins up inside that yields via MessageChannel and orders work with its own priority queue.

React 的工作方式与此类似,只是在此基础上加入了更复杂的机制,例如饥饿保护、批处理和续体(continuation)。使用 startTransitionuseDeferredValue 时,React 内部会启动一个调度器,通过 MessageChannel 让出执行权,并用自己的优先级队列安排工作顺序。

But once the user clicks a particular photo to check that it attached properly, that photo’s preview becomes the most urgent job there is. With a priority queue like the one above, the urgent job can be handled first. This approach, where you get ahead on the work while idle and then rush it when it becomes needed, is sometimes called the idle-until-urgent pattern [4].

但当用户点击某张特定照片,想确认它是否已经正确附加时,这张照片的预览就成了最紧急的任务。借助上面这样的优先级队列,紧急任务可以得到优先处理。这种在空闲时提前推进工作、等到需要时再加急完成的做法,有时称为 idle-until-urgent(空闲时先做、紧急时优先)模式 [4:1]

// Build previews for the attached photos, in order / 按顺序为附加的照片生成预览
files.forEach((file, i) => {
  const job = () => createPreview(file, i);
  job.photoId = i; // tag it so we can find it in the queue later / 添加标记,以便稍后在队列中找到它
  postJob(job);
});

// Clicking a photo that isn't ready pulls its job to the front → priority bump / 点击尚未就绪的照片时,将其任务提到队首 → 提升优先级
onClickPhoto((i) => {
  const idx = queue.findIndex((job) => job.photoId === i);
  if (idx > 0) queue.unshift(queue.splice(idx, 1)[0]);
});

Feel the difference in the demo below. Sixty photos have been attached, and each preview is actually generated with per-pixel filtering. While the previews are being built in order, click one of the gray tiles that isn’t ready yet. In “In order” mode you have to wait until that tile’s turn comes, but in “Clicked first” mode it skips the queue and fills in right away. The total amount of work is the same and only the order changed, yet the experience for the user is completely different.

请在下面的演示中亲自感受这种差异。这里附加了 60 张照片,每张预览图实际上都通过逐像素滤镜生成。在预览图按顺序生成的过程中,点击一个尚未就绪的灰色方块。在“In order”(按顺序)模式下,你必须等到轮到这个方块;而在“Clicked first”(先处理点击项)模式下,它会越过队列,立刻显示出来。总工作量完全相同,改变的只有顺序,但用户体验却截然不同。

在原文中查看交互动画

Priority, then, is a matter of working out what matters most to the user at this particular moment.

因此,优先级的本质,就是判断在当前这一刻,什么对用户最重要。

Modern browsers offer standards for this, such as the Scheduler API and TaskController. Support is still incomplete, so in practice people pair them with polyfills or build their own queues. This article uses the hand-built-queue approach.

现代浏览器为此提供了 Scheduler API 和 TaskController 等标准。不过,它们目前的支持仍不完整,因此在实践中,人们会搭配 polyfill 使用,或自行构建队列。本文采用手写队列的方式。

Deferring

延后执行

The last and most reliable way to conserve the main thread is to not do now what doesn’t need to be done now. Where splitting and batching ask “at what size” and prioritizing asks “in what order,” deferring asks whether this work really has to happen right now at all.

节省主线程资源的最后一种方法,也是最可靠的方法,就是:不必现在做的事,就不要现在做。 如果拆分和批处理关心的是“以多大的粒度执行”,确定优先级关心的是“按什么顺序执行”,那么延后执行所问的就是:这项工作是否真的非得现在做不可?

Initial page load is the classic place where deferring pays off. There’s no need to download and execute all of your JavaScript up front. With code splitting, only the code the current screen needs runs first, and the rest is loaded when it becomes necessary, which keeps the main thread from slowing down right from the start.

页面首次加载是延后执行最能发挥价值的典型场景。没有必要一开始就下载并执行全部 JavaScript。借助代码拆分,先只运行当前屏幕所需的代码,其余代码等到真正需要时再加载,这样就不会让主线程从一开始便慢下来。

You can defer rendering itself, too. Think of a social feed. Some apps freeze for a moment when you come back from the notifications tab after scrolling far enough to accumulate hundreds of posts. Even if the feed’s DOM is kept alive while switching tabs, when it becomes visible again the browser recomputes style and layout for all several hundred posts at once, including the ones nowhere near the viewport. So what if off-screen posts were left as empty shells that only take up their height, and got filled with real content as they approach the screen? The tool that tells you about that “approaching” moment is IntersectionObserver. It’s the same method image lazy-loading libraries use.

渲染本身也可以延后。以社交信息流为例:有些应用在你向下滚动、积累了数百条帖子后,切到通知标签页再返回时,会短暂卡住。即使切换标签页时信息流的 DOM 一直保留着,当它重新变得可见时,浏览器仍会一次性为全部数百条帖子重新计算样式和布局,其中也包括远离视口的帖子。那么,如果把屏幕外的帖子留成只占据自身高度的空壳,等它们快要进入屏幕时再填入真实内容,会怎么样?负责通知你这个“正在接近”时刻的工具就是 IntersectionObserver。图片懒加载库使用的也是同一种方法。

const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) fill(entry.target); // fill as it approaches / 接近视口时填充内容
      else empty(entry.target); // empty it when it leaves, keeping its place / 离开视口时清空内容,但保留其位置
    }
  },
  { rootMargin: '400px' } // headroom to fill before the scroll arrives / 预留空间,以便在滚动到达前填充内容
);

feed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));

The demo below is a feed with 1,500 posts piled up [5]. It starts with “Render only when visible” turned on. Visit the notifications tab and come back, and the return is instant regardless of how much has accumulated. Now switch to “Render everything” and make the round trip again. Every return freezes for hundreds of milliseconds while all 1,500 posts are laid out again. Apart from unfilled slots showing briefly during fast scrolling, the two modes look the same.

下面的演示是一条堆积了 1,500 条帖子的动态信息流 [5:1]。演示开始时启用了“Render only when visible”(仅在可见时渲染)。切换到通知标签页再返回,无论积累了多少内容,都能立即完成切换。现在改用“Render everything”(渲染全部内容),再来回切换一次。每次返回时,页面都会卡住数百毫秒,因为所有 1,500 条帖子都要重新布局。除了快速滚动时会短暂看到尚未填充的空位,两种模式看起来没有区别。

在原文中查看交互动画

Rendering isn’t the only thing this approach defers. Off-screen posts never get their DOM built at all, so the cost of creating and maintaining it is deferred along with everything else. If a widget carries heavy initialization, that initialization can also wait until the widget nears the screen. There’s also a CSS property, content-visibility: auto, that aims for a similar effect in a single line. As of this writing, though, implementations vary between engines, and Safari has a performance bug that makes returning to the page slower rather than faster, so for now IntersectionObserver is the option that behaves predictably everywhere.

这种方法延后的并不只是渲染。屏幕外的帖子根本不会构建 DOM,因此创建和维护 DOM 的成本也与其他工作一同被延后。如果某个组件的初始化开销很大,也可以等到它接近屏幕时再初始化。CSS 还有一个属性 content-visibility: auto,试图用一行代码实现类似效果。不过在本文写作时,不同浏览器引擎的实现仍有差异,而且 Safari 存在一个性能缺陷,会让返回页面变得更慢而不是更快。因此,目前 IntersectionObserver 仍是在各浏览器中表现更可预测的选择。

Continuously running work, like carousels, animated promo banners, and live charts, is pure waste while off screen. You’re spending main-thread time every frame to redraw a picture nobody can see. Run it while it’s visible and stop it when it leaves. That’s also how the dozen-plus demos in this article manage to coexist on a single page. Each one is built to stop once it scrolls out of view.

对于轮播图、动态宣传横幅和实时图表这类持续运行的工作,在屏幕外继续运行纯属浪费。你每一帧都在耗费主线程时间,重绘一幅根本没人能看见的画面。应该只在它可见时运行,一旦离开视口就停止。这也是本文十多个演示能够共存于同一个页面的原因:每个演示在滚出视野后都会停止运行。

Not Using the Expensive Resource

不使用昂贵的资源

Everything so far was about using the main thread, but using it carefully. The second family of techniques is about not doing the work on the main thread in the first place.

到目前为止,所有方法讨论的都是如何使用主线程,只是要谨慎使用。第二类方法则是从一开始就不在主线程上执行这些工作

Let’s return to the question left hanging in the long-task demo. The main thread was completely blocked, so why did the CSS animation keep running as if nothing had happened? The answer is that the animation was never running on the main thread to begin with. Inside the browser, several threads divide up the work. These are the notable ones.

让我们回到长任务演示中那个尚未解答的问题:主线程已经被完全阻塞,为什么 CSS 动画却像什么都没发生一样继续运行?答案是,这个动画从一开始就没有运行在主线程上。浏览器内部由多个线程分担工作,其中值得关注的有以下几类。

  • Main thread: runs JavaScript, manipulates the DOM, calculates styles, performs layout, handles events.
  • 主线程:运行 JavaScript、操作 DOM、计算样式、执行布局并处理事件。
  • Compositor thread: composites already-drawn layers onto the screen. Handles scrolling and certain animations.
  • 合成线程:把已经绘制好的图层合成到屏幕上,负责滚动和某些动画。
  • Raster threads: turn paint commands into actual pixels.
  • 光栅线程:把绘制命令转换为实际像素。
  • Worker threads: separate JavaScript execution spaces that we create explicitly.
  • Worker 线程:由我们显式创建的独立 JavaScript 执行空间。

Unfortunately, we can’t control these threads however we like. The compositor and raster threads are territory the browser manages on its own, so we can’t give them direct orders, and worker threads, which we can create, come with the major restriction of having no DOM access.

遗憾的是,我们无法随心所欲地控制这些线程。合成线程和光栅线程属于浏览器自行管理的领域,我们不能直接向它们下达指令;而我们能够创建的 Worker 线程,又受到一个重大限制:无法访问 DOM。

So “not using” the main thread doesn’t mean sending arbitrary work elsewhere. It means picking out the work that can take a form other threads can handle, and sending that. There are two main ways to do it.

因此,“不使用”主线程并不意味着可以把任意工作随便扔到其他地方。它的含义是:找出那些可以转化为其他线程能够处理的形式的工作,再把这部分工作交出去。主要有两种做法。

Moving Work to the Compositor

将工作移交给合成线程

The compositor thread is the reason the CSS animation didn’t stop. transform and opacity don’t change an element’s position, size, or color in the document. They move an already-painted layer or adjust its transparency, so there’s no need to redo layout or paint. That lets the browser handle them directly on the compositor thread without going through the main thread at all. Even when the main thread is busy, the compositor runs separately, so the animation stays smooth.

CSS 动画没有停下来的原因,正是合成线程。transformopacity 不会改变元素在文档中的位置、尺寸或颜色。它们只是移动已经绘制好的图层,或调整图层的透明度,因此无需重新执行布局或绘制。这样一来,浏览器就能直接在合成线程上处理这些变化,完全不必经过主线程。即使主线程很忙,合成线程也会独立运行,所以动画仍能保持流畅。

If you move an element with properties like top, left, width, or height instead, layout has to be recomputed every frame, and that is main-thread work. In the demo below, the two boxes slide side to side in the same way, but one moves with transform and the other with left. Press the button to put load on the main thread.

相反,如果使用 topleftwidthheight 等属性移动元素,每一帧都必须重新计算布局,而这属于主线程的工作。在下面的演示中,两个方块以相同方式左右滑动,但其中一个使用 transform 移动,另一个使用 left。按下按钮即可给主线程施加负载。

在原文中查看交互动画

Once the main thread gets busy, only the bottom box, the one moving with left, starts to stutter. The transform box at the top is being driven by the compositor and stays smooth whatever the load. The same “slide sideways” ends up on a completely different thread depending on which property you animate. That’s why it’s better for performance to build animations that move things with transform: translate rather than left, and animations that resize things with transform: scale rather than width.

主线程一忙起来,只有下方那个使用 left 移动的方块开始卡顿。上方使用 transform 的方块由合成线程驱动,无论负载多大都能保持流畅。同样是“横向滑动”,根据所动画化的属性不同,最终可能运行在完全不同的线程上。正因如此,为了获得更好的性能,移动元素的动画应优先使用 transform: translate 而不是 left,调整元素大小的动画应优先使用 transform: scale 而不是 width

But what about animations where the layout genuinely has to change? Picture a list where deleting an item makes the items below it slide smoothly up into place. This isn’t decorative motion. The positions really do change. Yet animating top means layout on every frame. The technique that resolves this dilemma is FLIP (First, Last, Invert, Play) [6]. In short, you cause exactly one layout change and leave the entire movement to transform. It goes in this order.

但如果动画确实必须改变布局呢?想象一个列表:删除某一项后,下方各项平滑地上移到位。这并不是装饰性的位移,它们的位置确实发生了变化。然而,对 top 做动画意味着每一帧都要执行布局。解决这一两难问题的技术是 FLIP(First、Last、Invert、Play,即初始、最终、反转、播放)[6:1]。简而言之,只触发一次布局变化,并把整个移动过程交给 transform。 它按以下顺序执行。

  • First: measure the position before the move
  • First(初始):测量移动前的位置
  • Last: actually change the layout and measure the new position. Layout happens exactly once, here
  • Last(最终):真正改变布局并测量新位置。布局只在这里发生一次
  • Invert: apply a transform to the element in its new position so it appears to still be in the old one
  • Invert(反转):对处于新位置的元素应用 transform,让它看起来仍停留在旧位置
  • Play: animate that transform away. This part belongs to the compositor
  • Play(播放):通过动画撤销这个 transform。这一部分由合成线程负责
const first = el.getBoundingClientRect(); // First: where it is now / First:元素当前的位置

list.prepend(el); // the one and only layout change / 唯一一次布局变化

const last = el.getBoundingClientRect(); // Last: where it ended up / Last:元素最终所在的位置
const dx = first.left - last.left;
const dy = first.top - last.top;

// Invert: make it look like it's back at the old position → Play: release it / Invert:让它看起来回到了旧位置 → Play:释放它
el.animate([{ transform: \`translate(${dx}px, ${dy}px)\` }, { transform: 'none' }], {
  duration: 300,
  easing: 'ease-in-out',
});

To the user’s eye, the element glides from its old spot to its new one, but in reality the element has already arrived at its new spot, and the transform briefly drags it back before releasing it into place. While the animation plays, the only per-frame work is the compositor interpolating a transform. Most list-reordering animations are built this way, and Vue’s TransitionGroup and Framer Motion’s layout animations are FLIP under the hood.

在用户看来,元素从旧位置平滑滑动到了新位置;但实际上,它早已抵达新位置,只是 transform 暂时把它拉回旧位置,再将它释放到位。动画播放期间,每一帧唯一要做的工作就是由合成线程对 transform 进行插值。大多数列表重排动画都以这种方式实现,Vue 的 TransitionGroup 和 Framer Motion 的布局动画底层使用的也是 FLIP。

The demo below shows the difference at a glance. Press “Play rank shuffle” and both ranking lists reshuffle in the same way. The left list animates top with a transition, and the right list moves only transform, via FLIP. With no load, both look smooth. Now turn on “Load the main thread” and play it again. The left list stutters its way to the finish, while the right one stays smooth even under load.

下面的演示能让你一眼看出差异。按下“Play rank shuffle”(播放排名重排),两个排行榜会以相同方式重新排序。左侧列表通过 transition 对 top 做动画;右侧列表则使用 FLIP,只改变 transform。没有负载时,两者看起来都很流畅。现在打开“Load the main thread”(加载主线程),再播放一次。左侧列表会一路卡顿到动画结束,而右侧列表即使在负载下依然流畅。

在原文中查看交互动画

Finally, two things worth knowing before handing work to the compositor. One is will-change: transform. It gives the browser a hint that “this element is about to change, so prepare it as its own layer in advance,” which can smooth out the start of an animation. Overuse it, though, and the number of layers balloons and memory gets wasted instead.

最后,在把工作交给合成线程之前,还有两件事值得了解。第一件是 will-change: transform。它会向浏览器提示:“这个元素即将发生变化,请提前把它准备成独立图层。”这样可以让动画启动得更流畅。不过,如果过度使用,图层数量就会急剧增加,反而浪费内存。

The other is reading layout values, which you just saw in the FLIP code. If you get the ordering wrong between code that reads layout values (getBoundingClientRect, offsetWidth) and code that writes styles, you run into a problem called layout thrashing.

第二件事是读取布局值,刚才的 FLIP 代码中已经出现过这种操作。如果读取布局值(getBoundingClientRectoffsetWidth)的代码与写入样式的代码顺序安排不当,就会遇到一个称为布局抖动(layout thrashing)的问题。

// 🔴 Reads and writes interleaved — forces a layout recalculation every iteration / 🔴 读取与写入交错进行——每次迭代都会强制重新计算布局
for (const el of elements) {
  const width = el.offsetWidth; // read (needs layout) / 读取(需要布局)
  el.style.width = width + 10 + 'px'; // write (invalidates layout) / 写入(使布局失效)
}

// 🟢 Finish all the reads, then do the writes together / 🟢 先完成所有读取,再集中执行写入
const widths = elements.map((el) => el.offsetWidth); // gather reads / 集中读取
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px'; // gather writes / 集中写入
});

If you read a layout value right after changing one, the browser has no choice but to recompute layout on the spot to give you an up-to-date answer. When that happens inside a loop, layout runs dozens of times in a single frame and the main thread slows down. Simply getting into the habit of grouping reads with reads and writes with writes is enough to avoid it.

如果刚修改完布局就立即读取布局值,浏览器为了给出最新结果,别无选择,只能当场重新计算布局。如果这种情况发生在循环内部,布局计算就会在一帧之内运行几十次,拖慢主线程。只要养成把读取操作归在一起、把写入操作归在一起的习惯,就足以避免这个问题。

Sending Work to a Worker

将工作交给 Worker

Then what about heavy work that can’t be re-expressed with transform? What do you do with things like parsing a large payload, processing images, or running complex computation? Pure calculation like that can be sent outside the main thread in its entirety with a web worker.

那么,无法用 transform 重新表达的繁重工作怎么办?比如解析大型载荷、处理图像或执行复杂计算,又该如何处理?这类纯计算任务可以完整地交给 Web Worker,在主线程之外执行。

A worker runs JavaScript on a separate thread, fully separated from the main one. Hand the heavy computation to a worker, and in the meantime the main thread can concentrate solely on keeping the UI responsive.

Worker 在与主线程完全分离的独立线程中运行 JavaScript。把繁重计算交给 Worker 后,主线程便能专注于维持 UI 的响应能力。

// Main thread / 主线程
const worker = new Worker('parser.js');
worker.postMessage(hugeRawData);
worker.onmessage = (e) => {
  render(e.data); // receive only the result and put it on screen / 只接收结果并显示到屏幕上
};

It isn’t free, of course. As we saw above, workers can’t access the DOM, so they can’t touch the screen directly. They can only compute and then send the results back to the main thread. And the main thread and the worker communicate only through postMessage, which copies (serializes) the data, so when the data being passed around is large, that cost is considerable.

当然,这并非毫无代价。正如前面所说,Worker 无法访问 DOM,因此不能直接操作屏幕。它只能进行计算,再把结果发回主线程。主线程与 Worker 之间只能通过 postMessage 通信,而这一过程会复制(序列化)数据;传输的数据量很大时,这项成本不容忽视。

So workers aren’t a cure-all. They shine when the computation is heavy enough to outweigh the communication cost and has nothing to do with the DOM. If you send a short, light job to a worker, the communication cost ends up larger than the computation cost and you come out behind. The key is to keep asking, every time, whether this work really needs to run on the main thread.

所以,Worker 并非万能解法。只有当计算足够繁重、足以抵消通信成本,并且与 DOM 无关时,它才最有价值。若把短小轻量的任务交给 Worker,通信成本反而可能超过计算成本,得不偿失。关键在于每次都问自己:这项工作真的必须在主线程上运行吗?

Since words only go so far, let’s bring in some genuinely heavy image processing. Seam carving is an algorithm that finds the vertical path of lowest energy (least color change) through a photo and removes it one path at a time, narrowing the image while preserving the important subject [7]. Removing a single seam means sweeping through hundreds of thousands of pixels, so removing 250 or so seams adds up to hundreds of millions of operations. Run that on the main thread and the whole page will freeze. Send it to a worker, though, and the screen can stay responsive the entire time the computation is running.

文字说明终究有限,我们来看一项真正繁重的图像处理任务。接缝裁剪(seam carving)是一种算法:它在照片中找出能量最低(颜色变化最小)的一条纵向路径,每次移除一条,在保留重要主体的同时缩窄图像 [7:1]。移除一条接缝就要遍历数十万个像素,移除约 250 条接缝便会累积到数亿次运算。若在主线程上执行,整个页面都会冻结;若交给 Worker,计算期间屏幕仍能始终保持响应。

Press “Run on main thread” in the demo below. For the one or two seconds the computation runs, the entire page freezes, and the result appears all at once only after it’s finished. Because there are no task boundaries for paint to slip into, you couldn’t show the intermediate steps even if you wanted to. Now switch to “Run in worker”. While the same computation runs, you get to watch the image narrow in real time.

在下面的演示中点击“Run on main thread”。计算运行的一两秒内,整个页面都会冻结,直到完成后结果才会一次性出现。由于没有可供绘制任务插入的任务边界,即使想展示中间步骤也做不到。接着切换到“Run in worker”。执行相同计算时,你会看到图像宽度实时缩小。

在原文中查看交互动画

There is one more thing behind those smooth intermediate frames. If the worker copied a multi-megabyte pixel buffer every time it sent a frame, that cost would add up too. So postMessage offers an alternative to copying the data: transferring ownership of it outright. Transferable objects like ArrayBuffer move by reference only, so the cost is close to zero regardless of size. The side that hands the buffer over can no longer use it, and in exchange the copy cost disappears.

这些流畅的中间帧背后还有另一个关键点。如果 Worker 每发送一帧都要复制数 MB 的像素缓冲区,成本同样会不断累积。因此,postMessage 提供了复制之外的另一种选择:直接转移数据的所有权。 ArrayBuffer 等可转移对象只需按引用移动,所以无论大小,成本都接近于零。交出缓冲区的一方此后无法继续使用它,换来的则是复制成本的消失。

// Hand the pixel buffer to the worker without copying / 不复制数据,直接把像素缓冲区交给 Worker
// After the transfer, this side can no longer use it / 转移后,本侧无法再使用该缓冲区
worker.postMessage({ buf: pixels.buffer, width, height }, [pixels.buffer]);

Eliminating the Work Itself

从根本上消除工作

So far we’ve been asking whether a piece of work really needs to run on the main thread. This time, let’s ask whether the work needs to happen at all. The best thing for performance is not doing the work in the first place.

到目前为止,我们一直在问某项工作是否真的必须在主线程上运行。现在换一个问题:这项工作是否有必要发生? 对性能最有利的做法,就是从一开始便不执行它。

Backpressure came up earlier. Batch as well as you like, and once the inflow exceeds the maximum throughput the backlog still grows without limit. Unfortunately, the browser has no good way to tell the server to slow down. At some point you have to give up on the idea of doing everything you’re given. There are generally three ways to eliminate work.

前文提到过背压。无论批处理做得多好,只要流入速度超过最大吞吐量,积压仍会无限增长。遗憾的是,浏览器没有很好的办法让服务器放慢速度。到了某个阶段,你必须放弃“处理收到的一切”这个想法。消除工作通常有三种方式。

Three ways to eliminate work

消除工作的三种方式

The first is dropping. For data that just flows past, like live logs, once processing starts falling behind you can quietly discard the oldest entries and users won’t notice. Keeping up with the present matters more than showing everything.

第一种是丢弃(dropping)。对于实时日志这类不断流过的数据,一旦处理开始落后,就可以悄悄丢弃最旧的条目,用户通常不会察觉。跟上当前状态比展示全部内容更重要。

The second is merging. For data where only the latest value means anything, like rankings, you can merge the backlogged updates and apply only the final value. With merging, the amount of work is pinned to what the screen can digest, no matter how fast the inflow gets.

第二种是合并(merging)。对于排名这类只有最新值才有意义的数据,可以合并积压的更新,只应用最终值。无论数据流入多快,合并都能把工作量限制在屏幕可消化的范围内。

The third, skipping, targets repeated work rather than incoming work. If a computation gives the same result for the same input, there’s no reason to do it a second time. Remembering results and reusing them is called memoization.

第三种是跳过(skipping),它针对的不是传入的工作,而是重复工作。如果相同输入总会得到相同结果,就没有理由再计算一次。记住并复用结果的做法称为记忆化(memoization)。

This idea has been hiding throughout the article. Debounce skipped executions during typing, and the feed demo skipped rendering posts that weren’t visible. Looked at from this angle, half of this article was about eliminating work.

这个思路其实贯穿全文。防抖跳过了输入过程中的多次执行,信息流演示则跳过了不可见帖子的渲染。从这个角度看,这篇文章有一半都在讨论如何消除工作。

When you study optimization, your attention tends to go to ways of doing work well, but the biggest gains usually come from removing work. Before making some task faster, think about it first. Does this work have to happen, now, here, at all?

研究优化时,人们往往把注意力放在如何把工作做得更好,但最大的收益通常来自移除工作。在设法让某项任务更快之前,先想一想:这项工作真的必须发生吗?必须现在发生吗?必须在这里发生吗?

Closing

结语

Some developers think of frontend work as the easy kind. But the browser is a far more complex system than we tend to assume. Drawing screens with HTML, CSS, and JavaScript is not the whole story.

有些开发者认为前端工作比较简单,但浏览器远比我们想象的复杂。使用 HTML、CSS 和 JavaScript 绘制界面,并不是故事的全部。

Apps that deal with a flood of real-time data, like streaming platforms, or whose screens never stop changing, like image editors, maps, and games, start to feel slow whenever the main thread gets busy. Not everyone is on the latest hardware, so for services like these, optimization is essential. And solving these problems takes more than optimizing code. It takes understanding how the browser works, spending the main thread’s time sparingly, and not doing work that doesn’t need doing at all.

流媒体平台等需要处理海量实时数据的应用,以及图像编辑器、地图和游戏等画面持续变化的应用,只要主线程一忙,就会显得迟钝。并非每个人都在使用最新硬件,因此对这类服务来说,优化至关重要。解决这些问题,仅仅优化代码还不够;你需要理解浏览器的工作方式,精打细算地使用主线程时间,并彻底避免那些没有必要的工作。

In the end, nothing is easy once you dig deep enough. So much of development is trade-offs, and you have to choose according to the situation, which ultimately comes down to the developer’s experience and judgment. Neither is built quickly, but both can certainly be built through study and experiment. I hope this article helps a little along the way.

归根结底,任何事情只要钻研得足够深入,都不会简单。开发中的许多问题都关乎取舍,必须结合具体情境做出选择,而这最终依赖开发者的经验与判断。两者都无法速成,却都能通过学习和实验逐步积累。希望这篇文章能在这条路上为你提供一点帮助。


  1. The compositor thread is responsible for compositing already-drawn layers onto the screen. We’ll come back to it later in the article.
    合成线程负责将已经绘制好的图层合成为最终屏幕画面。本文后面还会再次讨论它。 ↩︎ ↩︎

  2. https://web.dev/articles/rendering-performance
    参考资料:Web.dev 的渲染性能指南。 ↩︎ ↩︎

  3. The HTML spec mandates a minimum 4-millisecond delay once setTimeout calls nest more than 5 levels deep. Yielding repeatedly inside a loop, as in the code above, trips this condition almost immediately, so even with the delay set to 0, each piece waits at least 4 milliseconds.
    HTML 规范规定,当 setTimeout 的嵌套调用超过 5 层后,延迟时间不得低于 4 毫秒。像上面的代码那样在循环中反复让出执行权,几乎立刻就会触发这一条件,因此即使把延迟设为 0,每一段工作也至少要等待 4 毫秒。 ↩︎ ↩︎

  4. https://philipwalton.com/articles/idle-until-urgent/
    参考资料:Philip Walton 对 idle-until-urgent 模式的介绍。 ↩︎ ↩︎

  5. Granted, 1,500 posts rarely pile up on one page in practice. The demo stacks that many on purpose, to make the effect of deferred rendering dramatic.
    诚然,实际情况中很少会在一个页面里积累 1,500 条帖子。演示故意堆叠这么多内容,是为了让延后渲染的效果更加明显。 ↩︎ ↩︎

  6. https://aerotwist.com/blog/flip-your-animations/
    参考资料:FLIP 动画技术介绍。 ↩︎ ↩︎

  7. https://en.wikipedia.org/wiki/Seam/_carving
    参考资料:接缝裁剪(Seam carving)算法介绍。 ↩︎ ↩︎

posted @ 2026-09-04 16:23  talentzemin  阅读(9)  评论(0)    收藏  举报