steven026 发表于 2022-8-30 00:12:35

更新v2 浏览器非活动标签中JS定时器最小定时间隔处理方法

本帖最后由 steven026 于 2022-9-2 20:08 编辑

> 本帖最后由 steven026 于 2022-9-2 19:32 编辑

## 前言
本文仅针对JS定时器setInterval和setTimeout在浏览器非活动标签页中的最小定时间隔进行探究并给出解决方案,非JS定时器入门指南,不再赘述其基础用法,如有需要可自行阅读MDN文档。
https://developer.mozilla.org/zh-CN/docs/Web/API/setInterval
https://developer.mozilla.org/zh-CN/docs/Web/API/setTimeout

## 介绍
原生JavaScript中并未setInterval和setTimeout的最小定时间隔,但根据setInterval和setTimeout定时原理,过小的定时间隔可能会出现各种性能错误,因此主流浏览器出于稳定性考虑人为规定了一个最小间隔4ms来减少错误,若定时器被创建时的定时间隔缺省或小于4ms,定时间隔会被浏览器提升至4ms(实际间隔受到原理影响或代码影响会略有浮动,并非严格>=4ms)。
且出于节能考虑,当浏览器标签处于不活动状态时(大多数情况下可以通过```!document.hidden```或是否正在播放音频来判断标签是否活动),这个最小定时间隔setInterval会被提升到1000ms而setTimeout会增加1000ms(经测试会不断在原始间隔和浏览器提升间隔中来回跳动,间隔越低越不稳定)。
根据这一浏览器特性,未经特殊处理的JS定时器往往会出现各种不符合预期的错误。

## 定时器实际间隔测试
一般可以通过记录定时器运行前的时间和运行后的时间,并将两次时间相减来获取实际定时器间隔。
时间记录一般可以使用```new Date()```或```performance.now()```。
具体代码表现为
```
//setInterval
let start = past = performance.now()
let delay = 0
let i = 1
function timer(text) {
    let now = performance.now()
    let cost = now - past
    let costAll = now - start
    past = now
    console.log(`${text}+${document.visibilityState} 第${i}次运行;总间隔${costAll};平均间隔${costAll / i};两次间隔${cost};设置间隔${delay};`)
    i++
}
let timerInterval = setInterval(() => {
    timer("setInterval")
    if (i > 1000) clearInterval(timerInterval)
}, delay)
```
```
//setTimeout
let start = past = performance.now()
let delay = 0
let i = 1
!(function timer(text) {
    let now = performance.now()
    let cost = now - past
    let costAll = now - start
    past = now
    console.log(`${text}+${document.visibilityState} 第${i}次运行;总间隔${costAll};平均间隔${costAll / i};两次间隔${cost};设置间隔${delay};`)
    i++
    if (i <= 1000) setTimeout(timer, delay, "setTimeout")
})()
```
#### 测试结果仅供参考(EDGE v104)
##### setInterval 活动 设置0ms 实际4ms
!(data/attachment/forum/202208/29/225858jhrzmmd0nnj11tnm.png)
##### setInterval 不活动 设置0ms 实际0~1000ms 平均500ms
!(data/attachment/forum/202208/29/230024iq1vqwbq348rdbp4.png)
##### setInterval 不活动 设置200ms 实际1000ms(未浮动 但一段时间后会被提升至60000ms)
!(data/attachment/forum/202208/30/101355wmsjyouwzjcze1do.png)
!(data/attachment/forum/202208/30/102616xmgunuwvugepu2g0.png)
##### setTimeout 活动 设置0ms 实际5ms
!(data/attachment/forum/202208/29/230137s93rzr21qn98l8pd.png)
##### setInterval 不活动 设置2000ms 实际2000~3000ms 平均2500ms
!(data/attachment/forum/202208/29/230320um9399l73rzvogvf.png)
测试数据基本和上述介绍中数据吻合
(和MDN部分数据冲突,可能和浏览器新行为特性有关)

***

***

