使用户激活在所有 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),我们认为这可以简化围绕用户激活的网络开发。此外,新的实现还为提议的新规范提供了参考实现,该规范旨在长期将所有浏览器整合在一起。

用户激活 v2 的工作原理是什么?

新 API 会在帧层次结构中的每个 window 对象处保持两位用户激活状态:一个固定位用于历史用户激活状态(如果帧曾见过用户激活),一个瞬时位用于当前状态(如果帧在大约一秒内出现用户激活)。固定位一经设置,便不会在帧的生命周期内重置。该瞬态位会在每次用户互动时进行设置,并在失效间隔(约 1 秒)后或通过调用消耗激活的 API(例如 window.open())重置。

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

有何变化?

  • 用户激活 v2 规定了跨帧边界的用户激活可见性的概念:用户与特定帧的互动现在将激活所有包含的帧(且仅激活这些帧),无论其来源如何。(在 Chrome 72 中,我们采取了一种临时的变通方案,将可见性扩展到所有同源帧。一旦有办法将用户激活明确传递到子框架,我们就会移除此权宜解决方法。)
  • 如果从已激活的帧调用但从事件处理脚本代码外部调用受激活限制的 API,那么只要用户激活状态为“活跃”(例如未过期且未被消耗),该 API 就会正常工作。在 User Activation v2 之前,它将无条件地失败。
  • 到期时间间隔内多次未使用的用户互动会融合成与最终互动对应的单个激活。

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

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

链接的 setTimeout() 调用

此示例来自我们的 setTimeout() 演示。如果 click 处理程序尝试在一秒内打开一个弹出式窗口,则无论代码如何“编写”延迟,该处理程序都应该会成功。用户激活 v2 满足此预期,因此,以下每个事件处理脚本都会在 click 上打开一个弹出式窗口(具有 100 毫秒的延迟):

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

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

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

如果没有用户激活 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。