上一主题 下一主题
ScriptCat,新一代的脚本管理器脚本站,与全世界分享你的用户脚本油猴脚本开发指南教程目录
12下一页
返回列表 发新帖

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

[复制链接]
  • TA的每日心情
    慵懒
    14 小时前
  • 签到天数: 811 天

    [LV.10]以坛为家III

    31

    主题

    552

    回帖

    1555

    积分

    荣誉开发者

    积分
    1555

    荣誉开发者新人进步奖油中2周年生态建设者新人报道挑战者 lv2油中3周年喜迎中秋

    发表于 2022-8-30 00:12:35 | 显示全部楼层 | 阅读模式

    本帖最后由 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

    setInterval+visible 0.png

    setInterval 不活动 设置0ms 实际0~1000ms 平均500ms

    setInterval+hidden 0.png

    setInterval 不活动 设置200ms 实际1000ms(未浮动 但一段时间后会被提升至60000ms)

    setInterval+hidden 200.png
    setInterval+hidden 200+.png

    setTimeout 活动 设置0ms 实际5ms

    setTimeout+visible 0.png

    setInterval 不活动 设置2000ms 实际2000~3000ms 平均2500ms

    setTimeout+hidden 2000.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([worker_fuc]); //生成一个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([worker_fuc]); //生成一个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
    你可以使用 Tampermonkey 然后打开插件配置 extension://iikmkjmpaadaobahmlepeloendndfphd/options.html#nav=settings

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

    具体情况请看 issues/1

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

    测试结果(测试网站MDN)

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

    完结撒花

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

    更新日志:

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

    已有3人评分好评 油猫币 贡献 理由
    Kished + 1 很给力!
    王一之 + 1 + 4 + 1 感谢分享
    由禅姌 + 1 + 7 ggnb!

    查看全部评分 总评分:好评 +3  油猫币 +11  贡献 +1 

  • TA的每日心情
    开心
    2022-10-9 08:55
  • 签到天数: 40 天

    [LV.5]常住居民I

    18

    主题

    16

    回帖

    261

    积分

    高级工程师

    积分
    261

    荣誉开发者新人报道油中2周年生态建设者

    发表于 2022-8-30 08:06:52 | 显示全部楼层
    哥哥牛逼!!!!!!
    回复

    使用道具 举报

  • TA的每日心情
    开心
    6 小时前
  • 签到天数: 213 天

    [LV.7]常住居民III

    305

    主题

    4189

    回帖

    4056

    积分

    管理员

    积分
    4056

    管理员荣誉开发者油中2周年生态建设者喜迎中秋油中3周年挑战者 lv2

    发表于 2022-8-30 09:50:27 | 显示全部楼层
    学习到了,之前经常遇到过类似问题

    好像还有一种电脑休眠的情况,也会导致有差异,应该和休眠的情况差不多
    已有1人评分好评 油猫币 理由
    由禅姌 + 1 + 7 ggnb!

    查看全部评分 总评分:好评 +1  油猫币 +7 

    上不慕古,下不肖俗。为疏为懒,不敢为狂。为拙为愚,不敢为恶。
    回复

    使用道具 举报

  • TA的每日心情
    慵懒
    14 小时前
  • 签到天数: 811 天

    [LV.10]以坛为家III

    31

    主题

    552

    回帖

    1555

    积分

    荣誉开发者

    积分
    1555

    荣誉开发者新人进步奖油中2周年生态建设者新人报道挑战者 lv2油中3周年喜迎中秋

    发表于 2022-8-30 10:23:54 | 显示全部楼层
    王一之 发表于 2022-8-30 09:50
    学习到了,之前经常遇到过类似问题

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

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

    使用道具 举报

  • TA的每日心情
    慵懒
    2024-10-28 07:07
  • 签到天数: 193 天

    [LV.7]常住居民III

    712

    主题

    5959

    回帖

    6758

    积分

    管理员

    非物质文化遗产社会摇传承人

    积分
    6758

    荣誉开发者喜迎中秋油中2周年生态建设者

    发表于 2022-8-30 10:31:55 | 显示全部楼层
    说实话我一直搞不懂触发条件是啥
    我看别人总触发
    可是在我电脑上我上次测试即使我把标签页最小化也不会节能= =
    就感觉触发条件很迷
    浏览器厂商还不告诉你
    混的人。
    ------------------------------------------
    進撃!永遠の帝国の破壊虎---李恒道

    入驻了爱发电https://afdian.net/a/lihengdao666
    个人宣言:この世界で私に胜てる人とコードはまだ生まれていません。死ぬのが怖くなければ来てください。
    回复

    使用道具 举报

  • TA的每日心情
    慵懒
    14 小时前
  • 签到天数: 811 天

    [LV.10]以坛为家III

    31

    主题

    552

    回帖

    1555

    积分

    荣誉开发者

    积分
    1555

    荣誉开发者新人进步奖油中2周年生态建设者新人报道挑战者 lv2油中3周年喜迎中秋

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

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

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

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

    回复

    使用道具 举报

  • TA的每日心情
    开心
    2024-7-30 00:00
  • 签到天数: 122 天

    [LV.7]常住居民III

    29

    主题

    601

    回帖

    542

    积分

    专家

    积分
    542

    油中2周年生态建设者油中3周年挑战者 lv2

    发表于 2022-8-30 12:47:34 | 显示全部楼层
    async/await这种写法 会不会中途被中断呢
    回复

    使用道具 举报

  • TA的每日心情
    慵懒
    14 小时前
  • 签到天数: 811 天

    [LV.10]以坛为家III

    31

    主题

    552

    回帖

    1555

    积分

    荣誉开发者

    积分
    1555

    荣誉开发者新人进步奖油中2周年生态建设者新人报道挑战者 lv2油中3周年喜迎中秋

    发表于 2022-8-30 13:06:17 | 显示全部楼层
    脚本体验师001 发表于 2022-8-30 12:47
    async/await这种写法 会不会中途被中断呢

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

    使用道具 举报

    该用户从未签到

    1

    主题

    4

    回帖

    93

    积分

    初级工程师

    积分
    93
    发表于 2022-10-6 00:23:12 | 显示全部楼层

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

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

    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

    回复

    使用道具 举报

  • TA的每日心情
    慵懒
    14 小时前
  • 签到天数: 811 天

    [LV.10]以坛为家III

    31

    主题

    552

    回帖

    1555

    积分

    荣誉开发者

    积分
    1555

    荣誉开发者新人进步奖油中2周年生态建设者新人报道挑战者 lv2油中3周年喜迎中秋

    发表于 2022-10-6 01:13:26 | 显示全部楼层

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

    [md]```typescript
    function setIntervalWorker(callback: () => void, interval: number) {
    const worke ...

    可以 这样看上去简洁、清晰许多👍

    回复

    使用道具 举报

    发表回复

    本版积分规则

    快速回复 返回顶部 返回列表