基于官方规范系列篇之——HTML 篇(四)用户交互(一)

大家好呀,我是前端创可贴。

上一章我们学习了 Document 对象的相关内容,这一章我们一起来看看用户交互的相关内容,由于内容比较多所以会分为几个章节。

前端开发工程师作为离用户最近的开发者,网站精美的样式和交互全都需要我们来实现,所以掌握用户交互相关的实现方式和原理至关重要,接下来就让我们一起来看看,涉及到用户交互技术的水有多深吧~

hidden 属性

每个元素都可以设置 hidden 属性,用来隐藏元素,hidden 属性的值可以是:

  • hidden 或者空字符串(空字符串时可省略值):内容不会被渲染

  • until-found:内容不会被渲染,但是浏览器的“查找”功能(Ctrl + F 或 Command + F)和修改 URL 的哈希值为页面上隐藏元素的 id 值时,仍然可以找到并突出显示隐藏元素中的内容,并且不再隐藏,即使这些内容没有显示在页面上。

当元素处于 hidden 状态时,表明该元素与当前页面的状态不直接相关,或者该元素仅供页面的其他部分重用,而不是供用户直接访问。浏览器在渲染页面时不能显示处于 hidden 状态的元素。

当元素处于 hidden until found 状态时,它的行为类似于 hidden 状态,该元素的内容不会被渲染,但通过页面查找或修改 URL hash 值为隐藏元素 id 值,浏览器会自动移除 hidden 属性,使该元素变为可见,然后再滚动到该元素的位置。并且在移除 hidden 属性并显示内容之前,浏览器会在该元素上触发一个名为 beforematch 的事件,我们就可以在元素被显示之前执行一些额外的逻辑(如动画、样式调整等)。

<h1>前端创可贴</h1>

<div hidden>hidden 隐藏</div>
<div hidden="until-found">until-found 隐藏</div>

<script>
  const divDOM = document.querySelector('div[hidden=until-found]');
  divDOM.addEventListener('beforematch', () => {
    console.log('beforematch 事件触发');
  })
</script>

在页面搜索 until-found 状态元素之前,元素是隐藏看不到的,并且有 hidden 属性,当我们通过浏览器的页面查找 until-found 状态元素后:

可以看到,hidden 属性自动消失了,并且触发了 beforematch 事件。

浏览器底层实现 hidden 隐藏逻辑,是通过样式层来实现的,hidden 状态使用的是 display: none;,hidden until found 状态使用的是 content-visibility: hidden;

hidden 状态

hidden until found 状态

那么问题来了,由于 hidden 属性是通过 CSS 实现的,所以我们就可以使用 CSS 规则来覆盖隐藏状态。例如,一个为所有元素应用 display: block 的规则将取消 hidden 状态的效果。所以我们在编写样式时需要注意,应用了 hidden 属性的元素可能会被意外覆盖掉这个隐藏效果,要注意样式的编写冲突。

对于不支持 hidden until found 状态的旧版浏览器,这些元素会使用 display: none 代替 content-visibility: hidden,所以我们对于 hidden until found 状态的元素, 要确保他们的 displaycontent-visibility 属性都不会被覆盖。

当元素处于 hidden until found 状态时,使用 content-visibility: hidden 进行隐藏,而不是 display: none,虽然内容是隐藏的,但元素仍然会生成一个盒模型。因此,元素的边框、外边距和内边距仍然会显示。而 display: none 不会生成盒模型以及渲染边框等。

通过 Javascript 也可以修改 hidden 属性:

element.hidden = true;
element.hidden = 'until-found';

页面可见性

系统可见性状态是由浏览器决定的,这一状态表示的是页面的可见性,例如

  • 浏览器窗口是否被最小化;
  • 当前浏览器选项卡是否处于后台状态;
  • 是否有系统元素(例如任务切换器)挡住了页面。

简单来说,就是当前页面是不是看不见了。

