问题背景
在我们实现页面交互时,时常需要通过 「延迟执行」 来实现一些特定的功能。
比如使用 setTimeout 延迟执行渲染,来实现 「逐字输出」 效果。
如下代码示例:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="text"></div>
<script>
const textElement = document.getElementById("text");
const text =
"春天,我从来没有强烈的感觉到春天在去年。有人说春天应该是一个快乐的季节。但我从来没有感觉到。我一直喜欢秋天,因为我认为秋天是一个浪漫的季节。我很喜欢夏天,因为我很年轻,我喜欢我的裙子和花边,现在,我还喜欢秋天和夏天,而我喜欢春天和冬天。以前我不喜欢各种颜色的花朵,我认为他们是不负责任的和肤浅的。我认为只有蓝色的海洋是深的,金色的秋天是优雅的。然而,现在我有一个不同的想法,我觉得春天美好。我喜欢在现场和在山的花。从他们身上我有生命的精神。";
let index = 0;
let now = Date.now();
function typeWriter() {
if (index < text.length) {
textElement.textContent += text.charAt(index);
index++;
console.log(index, Date.now() - now); // 统计计时器执行时间
now = Date.now();
setTimeout(typeWriter, 100);
}
}
typeWriter();
</script>
</body>
</html>
这段 setTimeout 计时器代码,在你正常浏览「当前页面」时,运行上不会有问题。
一旦你切换到其他 Tab 页,或是其他类似操作致使「当前页面」不在电脑屏幕的可视区域时,
你会发现:刚才页面进行中的 setTimeout 停止了工作(休眠),准确来说是放慢了工作,当你设定计时器延迟时间为 100ms 时,它会放慢到 1000ms 来工作。只有在你重新切回页面后,计时器延迟时间才会恢复正常。。
这其实与浏览器的优化机制(省电策略)有关:setTimeout()、setInterval() 以及 requestAnimationFrame() 在浏览器窗口 「非激活」 的状态下会停止工作或者以极慢的速度工作。
有一个
requestIdleCallback(),在浏览器窗口 「非激活」 的状态下不会停止工作,但是它没办法像 setTimeout 那样能够手动控制延迟时间。
但有时候这个优化并不是我们想要的,它会限制我们程序代码的正常执行。
解决方案
为了让浏览器窗口在非激活状态(或者最小化)下 计时器 有效不休眠,可以用 HTML5 的新特性:Web Workers 来解决。
Web Workers 是 HTML5 提供的一个 JavaScript 多线程解决方案,可以将一些大计算量的代码交由 Web Workers 运行而不冻结、阻塞用户界面。
基于 Web Workers 的特性,我们将 计时器 的执行放入 worker 子线程中,主线程只用不断接收,子线程在延迟时间过期后推送的通知就好了。
下面我们一起来看看具体的实现。
1、定义 Worker.js
首先,我们定义一个 delayWorker.js 文件,作为开启子线程要执行的文件。计时器在这里注册,当延迟时间过期后,会向外推送消息,来执行对应的 callback 延迟函数。
同时,也支持计时器清除的实现。
// delayWorker.js
// 存储 setTimeout 返回值 timeoutID 的集合,用于计时器清除
const setTimeoutIdsMap = new Map();
onmessage = function (evt) {
console.log("delayWorker.js 接收到用户传递的 data: ", evt);
const { action, id, timeout } = evt.data;
// 创建计时器
if (action === "setTimeout") {
// 在 worker 中开启计时器代码
const timeoutID = setTimeout(() => {
postMessage(id);
setTimeoutIdsMap.delete(id);
}, timeout);
setTimeoutIdsMap.set(id, timeoutID);
}
// 清除计时器
else if (action === "clearTimeout") {
const timeoutID = setTimeoutIdsMap.get(id);
if (timeoutID !== undefined) {
clearTimeout(timeoutID);
setTimeoutIdsMap.delete(id);
}
}
};
2、实现计时器方法
接着,我们封装两个方法:
setTimeoutWork()代替setTimeout,它的用法与setTimeout一致;clearTimeoutWork()代替clearTimeout,它的用法与clearTimeout一致。
// setTimeoutWork.js
// 引入 work.js
const worker = new Worker("delayWorker.js");
// 收集计时器回调
const worksMap = new Map();
// 模拟 setTimeout 的返回值 timeoutID,从 1 开始,可用于取消该定时器。
let timeoutID = 1;
worker.onmessage = function (evt) {
console.log("delayWorker.js 推送过来的 data: ", evt);
const id = evt.data;
if (worksMap.has(id)) {
const { callback } = worksMap.get(id);
callback(); // 执行回调
worksMap.delete(id);
}
};
function setTimeoutWork(callback, timeout) {
// 为 work 提供一个唯一 id
const id = timeoutID++;
// 保存 id 与 callback 的关系
worksMap.set(id, { callback, timeout });
worker.postMessage({ action: "setTimeout", id, timeout }); // 向 worker 发送数据
return id; // 返回 id,可用于清除计时器
}
function clearTimeoutWork(id) {
if (worksMap.has(id)) {
worksMap.delete(id);
worker.postMessage({ action: "clearTimeout", id });
}
}
setTimeoutWork 的执行流程分析:
- 在使用
setTimeoutWork时传入callback延迟回调函数 和timeout延迟时间; setTimeoutWork为延迟任务生成一个唯一id,并将callback存放到Map集合中;- 接着向
Web Worker子线程推送消息,在子线程中开启计时器代码的执行; - 子线程会在计时器延迟时间达到以后,推送通知,主线程
work.onmessage会接收到消息; - 最后,通过唯一
id在Map集合中找到callback执行回调函数。
3、使用示例
最后,上面的 「逐字输出」 示例可以改成 setTimeoutWork 去执行:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="text"></div>
<script src="./setTimeoutWork.js"></script>
<script>
const textElement = document.getElementById("text");
const text =
"春天,我从来没有强烈的感觉到春天在去年。有人说春天应该是一个快乐的季节。但我从来没有感觉到。我一直喜欢秋天,因为我认为秋天是一个浪漫的季节。我很喜欢夏天,因为我很年轻,我喜欢我的裙子和花边,现在,我还喜欢秋天和夏天,而我喜欢春天和冬天。以前我不喜欢各种颜色的花朵,我认为他们是不负责任的和肤浅的。我认为只有蓝色的海洋是深的,金色的秋天是优雅的。然而,现在我有一个不同的想法,我觉得春天美好。我喜欢在现场和在山的花。从他们身上我有生命的精神。";
let index = 0;
function typeWriter() {
if (index < text.length) {
textElement.textContent += text.charAt(index);
index++;
setTimeoutWork(typeWriter, 100);
}
}
typeWriter();
</script>
</body>
</html>