你不知道的 web 生命周期

原文连接javascript

背景

最近作 web 性能采集分析,一直以为跟用户交互无关的采集都放在 onLoadDOMContentLoaded 中很不合理。 一番搜索,发现 web 页面也是有生命周期的。一番研究,解决了如何避免干扰用户采集信息的困惑。 W3C 最新的规范 Page Lifecycle, 提供了一系列的生命周期钩子函数,方便开发者可以在不干扰用户交互的状况下监听处理一些操做。html

问题:如何利用生命周期优雅的处理上报分析数据,既能保证在某些场景下不漏报,又能尽量少的干扰用户?java

概要

应用程序生命周期是现代操做系统管理资源的关键方法。在移动iOS、Android和最新的桌面系统中, apps 在任什么时候候都能被 OS 启动或关闭,生命周期使得这些系统 streamline(流线型,使增产节约),从新分配资源更加合理高效,极大的优化了用户的体验。git

历史上,web 并无生命周期的概念,致使 web 应用能够一直存活占用系统资源。浏览器打开大量的 Tab 页, 关键系统资源如内存、CPU、电池和网络被过分占用而没法释放,致使系统卡顿。例如老版本的 Chrome 虽然性能 在当时的浏览器单页执行对比中一直是翘楚,但开多了页面,特别吃内存,得益于生命周期,能够合理的回收内存。github

而 web 平台长期以来都有与生命周期状态相关的事件,如 loadunloadvisibilitychange, 这些事件容许开发者监听生命周期状态的改变。对于移动设备特别是一些低端机型,浏览器须要一种主动回收内存和从新分配内存的方式。web

事实上,如今的浏览器已经采起了积极的措施来节省后台标签页的资源,许多浏览器但愿作更多的事情来减小它们的资源占用。ajax

问题是开发人员目前没有办法为这些类型的系统启动干预作好准备,甚至没法知道它们正在发生。这意味着浏览器须要保守,不然就有可能破坏网页。chrome

Page Lifecycle API 试图经过如下方式解决这些问题:api

  1. 在web上引入并标准化生命周期状态的概念。
  2. 定义新的系统启动状态,容许浏览器限制隐藏或非激活选项卡可以使用的资源。
  3. 建立新的 APIs 和事件,容许 web 开发人员响应这些新的系统启动状态之间的转换。

该解决方案提供了web开发人员构建对系统干预具备弹性的应用程序所需的可预测性,并容许浏览器更积极地优化系统资源,最终使全部web用户受益。浏览器

本文的将介绍新的页面生命周期特性,并探讨它们与全部现有web平台状态和事件的关系。它还将为开发人员在每一个状态下应该(和不该该)作的工做类型提供建议和最佳实践。

生命周期状态与事件

全部页面生命周期状态都是离散和互斥的,这意味着一个页面一次只能处于一个状态。页面生命周期状态的大多数更改一般均可以经过DOM事件观察到(关于异常,请参见开发人员对每一个状态的建议)。

生命周期状态转变以及触发的事件

page-lifecycle-api-state-event-flow

状态

状态 描述 可能前一个的状态(触发事件) 可能下一个状态(触发事件)
Active 页面可见document.visibilityState === 'visible' 而且有 input focus 1. passive (focus) 1. passive (blur)
Passive 页面可见且没有input 处于 focus 1. active (blur)
2. hidden (visibilitychange)
1. active (focus)
2. hidden (visibilitychange)
Hidden 页面不可见document.visibilityState === 'hidden'且不被冻结 1. passive (visibilitychange) 1. passive (the visibilitychange)
2. frozen (freeze)
3. terminated (pagehide)
Frozen frozen状态浏览器会挂起任务队列中可冻结任务的执行,这意味着例如 JS timerfetch回调不会执行。正在执行的任务能被完成,可是可执行的操做和运行的时间会被限制。

浏览器冻结是为了节约 CPU、内存、电量的消耗。同时使前进后退更加快速,避免从网络从新加载全量页面
1. hidden (freeze) 1. active (resume -> pageshow)
2. passive (resume -> pageshow)
3. hidden (resume)
Terminated terminated状态表示浏览器已卸载页面并回收了资源占用,不会有新的任务执行,已运行的长任务可能会被清除。 1. hidden (pagehide)
Discarded discarded状态发生在系统资源受限,浏览器会主动卸载页面释放内存等资源用于新进/线程。该状态下任何任务、事件回调或任何类型的JS都没法执行。尽管页面不在了,但浏览器 Tab 页的标签名和 favicon用户仍可见 1. frozen (no events fired)