当浏览器发现系统可见性状态变为 newState(如从前台切换到后台,或者浏览器窗口被最小化),它会在“用户交互任务源”(user interaction task source,是处理用户操作如点击、键盘输入等的任务源)中入队一个任务,该任务会更新该文档的可见性状态为 newState。

上面说的任务源是个啥呢?任务源(Task Source)用于给不同类型的宏任务进行区分和分组(例如用户交互相关的放在一起,DOM 操作相关的放在一起),是一个宏任务的一部分。常见的任务源有:DOM 操作任务源、用户交互任务源、渲染任务源等。所以,用户交互事件例如 click 事件处理函数,事实上是一个宏任务注意不要单纯使用 element.click() 方法来验证点击事件和微任务的执行顺序,手动触发 click 方法会触发一个合成的点击事件,会同步执行事件处理函数,而不像用户触发点击那样会放入宏任务队列中异步执行,因为它不是真的用户交互。click 方法在下面的小节中有介绍,如何验证点击事件是宏任务也在下面小节中介绍

通过 document.visibilityState 可以获取页面可见性状态,值为 hidden 或者 visible。当页面可见性为 hidden 状态时,document.hidden 值为 true。

document.visibilityState 发生变化时:

  • 首先会入队一个 VisibilityStateEntry 对象,该对象的 visibility state 属性值即为变化的新值,startTime 属性值为当前高精度事件,后面会详细介绍这个对象。

  • 然后会触发一个名为 visibilitychange 的事件,我们可以在这个事件处理函数中做一些优化以提升页面性能,比如:暂停视频播放、停止数据轮询、减少动画或其他高资源占用的操作、调用保存接口以实现实时保存用户数据等。

<h1>前端创可贴</h1>

<script>
  document.addEventListener('visibilitychange', () => {
    console.log('document.visibilityState 的值为', document.visibilityState);
    console.log('document.hidden 的值为', document.hidden);
  })
</script>

当切换 Tab 或者浏览器窗口最小化等时,再切换回来,打印结果为:

我们有时候会使用 unload、beforeunload 事件在页面关闭之前做一些事情,但是通过上一章的学习我们知道 unload 这样的事件会让 bfcache 失效,不仅如此,unload 事件在移动端有时候也会不生效,比如用户不是正常关闭网页,而是打开任务管理器,然后 kill 掉了浏览器进程,这时是不会触发 unload 事件的。所以推荐大家使用 visibilitychange 事件代替 unload 事件,也可以使用 pagehide 事件代替 unload 事件

VisibilityStateEntry

VisibilityStateEntry 接口用于表示页面可见性状态变化,每当页面的可见性发生变化时,VisibilityStateEntry 将记录该变化,提供相关的时间戳和可见性状态。

interface VisibilityStateEntry : PerformanceEntry {
  readonly attribute DOMString name;                 // visibility state 的值
  readonly attribute DOMString entryType;            // 固定值 visibility_state
  readonly attribute DOMHighResTimeStamp startTime;  // 产生该对象时的当前高精度时间
  readonly attribute unsigned long duration;         // 固定值 0
};

通过 performance.getEntriesByType("visibility-state") 可以获取当前页面所有产生过的 VisibilityStateEntry 对象。

举个例子,我打开了页面,然后切换 Tab,再切换回来,一共会有 3 种状态:第一次进入页面、第二次切换出去页面、第三次切换回来页面,执行 performance.getEntriesByType("visibility-state") 的结果为:

通过下面代码,可以判断在 FCP 触发之前页面可见性有没有变为不可见:

function wasHiddenBeforeFirstContentfulPaint() {
    const fcpEntry = performance.getEntriesByName("first-contentful-paint")[0];
    const visibilityStateEntries = performance.getEntriesByType("visibility-state");
    return visibilityStateEntries.some(e =>
                                            e.startTime < fcpEntry.startTime &&
                                            e.name === "hidden");
}

