使用户激活在所有 API 之间保持一致

Mustaq Ahmed
Joe Medley
Joe Medley

为防止恶意脚本滥用弹出式窗口、全屏等敏感 API,浏览器会通过用户激活来控制对这些 API 的访问。“用户激活”是指浏览会话与用户操作相关的状态:“活跃”状态通常表示用户当前正在与网页互动,或自网页加载以来已完成互动。对于相同的概念,用户手势是一个热门但具有误导性的术语。例如,用户的滑动或翻动手势不会激活页面,因此从脚本的角度来看,这不属于用户激活。

如今,各大浏览器在用户激活控制受激活控制的 API 方面表现出的行为大相径庭。在 Chrome 中,实现基于基于令牌的模型,事实证明该模型过于复杂,无法为所有受激活控制的 API 定义一致的行为。例如,Chrome 一直允许通过 postMessage()setTimeout() 调用对受激活控制的 API 进行不完整访问;promiseXHR游戏手柄交互等不支持用户激活。请注意,其中一些 bug 很受欢迎,但已经存在很长时间。

在版本 72 中,Chrome 发布了 User Activation v2,这可让所有受激活限制的 API 都完成用户激活可用性。这解决了上述(以及 MessageChannels 等)中提到的一些不一致问题,我们认为这会简化用户激活方面的 Web 开发工作。此外,新实现为提议的新规范提供了参考实现,该规范旨在从长远来看将所有浏览器整合在一起。

User Activation v2 的工作原理是什么?

新的 API 会在帧层次结构中的每个 window 对象上维持 2 位的用户激活状态:用于记录历史用户激活状态的固定位(如果帧曾经历过用户激活),以及用于当前状态的瞬时位(如果帧在大约 1 秒内发生了用户激活)。设置完成后,粘滞位在框架的生命周期内绝不会重置。瞬态位会在每次用户互动时设置,并在过期时间(大约 1 秒)后重置,或通过调用消耗激活的 API(例如 window.open())重置。

请注意,不同的激活控制 API 以不同的方式依赖于用户激活;新的 API 不会改变任何这些 API 特有行为。例如,每次用户激活只允许出现一个弹出式窗口,因为 window.open() 会像以前一样消耗用户激活,而如果帧(或其任何子帧)曾发生过用户操作,则 Navigator.prototype.vibrate() 仍然有效,依此类推。

具体变化

  • 用户激活 v2 正式定义了跨帧边界的用户激活可见性概念:现在,当用户与特定帧交互时,将激活所有包含的帧(且仅激活这些帧),无论其来源如何。(在 Chrome 72 中,我们采取了临时的解决方法,可将可见性扩展到所有同源帧。一旦我们有办法将用户激活显式传递给子框架,便会移除此权宜解决方法。)
  • 如果从已激活的帧调用受激活控制的 API,但从事件处理脚本代码外部调用该 API,只要用户激活状态为“有效”(例如,既未过期也未消耗掉),该 API 就会起作用。在 User Activation v2 之前,该操作将无条件失败。
  • 过期时间间隔内多次未使用的用户互动会融合为与最后一次互动对应的一次激活。

受激活控制的 API 中的一致性示例

下面是两个带有弹出式窗口(使用 window.open() 打开)的示例,其中显示了 User Activation v2 如何使受激活控制的 API 的行为保持一致。

链式 setTimeout() 调用

此示例来自 setTimeout() 演示。如果 click 处理程序尝试在一秒内打开一个弹出式窗口,则无论代码以何种方式“构成”延迟,都应该会成功运行。User Activation v2 符合此预期,因此,以下每个事件处理脚本都会在 click 上打开一个弹出式窗口(延迟 100 毫秒):

function popupAfter100ms() {
  setTimeout(callWindowOpen, 100);
}

function asyncPopupAfter100ms() {
  setTimeout(popupAfter100ms, 0);
}

someButton.addEventListener('click', popupAfter100ms);
someButton.addEventListener('click', asyncPopupAfter100ms);

如果没有 User Activation v2,第二个事件处理脚本在我们测试的所有浏览器中都会失败。(在某些情况下,即使是第一个请求也会失败。)

跨网域 postMessage() 调用

以下是我们的 postMessage() 演示中的示例。假设跨源子帧中的 click 处理程序直接向父帧发送两条消息。收到以下任一消息(但不是两者)时,父框架应该能够打开弹出式窗口:

// Parent frame code
window.addEventListener('message', e => {
  if (e.data === 'open_popup' && e.origin === child_origin)
    window.open('about:blank');
});

// Child frame code:
someButton.addEventListener('click', () => {
  parent.postMessage('hi_there', parent_origin);
  parent.postMessage('open_popup', parent_origin);
});

如果没有 User Activation v2,父框架将无法在收到第二条消息时打开弹出式窗口。如果第一条消息被“链接”到另一个跨源帧(换句话说,如果第一个接收器将消息转发给另一个接收方),那么这条消息也会失败。

无论是原始形式还是链接形式,这都适用于 User Activation v2。