事件

下面描述了与生命周期相关的全部事件,并列出了它们可能转换的状态。

focus

  • 描述:DOM元素获取焦点
  • 前一个可能状态
  1. passive
  • 当前可能状态
  1. active
  • 注意:focus 事件并不总触发生命周期状态改变,只有在页面以前并无聚焦才会发生改变。

blur

  • 描述:DOM元素失去焦点
  • 前一个可能状态
  1. active
  • 当前可能状态
  1. passive
  • 注意:blur 事件并不总触发生命周期状态改变,只有在页面再也不获取焦点才会发生改变。例如在页面元素之间切换焦点就不会。

visibilitychange

  • 描述:document.visibilityState 值变化。触发场景:
  1. 刷新或导航到新页面
  2. 切换到新 Tab 页面
  3. 关闭 Tab、最小化、或关闭浏览器
  4. 移动端切换 app,如按了 Home 键,点击头部通知切换等
  • 前一个可能状态
  1. passive
  2. hidden
  • 当前可能状态
  1. passive
  2. hidden

freeze *

  • 描述:页面被冻结,任务队列中的可冻结任务都不会执行。
  • 前一个可能状态
  1. hidden
  • 当前可能状态
  1. frozen

resume *

  • 描述:浏览器重启了一个被冻结的页面
  • 前一个可能状态
  1. frozen
  • 当前可能状态
  1. active (if followed by the pageshow event)
  2. passive (if followed by the pageshow event)
  3. hidden

pageshow

  • 描述:检索页面导航缓存是否存在,存在则从缓存中取出,不然加载一个全新的页面。 若是页面是从导航缓存中取出,则事件属性 persisted 为 true,反之为 false。
  • 前一个可能状态
  1. frozen (此时 resume 事件也会触发)
  • 当前可能状态
  1. active
  2. passive
  3. hidden

pagehide

  • 描述:页面会话是否可以存入导航缓存。若是用户导航到另外一个页面,而且浏览器可以将当前页面添加到页面导航缓存以供之后重用 ,则事件属性 persisted 为true。若是为true,则页面将进入 frozen 状态,不然将进入 terminated 状态。
  • 前一个可能状态
  1. hidden
  • 当前可能状态
  1. frozen (event.persisted is true, freeze event follows)
  2. terminated (event.persisted is false, unload event follows)

beforeunload

  • 描述:当前页面即将被卸载。此时当前页面文档内容仍然可见,关闭页面能够在该阶段取消。
  • 前一个可能状态
  1. hidden
  • 当前可能状态
  1. terminated
  • 警告:监听 beforeunload 事件,仅用来提醒用户有未保存的数据改变,一旦数据保存完成,该监听事件回调应该移除。 不该该无条件地将它添加到页面中,由于这样作在某些状况下会损害性能。

unload

  • 描述:页面正在被卸载。
  • 前一个可能状态
  1. hidden
  • 当前可能状态
  1. terminated
  • 警告:不建议监听使用 unload 事件,由于它不可靠,在某些状况下可能会影响性能。
  • 表示生命周期定义的新事件。

新特性

frozen 和 discarded 是系统行为而不是用户主动行为,现代浏览器在标签页不可见事,可能会主动冻结或废弃当前页。 开发人员并不能知道这二者的发生过程。

Chrome 68+ 中提供了freeze、resume 事件,当页面从 hidden 状态转变为冻结和非冻结状态,开发人员能够监听 document 得知。

document.addEventListener('freeze', (event) => {
  // The page is now frozen.
});

document.addEventListener('resume', (event) => {
  // The page has been unfrozen.
});
复制代码

而且提供了 document.wasDiscarded 属性来获取当前加载的页面,以前是否非可见时被废弃过。

if (document.wasDiscarded) {
  // Page was previously discarded by the browser while in a hidden tab.
}
复制代码

代码观察生命周期状态

获取 activepassivehidden

const getState = () => {
  if (document.visibilityState === 'hidden') {
    return 'hidden';
  }
  if (document.hasFocus()) {
    return 'active';
  }
  return 'passive';
};
复制代码

frozenterminated 状态须要监听 freezepagehide 事件获取。