大家可以去一些页面加载比较慢的网站试一下,在 FCP 触发之前切换 Tab,然后执行一下上面的代码,可以看到结果为 true。

惰性子树

当一个节点 node 处于 inert 状态时,会表现出以下特性:

  • 节点将像 pointer-events: none 一样处理,用户无法与其进行交互,点击、悬停等事件不会被触发;

  • 文本选择功能像 user-select: none 一样,用户无法选择该节点中的文本;

  • 如果节点是可编辑的(例如输入框),设置为 inert 后会表现为不可编辑,用户无法在其中输入或修改内容;

  • 在浏览器查找页面内容时,会忽略该节点,使其在搜索结果中不可见。

但是浏览器可能允许用户覆盖对“查找页面内容”和文本选择的限制,也就是说尽管节点处于 inert 状态,用户仍然有可能通过特定的设置或功能来选择文本或进行查找。例如 Chrome 默认是可以页面查找到 inert 节点的。

默认情况下一个节点是非 inert 节点。

inert 属性

inert 属性是一个布尔属性,它表示当前元素及其所有受 inert 状态影响的后代元素均应被浏览器视为“无效”或“不可交互”。

使用 inert 属性时,应确保被标记内容确实是临时不可用的,而不只是为了简单的样式和布局调整。

<h1>前端创可贴</h1>
<input inert type="text">
<a inert href="https://google.com">Google</a>

执行以上代码后,输入框是无法编辑的,超链接也无法点击。

用户激活跟踪

大家都知道我们的页面有很多 API 可以与用户进行交互,但是有些 API 只应该让用户主动去触发,而不是被我们开发者随意使用,比方说你打开了一个网站,你没有做任何交互,但是网站疯狂给你弹出很多新的 Tab,这你受得了吗。

所以为了防止某些可能会让用户感到烦扰的 API 被滥用(例如,打开弹窗或振动手机),浏览器仅在用户主动与网页互动或至少曾经与页面互动过时,才允许使用这些API。

所以就有了用户激活的概念。用户激活(User Activation) 是浏览器用来判断用户是否与页面进行了有效交互的机制。用户激活用于限制某些潜在会滥用的 API,确保只有在用户明确操作页面时才可以调用这些功能,保护用户不受网站的烦扰

可以产生用户激活的行为

我们知道了用户激活的概念,那么用户的哪些行为会产生用户激活呢?

     1. 鼠标相关操作:

  • 单击(mousedown 或 mouseup 事件)
  • 鼠标指针按下(pointerdown,当 pointerType 是 mouse 时)
  • 鼠标指针抬起(pointerup,当 pointerType 不是 mouse 时)

     2. 键盘相关操作:

  • 按键(keydown 事件,除了 Esc 键和浏览器保留的快捷键)

     3. 触摸相关操作:

  • 触摸结束(touchend 事件)

这些交互事件被称为激活触发输入事件(activation triggering input events),它们表明用户明确地与页面进行了交互。

数据模型

为了跟踪用户激活状态,每个窗口 Window 都有以下两个相关值:

  • 最后激活时间戳(last activation timestamp):用于记录用户最后一次与窗口进行交互的时间。通过这个值,浏览器可以判断用户是否仍处于活跃状态。值为高精度时间戳 DOMHighResTimeStamp、正无穷(表示窗口从未被激活)或负无穷(表示已激活)。初始值为正无穷。

  • 最后历史动作激活时间戳(last history-action activation timestamp):用于追踪与浏览历史相关的激活事件,用来确定用户的交互是否影响到浏览历史。值为高精度时间戳 DOMHighResTimeStamp 或正无穷。初始值为正无穷。

此外,浏览器还定义了一个瞬时激活持续时间(transient activation duration),这是一个常量,表示在某些用户激活受限的 API(例如通过 window.open 打开新窗口)中,用户激活的可用时长。

