Idle Until Urgent(闲置直到紧急)
译者注:大家耳熟能详的优化策略已经谈论了好多年了,用 Chrome 性能分析工具发现瓶颈并针对性优化的文章网络上也有不少,但是从运行时调度策略来思考优化方式的却凤毛麟角,正如我们之前只知道使用 setTimeout 来进行 throttling 和 debounce。因此在偶然看到这篇文章时,我有一种豁然开朗的感觉:原来我们还可以在这么细致的粒度上进行调度。
几周前,我正着手查看我网站的一些性能指标。具体来说,我想看看我在我们最新的性能标准,即首次输入延迟 (FID)上的表现。由于我的网站只是一个博客(并没有运行很多 JavaScript ),所以我希望我能看到一个相当不错的结果。
小于 100 毫秒的输入延迟通常被用户视为即时响应,因此我们建议的性能目标(以及我希望在我的分析中看到的数字)是:对于 99% 的页面加载来说,FID <100ms。
令我惊讶的是,我的网站在第 99 百分位数下的 FID 为 254 毫秒。虽然那并不可怕,但我的完美主义性格却令我无法松懈。嗯,我必须解决它!
总而言之,在不删除我网站的任何功能的情况下,我需要能够在第 99 百分位数下将我的FID控制在 100 毫秒以下。但我确信,你们这些读者更感兴趣的是以下信息:
- 我是如何诊断问题的。
- 我用了什么具体的策略和技术来解决问题。 对于上面的第二点,当我试图解决我的问题时,我偶然发现了一个让我想分享的,非常有趣的性能策略(这就是我写这篇文章的主要原因)。
我将这个策略称之为:idle-until-urgent(闲置直到紧急) 。
我的性能问题
首次输入延迟(FID)是一个度量标准,用于衡量用户首次与您的网站进行交互的时间(对于像我这样的博客来说,最有可能的情况是点击链接)以及浏览器能够响应该互动的时间(对于我博客的点击交互来说,就是请求加载下一页)。
这里可能存在延迟的原因是浏览器的主线程正在忙于做其他事情(通常是执行 JavaScript 代码)。因此,要诊断出高于预期的 FID,您首先应当做的是在页面加载时启用站点的性能跟踪(同时启用 CPU 和网络限制),然后在主线程上查找需要执行很长时间的各个任务。一旦确定了这些长任务,你就可以尝试将它们分解为更小的任务。
以下是我在对网站进行性能跟踪时的发现: 一份加载我网站时的 JavaScript 性能跟踪(启用网络/ CPU限制)。 请注意,在 main 脚本 bundle 执行时,它作为单个任务需要 233 毫秒才能运行完成。 执行我网站的 main bundle 需要 233 毫秒。 这些代码中的一些是 webpack boilerplate 和 babel polyfill,但大多数代码来自我脚本中的 main() 入口函数,它本身需要 183 毫秒才能完成: 执行我的站点的main()入口函数需要 183 毫秒。 然而我并没有在我的 main() 函数中做什么奇怪的事情。我在函数中只是初始化我的 UI 组件,然后运行分析:
const main = () => {
drawer.init();
contentLoader.init();
breakpoints.init();
alerts.init();
analytics.init();
};
那么到底是什么花了这么长时间在运行?
好吧,如果你看一下这个火焰图的尾部,你不会看到有任何函数在执行时明显地占据了大部分时间。大多数单个函数会在不到 1 毫秒的时间内运行,但是当你将它们全部添加起来之后,在单个同步调用堆栈中运行它们就需要花费超过 100 毫秒。
这就是杀千刀的 JavaScript。
由于问题是所有这些功能都作为单个任务的一部分在运行,因此浏览器必须等到此任务完成才能够响应用户的交互。很明显,解决方案是将这些代码分解为多个任务。但这说起来容易,做起来难。
乍一看,似乎显而易见的解决方案是给 main() 函数中的每个组件排个优先级(它们实际上已经按优先级顺序排列),最快初始化最高优先级的组件,然后将其他组件的初始化推迟到后续的任务去做。
虽然这个方法可能对某些人有所帮助,但它并不是每个人都可以实施的通用解决方案,也不能很好地扩展到一个非常大的网站中。原因如下:
- 推迟 UI 组件初始化仅在组件尚未渲染时才有用。如果它已经被渲染过了,那么延迟这个组件的初始化运行,则会带来在用户交互时组件并未准备好的风险。
- 在许多情况下,所有 UI 组件要么同等重要,要么彼此依赖,因此它们都需要同时进行初始化。
- 有时单个组件需要足够长的时间来初始化,此时即使它们只在自己的任务中运行,它们也会阻塞主线程。
实际情况是,在自己的任务中初始化每个组件通常是不够高效的,并且往往是不可能的。我们通常需要把任务分解到每个被初始化的组件中。
贪婪的组件
一个真正需要将其初始化代码分解的组件的完美示例可以通过将此性能跟踪结果进一步缩放观察看到。在 main() 函数的中间,你会看到我的一个组件使用 Intl.DateTimeFormat API: 创建 Intl.DateTimeFormat 实例花了 13.47ms! 创建此对象需要 13.47 毫秒!
问题在于, Intl.DateTimeFormat 实例虽然在组件的构造函数中被创建了,但实际上并没有被引用,直到其他组件将其用于格式化日期为止。但是,此组件不知道它什么时候会被引用,因此它只能谨慎行事,立即实例化 Int.DateTimeFormat 对象。
但这真的是正确的代码求值策略吗?如果不是,那正确的应该是什么?
代码求值策略
在为可能执行代价高昂的代码选择求值策略时 ,大多数开发人员会选择以下其中一项:
- 及早求值(Eager evaluation):您可以立即运行代价高昂的代码。
- 惰性求值(Lazy evaluation):你等到程序的另一部分需要这段代价高昂代码的结果时,你才运行它。
这也许是两种最受欢迎的求值策略,但在重构完我的网站后,我现在认为这些可能是你最糟糕的选择。
及早求值的缺点
我网站上的性能问题很好地说明了及早求值的一个缺点,即如果用户在代码求值时尝试与您的页面进行交互,浏览器必须等到代码完成求值才能做出响应。
如果您的页面看起来已准备好响应用户输入,但实际上却无法响应,在这种情况下,这尤其成问题。用户会认为您的页面缓慢甚至完全是坏的。
你预先求值的代码越多,您的页面达到可以交互所需的时间就越长。
惰性求值的缺点
如果立即运行所有代码是不好的,那么最明显的解决方案就是等到实际需要它的时候再运行。这样就不会不必要地运行代码,特别是在用户实际上从未需要它的情况下。
当然,等到用户需要该代码的结果时再运行代码的问题在于,用户输入肯定会被你那些代价高昂的代码给堵塞住。
对于某些事情(比如从网络加载其他内容),将其推迟到用户请求时再执行是有意义的。但是对于您正在运行的大多数代码(例如从 localStorage 读取数据,处理大型数据集等),您肯定希望在需要它的用户交互开始之前就能开始执行。
其他选择
你也可以在及早和惰性求值之间选取一种其它求值策略,我不确定以下两种策略是否有官方名称,但我会称之为延迟求值和空闲求值:
- 延迟求值(Deferred evaluation):使用类似于 setTimeout 之类的方法将代码安排在一个未来的任务里执行
- 空闲求值(Idle evaluation):一种延迟求值,您可以使用像 requestIdleCallback 这样的API来安排代码执行。
这两个选项通常都比及早或惰性求值更好,因为它们不太可能导致阻止输入的单个长任务发生。这是因为,虽然浏览器无法中断任何一个任务以响应用户输入时(这样做将极有可能让页面崩溃),但它可以在计划任务的队列之间运行任务,大多数浏览器会将用户输入引发的任务这么安排。这称为输入优先级 。
换句话说:如果能够确保所有代码都运行在简短,不同的任务中(最好少于 50 毫秒 ),您的代码将永远不会堵塞用户输入。
重要! 虽然浏览器可以在排队任务之前执行输入的回调,但它们无法在排队的微任务之前运行输入回调。由于 promises 和 async 函数会作为微任务运行,所以将同步代码转换为基于 promise 的代码并不会避免它堵塞用户输入!
如果您不熟悉任务和微任务之间的区别,我强烈建议您观看我的同事 Jake 关于事件循环的精彩演讲 。
鉴于我刚才所说,我可以重构我的 main() 函数,使用 setTimeout() 和 requestIdleCallback() 将我的初始化代码分解为独立的任务:
const main = () => {
setTimeout(() => drawer.init(), 0);
setTimeout(() => contentLoader.init(), 0);
setTimeout(() => breakpoints.init(), 0);
setTimeout(() => alerts.init(), 0);
requestIdleCallback(() => analytics.init());
};
main();
然而,虽然这比以前好了一点(许多小任务 vs 一项长任务),但正如我上面解释的那样,它可能仍然不够好。例如,如果我推迟我 UI 组件(特别是 contentLoader 和 drawer)的初始化,它们将不太可能堵塞用户输入,但是当用户尝试与它们交互时,它们也存在未准备好的风险!
虽然使用 requestIdleCallback() 将我的 analytics 推迟可能是一个好主意,但在下一个空闲时段之前我关心的任何交互都将被遗漏。如果在用户离开页面之前没有空闲时段,这些回调代码可能永远不会运行!
因此,如果所有的求值策略都有缺点,你应该选择哪一个呢?
Idle Until Urgent (闲置直到紧急)
在花了很多时间思考这个问题之后,我意识到我真正想要的求值策略是让我的代码在最初时被推迟到空闲时段,但是在需要时能够立即运行。换句话说: idle-until-urgent 。
idle-until-urgent 策略避免了我在上一节中描述的大多数缺点。在最坏的情况下,它具有与延迟求值完全相同的性能特征,并且在最好的情况下它根本不堵塞交互性,因为代码执行发生在空闲期间。
我还应当提到的一点是,这种策略既适用于单个任务(闲时计算值),也适用于多个任务(可以在空闲时运行的一个有序任务队列)。我将首先解释单任务(空闲值)形式,因为它更容易理解。
空闲值
在上面,我向大家展示了初始化 Int.DateTimeFormat 对象可能代价非常昂贵,因此如果不是立即需要这个实例的话,最好在空闲期间初始化它。当然,一旦当它被需要时,你就想让它存在,所以这是一个 idle-until-urgent 求值策略的完美候选对象。
考虑以下我们要重构以使用此新策略的简化组件示例:
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
this.formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
});
}
handleUserClick() {
console.log(this.formatter.format(new Date()));
}
}
上面的 MyComponent 实例在它的构造函数中做了两件事:
- 为用户交互添加事件侦听器。
- 创建 Intl.DateTimeFormat 对象。
该组件完美地说明了为什么您经常需要在单个组件内部拆分任务(而不仅仅是在组件层面上拆分)。
在这种情况下,事件监听器的立即运行非常重要,但在事件处理程序需要用到之前,是否创建了 Intl.DateTimeFormat 实例并不重要。当然我们不想在事件处理程序中创建 Intl.DateTimeFormat 对象,因为它的龟速会推迟该事件的运行。
所以,这就是我们更新此代码以使用 idle-until-urgent 策略的方法。注意,我正在使用 IdleValue 辅助类,我将在下面解释它:
import {IdleValue} from './path/to/IdleValue.mjs';
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
this.formatter = new IdleValue(() => {
return new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
});
});
}
handleUserClick() {
console.log(this.formatter.getValue().format(new Date()));
}
}
正如你所见,这个代码与以前的版本看起来没有太多不同,但是与将 this.formatter 分配给一个新的 Intl.DateTimeFormat 对象相反,我将 this.formatter 分配给了一个 IdleValue 对象,在对象中我传递了一个初始化功能。
此 IdleValue 类起作用的方式是,它会调度在会下一个空闲期间运行的初始化函数。如果空闲阶段发生在 IdleValue 被引用实例之前,则不会发生阻塞,并且可以在请求时立即拿到该返回值。但另一方面,如果在下一个空闲周期之前引用该值,则安排好的空闲回调会被取消,并且初始化函数会被立即调用。
下面是如何实现 IdleValue 类的要点(注意:我还发布了这段代码作为 idlize 包 的一部分,其中包含了本文中显示的所有 helper 类):
export class IdleValue {
constructor(init) {
this._init = init;
this._value;
this._idleHandle = requestIdleCallback(() => {
this._value = this._init();
});
}
getValue() {
if (this._value === undefined) {
cancelIdleCallback(this._idleHandle);
this._value = this._init();
}
return this._value;
}
// ...
}}
虽然在上面的示例中引入 IdleValue 类并不需要很多更改,但它在技术上改变了公共API( this.formatter 与 this.formatter.getValue() )。
如果您处于想要使用 IdleValue 类但无法更改公共 API 的情况,则可以将 IdleValue 类与 ES2015 getter 一起使用:
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
this._formatter = new IdleValue(() => {
return new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
});
});
}
get formatter() {
return this._formatter.getValue();
}
// ...
}}
或者,如果你不介意一点抽象,你可以使用 defineIdleProperty() helper 类(它在底层使用了 Object.defineProperty() ):
import {defineIdleProperty} from './path/to/defineIdleProperty.mjs';
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
defineIdleProperty(this, 'formatter', () => {
return new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
});
});
}
// ...
}}
对于计算成本可能很高的单个属性值,实际上没有理由不使用此策略,尤其是你可以在不更改API的情况下使用它!
虽然这个例子使用了 Intl.DateTimeFormat 对象,但它也可能是下列任一项操作的好候选方案:
- 处理大量值的集合。
- 从 localStorage(或 cookie )获取值。
- 运行 getComputedStyle() , getBoundingClientRect() 或任何其他可能需要在主线程上重新计算样式或布局的 API。
空闲任务队列
上述技术适用于其值可以使用单个函数计算的独立属性,但在某些情况下,您的逻辑可能不适合用单个函数表达,或者,即使技术上可行,您仍然希望将它分解为几个更小的函数,因为不这样做的话,你可能会长时间阻塞主线程。
在这种情况下,您真正需要的是一个队列,您可以在其中安排多个任务(函数),在浏览器空闲时运行。队列将在可能的情况下运行任务,并且当需要回到浏览器时(例如,如果用户正在进行交互),它将暂停执行任务。
为了解决这个问题,我构建了一个 IdleQueue 类,您可以像这样使用它:
import {IdleQueue} from './path/to/IdleQueue.mjs';
const queue = new IdleQueue();
queue.pushTask(() => {
// Some expensive function that can run idly...
});
queue.pushTask(() => {
// Some other task that depends on the above
// expensive function having already run...
});
注意:将同步 JavaScript 代码分解为可作为任务队列的一部分异步运行的单独任务与代码分割不同,后者是将大型 JavaScript bundle 分解为较小的文件(这对于提高性能也很重要)。
与上面显示的空闲初始化属性策略一样,空闲任务队列也可以在需要立即执行结果的情况下立即运行(即“紧急”情况下)。
同样,最后一点非常重要:有时不仅是因为你需要尽快计算某些东西,而是通常因为你要与同步的第三方 API 集成,所以为了兼容,你需要能够同步运行你的任务。
在一个完美的世界中,所有 JavaScript API 都是非阻塞的,异步的,并且由可以随意返回主线程的小块代码组成。但在现实世界中,由于遗留代码库或与我们无法控制的第三方库的集成,我们通常别无选择,只能保持同步。
正如我之前所说,这是 idle-until-urgent 模式的巨大优势之一。它可以轻松应用于大多数程序,而无需大规模重写架构。
保证紧急情况
我在上面提到过 requestIdleCallback() 并没有保证回调将会运行。在与开发人员讨论 requestIdleCallback() 时,这是我听到的他们不使用它的主要原因。在许多情况下,代码无法运行的可能性足以成为不使用代码的理由 - 为了使代码安全运行并保持代码同步(当然同时也会阻塞)。
一个完美的例子就是分析代码。分析代码的问题是在很多情况下需要在页面卸载时运行(例如跟踪出站链接点击等),在这种情况下, requestIdleCallback() 根本无法作为一个选项,因为回调永远不会运行。由于分析库不知道他们的用户何时会在页面生命周期中调用他们的 API,故而他们也倾向于安全并同步运行所有代码(这很不幸,因为分析代码对于用户体验来说并不关键)。
但是在 idle-until-urgent 模式下,有一个简单的解决方案。我们所要做的就是确保队列只要当页面处于可能很快卸载的状态,就会立即运行。
如果您熟悉我在最近关于 Page Lifecycle API 的文章中给出的建议,您就会知道在页面被终止或丢弃之前,最后一个开发者可以依赖的可靠回调是 visibilitychange 事件(因为页面的 visibilityState 变为隐藏)。而且由于在隐藏状态下用户无法与页面进行交互,因此这是运行任何排队中的空闲任务的最佳时机。
实际上,如果使用 IdleQueue 类,则我们可以使用传递给构造函数的简单配置项来启用此功能。
const queue = new IdleQueue( { ensureTasksRun : true } );
对于渲染等任务,无需确保在页面卸载之前运行任务,但对于保存用户状态和在会话结束时发送分析等任务,你可能希望将此选项设置为 true。
注意:监听 visibilitychange 事件应该足以确保在卸载页面之前运行任务,但是由于 Safari 的漏洞,当用户关闭选项卡时, pagehide 和 visibilitychange 事件并不总是触发 ,你必须针对 Safari 实现一个应急方法。这个解决方法在 IdleQueue 类中已经为您实现 ,但如果您自己实现它,则必须对它有足够了解。
警告! 不要为了在页面卸载之前运行队列而监听 unload 事件。unload 事件并不可靠,并且在某些情况下会有损性能。有关更多详细信息,请参阅我的 Page Lifecycle API 文章 。
idle-until-urgent 的用例
每当你需要运行可能代价高昂的代码时,你应该尝试将其分解为更小的任务。如果现在不需要立即使用该代码,但未来某些时候可能需要该代码,那么它就是一个完美的,可使用空闲直到紧急策略的用例 。
在你自己的代码中,我建议做的第一件事就是查看你所有的构造函数,如果它们中的任何一个会运行可能很耗时的操作,那么重构它们以使用 IdleValue 对象来代替。
对于其他的一些逻辑,如果这些逻辑对于直接用户交互是必要的,但并不一定是决定性的,那么请考虑将该逻辑添加到 IdleQueue 。不用担心,如果你需要立即运行该代码,你随时可以。
特别适合这种技术的两个具体示例(并且与大部分网站相关)是持久化应用程序状态(例如,使用Redux之类)和网站分析。
注意:这些用例想表明的意图是任务应该在空闲期间运行,当然,如果它们没有立即运行也没有问题。如果您需要处理高优先级的任务,这些任务旨在尽快运行(但仍然需要响应输入),那么 requestIdleCallback() 可能无法解决您的问题。
幸运的是,我的一些同事提出了新的Web平台API( shouldYield() 和原生的 Scheduling API ),它们可能会对你有所帮助。
持久化应用状态
考虑这样一个 Redux 应用程序,它将应用程序状态存储在内存中,但也需要将其存储在持久存储(如 localStorage )中,以便下次用户访问页面时可以重新加载。
在 localStorage 中存储状态的大多数 Redux 应用程序使用的 debounce 技术大致是这样的:
let debounceTimeout;
// Persist state changes to localStorage using a 1000ms debounce.
store.subscribe(() => {
// Clear pending writes since there are new changes to save.
clearTimeout(debounceTimeout);
// Schedule the save with a 1000ms timeout (debounce),
// so frequent changes aren't saved unnecessarily.
debounceTimeout = setTimeout(() => {
const jsonData = JSON.stringify(store.getState());
localStorage.setItem('redux-data', jsonData);
}, 1000);
});
虽然使用 debounce 技术肯定比什么都不用好,但它并不是一个完美的解决方案。问题是你无法保证当 debounced 函数运行时,它不会在对用户来说很关键的时间点阻塞主线程。
在空闲时间安排 localStorage 写入会好得多。你可以将上述代码从 debounce 策略转换为 idle-until-urgent 策略,如下所示:
const queue = new IdleQueue({ensureTasksRun: true});
// Persist state changes when the browser is idle, and
// only persist the most recent changes to avoid extra work.
store.subscribe(() => {
// Clear pending writes since there are new changes to save.
queue.clearPendingTasks();
// Schedule the save to run when idle.
queue.pushTask(() => {
const jsonData = JSON.stringify(store.getState());
localStorage.setItem('redux-data', jsonData);
});
});
请注意,此策略肯定比使用 debounce 更好,因为即使用户离开页面,它也可以保证状态得到保存。而使用 debounce 的例子,写入可能会在这种情况下失败。
网站分析
idle-until-urgent 的另一个完美用例是分析代码。下面是一个示例,说明如何使用 IdleQueue 类来安排发送分析数据,以确保即使用户关闭选项卡或在下一个空闲时段之前导航网页, 也会发送分析数据。
const queue = new IdleQueue({ensureTasksRun: true});
const signupBtn = document.getElementById('signup');
signupBtn.addEventListener('click', () => {
// Instead of sending the event immediately, add it to the idle queue.
// The idle queue will ensure the event is sent even if the user
// closes the tab or navigates away.
queue.pushTask(() => {
ga('send', 'event', {
eventCategory: 'Signup Button',
eventAction: 'click',
});
});
});
除了确保紧急时执行之外,将此任务添加到空闲队列还可确保它不会阻止任何其他响应用户单击操作所需的代码。
实际上,通常最好的方法是在闲暇时运行所有分析代码,包括初始化代码。对于像 analytics.js 这样的 API 已经成为队列的库 ,可以很容易地将这些命令添加到我们的 IdleQueue 实例中。
例如,您可以将 默认analytics.js安装代码段 的最后一部分这样转换: 从:
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
转成这样:
const queue = new IdleQueue({ensureTasksRun: true});
queue.pushTask(() => ga('create', 'UA-XXXXX-Y', 'auto'));
queue.pushTask(() => ga('send', 'pageview'));
(你也可以给 ga() 函数创建一个能够自动把命令加入队列的 wrapper,这就是我所做的 )。
requestIdleCallback 的浏览器支持
在撰写本文时,只有 Chrome 和 Firefox 支持 requestIdleCallback() 。虽然真正的 polyfill 是不可能实现的(因为只有浏览器自己可以知道它何时空闲),但是很容易编写一个退回 setTimeout 的 fallback(本文中提到的所有 helper 类和方法都使用了这个 fallback )。
即使在不原生支持 requestIdleCallback() 的浏览器中, 使用 setTimeout 的 fallback 肯定比不使用此策略更好,因为浏览器仍然可以在通过 setTimeout() 进行排队的任务之前进行输入优先级排序。
这实际上提高了多少性能?
在本文开头我提到我想出了这个策略,是因为我试图提高我的网站的 FID 值。我试图将 main bundle 加载后的立即运行的所有代码分割开,但我还需要确保我的网站继续使用只有同步 API 的某些第三方库(例如 analytics.js )。
我在实现 idle-until-urgent 之前做的跟踪显示出,我有一个包含所有初始化代码的233ms 任务。在实现我在此描述的技术之后,你可以看到我有多个更短时间的任务。事实上,最长的一个现在只有37毫秒! 我网站的 JavaScript 的性能跟踪显示了许多简短的任务。 这里要强调的一个非常重要的一点是,它完成的工作和之前是一样的,只是现在分散在多个任务上并在闲置期间运行。
因为没有任何一项任务超过 50 毫秒,所以没有一个任务影响我的交互时间(TTI),这对我的 lighthouse 得分很有帮助: 我实施了 idle-until-urget 之后的 Lighthouse 报告。
最后,由于所有这些工作的重点是提高我的FID,在将这些更改发布到生产环境并查看结果后,我很高兴发现我在第 99 百分位数下的 FID 值减少了67%!
结论
在一个完美的世界中,我们的网站永远不会在不必要的时刻阻塞主线程。我们都使用 Web worker 来完成非 UI 的工作,并且我们在浏览器中内置了 shouldYield()和原生 Scheduling API 。
但是在我们当前的世界中,我们的 Web 开发人员通常别无选择,只能在主线程上运行非 UI 代码,这会导致无响应。
希望本文能够说服你切分长期运行的 JavaScript 任务。而且,由于 idle-until-urgent 可以将看起来同步的 API 变成实际上在空闲时段运行的代码,因此它是一个很好的解决方案,并适用于我们今天广泛使用的库。
作者简介:serics,芦苇科技web前端开发工程师,擅长网站建设、公众号开发,专注于前端领域框架、交互设计、图像绘制、数据分析等研究,访问 www.talkmoney.cn 了解更多。