深度好文: 从js visibilitychange Safari下无效说开去

这篇文章发布于 2021年11月24日,星期三,00:56,归类于 JS API。 阅读 24258 次, 今日 19 次 10 条评论

 

占位图

关于 visibilitychange 事件,在 2012 年的时候我就撰文介绍过了,详见“Page Visibility(页面可见性) API介绍、微拓展”,是一个非常有历史的 Web 特性,不过实际开发中,由于浏览器实现的细节差异,Page Visibility API 并不总是能够满足实际的生成需求,这是怎么回事呢?

本文就会从 visibilitychange 事件触发,带大家深入了解下 Web 页面的生命周期过程。

一、Safari下问题说明

在 Safari 浏览器下,无论是桌面端 Safari,还是 iOS Safari,visibilitychange 事件不总是触发的。

对于窗口最小化,Tab 隐藏等行为 visibilitychange 事件是正常的,但是如果是点击页面某个链接发生的当前页导航跳转,则 visibilitychange 事件不会触发。

所以,虽然 visibilitychange 看起来兼容性不错,IE10+支持,但是实际使用的时候还是有一些问题的,上述问题在 caniuse 上也是有对应的描述的。

visibilitychange 与 Safari

这就会给我们的业务开发带来困扰,例如,有一个数据上报的需求,希望用户不再访问此页面的时候,进行一次数据上报,则如果使用 visibilitychange 事件进行处理,Safari 浏览器下就会有数据异常的情况发生。

document.addEventListener('visibilitychange', function logData() {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/log', { /* 要发送的数据 */ });
  }
});

那有没有什么办法解决这个问题呢?

那就是使用 pagehide 事件。

二、和pageshow/pagehide的区别

1. 功能区别

虽然都是有显示与隐藏的含义,但是 visibilitychange 指的是页面的可见与不可见,pageshow/pagehide 指的是页面的进入与离开。

我们可以通过下面一段测试代码了解两者功能上的区别:

<div id="result"></div>
log = function (content) {
    result.innerHTML += content + '<br>';
};

window.addEventListener('pageshow', function () {
    log('pageshow: 页面显示');
});
window.addEventListener('pagehide', function () {
    log('pagehide: 页面隐藏');
});

document.addEventListener('visibilitychange', function () {
    if (document.hidden) {
        log('visibilitychange: 页面隐藏');
    } else {
        log('visibilitychange: 页面显示');
    }
});

页面显示与隐藏事件测试结果录屏

具体描述为:

  • 页面进入,包括刷新会触发 pageshow;
  • 选项卡切换,只会触发 visibilitychange 显示与隐藏;
  • 前进和后退,所有浏览器都会依次触发 pagehide,visibilitychange 和 pageshow;
  • 如果是点击某个链接跳转出去,则Safari浏览器会出现不一样的表现。

大家若有兴趣,可以访问这里感受下事件变化的触发。其实上面的第 4 点大家可以在 Safari 浏览器下测试下,点击页面链接然后再返回,会发现 visibilitychange 事件并未执行。

safari visibilitychange未执行示意图

2. 用法区别

'visibilitychange' 事件通常都是挂载在 document 对象上,虽然现在最新的浏览器也支持挂载在 window 对象上,不过由于 Safari 14 之前的版本不支持,因此,是不推荐使用下面的语法的:

window.addEventListener('visibilitychange', () => {});

而 pageshow 和 pagehide 事件都是通过 window 对象进行注册的。

3. 兼容性区别

pageshow 和 pagehide 事件是 IE11 及其以上浏览器支持的,而 visibilitychange 事件是 IE10 及其以上版本支持的。

具体如下截图示意:

pageshow兼容性

虽然 pageshow 和 pagehide 的兼容性略逊一筹,但是人家稳定啊,以及放眼整个世界,使用 IE10 浏览器的用户微乎其微,因为 IE10 就是个过渡版本。

