用户激活API

作为一名网页开发者,你可能已经注意到某些API只有在最终用户点击或轻触HTML元素后才能工作。例如,如果你尝试在Safari的Web Inspector中运行以下代码,它将导致错误

await navigator.share({ text: "hi" });
NotAllowedError: The request is not allowed by the user agent or 
the platform in the current context, possibly because the user denied permission.

当代码不是最终用户点击或轻触HTML元素(例如,<button>)的直接结果时,就会发生此错误。

让代码作为最终用户操作的结果运行,这就是HTML规范所指的“用户激活”。Web上有大量API依赖于用户激活。常见的包括

  • window.open()
  • navigator.share()
  • navigator.wakelock.request()
  • PaymentRequest.prototype.show()
  • 还有很多很多…

那么,“用户激活”包括什么?

HTML规范将以下事件定义为“激活触发用户事件

总而言之,这个列表有效地构成了“用户激活”。你会注意到上面的事件列表非常小。它被限制是为了让某些API调用只能作为这些非常独特的最终用户操作的结果发生。这可以防止最终用户意外地(或故意!)被弹出窗口或其他侵入性浏览器对话框骚扰。

既然我们了解了这些特殊事件,我们现在可以编写考虑用户激活的代码

button.addEventListener("click", async () => {
   // This works fine...
   await navigator.share({text: "hi"});
});

因此,即使我们没有专门监听 "mousedown" 事件,我们也知道激活触发事件已经发生,所以我们的代码可以运行而不会抛出任何错误。

最终用户保护

你现在可能会想,能否在一个或多个事件监听器中执行多个需要用户激活的命令?考虑以下代码示例

button.addEventListener("click", async () => {

   // This works fine...
   await navigator.share({text: "hi"});

   // This will fail...
   window.open("https://example.com");
});

button.addEventListener("click", async () => {
   // This will now fail too...
   window.open("https://example.com");
});

但是为什么 window.open() 的调用会失败呢?为了理解这一点,我们需要更深入地探讨浏览器如何在底层处理用户激活。

了解“瞬时”和“粘性”激活

当“激活触发用户事件”发生时,实际情况是浏览器启动一个专门与浏览器标签页绑定的内部计时器。这个计时器不直接暴露给网页,并且运行时间很短(几秒钟左右)。每个浏览器引擎都可以决定分配多少时间,并且它可能由于多种原因而改变(即,它故意不能被JavaScript观察到!)。它旨在为你的代码提供足够的时间来执行某些特定任务(例如,它可以处理一些图像数据,然后调用 navigator.share() 将图像分享给另一个应用程序)。

在HTML中,这个计时器被称为瞬时激活。当这个计时器运行时,该浏览器窗口“具有瞬时激活”。HTML还定义了一个称为粘性激活的概念。这仅仅意味着网页在过去某个时间点具有瞬时激活。

尽管罕见,一些API(例如Web Audio)使用粘性激活来执行某些操作。

现在,上述内容并未解释为什么 window.open() 会失败。为了理解原因,我们现在需要讨论HTML所谓的“激活消耗API”。

“消耗”用户激活的API

顾名思义,“激活消耗API”会消耗用户激活。也就是说,当调用这些API时,它们会有效地重置瞬时激活计时器,因此网页不再具有瞬时激活。

这种行为是 window.open() 失败的原因:调用 navigator.share() 消耗了用户激活,这意味着 window.open() 不再具有瞬时激活(因此它失败了)。

WebKit中消耗瞬时激活的常见API列表

  • Web Notification 的 requestPermission() 方法。
  • Payment Request: show() 方法。
  • 以及,我们已经讨论过的,Web Share 的 share() 方法。

此列表并非详尽无遗,并且不断有新的API添加到Web中,它们要么依赖于瞬时激活,要么消耗瞬时激活。

有趣的是:并非所有API都消耗用户激活。有些API只需要瞬时激活但不会消耗它。这允许依赖用户激活的多个异步操作发生。否则,这将要求用户一遍又一遍地点击或按下按钮来完成任务,这对他们来说会非常烦人。

瞬时激活的范围

关于瞬时激活一个非常有用的知识是,它的作用域是整个窗口(或浏览器标签页)!这意味着,只要页面上的所有 iframe 都同源,它们都具有瞬时激活。然而,出于安全原因,跨源 iframe 将不具有瞬时激活。

Transient activation across all same origin iframes

要使第三方 iframe 具有瞬时激活,用户必须在第三方 iframe 内部明确激活一个HTML元素。但是,一旦他们激活了一个元素,瞬时激活就会传播到父级以及与激活发生的 iframe 同源的任何 iframe

Activation propagating to parent frame, to other iframes that match the third-party iframe

安全提示:你可以(并且应该!)通过根据需要设置 allow= 和/或 sandbox= 属性来限制第三方iframe的访问权限。

UserActivation API

为了帮助开发者处理用户激活,HTML标准引入了一个简单的API来检查页面是否具有瞬时和/或粘性激活。

  • navigator.userActivation.isActive:
    当窗口具有瞬时激活时返回true。
  • navigator.userActivation.hasBeenActive:
    如果窗口在过去具有瞬时激活(即“粘性激活”),则返回true。

所以例如,你可以这样做

if (navigator.userActivation.isActive) {
    await navigator.share({text: "hi"})
}

限制和正在进行中的标准工作

当前用户激活模型存在两个显著限制,标准制定者仍在努力解决。
首先,考虑以下情况,当文件下载时间过长且瞬时激活计时器耗尽时

button.onclick = () => {
    // Slow network + really big file
    const image = await fetch("really-big-file");

    // Oh no!!! transient activation expired! 😢
    navigator.share({files: [image]});
}   

WHATWG和W3C正在就如何解决上述问题进行讨论。不幸的是,我们还没有解决方案,但自然我们需要一些方法来延长瞬时激活,这样上面的代码就不会失败。

其次,在第一方文档中启用第三方 iframe 中的瞬时激活存在合法的用例(例如,允许第三方处理支付请求)。正在进行讨论,以查看是否存在某种方法可以在特殊情况下安全地启用第三方 iframe 也具有瞬时激活。

自动化和测试

为了帮助开发者处理瞬时激活意外过期可能导致的棘手边缘情况,WebKit一直与其他浏览器厂商合作,允许通过Web Driver消耗用户激活

结论

Web API受用户激活控制有助于保护用户免受烦人的干扰,例如多个弹出窗口或通知垃圾邮件,同时允许开发者根据用户交互做正确的事情。UserActivation API可以帮助你确定是否可以调用依赖用户激活的函数。

你可以在Safari 技术预览版 160 或更高版本中试用用户激活 API。