// Stores the initial state using the `getState()` function (defined above).
let state = getState();

// Accepts a next state and, if there's been a state change, logs the
// change to the console. It also updates the `state` value defined above.
const logStateChange = (nextState) => {
  const prevState = state;
  if (nextState !== prevState) {
    console.log(`State change: ${prevState} >>> ${nextState}`);
    state = nextState;
  }
};

// These lifecycle events can all use the same listener to observe state
// changes (they call the `getState()` function to determine the next state).
['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
  window.addEventListener(type, () => logStateChange(getState()), {capture: true});
});

// The next two listeners, on the other hand, can determine the next
// state from the event itself.
window.addEventListener('freeze', () => {
  // In the freeze event, the next state is always frozen.
  logStateChange('frozen');
}, {capture: true});

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // If the event's persisted property is `true` the page is about
    // to enter the page navigation cache, which is also in the frozen state.
    logStateChange('frozen');
  } else {
    // If the event's persisted property is not `true` the page is
    // about to be unloaded.
    logStateChange('terminated');
  }
}, {capture: true});
复制代码

上面代码作了三件事:

  1. getState() 初始化状态
  2. 定义 logStateChange 函数接收下一个状态,如改变则 console
  3. 监听 捕获阶段 事件,一次调用 logStateChange ,传入状态改变。

注意:上述 console 打印的顺序在不一样的浏览器中可能不一致。

  • 为何经过传入第三个参数 {capture: true} 且都在 window 上监听事件
  1. 并非全部生命周期事件都有相同的 target
    1. pagehidepageshowwindow 上触发
    2. visibilitychange, freeze, resumedocument 上触发
    3. focusblur 在相应的 DOM 元素上触发
  2. 大多数事件并不会冒泡,这意味着在冒泡阶段,只经过监听 window 没法实现
  3. 捕获阶段发生在 target 阶段和冒泡阶段,这意味着捕获阶段事件不会被其余冒泡事件取消

跨浏览器兼容

因为生命周期API刚刚被引入,新的事件和DOM api并无在全部浏览器中实现。此外,全部浏览器实现并不一致。 例如:

  1. 一些浏览器切换 Tab 时,不会触发 blur 事件,意味着 active 状态不通过 passive 状态而直接变成了 hidden
  2. 一些浏览器虽然实现了 page navigation cachePage Lifecycle API 把缓存的页面分类为冻结状态, 可是尚未实现freezeresume 等最新的 API,虽然非/冻结状态也能够经过 pageshowpagehide 事件监听到。
  3. IE 10 以及如下版本未实现 pagehide 事件
  4. pagehidevisibilitychange 触发顺序已改变。 当页面正在被卸载时,若是页面可见,会先触发 pagehide 在触发 visibilitychange。 最新版本的 Chrome ,不管页面是否可见都会先触发 visibilitychange 在触发 pagehide
  5. Safari 关闭 Tab 页可能不会触发 pagehidevisibilitychange。须要监听 beforeunload 来作兼容, beforeunload 须要在冒泡阶段结束才能知道状态是否变成 hidden,所以容易被其余事件取消。

推荐使用PageLifecycle.js,确保跨浏览器的一致性。

每一个状态的建议

做为开发人员,理解页面生命周期状态并知道如何在代码中观察它们很重要,由于您应该(也不该该)执行的工做类型在很大程度上取决于您的页面处于什么状态。

例如,若是页面处于不可见状态,则向用户显示临时通知显然没有意义。虽然这个例子很明显,但还有一些不太明显的建议值得列举。

状态 建议
Active 该状态是对用户来讲最重要的阶段,此时最重要的就是响应用户输入。长时间阻塞主线程的非no-UI任务能够交给idle时期或web worker处理
Passive 该状态下,用户没有与页面交互,可是他们仍然能够看到它。这意味着UI更新和动画应该仍然是平滑的,可是这些更新发生的时间不那么关键。当页面从 active 变为 passive 时,是存储未保存数据的好时机。
Hidden passive 转变为 hidden,用户颇有可能再也不与页面交互直到从新加载。

hidden 状态每每是开发人员能够信赖的最后状态,尤为在移动端,例如切换 APP 时beforeunloadpagehideunload 事件都不会触发。

这意味着,对于开发人员应该把 hidden 状态当成是页面会话的最终状态。在此时应该持久化未保存的应用数据,采集上报分析数据。

