likes
comments
collection
share

JS 异步之旅

作者站长头像
站长
· 阅读数 4

大家好,这里是大家的林语冰。坚持阅读,自律打卡,每天一次,进步一点

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 A tour of JavaScript timers on the web

面试官问:这些 JS 异步 API 有何区别?

  • Promise
  • queueMicrotask
  • setImmediate
  • setTimeout
  • setInterval
  • MutationObserver
  • requestAnimationFrame
  • requestIdleCallback

具体而言,如果我们将所有这些异步操作立刻排队,您知道它们会按什么顺序触发吗?

在本文中,我会简述这些异步 API 的工作原理,以及您何时需要使用它们。我还会介绍 Lodash 的 debounce()throttle(),因为我发现它们也大有用处。

Promise 和微任务

Promise 可能是最简单的。Promise 回调也称为“微任务”,其运行频率与 MutationObserver 回调相同。假设浏览器兼容 queueMicrotask(),其功能也大抵相同。

一个值得一提的 Promise 误区是,它们不给浏览器喘息的机会。只因为您正在排队异步回调,那并不意味着,浏览器可以渲染、处理输入,或执行我们希望浏览器执行的任何操作。

举个栗子,假设我们有一个函数会阻塞主线程 1 秒钟:

const block = () => {
  const start = Date.now()

  while (Date.now() - start < 1000) {
    // 同步的循环区块
  }
}

如果我们通过排队一堆微任务,以此调用该函数:

for (let i = 0; i < 100; i++) {
  Promise.resolve().then(block)
}

这会阻塞浏览器大约 100 秒。这与下述代码大抵相同:

for (let i = 0; i < 100; i++) {
  block()
}

微任务在任何同步操作完成后立即执行。两者间没有机会插入其他工作。因此,如果您认为可以通过将长时任务分成微任务来拆分它,那么它就不会如您所愿缩短时间。

setTimeoutsetInterval

这两货是近亲:setTimeout 将任务排队在 x 毫秒内运行,而 setInterval 将循环任务排队,每 x 毫秒运行一次。

问题在于,浏览器并不会真正守时。从历史上看,您会目睹一大坨 Web 开发者曾一度滥用 setTimeout。浏览器不得不为 setTimeout(/* ... */, 0) 添加急救措施,避免卡死浏览器的主线程,因为一大坨网站倾向于像天女散花般乱用 setTimeout(0)

一般而言,setTimeout(0) 并非真正在 0 毫秒内运行。通常,它在 4 毫秒内运行。有时,它可能在 16 毫秒内运行。有时它可能会被限制为 1 秒(比如,在后台选项卡中运行时)。这些是浏览器必须发明的技巧,防止失控的网页占用您的 CPU,进行无用的 setTimeout 工作。

换而言之,setTimeout 确实允许浏览器在回调触发之前运行某些工作,这与微任务不同。虽然但是,如果您的目标是允许输入或渲染在回调之前运行,那么 setTimeout 通常不是最佳选择,因为它只是偶然允许这些事情发生。如今,有更好的浏览器 API 可以更直接地连接到浏览器的渲染系统。

setImmediate

值得一提的是,setImmediate 的命名稍显奇葩。如果您在 caniuse.com 上查找,您会发现有且仅有 Microsoft 浏览器支持它。然而它也存在于 Node 中,且 npm 上有一大坨“polyfills(功能补丁)”。这货到底是什么鬼物?

setImmediate 最初由 Microsoft 提出,旨在解决上述 setTimeout 的问题。基本上,setTimeout 已经被滥用了,所以我们的想法是,我们可以创建一个新的东东,来使得 setImmediate(0) 名副其实是 setImmediate(0),而不是 setTimeout('', 0) 这种花里胡哨地“限制为 4ms”的东东。

不幸的是,setImmediate 有且仅有被 IE 和 Edge 采用。它仍在使用的部分原因是,它在 IE 中具有某种超能力,它允许输入事件(比如键盘和鼠标单击)“跳过队列”并在执行 setImmediate 回调之前触发,而 IE 对于 setTimeout 没有雷同的“黑魔法”。(Edge 最终修复了此问题。)

粉丝请注意,如果您知道自己在做什么,并且正在尝试优化 IE 的输入性能,请使用 setImmediate。如果没有,那就庸人勿扰。(或者只在 Node 中使用它。)

requestAnimationFrame

现在我们开始科普 setTimeout 最重要的竞品,一个实际上挂钩到浏览器渲染循环的计时器。顺便一提,如果您还不了浏览器事件循环的工作机制,记得关注 up 猪。