三、unload 和 beforeunload 事件呢?

除非是要兼容古老的 IE 浏览器,以及在桌面端浏览器环境下阻止用户退出网页(如,您写的内容尚未保存,是否退出,如下代码所示),否则,没有任何理由使用 unload 和 beforeunload 事件,尤其是移动端的页面。

window.addEventListener('beforeunload', function (event) {
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = '您写的内容尚未保存,是否退出?';
  }
});

因为用户访问完一个页面,往往是直接切换到其他 APP,然后通过杀进程关掉整个浏览器 APP,unload 事件就不会触发。

以及另外一个比较重要的原因,unload 和 beforeunload 会阻止浏览器把页面存入缓存,影响浏览器前进和后退时候的响应速度。

unload 事件兼容性

四、痛快点,终极方案是什么?

回到一开始,只是说了 pagehide 解决 Safari 的问题,可具体该如何解决呢?

很简单,判断是不是 Safari 浏览器,然后额外增加一个 pagehide 事件:

document.addEventListener('visibilitychange', function logData() {
    if (document.visibilityState === 'hidden') {
      navigator.sendBeacon('/log', postData );
    }
});
if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
    window.addEventListener('pagehide', function () {
        navigator.sendBeacon('/log', postData );
    });
}

但是,上面的实现其实是有风险的,因为你并不知道哪一天 Safari 浏览器会改变自己的策略,也就是说不定 Safari 16 或者后面某一个版本 pagehide 也会触发 visibilitychange 行为,则上面的代码又会有重复上报的问题。

所以,比较稳妥,且自己不需要动脑子的方法,就是拾人牙慧,使用他人已经做好的项目进行开发,例如谷歌实验室开源的这个名为 PageLifecycle.js 的项目:github.com/GoogleChromeLabs/page-lifecycle

使用如下:

<script src="./lifecycle.es5.js"></script>
<script>
lifecycle.addEventListener('statechange', function(event) {
    console.log('状态变化:' + event.oldState + ' → ' + event.newState);
});
</script>

此时,当我们导航跳转再返回,就会出现如下截图所示的输出效果:

lifecycle.js 示意

您也可以访问这里亲自感受下输出结果。

Safari 下虽然细节上有差异,但是从 passive → hidden 这个状态和 Chrome 浏览器是一致的,如下截图所示:

Safari 下的生命周期状态变化

所以,我们希望页面离开时候上报数据,可以试试下面的代码,理论上应该是没问题的:

lifecycle.addEventListener('statechange', function(event) {
    if (event.oldState == 'passive' && event.newState == 'hidden') {
        navigator.sendBeacon('/log', postData);
    }
});

上述截图除了 passive 和 hidden 这了两个状态,还出现了 active 和 frozen,这些状态都表示什么意思呢,是浏览器原本就有的,还是 PageLifecycle.js 自定义的呢?

都是浏览器都有的,写入规范标准的状态,都属于页面生命周期的一部分。

五、了解页面的生命周期

完整的页面生命周期状态包括这些:

  • ACTIVE 激活
  • PASSIVE 未激活(页面可以看到,但焦点不在此页面,打开开发者工具可以触发此状态)
  • HIDDEN 隐藏,最小化、标签页切换都属于隐藏
  • FROZEN 冻结
  • TERMINATED 结束 (页面被关闭)
  • DISCARDED 废弃(页面内容被浏览器清空)

其中,从 HIDDEN 状态到 FROZEN 状态之间的变化是有新的 API 事件名称检测的,分别是 resume 事件和 freeze 事件,使用示意如下:

document.addEventListener('freeze', (event) => {
  // 页面被冻结
});

document.addEventListener('resume', (event) => {
  // 页面解冻了
});

Web 网页完整的生命周期流程见下面的高清大图(看不清可双指放大,或点击小图查看),原图是英文的,源自 google 官方的这篇文章,自己重新翻译了下,方便大家的学习。