同时,你应该中止UI更新,由于用户已经看不到了。也该中止那些用户并不想在后台执行的任务,节省电量等资源。
Frozen frozen 状态,任务队列可冻结的任务会被挂起,直到页面解冻(也许永远不会发生,例如页面被废弃discarded)。

此时有必要中止全部的timer和关闭链接(IndexedDBBroadcastChannelWebRTCWeb Socket connections。释放Web Locks),不该该影响其余打开的同源页面或影响浏览器把页面存入缓存(page navigation cache)。

你也应该持久化动态视图信息(例如无限滑动列表的滑动位置)到 sessionStorage或IndexedDB via commit(),以便discarded 和 reloaded以后重用。

当状态从新变回 hidden 时您能够从新打开任何关闭的链接,或从新启动最初冻结页面时中止的任何轮询。
Terminated 当页面变成 terminated 状态,开发人员通常不须要作任何操做。由于用户主动卸载页面时总会在 terminated 以前经历 hidden 状态(页面刷新和跳转时不必定会触发 visibilitychange,少部分浏览器实现了,大部分可能须要 pagehide 甚至beforeunloadunload 来弥补这些场景),你应该在 hidden 状态执行页面会话的结束逻辑(持久化存储、上报分析数据)。

开发人员必须认识到,在许多状况下(特别是在移动设备上),没法可靠地检测到终止状态,所以依赖终止事件(例如beforeunloadpagehideunload)可能会丢失数据。
Discarded 开发人员没法观察到被废弃的状态。由于一般在系统资源受限下被废弃,在大多数状况下,仅仅为了容许脚本响应discard事件而解冻页面是不可能的。所以,不必从hidden更改成frozen时作处理,能够在页面加载时检查 document.wasDiscarded,来恢复以前被废弃的页面。

避免使用老旧的生命周期API

  • unload,不要在现代浏览器中使用
  1. 不少开发人员会把 unload 事件当作页面结束的信号来保存状态或上报分析数据,但这样作很是不可靠,特别是在移动端。 unload 在许多典型的卸载状况下不会触发,例如经过移动设备的选项卡切换、关闭页面或系统切换器切换、关闭APP。
  2. 所以,最好依赖 visibilitychange 事件来肯定页面会话什么时候结束,并将 hidden 状态视为最后保存应用和用户数据的可靠时间。
  3. unload 会阻止浏览器把页面存入缓存(page navigation cache),影响浏览器前进后退的快速响应。
  4. 在现代浏览器(包括IE11),推荐使用 pagehide 事件代替 onload 监测页面卸载(terminated)。onload 最多用来兼容IE10。
const terminationEvent = 'onpagehide' in self ? 'pagehide' : 'unload';

addEventListener(terminationEvent, (event) => {
  // Note: if the browser is able to cache the page, `event.persisted`
  // is `true`, and the state is frozen rather than terminated.
}, {capture: true});
复制代码
  • beforeunload,和 unload 有相似的问题,仅仅用来提醒用户关闭或跳转页面时有未保存的数据,一旦保存当即清除。
// bad:无条件使用
addEventListener('beforeunload', (event) => {
  // A function that returns `true` if the page has unsaved changes.
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = 'Are you sure you want to exit?';
  }
}, {capture: true});
复制代码
// good
const beforeUnloadListener = (event) => {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};
const unsavedChanges = [];
/** * @param {Symbol|Object} id A unique symbol or object identifying the *. pending state. This ID is required when removing the state later. */
function addUnsavedChanges(id) {
  if(unsavedChanges.indexOf(id) > -1) return; // 重复退出
  if (unsavedChanges.length === 0) { // 首次监听
    addEventListener('beforeunload', onbeforeunload);
  }
  unsavedChanges.push(id);
}
/** * @param {Symbol|Object} id A unique symbol or object identifying the *. pending state. This ID is required when removing the state later. */
function removeUnsavedChanges(id) {
  const idIndex = unsavedChanges.indexOf(id);
  if (idIndex > -1) {
    unsavedChanges.splice(idIndex, 1);
    // If there's no more pending state, remove the event listener.
    if (unsavedChanges.length === 0) {
      removeEventListener('beforeunload', onbeforeunload);
    }
  }
}
复制代码

FAQs

  • 页面不可见(hidden)时有重要的任务在执行,如何阻止页面被冻结(frozen)或废弃(discarded)?