requestAnimationFrame 基本工作原理如下:它有点类似 setTimeout,除了等待某些不可预测的时间(4 毫秒,16 毫秒)、1 秒等),它在浏览器的下一个样式/布局计算步骤之前执行。现在有一个小问题,在 Safari、IE 和 Edge <18 中,它实际上是在这一步之后执行的,但现在让我们无视它,因为这通常不是一个重要的细节。

关于 requestAnimationFrame,我的个人心证是:每当我想做某些我知道会修改浏览器样式或布局的工作时 —— 举个栗子,更改 CSS 属性或启动动画 —— 我都会坚持在 requestAnimationFrame(下文简写为 rAF)中操作。这确保了某些东东:

  1. 我不太可能出现布局混乱,因为对 DOM 的所有更改都正在排队和协调。
  2. 我的代码自然会适应浏览器的性能特征。举个栗子,如果它是一个正在努力渲染某些 DOM 元素的低成本设备,rAF 会自然地从通常的 16.7 毫秒间隔(在 60 赫兹屏幕上)减速,因此机器不会卡死,那与运行一大坨 setTimeoutsetInterval 的情况大抵相同。

这就是为什么不依赖 CSS 过渡或关键帧的动画库通常会在 rAF 回调中进行更改。如果您要对 opacity: 0opacity: 1 之间的元素进行动画处理,那么排队十亿个回调来对每个可能的中间状态(包括但不限于 opacity: 0.0000001)进行动画处理毫无卵用。

反而言之,您最好只使用 rAF 让浏览器告诉您,在给定时间段内可以绘制多少帧,并计算该特定帧的“补间”。这样,垃圾设备自然会得到较慢的帧率,而牛逼设备最终会得到更快的帧率,如果您使用诸如 setTimeout 这样独立于浏览器渲染运行的东东,那么情况不一定是这样。

requestIdleCallback

rAF 可能是工具人中最有用的计时器,但 requestIdleCallback 也值得一提。浏览器支持未必很好,但是有一个 polyfill 可以完美奏效(且其底层使用了 rAF)。

在许多方面,rAFrequestIdleCallback(下文简写为 rIC) 类似。

rAF 一样,rIC 会自然地适应浏览器的性能特征:如果设备负载较重,rIC 可能会延迟。不同之处在于,rIC 在浏览器“空闲”状态下触发,即当浏览器确定它没有任何任务、微任务或输入事件要处理时,您可以自主执行某些操作。它还为您提供了一个“截止日期”来跟踪您使用了多少预算,这是一个棒棒哒的功能。

虽然但是,我注意到一件事,rIC 在 Chrome 中有点挑剔。在 Firefox 中,每当我凭直觉认为浏览器处于“空闲”状态并准备好运行某些代码时,它似乎都会触发。(polyfill 也是如此。)虽然但是,在 Android 版移动 Chrome 中,我注意到每当我通过触摸滚动进行滚动时,即使在触摸完屏幕后,它也可能会延迟 rIC 几秒钟,并且浏览器啥也没做。

无论如何,rIC 是另一个可以添加到百宝箱中的好工具。我倾向于这样考虑:使用 rAF 进行关键渲染工作,使用 rIC 进行非关键工作。

防抖节流

这两个函数没有内置在浏览器中,但它们大有用处。

debounce 的标准用法是在 resize 回调内部。当用户调整浏览器窗口大小时,没必要为每个 resize 回调更新布局,因为它触发得太过频繁。相反,您可以 debounce 持续几百毫秒,确保用户完成调整窗口大小后,最终触发回调。

另一方面,throttle 是我更自由地使用的东东。举个栗子,一个优秀用例是,在 scroll 事件内部。我再重申一遍,尝试为每个 scroll 回调更新 App 的渲染状态通常毫无卵用,因为它触发得太过频繁(并且频率可能因浏览器和输入法而异)。使用 throttle 标准化此行为,并确保它每 x 毫秒能且仅能触发一次。您还可以调整 Lodash 的 throttle/debounce 函数,在延迟开始时、结束时触发、两者都触发或都不触发。

相反,我不会在滚动场景中使用 debounce,因为我不希望 UI 仅在用户明确停止滚动后才更新。这可能会十分头大,甚至令人懵逼,因为用户可能会感到蛋疼,并尝试继续滚动,以更新 UI 状态(比如在无限滚动列表中)。在此情况下,throttle 更好,因为它不会等待 scroll 事件停止触发。

throttle 是我在各种用户输入中到处使用的函数,甚至用于某些定期计划的任务,比如 IndexedDB 清理。这十分有用。也许有一天,它应该被集成到浏览器中!

本期话题是 —— 你知道 Promise 的短板是什么吗?

欢迎在本文下方群聊自由言论,文明共享。谢谢大家的点赞,掰掰~

《前端 9 点半》每日更新,坚持阅读,自律打卡,每天一次,进步一点

JS 异步之旅