## 应对方法
查阅相关资料可以发现一般有3种办法,使浏览器非活动标签中页的实际间隔降低到活动标签的最小间隔。
注:方法1个人在v104+Chrome\EDGE中测试失效,不推荐使用;方法2、方法3受CSP跨域限制,应对方法见后文。
#### 1.修改浏览器设置(个人测试没什么效果且设置较麻烦)
仅供参考
https://wiki.melvoridle.com/w/Mitigating_Browser_Throttling

#### 2.循环播放一段-58db以上的空音频
当一个标签页在播放音频时会被浏览器认为该标签页属于活动页,即使```document.hidden == true```
下方代码改进自原文章 https://stackoverflow.com/a/51191818/914546
原文章写于2018年,代码中的@resource音频链接以后可能会失效,建议下载到本地或保存为Base64
大致思路是通过@resource+GM_getResourceURL使用油猴下载一段-58db以上的空音频,然后通过new Audio+loop方式挂载到DOM标签中,并为document添加"visibilitychange"事件,当document隐藏时播放音频,显示时暂停播放。
(由于原音频是http协议,无法直接在https协议中使用,偷懒直接用GM_getResourceURL+unsafeWindow了,可以自行上传到https地址或手动将其转换成base64在none环境下直接用)

```
// ==UserScript==
// @name         保持浏览器标签活动
// @version      1.0
// @description循环播放一段-58db空音频
// @author       DreamNya
// @match      https://bbs.tampermonkey.net.cn/
// @grant      GM_getResourceURL
// @grant      unsafeWindow
// @resource ogg http://adventure.land/sounds/loops/empty_loop_for_js_performance.ogg
// @resource wav http://adventure.land/sounds/loops/empty_loop_for_js_performance.wav
// ==/UserScript==
let ogg = new Audio(GM_getResourceURL("ogg").replace("data:application", "data:audio/ogg"));
ogg.loop = true;
unsafeWindow.ogg = ogg;

let wav = new Audio(GM_getResourceURL("wav").replace("data:application", "data:audio/wav"));
wav.loop = true;
unsafeWindow.wav = wav;

document.addEventListener("visibilitychange", function () { //document不可见时循环播放空音频 可见时暂停
    document.hidden ? ogg.play() : ogg.pause() //ogg和wav二选一就行
})
```

#### 3.Web Worker
没有参考文章,自己根据MDN文档研究改进的方法。
大致思路是由于Web Woker只接受同域.js或者Blob,因此可以通过new Blob创建一个Worker函数,再通过URL.createObjectURL创建Blob链接供new Worker创建一个不受浏览器限制的独立线程,由Worker不断给油猴作用域postMessage,然后通过油猴作用域的onmessage回调函数。
由于Web Woker线程有自己独立的作用域,且postMessage无法传递也无法使Worker访问window,因此Web Woker除了定时setInterval或setTimeout不需要做任何事,只要当个工具人就可以了,回调函数全部写在油猴内就行。postMessage也不需要传递任何内容,传递本身这个动作足矣。
另外Web Worker的最小定时间隔也是4ms,Web Worker中的setTimeout也从5ms降低到了4ms。具体测试结果运行下方代码就可以看到。
```
// ==UserScript==
// @name         工具人Web Worker setInterval
// @version      1.0
// @description安安静静定时定时postMessage就够了
// @author       DreamNya
// @match      https://bbs.tampermonkey.net.cn/
// @grant      none
// ==/UserScript==
let worker_fuc = "setInterval(()=>{postMessage('')},0)"; //Worker函数
let worker_Blob = new Blob(); //生成一个Blob
let worker_URL = URL.createObjectURL(worker_Blob); //生成一个Blob链接
let worker = new Worker(worker_URL); //创建一个Web Worker

worker.onmessage = function (e) {
    callback()
    if (i > 1000) worker.terminate()
}


let start = performance.now();
let past = start;
let delay = 0;
let i = 1;

function callback(text = "setInterval+Worker") {
    let now = performance.now()
    let cost = now - past
    let costAll = now - start
    past = now
    console.log(`${text}+${document.visibilityState} 第${i}次运行;总间隔${costAll};平均间隔${costAll / i};两次间隔${cost};设置间隔${delay};`)
    i++
}
```
```
// ==UserScript==
// @name         工具人Web Worker setTimout
// @version      1.0
// @description安安静静定时postMessage就够了
// @author       DreamNya
// @match      https://bbs.tampermonkey.net.cn/
// @grant      none
// ==/UserScript==

let worker_fuc = "onmessage=function(e){setTimeout(()=>{postMessage('')},0)}"; //Worker函数 onmessage后setTimeout
let worker_Blob = new Blob(); //生成一个Blob
let worker_URL = URL.createObjectURL(worker_Blob); //生成一个Blob链接
let worker = new Worker(worker_URL); //创建一个Web Worker

worker.onmessage = function (e) {
    callback()
    if (i > 1000) worker.terminate()
}


let start = performance.now();
let past = start;
let delay = 0;
let i = 1;

function callback(text = "setTimeout+Worker") {
    let now = performance.now()
    let cost = now - past
    let costAll = now - start
    past = now
    console.log(`${text}+${document.visibilityState} 第${i}次运行;总间隔${costAll};平均间隔${costAll / i};两次间隔${cost};设置间隔${delay};`)
    i++
    worker.postMessage("") //完成上述函数后postMessage相当于setTimeout
}
```

