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

Mustaq Ahmed
Joe Medley
Joe Medley

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

目前,主流浏览器在用户激活如何控制需要激活的 API 方面表现出截然不同的行为。在 Chrome 中,此实现基于基于令牌的模型,但该模型太过复杂,无法在所有需要激活的 API 中定义一致的行为。例如,Chrome 一直允许通过 postMessage()setTimeout() 调用对需要激活才能访问的 API 进行不完整访问;并且 PromiseXHRGamepad 互动等不支持用户激活。请注意,其中一些是常见且存在已久的 bug。

在版本 72 中,Chrome 推出了 User Activation v2,这可让所有受激活限制的 API 都提供用户激活功能。这解决了上述不一致性(以及 MessageChannels 等其他一些问题),我们认为这有助于简化围绕用户激活的 Web 开发。此外,新实现还为提议的新规范提供了参考实现,旨在长期整合所有浏览器。

用户激活 v2 如何运作?

新 API 会在帧层次结构中的每个 window 对象中维护一个二进制用户激活状态:一个用于历史用户激活状态的粘性位(如果帧曾经出现过用户激活),一个用于当前状态的瞬时位(如果帧在约一秒内出现过用户激活)。设置完成后,粘滞位在框架的生命周期内绝不会重置。系统会在每次用户互动时设置暂时性位,并在到期间隔(大约 1 秒)后或通过调用会消耗激活期的 API(例如 window.open())重置该位。

请注意,不同需要用户激活的 API 依赖于用户激活的方式;新 API 不会更改任何这些 API 专用行为。例如,系统仅允许在每次用户激活时显示一个弹出式窗口,因为 window.open() 会像以前一样消耗用户激活次数,如果某个帧(或其任何子帧)曾经获得过用户操作,Navigator.prototype.vibrate() 将继续有效,依此类推。

有何变化?

  • 用户激活 v2 正式定义了跨帧边界的用户激活可见性的概念:用户与特定帧的互动现在会激活所有包含该帧的帧(且仅限这些帧),无论其来源如何。(在 Chrome 72 中,我们提供了一项临时权宜解决方法,可将可见性扩展到所有同源框架。一旦我们有办法将用户激活显式传递到子框架,便会移除此权宜解决方法。)
  • 当从已激活的帧(但从事件处理脚本代码之外)调用启用受限 API 时,只要用户激活状态为“有效”(例如,未过期且未被使用),该 API 就会正常运行。在用户激活 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,父级框架在收到第二个消息后将无法打开弹出式窗口。如果第一条消息被“链接”到另一个跨源帧(换句话说,如果第一个接收器将消息转发给另一个接收方),那么这条消息也会失败。

这适用于原始形式和链接形式的用户激活 v2。