大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

在为耗时的代码选择执行策略时,可以有以下几种方案。
立即执行有一个缺点,即如果用户在代码执行时尝试与页面交互,浏览器必须等到代码执行完成后才能响应。此时如果页面看起来已经准备好响应用户输入,但实际上却不能,将严重影响用户体验。
同时,立即执行的代码越多,页面交互所需的时间就越长。
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()));
}
}惰性执行表明等到真正需要时再执行代码,但是该方案的问题很明显,即可能阻止用户输入。对于推迟从网络加载其他内容该方案是有意义的,但对于正在执行的大多数代码,例如:访问 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()));
}
}以上两种策略一般都 比立即求值或惰性求值更好,由于不太可能导致阻塞用户输入的单个长任务。这是由于,虽然浏览器无法中断任何单个任务来响应用户输入,但可以在调度的任务队列之间运行任务,并且大多数浏览器在任务由用户输入引起时都会这样做,即输入优先级。
换句话说,如果确保所有代码都在简短、不同的任务中运行(最好少于 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 组件( contentLoader 和 drawer)的初始化,因此不太可能阻止用户输入,但问题是有可能在用户交互时尚未渲染好!
同时,虽然使用 requestIdleCallback() 延迟埋点上报,但在下一个空闲期之前任何交互都会丢失(未实例化好)。同时,如果在用户离开页面之前没有空闲期,这些回调可能根本不会运行。
实则真正好的代码执行策略是: 代码默认被推迟到空闲时段,但一旦需要就会立即运行,即 Idle-Until-Urgent。该策略在最坏的情况下具有与惰性执行完全一样的性能,而在最好的情况下,根本不会阻止用户交互,由于执行发生在空闲时段。
import {IdleValue} from './path/to/IdleValue.mjs';
class MyComponent {
constructor() {
addEventListener('click', () => this.handleUserClick());
// 不会在事件监听函数中实例化 formatter,由于会影响用户响应
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 类实现了以下两个目的:
对于计算成本高的单个属性值,没有理由不使用此策略,除了 Intl.DateTimeFormat 外还包括以下场景:
上述技术对于可以使用单个函数计算其值的场景超级有效,但在某些情况下,逻辑不适合单个函数,或者即使从技术上讲可以,但仍希望将其分解为较小的函数从而不阻塞主线程。
在这种情况下,真正需要的是一个队列,开发者可以在其中调度多个任务以在浏览器空闲时运行,并在需要让出主线程时暂停任务的执行,例如:用户交互。
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;
// 页面卸载 unload 时渲染可以不用执行
// 但是保存用户状态和发送埋点等不可避免要执行
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 也提供了一种方法用于在需要时立即执行。同样,最后一点也超级重大:不仅仅是由于有时需要尽快计算,而且代码常常会与同步的第三方 API 集成,因此如果想要兼容则也需要能够同步运行任务。
在理想的世界中,所有 JavaScript API 都是非阻塞、异步的,并且由可以随意返回主线程的小块代码组成。 但在现实世界中,由于遗留的代码库或与无法控制的第三方库集成,因此可能一般别无选择,只能同步。
而 Idle-Until-Urgent 模式的一大优势在于:其可以轻松应用于大多数程序,而无需大规模重写架构。
例如 Redux 应用程序将状态存储在内存中,但也需要将其存储在持久存储(如 localStorage)中,以便下次用户访问页面时可以重新加载。
大多数将状态存储在 localStorage 中的 Redux 应用程序使用防抖技术:
let debounceTimeout;
store.subscribe(() => {
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);
});虽然使用防抖技术有必定的效果,但并不完美,缺点在于无法保证当防抖函数运行时不会阻塞主线程,最终影响用户操作。因此,最好是将 localStorage 写入调度放在空闲时间。同时,借助于 ensureTasksRun 配置,最终保证用户离开页面也能正常写入。
const queue = new IdleQueue({ensureTasksRun: true});
store.subscribe(() => {
queue.clearPendingTasks();
queue.pushTask(() => {
const jsonData = JSON.stringify(store.getState());
localStorage.setItem('redux-data', jsonData);
});
});另一个超级适合使用 idle-until-urgent 的用例是发送埋点,下面的示例使用 IdleQueue 类来安排发送分析数据,以确保即使用户在下一个空闲期之前关闭选项卡或离开,也会发送分析数据。
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 还可以确保不会阻塞响应用户点击所需的任何其他代码。
实际上,一般最好让所有 Analytics 代码(包括初始化代码)放在空闲状态执行。对于 analytics.js 等库而言,其 API 实际上已经是一个队列,因此只需将这些命令添加到 IdleQueue 实例中即可。
const queue = new IdleQueue({ensureTasksRun: true});
queue.pushTask(() => ga('create', 'UA-XXXXX-Y', 'auto'));
queue.pushTask(() => ga('send', 'pageview'));
// 添加到任务队列https://philipwalton.com/articles/idle-until-urgent/
https://github.com/GoogleChromeLabs/idlize
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat