前端处理大量数据时经常会遇到页面假死的问题,无法响应任何页面交互,严重影响用户体验。
这是因为 js 是单线程执行的,而浏览器的页面渲染和 js 执行共享同一个主线程,因此一个执行时间过长的 js task 会阻塞主线程,导致页面无法响应。
使用 Chrome devtools 提供的 Performance 工具可以看到相关的提示:
我们可以通过将一个 long task 切分为多个 short task 的方式,交出主线程的执行权,这种方式也常被称为时间切片(Time Slice)。
Demo
const DATA = new Array(5000).fill();
const COSTLY_FUNC = () => {
for (let i = 0; i < 1000; i++) {
document.querySelector('div');
}
};
/**
* @param {function} runFn An iteratee function to be wrapped
* @param {function} cbFn A callback function when all iteratees are called
* @param {number} size Size of each short task
* @return {function} Wrapped iteratee function
*/
function timeSlice(runFn, cbFn, size = 10) {
const self = this;
const nextTick = window.setTimeout;
const queue = [];
const results = [];
let timer = 0;
const run = async () => {
let count = size;
while (count-- && queue.length) {
const args = queue.shift();
results.push(runFn.call(self, ...args));
}
if (queue.length > 0) {
timer = nextTick(run);
} else {
timer = 0;
await Promise.all(results);
cbFn();
}
};
const add = (...args) => {
queue.push([...args]);
if (timer === 0) {
timer = nextTick(run);
}
};
return add;
}
function loop() {
const start = performance.now();
DATA.forEach(() => {
COSTLY_FUNC();
});
console.log(performance.now() - start);
}
function loopWithTimeSlice() {
const start = performance.now();
await new Promise(resolve =>
DATA.forEach(timeSlice(() => {
COSTLY_FUNC();
}, resolve))
);
console.log(performance.now() - start);
}
如上所示,timeSlice
函数将一个迭代回调包装为一个支持 time slice 的函数,调用该迭代回调时将不再立即调用,而是将其推入调用队列中,并通过 setTimeout
在后续的 task 中依次调用。
使用 Performance 工具 debug 上述 loop
和 loopWithTimeSlice
函数,可以看到 long task 已经被拆分为了多个 task:
Pro/Con
使用 Time Slice 的方式可以有效解决页面无响应的问题,但是由于执行权的释放让任务总体的执行时间也变得更长了。上述示例代码中使用 setTimeout
拆分任务,也可以替换使用 requestIdleCallback
进一步降权,但这也会拉长任务的总执行时间。
此外,子任务的大小也是一个需要调优的参数,根据一次迭代的执行时间长短相应地缩小或者扩大子任务中迭代执行的次数(即 timeSlice
函数中的 size 参数)。
Conclusion
Time Slice 并不是唯一的解决方案,也可以使用 Web Worker 将大量计算放在子线程中进行。
Time Slice 也不是通用的解决方案,需要根据情况选用,每种解决方案都有各自的额外开销,需要根据迭代选用适合的方案。