
在为耗时的代码选择执行策略时,通常有以下几种方案。
1.1 立即执行 (Eager Evaluation)
立即执行有一个明显的缺点:如果用户在代码执行期间尝试与页面交互,浏览器必须等待代码执行完成后才能响应。此时如果页面看起来已经准备好,却无法响应用户输入,将严重影响用户体验。同时,立即执行的代码越多,页面可交互所需的时间就越长。
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()));
}
}
1.2 惰性执行 (Lazy Evaluation)
惰性执行意味着等到真正需要时才执行代码。但该方案的问题在于,它仍可能阻塞用户输入。对于推迟网络加载资源有意义,但对于访问 localStorage、处理大型数据集等大多数场景,开发者仍希望这些操作在用户交互前完成。
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
}
handleUserClick() {
// 在用户点击时惰性初始化 formatter
if (!this.formatter) {
this.formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
});
}
console.log(this.formatter.format(new Date()));
}
}
1.3 其他选择
- 延迟执行 (Deferred evaluation):使用
setTimeout 等方法调度代码在未来运行。
- 空闲执行 (Idle evaluation):使用
requestIdleCallback 等 API 调度代码在浏览器空闲期运行。
以上两种策略通常比立即或惰性求值更好,因为它们不太可能产生阻塞用户输入的单一长任务。浏览器虽然无法中断单个任务,但可以在任务队列之间响应用户输入。换句话说,如果确保所有代码都在简短、独立的任务中运行(最好少于 50 毫秒),那么代码执行就永远不会阻塞用户输入。
需要注意的是,虽然浏览器可以在调度的任务之间运行用户输入回调,但无法在微任务之前运行输入回调。由于 Promise 和 async 函数作为微任务运行,因此将同步代码简单转换为基于 Promise 的代码同样会阻止用户输入。
结合延迟执行和空闲执行的代码示例如下:
const main = () => {
setTimeout(() => drawer.init(), 0);
setTimeout(() => contentLoader.init(), 0);
setTimeout(() => breakpoints.init(), 0);
setTimeout(() => alerts.init(), 0);
requestIdleCallback(() => analytics.init());
};
main();
这段代码通过 setTimeout 推迟了 UI 组件的初始化,降低了阻塞用户输入的概率。但问题在于,用户交互时组件可能尚未初始化完成。同时,使用 requestIdleCallback() 延迟的埋点上报,如果在下一个空闲期之前发生交互,数据可能会丢失;如果用户在空闲期到来前离开页面,回调则根本不会执行。
2. 空闲直到紧急 (Idle-Until-Urgent) 的单任务策略
一个更优的策略是:代码默认被推迟到空闲时段执行,但一旦被需要就会立即运行,即 Idle-Until-Urgent。该策略在最坏情况下(急需时)与惰性执行的性能相同,而在最好情况下(空闲时已执行)则完全不会阻塞用户交互。
import {IdleValue} from './path/to/IdleValue.mjs';
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
// 不会在构造函数中立即实例化 formatter,改用 IdleValue
this.formatter = new IdleValue(() => {
return new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
});
});
}
handleUserClick() {
console.log(this.formatter.getValue().format(new Date()));
}
}
以下是 IdleValue 类的核心实现:
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 类实现了两个目的:
- 如果在
requestIdleCallback 调度之前(即空闲期之前)调用 getValue,则立即执行初始化函数并返回,此时性能与惰性求值一致。
- 如果在
requestIdleCallback 调度之后(即空闲期已执行)调用 getValue,则直接返回已计算好的值,不会阻塞用户输入。
对于计算成本高的单个属性值,此策略非常适用。除了 Intl.DateTimeFormat,典型场景还包括:
- 处理大型数据集集合。
- 从
localStorage(或 Cookie)获取值,这类数据库/中间件操作。
- 运行
getComputedStyle()、getBoundingClientRect() 或任何其他可能触发主线程样式重计算或布局的 API。
3. 空闲直到紧急 (Idle-Until-Urgent) 的多任务策略
上述技术适用于可由单个函数计算其值的场景。但对于更复杂的逻辑,或者希望将任务分解以避免阻塞主线程时,我们需要一个任务队列。该队列允许调度多个任务在浏览器空闲时运行,并能在需要让出主线程时(如用户交互)暂停执行。
以下是 IdleQueue 类的简化实现:
import {cIC, rIC} from './idle-callback-polyfills.mjs';
import {now} from './lib/now.mjs';
import {queueMicrotask} from './lib/queueMicrotask.mjs';
const DEFAULT_MIN_TASK_TIME = 0;
const isSafari_ = !!(typeof safari === 'object' && safari.pushNotification);
export class IdleQueue {
constructor({
ensureTasksRun = false,
defaultMinTaskTime = DEFAULT_MIN_TASK_TIME,
} = {}) {
this.idleCallbackHandle_ = null;
this.taskQueue_ = [];
this.isProcessing_ = false;
this.state_ = null;
this.defaultMinTaskTime_ = defaultMinTaskTime;
this.ensureTasksRun_ = ensureTasksRun;
this.runTasksImmediately = this.runTasksImmediately.bind(this);
this.runTasks_ = this.runTasks_.bind(this);
this.onVisibilityChange_ = this.onVisibilityChange_.bind(this);
if (this.ensureTasksRun_) {
addEventListener('visibilitychange', this.onVisibilityChange_, true);
if (isSafari_) {
addEventListener('beforeunload', this.runTasksImmediately, true);
}
}
}
pushTask(...args) {
this.addTask_(Array.prototype.push, ...args);
}
unshiftTask(...args) {
this.addTask_(Array.prototype.unshift, ...args);
}
runTasksImmediately() {
this.runTasks_();
}
hasPendingTasks() {
return this.taskQueue_.length > 0;
}
clearPendingTasks() {
this.taskQueue_ = [];
this.cancelScheduledRun_();
}
getState() {
return this.state_;
}
destroy() {
this.taskQueue_ = [];
this.cancelScheduledRun_();
if (this.ensureTasksRun_) {
removeEventListener('visibilitychange', this.onVisibilityChange_, true);
if (isSafari_) {
removeEventListener(
'beforeunload', this.runTasksImmediately, true);
}
}
}
addTask_(arrayMethod, task, {minTaskTime = this.defaultMinTaskTime_} = {}) {
const state = {
time: now(),
visibilityState: document.visibilityState,
};
arrayMethod.call(this.taskQueue_, {state, task, minTaskTime});
this.scheduleTasksToRun_();
}
scheduleTasksToRun_() {
if (this.ensureTasksRun_ && document.visibilityState === 'hidden') {
// 页面隐藏时,立即通过微任务调度执行
queueMicrotask(this.runTasks_);
} else {
if (!this.idleCallbackHandle_) {
this.idleCallbackHandle_ = rIC(this.runTasks_);
}
}
}
/**
* 真正执行任务
*/
runTasks_(deadline = undefined) {
this.cancelScheduledRun_();
if (!this.isProcessing_) {
this.isProcessing_ = true;
while (this.hasPendingTasks() &&
!shouldYield(deadline, this.taskQueue_[0].minTaskTime)) {
const {task, state} = this.taskQueue_.shift();
// 拆分小任务并执行 Task 任务
this.state_ = state;
task(state);
this.state_ = null;
}
this.isProcessing_ = false;
if (this.hasPendingTasks()) {
this.scheduleTasksToRun_();
}
}
}
cancelScheduledRun_() {
cIC(this.idleCallbackHandle_);
this.idleCallbackHandle_ = null;
}
// 页面隐藏是执行空闲任务的最佳时机
onVisibilityChange_() {
if (document.visibilityState === 'hidden') {
this.runTasksImmediately();
}
}
}
const shouldYield = (deadline, minTaskTime) => {
if (deadline && deadline.timeRemaining() <= minTaskTime) {
return true;
}
return false;
};
使用起来非常方便:
const queue = new IdleQueue();
queue.pushTask(() => {
// 空闲时要运行的任务
});
queue.pushTask(() => {
// 依赖于上面任务执行的任务
});
IdleQueue 也提供了在需要时立即执行所有任务的方法(runTasksImmediately)。这一点很重要:不仅因为有时需要尽快计算,还因为代码经常需要与同步的第三方 API 集成,同步执行能力保证了兼容性。
在理想情况下,所有 JavaScript API 都是非阻塞、异步的。但现实中,由于遗留代码或第三方库的限制,我们常常别无选择。Idle-Until-Urgent 模式的一大优势在于,它可以轻松应用于大多数现有程序,而无需大规模重写架构。
4. Idle-Until-Urgent 的典型用例
4.1 持久化应用程序状态
例如,使用 Redux 的应用程序需要将状态持久化到 localStorage 中。常见的防抖方案并不完美,因为无法保证执行写入时不会阻塞主线程。
let debounceTimeout;
store.subscribe(() => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
const jsonData = JSON.stringify(store.getState());
localStorage.setItem('redux-data', jsonData);
}, 1000);
});
更好的做法是使用 IdleQueue 来调度写入操作,并利用 ensureTasksRun 配置确保即使用户离开页面,数据也能被保存。
const queue = new IdleQueue({ensureTasksRun: true});
store.subscribe(() => {
queue.clearPendingTasks(); // 清除旧的待保存任务
queue.pushTask(() => {
const jsonData = JSON.stringify(store.getState());
localStorage.setItem('redux-data', jsonData);
});
});
4.2 Analytics 埋点数据发送
发送埋点数据是另一个非常适合使用 Idle-Until-Urgent 策略的用例。下面的示例确保即使用户在下一个空闲期之前关闭页面,分析数据也会被发送。
const queue = new IdleQueue({ensureTasksRun: true});
const signupBtn = document.getElementById('signup');
signupBtn.addEventListener('click', () => {
queue.pushTask(() => {
ga('send', 'event', {
eventCategory: 'Signup Button',
eventAction: 'click',
});
});
});
将任务添加到 IdleQueue 不仅能确保“紧急”执行,还能保证埋点发送不会阻塞响应用户点击的其他关键JavaScript代码。事实上,将分析库(如 analytics.js)的所有初始化命令和上报命令都通过 IdleQueue 来调度是一个最佳实践。
const queue = new IdleQueue({ensureTasksRun: true});
queue.pushTask(() => ga('create', 'UA-XXXXX-Y', 'auto'));
queue.pushTask(() => ga('send', 'pageview'));
// 后续所有 ga() 调用都可以通过 queue.pushTask 添加
参考资料