有不少合理的理由在页面不可见(hidden)状态不冻结(frozen)页面,例如APP正在播放音乐。

对于有些场景,浏览器放弃页面也存在风险,例如用户有未提交的输入或开发人员监听了beforeunload事件以便提醒用户。

所以,浏览器策略会趋于保守,只有在明确不会影响用户的时候才会放弃页面。例如如下场景不会废弃页面(除非受到设备的资源限制)。

  1. Playing audio
  2. Using WebRTC
  3. Updating the table title or favicon
  4. Showing alerts
  5. Sending push notifications

注意:对于更新标题或favicon以提醒用户未读通知的页面,建议使用 service worker,这将容许Chrome冻结或放弃页面,但仍然显示对选项卡标题或favicon的更改。

  • 什么是页面导航缓存(page navigation cache)?

页面导航缓存是一个通用术语,用于优化后退和前进按钮导航,利用缓存快速恢复先后页面。Webkit 称 Page CacheFirefoxBack-Forwards Cache (bfcache)。

冻结是为了节省CPU/电池/内存,而缓存是为了重载时快速恢复,二者配合才能相得益彰。所以,该缓存被视为冻结生命周期状态的一部分。

注意:beforeunloadunload 会阻止该项优化。

  • 为何生命周期里没有 load、DOMContentLoaded 事件?

页面生命周期状态定义为离散和互斥的。因为页面能够在activepassivehidden 状态下加载,所以单独的加载状态没有意义, 而且因为 loadDOMContentLoaded 事件不表示生命周期状态更改,所以它们与生命周期无关。

  • frozen 或 terminated 状态如何使用异步请求

在这两个状态,任务可能被挂起不执行,例如异步请求、基于回调的API等一样不会被执行。如下是一些建议

  1. sessionStorage,方法是同步的,且在废弃状态仍然能持久化数据。
  2. service worker,在 terminateddiscarded 状态时经过监听freeze or pagehide 经过 postMessage() 用来保存数据。 (受限与设备资源,可能唤起service worker 会加剧设备负担)
  3. navigator.sendBeacon 函数运行页面关闭时仍然能够发送异步请求。

chrome-discards

对分析型数据采集时机的启发

兼容性分析

lifecycle-events-testing

  1. 避免在 loadDOMContentLoadedbeforeunloadunload 中处理上报采集数据。
  2. 监听 visibilitychange 在各类切换APP、息屏时处理采集信息。
  3. 监听 pagehide 收集页面刷新导航跳转场景。
  4. 仅仅使用 beforeunload 兼容 Safari 关闭 Tab 和IE11如下版本的场景。
  5. 注意一旦收集信息当即销毁全部采集事件,避免重复上报。
function clear(fn) {
  ['visibilitychange', 'pagehide', 'beforeunload']
    .forEach(event => window.removeEventListener(event, fn));
}

function collect() {
  const data = { /* */ };
  const str = JSON.stringify(data);
  if('sendBeacon' in window.navigator) {
    if( window.navigator.sendBeacon(url, str) ) {
      clear(collect);
    } else {
      // 异步发请求失败
    }
  } else {
    // todo 同步 ajax
    clear(collect);
  }
}

const isSafari = typeof safari === 'object' && safari.pushNotification;
const isIE10 = 'onpagehide' in window;

window.addEventListener(`visibilitychange`, collect, true);
!isIE10 && window.addEventListener(`pagehide`, collect, true);

if(isSafari || isIE10) {
  window.addEventListener(`beforeunload`, collect, true);
}
复制代码

总结

对于性能有极致追求的开发人员,开发时都应该考虑到页面的生命周期。在不须要的状况下不消耗设备资源对用户来讲是很是重要的。

此外越多的开发人员开始使用生命周期 APIs,浏览器处理冻结或废弃再也不使用的页面就越安全。 这意味着浏览器将会消耗更少的内存、CPU、电量、网络资源,这都将有利于用户。

参考

  1. WebKit Page Cache
  2. Firefox Back-Forward Cache
  3. Page Lifecycle W3C
  4. Page Lifecycle API
  5. Don't lose user and app state, use Page Visibility
  6. page-lifecycle
  7. PageLifecycle.js
  8. Lifecycle events with Page Visibility + Beacon API
  9. Why does visibilitychange fire after pagehide in the unload flow?
相关文章
相关标签/搜索