## 关于CSP跨域限制的应对方法
注:似乎只能应对写在responseHeader中的CSP,无法解决写在DOM meta标签中的CSP(待研究)
**以下内容出处https://github.com/lisonge/vite-plugin-monkey/blob/main/README_zh.md#csp**
你可以使用 (https://www.tampermonkey.net/) 然后打开插件配置 `extension://iikmkjmpaadaobahmlepeloendndfphd/options.html#nav=settings`

在 `安全`, 设置 `如果站点有内容安全策略(CSP)则向其策略:` 为 `全部移除(可能不安全)`

具体情况请看 (https://github.com/lisonge/vite-plugin-monkey/issues/1)

如果你使用 `Violentmonkey`/`Greasemonkey`, 你能通过以下方式解决

- chrome - (https://chrome.google.com/webstore/detail/disable-content-security/ieelmcmcagommplceebfedjlakkhpden/)
- edge - (https://microsoftedge.microsoft.com/addons/detail/disable-contentsecurity/ecmfamimnofkleckfamjbphegacljmbp?hl=zh-CN)
- firefox - 在 `about:config` 菜单配置中,禁用 `security.csp.enable`

##### 测试结果(测试网站MDN)
当我们打开MDN时,默认情况MDN(或其他部分网站)出于安全防护目的会在responseHeader中设置CSP:'self'限制跨域防止被脚本攻击,同时这也可能会限制油猴/脚本猫脚本运行(见下图)
!(data/attachment/forum/202209/02/191624k34ybyflltty8trn.png)
!(data/attachment/forum/202209/02/191257asfkqqs5mmsm9gaq.png)
对于这种网站,若想使脚本中自定义的Audio/Worker正常运行,我们可以通过浏览器插件来绕开CSP限制。(以下为油猴设置方法,不需要额外插件;脚本猫或其他插件可以用上面介绍的方法绕开)
!(data/attachment/forum/202209/02/192418nnd86pp6u2m6dlr8.png)
刷新页面,重新测试,此时不再受CSP限制了。
!(data/attachment/forum/202209/02/192428u5mzcs95y5dd5iyd.png)

## 完结撒花
欢迎各位对此进行交流讨论,第一次写这类文章,如有不足之处尽请谅解。

##### 更新日志:
2022-09-02:v2 更新CSP跨域限制应对方法
2022-08-30:v1 初次发布

由禅姌 发表于 2022-8-30 08:06:52

哥哥牛逼!!!!!!

王一之 发表于 2022-8-30 09:50:27

学习到了,之前经常遇到过类似问题

好像还有一种电脑休眠的情况,也会导致有差异,应该和休眠的情况差不多

steven026 发表于 2022-8-30 10:23:54

王一之 发表于 2022-8-30 09:50
学习到了,之前经常遇到过类似问题

好像还有一种电脑休眠的情况,也会导致有差异,应该和休眠的情况差不多 ...

不知道为什么chrome一直在往节能那块发展,还是强制性的,节能到快魔怔了【不知道以后会不会对Worker下手……
老版本还能通过chrome://flags把检测标签是否活动的选项关了强制让浏览器认为标签都是活动的,后来更新了几次这个flag直接没了,要通过启动项的方式关闭,再后来连启动项都没用了……
本来setInterval和setTimeout就不是很准,现在一节能更加不准了,真让人头大

李恒道 发表于 2022-8-30 10:31:55

说实话我一直搞不懂触发条件是啥
我看别人总触发
可是在我电脑上我上次测试即使我把标签页最小化也不会节能= =
就感觉触发条件很迷
浏览器厂商还不告诉你

steven026 发表于 2022-8-30 11:04:37

李恒道 发表于 2022-8-30 10:31
说实话我一直搞不懂触发条件是啥
我看别人总触发
可是在我电脑上我上次测试即使我把标签页最小化也不会节能 ...

触发条件就是你看不见这个标签页,这个标签页就被浏览器判定为不活动状态
比如你在浏览器中开2个标签,你目前使用的标签算活动状态,另一个标签就算不活动状态
甚至于如果你在目前使用的标签中按F12打开控制台并将控制台最大化,这时候你就看不见你目前使用的标签了,也会被判定为不活动,非常严格

js代码大致可以通过document.hidden来判定,true为不活动,false为活动

旧版浏览器能单独设置或者把标签单独拖出来独立成一个窗口(Firefox没用过,只在Chrome和Edge试过)
最新的浏览器好像都已经强制节能了,行不通了

脚本体验师001 发表于 2022-8-30 12:47:34

async/await这种写法 会不会中途被中断呢

steven026 发表于 2022-8-30 13:06:17

脚本体验师001 发表于 2022-8-30 12:47
async/await这种写法 会不会中途被中断呢

不会 但是会阻塞其他代码运行

Kished 发表于 2022-10-6 00:23:12

本帖最后由 Kished 于 2022-10-6 01:12 编辑

> 本帖最后由 Kished 于 2022-10-6 00:46 编辑

```typescript
function setIntervalWorker(callback: () => void, interval: number) {
const workerBlob = new Blob([`setInterval(() => { postMessage('') }, ${interval})`]);
const workerURL = URL.createObjectURL(workerBlob);
const worker = new Worker(workerURL);
worker.onmessage = () => {
    callback();
};
return worker;
}

function setTimeoutWorker(callback: () => void, timeout: number) {
const workerBlob = new Blob([`setTimeout(() => { postMessage('') }, ${timeout})`]);
const workerURL = URL.createObjectURL(workerBlob);
const worker = new Worker(workerURL);
worker.onmessage = () => {
    callback();
    worker.terminate();
};
return worker;
}

export { setIntervalWorker, setTimeoutWorker };

```

我把它封装成了函数,可以直接替换需要用到定时器的地方。

之前一直用的循环播放音视频的方法,但最小化或完全遮挡窗口还是会降频,web worker 测试正常运行。

这个库直接全局替换了计时器,更方便一点
https://github.com/turuslan/HackTimer

封装成库的:
https://github.com/gorkemcnr/worker-interval

steven026 发表于 2022-10-6 01:13:26

Kished 发表于 2022-10-6 00:23
```typescript
function setIntervalWorker(callback: () => void, interval: number) {
const worke ...
可以 这样看上去简洁、清晰许多👍
页: [1] 2
查看完整版本: 更新v2 浏览器非活动标签中JS定时器最小定时间隔处理方法