页面声明周期完整示意高清大图

DISCARDED 废弃

其中,废弃状态是后来才有的,原本是没有的,目的是为了释放不必要的内存开销。

如果经常使用 Chrome 浏览器,应该都有遇到过这样的现象,就是一个很久没有访问的标签页再切换过去的时候,页面会重新加载一遍。

之所以会加载,是因为浏览器为了节约内存,把这个长时间不使用的页面给废弃了,所有页面的内存、缓存通通舍弃。

我个人是不太喜欢这样的处理的,因为有些页面,特别是图特别多的大型的文档(如 figma 设计稿),每次切换过去,都要重新 loading 一次,很不爽的。

关于这个,可以所啰嗦两句。

原本 IE 时代,Chrome 还没出现的时候,浏览器的标签页,如果你开了多个,只要 1 个崩掉了,整个浏览器都会崩溃,其他的标签页数据就会丢失。

当然 Chrome 出来的时候,其中宣传的一个优点就是每个标签页面独立,A 页面崩溃不会影响 B 页面,但是,这种不崩溃策略是以牺牲内存为代价的,因此,那个时候,经常有网络图戏谑 Chrome 是个内存怪兽(例如下面这个 GIF 图 – 1.05M 点击播放)。

Chrome 内存怪兽

而现在的这种冻结+废弃的策略,虽然省了内存,但是牺牲了用户体验,正所谓鱼和熊掌不可兼得,所以终极解决方法还是加大内存,16G内存走起。

在 Chrome 68 之后,我们可以使用 document.wasDiscarded 判断页面是不是处于废止状态。

以及,也可以在 Chrome 浏览器地址栏中输入 chrome://discards 查看各个页面的状态。

例如,我现在看了下(省略中间十几个大同小异):

标签页状态查看

可以看到,除了几个新打开不久的页面,其他页面都已经 DISCARDED 掉了,惨!

不说了,我要去找运维申请加内存条了。

六、例行的结尾内容

好,以上就是本文的全部内容,如果文中有表述不准确的地方,欢迎指正。

风萧萧兮易水寒,写作头发兮不复还,这句诗充分表现了写作的不易与辛苦,连头发都掉没了,所以,求分享,求转发。

参考文章

(本篇完)

分享到:


发表评论(目前10 条评论)

  1. Spirit说道:

    大佬,我发现一个问题
    visibilitychange 事件,在我当前页面打开控制台,来回切换Tab,这个时候visibilitychange 事件就不会生效,只有关闭控制台关闭才生效,这是为什么呢?

  2. 李子鱼说道:

    可惜lifecycle在微前端里子iframe里没发使用

  3. linyccc说道:

    visibilitychange 在大多场景是适配的,但是在ios,app下如果进行强制关闭进程会存在问题

  4. CTa说道:

    实测 safari 14.1 pagehide 触发的时候,同时会触发visibilitychange, 页面隐藏。
    链接离开再返回
    pageshow: 页面显示
    pagehide: 页面隐藏
    visibilitychange: 页面隐藏
    visibilitychange: 页面显示
    pageshow: 页面显示

    另外:如果 chrome 开了太多 tab,导航离开之后返回时只会触发 pageshow 事件。

  5. 岳俊奇说道:

    alert(1223)

  6. 晴窗一扇说道:

    前进和后退,所有浏览器都会依次触发 pagehide,visibilitychange 和 pageshow;
    谷歌浏览器测试下来,好像前进后退不会触发visibilitychange 和 pageshow;

  7. wyasha说道:

    一如既往的高质量文章,支持张哥

  8. Kiritani说道:

    从js visibilitychange Safari下无效说开去

  9. 无畏我说道:

    这个应用场景有哪些吗?我能想到的比如监控用户停留的页面时长,或者可以配合在线考试,判断用户是否离开了考试页面。

  10. meepo说道:

    转发了