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

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

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

    [LV.6]常住居民II

    9

    主题

    220

    帖子

    537

    积分

    荣誉开发者

    Rank: 10Rank: 10Rank: 10

    积分
    537

    新人进步奖荣誉开发者喜迎中秋

    发表于 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 初次发布

    已有2人评分好评 油猫币 贡献 理由
    王一之 + 1 + 4 + 1 感谢分享
    张正则 + 1 + 7 ggnb!

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

  • TA的每日心情
    开心
    3 天前
  • 签到天数: 39 天

    [LV.5]常住居民I

    13

    主题

    28

    帖子

    182

    积分

    荣誉开发者

    Rank: 10Rank: 10Rank: 10

    积分
    182

    荣誉开发者国庆纪念章家财万贯新人报道喜迎中秋

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

    使用道具 举报

  • TA的每日心情
    开心
    昨天 13:55
  • 签到天数: 100 天

    [LV.6]常住居民II

    171

    主题

    2253

    帖子

    2299

    积分

    管理员

    Rank: 10Rank: 10Rank: 10

    积分
    2299

    荣誉开发者喜迎中秋热心会员活跃会员突出贡献三好学生管理员家财万贯

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

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

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

    上不慕古,下不肖俗。为疏为懒,不敢为狂。为拙为愚,不敢为恶。/ 微信公众号:一之哥哥
    回复

    使用道具 举报

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

    [LV.6]常住居民II

    9

    主题

    220

    帖子

    537

    积分

    荣誉开发者

    Rank: 10Rank: 10Rank: 10

    积分
    537

    新人进步奖荣誉开发者喜迎中秋

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

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

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

    使用道具 举报

  • TA的每日心情
    开心
    前天 23:59
  • 签到天数: 88 天

    [LV.6]常住居民II

    386

    主题

    3405

    帖子

    3388

    积分

    管理员

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

    Rank: 10Rank: 10Rank: 10

    积分
    3388

    喜迎中秋国庆纪念章荣誉开发者家财万贯管理员

    发表于 2022-8-30 10:31:55 | 显示全部楼层
    说实话我一直搞不懂触发条件是啥
    我看别人总触发
    可是在我电脑上我上次测试即使我把标签页最小化也不会节能= =
    就感觉触发条件很迷
    浏览器厂商还不告诉你
    混的人。
    ------------------------------------------
    進撃!永遠の帝国の破壊虎---李恒道
    个人宣言:この世界で私に胜てる人とコードはまだ生まれていません。死ぬのが怖くなければ来てください。
    回复

    使用道具 举报

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

    [LV.6]常住居民II

    9

    主题

    220

    帖子

    537

    积分

    荣誉开发者

    Rank: 10Rank: 10Rank: 10

    积分
    537

    新人进步奖荣誉开发者喜迎中秋

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

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

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

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

    回复

    使用道具 举报

  • TA的每日心情
    开心
    7 天前
  • 签到天数: 22 天

    [LV.4]偶尔看看III

    12

    主题

    296

    帖子

    231

    积分

    高级工程师

    Rank: 6Rank: 6

    积分
    231

    活跃会员热心会员三好学生

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

    使用道具 举报

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

    [LV.6]常住居民II

    9

    主题

    220

    帖子

    537

    积分

    荣誉开发者

    Rank: 10Rank: 10Rank: 10

    积分
    537

    新人进步奖荣誉开发者喜迎中秋

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

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

    使用道具 举报

    发表回复

    本版积分规则

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