瞬时激活持续时间通常最多设定为几秒钟,这么设计是为了确保用户能够感知到与页面的交互和页面调用受激活限制的 API 之间的联系。比方说,用户点了一个按钮,过了半分钟才打开一个新的窗口,时间太长会淡化用户与页面交互的感知,要是交互做的再差一点,用户都快忘了打开新的窗口是因为前面的点击。

我们在平时开发中也会遇到这个问题,例如点击按钮下载一个文件,接口通过指定 Content-Disposition 或者 Content-Type 等方式,让浏览器打开一个新窗口并指定接口 URL 即可直接下载文件。但是获取文件的 URL 可能需要先调用一个接口去获取,但是这个接口耗时好几秒才返回结果,这时我们打开新窗口就会被浏览器拦截,在浏览器右上角会弹出提示:

点击后会展示一个弹框,用户可以控制是否跳转:

在用户激活机制中,窗口 W 有以下三种用户激活状态:粘性激活瞬时激活历史动作激活

粘性激活(Sticky Activation)

当当前的高精度时间大于或等于 W 的最后激活时间戳时,W 处于粘性激活状态。

也就是说用户曾经发生过交互,W 已经被用户激活过了。

初始状态为 false,一旦 W 接收到第一个激活通知后就会永远变为 true,不会再变为 false。

该状态记录了用户是否曾经与窗口 W 交互过,这是一个历史激活状态。

即使文档状态改变(例如导航到新页面或重新访问缓存页面),last activation timestamplast history-action activation timestamp 依然保留。只要文档再次被使用,粘性激活状态依然存在,反映出用户曾经与该文档交互过。

瞬时激活(Transient Activation)

当当前的高精度时间大于或等于 W 的最后激活时间戳,且小于该时间戳加上瞬时激活持续时间时,W 处于瞬时激活状态。

瞬时激活是 W 的当前激活状态,表示用户在最近与 W 交互过。

初始状态为 false,每次 W 接收到激活通知后变为 true,但只会保持有限的时间,在瞬时激活持续时间过去后,状态会变为 false;或者在激活被消耗时也会提前失效。

该状态用于限制某些短暂时间内的 API 调用(例如弹窗或剪贴板功能等)。

瞬时激活状态有一个固定的有效期,即前面提到的“瞬时激活持续时间”。即使文档状态改变,瞬时激活状态的过期时间也不会重置。从用户触发输入事件的时间点开始,瞬时激活状态只会持续预定的时间,即使导航到另一个页面或重新加载文档也是如此。

历史动作激活(History-action Activation)

当 W 的最后历史动作激活时间戳不等于最后激活时间戳时,W 处于历史动作激活状态。

也就是说用户或开发者触发过了历史动作的改变,并且用户又触发了别的交互。

该激活状态用于允许某些与会话历史相关的 API,例如返回上一页操作。如果这些 API 被过于频繁地使用,会妨碍用户使用浏览器的 UI 回到之前的页面。

初始状态为 false,用户每次与 W 交互时会变为 true,但通过消耗历史动作激活会将其重置为 false。该状态用于确保此类 API 不能在没有用户干预的情况下连续调用,避免用户操作混淆。与瞬时激活不同,这里没有时间限制,但每次激活后只能调用一次

基于用户激活的 API

基于用户激活的 API 被划分为以下几个级别:粘性激活受限 API瞬时激活受限 API瞬时激活消耗 API历史动作激活消耗 API

粘性激活受限 API(Sticky Activation-gated APIs)

这些 API 需要粘性激活状态(sticky activation state)为 true 才能调用。在页面加载后,直到第一次用户激活之前,这些 API 都会被阻止调用。这种状态是永久性的,一旦被触发就不会再重置,因此在第一次用户激活后,这些 API 可以一直被调用。

例如 navigator.vibrate() 移动端震动功能,需要用户的第一次交互后才能被调用,此后就可以一直被调用。

瞬时激活受限 API(Transient Activation-gated APIs)

这些 API 需要瞬时激活状态(transient activation state)为 true 才能调用。虽然这些 API 受限于瞬时激活,但每次调用并不会消耗激活状态,因此只要瞬时激活状态还没过期,就可以多次调用。

例如 navigator.clipboard.writeText() 向剪贴板写入数据,在一次用户交互后短时间内是可以多次调用的,不会消耗用户的激活状态。

瞬时激活消耗 API(Transient Activation-consuming APIs)

这些 API 也需要瞬时激活状态(transient activation state)为 true,但每次调用都会消耗激活状态。因此,在每次用户交互后,这类 API 只能调用一次,在激活状态被消耗后就无法再次调用,除非发生新的用户激活。

例如 window.open() 是消耗瞬时激活状态的 API,每次调用都会消耗一次用户激活,所以 window.open 这样的 API 在一次用户交互后,瞬时激活持续时间范围内只能被调用一次

btn.addEventListener('click', () => {
  window.open('https://google.com');
  window.open('https://baidu.com');
});

给按钮加点击事件,期望点击按钮后打开两个页面,但是因为 window.open 是瞬时激活消耗 API,所以只会打开第一个谷歌页面。

历史动作激活消耗 API(History-action Activation-consuming APIs)

这些 API 需要历史操作激活状态(history-action activation state)为 true 才能调用,并且每次调用都会消耗该激活状态。该状态与浏览器的会话历史记录(session history)相关,用于防止过度调用可能影响导航的功能。每次调用这些 API 后,历史操作激活状态会被消耗,防止用户频繁调用同一 API。

例如 history.back()history.forward() 等,这些 API 通过用户激活执行后,会消耗历史操作激活状态,因此用户需要在每次调用之间有明确的交互,这些 API 在一次用户交互后只能被调用一次

UserActivation 接口

每个 Window 对象都有一个与之关联的 UserActivation 对象。该对象负责追踪和管理与用户交互相关的激活状态。当 Window 对象被创建时,浏览器会在 Window 对象所属的作用域中创建一个新的 UserActivation 对象,并将其关联到这个 Window 对象上。

interface UserActivation {
  readonly attribute boolean hasBeenActive;
  readonly attribute boolean isActive;
};

partial interface Navigator {
  [SameObject] readonly attribute UserActivation userActivation;
};

通过 navigator.userActivation 对象上的属性即可获得当前页面的激活状态。

navigator.userActivation.hasBeenActive; // 处于粘性激活状态时返回 true

navigator.userActivation.isActive; // 处于瞬时激活状态时返回 true

元素的激活行为

某些元素具有激活行为(activation behavior),用户可以通过与这些元素进行交互(通常是点击)来激活它们。

激活行为是指用户对某些特定的 HTML 元素进行交互时,会触发这些元素的默认行为。常见的元素包括:button 按钮、a 链接、input 表单控件(如单选按钮、复选框)等。

这些元素在被点击时,会执行某种特定的动作,比如提交表单、打开链接或触发 JavaScript 函数。

激活行为代表了用户进行了交互,一些 API 例如打开新窗口,就要求必须有激活行为即用户触发事件才能正常工作,否则浏览器会阻止新窗口的打开。

激活行为通常由点击事件触发,但浏览器还允许用户通过其他输入方式激活这些元素,例如通过按下回车键或空格键激活按钮或链接、使用语音命令激活某些控件等。但是浏览器必须将这些非点击交互行为模拟为点击事件

激活行为减少了我们开发者为不同输入设备编写不同事件处理代码的复杂性,因为浏览器会将不同输入方式统一处理为点击事件,我们只需要针对点击事件进行处理就可以了,而不需要考虑每一种用户输入的细节。

click 方法

每个元素都有一个 click 方法,并且还有一个关联的 "点击进行中" 标志(click in progress flag),用于防止同一元素同时处理多个点击事件。初始状态为未设置。当调用了 click 方法后:

     1. 如果该元素是禁用的表单控件,则返回(即不执行后续操作)。

     2. 如果该元素的 "点击进行中" 标志已设置,则返回(即不执行后续操作)。

     3. 设置该元素的 "点击进行中" 标志。

     4. 在该元素上触发一个名为 click 的合成指针事件(synthetic pointer event,并设置 not trusted 标志,表示这是一个脚本模拟出来的事件,而不是由真实用户输入产生的点击事件。

     5. 取消设置该元素的 "点击进行中" 标志。

上面第 4 步可以看到,click 方法会触发一个合成指针事件,并且会立马同步执行点击事件处理函数,而不会像用户交互的那样作为宏任务来执行。所以如果你不知道 click 方法的这个底层细节,用 click 方法验证点击事件是不是宏任务的时候就会抓耳挠腮的看着控制台,心想不是说是宏任务吗,为什么会在微任务之前运行!!!

我们可以通过下面代码,测试点击事件处理函数是宏任务:

<h1>前端创可贴</h1>

<button id="btn">按钮</button>

<script>
  btn.addEventListener('click', () => {
    Promise.resolve().then(() => {
      console.log('微任务1');
    });

    console.log('第1个点击事件处理函数');
  });

  btn.addEventListener('click', () => {
    Promise.resolve().then(() => {
      console.log('微任务2');
    });

    console.log('第2个点击事件处理函数');
  });

  btn.click();
</script>

刚进入页面未点击按钮之前,控制台会显示:

这时我们点击按钮,触发点击事件,控制台会显示:

可以看到,通过 Javascript 的 click 方法触发点击事件,会让事件处理函数变成同步执行,所以第 2 个点击事件处理函数会在第 1 个点击事件处理函数的微任务之前执行而通过用户触发的点击,事件处理函数就是宏任务,所以第 2 个点击事件处理函数会在第 1 个点击事件处理函数的微任务之后执行

还可以这么验证:全局代码循环一亿次,进行一些数值操作,在此期间用户点击按钮,并不会立马执行事件处理函数,需要等待一会才会在控制台上看到点击事件的执行;而在循环中判断次数等于 10000 时执行 click 方法,会立马在控制台上看到点击事件的执行。我们来验证一下:

<h1>前端创可贴</h1>

<button id="btn">按钮</button>

<script>
  btn.addEventListener('click', () => {
    console.log('点击事件处理函数');
  });

  let num = 0;

  for(let i = 0; i < 100000000; i++) {
    num += Math.random();
  }
</script>

执行上面代码,进入页面后立马点击按钮,过一会儿才会在页面上打印。而如果把点击事件改成:

btn.addEventListener('click', () => {
  console.log('点击事件处理函数');
});

let num = 0;

for(let i = 0; i < 100000000; i++) {
  num += Math.random();

  i === 10000 && btn.click();
}

进入页面后就立马可以看到控制台的打印。

结束语

这一章我们学习了用户交互相关内容里的 hidden 属性,掌握了 hidden 的两个状态及其特性。

也学习了页面的可见性相关内容,掌握了要尽量使用 visibilitychange 或 pagehide 事件来代替 unload 事件。

还学习了惰性 inert 属性的特性和使用场景。

最后又学习了用户激活和元素激活行为的知识,从底层上学习掌握了一些受激活状态限制的一些 API,以为为什么要这么设计的原因,以及扩展了一下如何验证点击事件处理函数是宏任务的方法。

我们前端开发工程师一定要掌握用户交互的相关技术,提高页面的可交互性是我们应该具备的能力,只有掌握背后底层的规范,才能从容应对产品同事和 UI 同事们的“无礼请求”~

那么这一章的介绍就结束了,咱们下一章再见啦。

欢迎关注我的公众号,前端创可贴。

猜你喜欢

转载自blog.csdn.net/weixin_44168130/article/details/142622392