2022年7月13日

聚焦:focus/blur

当用户点击某个元素或使用键盘上的 Tab 键选中时,该元素将会获得聚焦(focus)。还有一个 HTML 特性(attribute)autofocus 可以让焦点在网页加载时默认落在一个元素上,此外还有其它途径可以获得焦点。

聚焦到一个元素通常意味着:“准备在此处接受数据”,所以,这正是我们可以运行代码以初始化所需功能的时刻。

失去焦点的时刻(“blur”)可能更为重要。它可能发生在用户点击页面的其它地方,或者按下 Tab 键跳转到下一个表单字段,亦或是其它途径的时候。

失去焦点通常意味着:“数据已经输入完成”,所以我们可以运行代码来检查它,甚至可以将其保存到服务器上,或进行其他操作。

当处理焦点事件时,有一些重要的特性。我们将尽力把这些内容介绍完整。

focus/blur 事件

当元素聚焦时,会触发 focus 事件,当元素失去焦点时,会触发 blur 事件。

让我们使用它们来校验一个 input 字段。

在下面这个示例中:

  • blur 事件处理程序检查这个字段是否输入了电子邮箱,如果没有输入,则显示一个 error。
  • focus 事件处理程序隐藏 error 信息(在 blur 事件处理程序上会被再检查一遍):
<style>
  .invalid { border-color: red; }
  #error { color: red }
</style>

Your email please: <input type="email" id="input">

<div id="error"></div>

<script>
input.onblur = function() {
  if (!input.value.includes('@')) { // not email
    input.classList.add('invalid');
    error.innerHTML = 'Please enter a correct email.'
  }
};

input.onfocus = function() {
  if (this.classList.contains('invalid')) {
    // 移除 "error" 指示,因为用户想要重新输入一些内容
    this.classList.remove('invalid');
    error.innerHTML = "";
  }
};
</script>

现代 HTML 允许我们使用 input 特性(attribute)进行许多验证:requiredpattern 等。有时它们正是我们所需要的。当我们需要更大的灵活性时,可以使用 JavaScript。如果数据是正确的,我们可以把它自动发送到服务器。

focus/blur 方法

elem.focus()elem.blur() 方法可以设置和移除元素上的焦点。

例如,如果输入值无效,我们可以让焦点无法离开这个 input 字段:

<style>
  .error {
    background: red;
  }
</style>

Your email please: <input type="email" id="input">
<input type="text" style="width:220px" placeholder="make email invalid and try to focus here">

<script>
  input.onblur = function() {
    if (!this.value.includes('@')) { // not email
      // 显示 error
      this.classList.add("error");
      // ...将焦点放回来
      input.focus();
    } else {
      this.classList.remove("error");
    }
  };
</script>

这段代码在除了火狐(bug)之外的浏览器上都可以正常工作。

如果我们在 input 中输入一些内容,然后尝试使用 Tab 键或点击远离 <input> 的位置,那么 onblur 事件处理程序会把焦点重新设置到这个 input 字段上。

请注意,我们无法通过在 onblur 事件处理程序中调用 event.preventDefault() 来“阻止失去焦点”,因为 onblur 事件处理程序是在元素失去焦点 之后 运行的。

但在实际中,在实现这样的功能之前应该认真考虑一下,因为我们通常 应该将报错展示给用户,但 不应该阻止用户在填写我们的表单时的进度。用户可能会想先填写其他表单项。

JavaScript 导致的焦点丢失

很多种原因可以导致焦点丢失。

其中之一就是用户点击了其它位置。当然 JavaScript 自身也可能导致焦点丢失,例如:

  • 一个 alert 会将焦点移至自身,因此会导致元素失去焦点(触发 blur 事件),而当 alert 对话框被取消时,焦点又回重新回到原元素上(触发 focus 事件)。
  • 如果一个元素被从 DOM 中移除,那么也会导致焦点丢失。如果稍后它被重新插入到 DOM,焦点也不会回到它身上。

这些特性有时候会导致 focus/blur 处理程序发生异常 —— 在不需要它们时触发。

最好的秘诀就是在使用这些事件时小心点。如果我们想要跟踪用户导致的焦点丢失,则应该避免自己造成的焦点丢失。

允许在任何元素上聚焦:tabindex

默认情况下,很多元素不支持聚焦。

列表(list)在不同的浏览器表现不同,但有一件事总是正确的:focus/blur 保证支持那些用户可以交互的元素:<button><input><select><a> 等。

另一方面,为了格式化某些东西而存在的元素像 <div><span><table> —— 默认是不能被聚焦的。elem.focus() 方法不适用于它们,并且 focus/blur 事件也绝不会被触发。

使用 HTML-特性(attribute)tabindex 可以改变这种情况。

任何具有 tabindex 特性的元素,都会变成可聚焦的。该特性的 value 是当使用 Tab(或类似的东西)在元素之间进行切换时,元素的顺序号。

也就是说:如果我们有两个元素,第一个具有 tabindex="1",第二个具有 tabindex="2",然后当焦点在第一个元素的时候,按下 Tab 键,会使焦点移动到第二个元素身上。

切换顺序为:从 1 开始的具有 tabindex 的元素排在前面(按 tabindex 顺序),然后是不具有 tabindex 的元素(例如常规的 <input>)。

不具有 tabindex 的元素按文档源顺序(默认顺序)切换。

这里有两个特殊的值:

  • tabindex="0" 会使该元素被与那些不具有 tabindex 的元素放在一起。也就是说,当我们切换元素时,具有 tabindex="0" 的元素将排在那些具有 tabindex ≥ 1 的元素的后面。

    通常,它用于使元素具有焦点,但是保留默认的切换顺序。使元素成为与 <input> 一样的表单的一部分。

  • tabindex="-1" 只允许以编程的方式聚焦于元素。Tab 键会忽略这样的元素,但是 elem.focus() 有效。

举个例子,这里有一个列表。点击第一项,然后按 Tab 键:

点击第一项,然后按 Tab 键。跟踪顺序。请注意,多按几次 Tab 键后,会将焦点移到这个通过 iframe 嵌入的示例的外面。
<ul>
  <li tabindex="1">One</li>
  <li tabindex="0">Zero</li>
  <li tabindex="2">Two</li>
  <li tabindex="-1">Minus one</li>
</ul>

<style>
  li { cursor: pointer; }
  :focus { outline: 1px dashed green; }
</style>

顺序就像这样:1 - 2 - 0。通常,<li> 不支持聚焦,但 tabindex 可以使它能聚焦,使这成为可能,并且还带有事件以及 :focus 样式。

属性 elem.tabIndex 也有效

我们可以使用 elem.tabIndex 通过 JavaScript 来添加 tabindex。效果是一样的。

focus/blur 委托

focusblur 事件不会向上冒泡。

例如,我们不能把 onfocus 放在 <form> 上来对其进行高亮,像这样:

<!-- on focusing in the form -- add the class -->
<form onfocus="this.className='focused'">
  <input type="text" name="name" value="Name">
  <input type="text" name="surname" value="Surname">
</form>

<style> .focused { outline: 1px solid red; } </style>

上面这个示例并不工作,因为当用户聚焦于 <input> 时,focus 事件只会在该 <input> 上触发。它不会向上冒泡。所以 form.onfocus 永远不会触发。

这里有两个解决方案。

方案一,有一个遗留下来的有趣的特性(feature):focus/blur 不会向上冒泡,但会在捕获阶段向下传播。

这样可以生效:

<form id="form">
  <input type="text" name="name" value="Name">
  <input type="text" name="surname" value="Surname">
</form>

<style> .focused { outline: 1px solid red; } </style>

<script>
  // 将处理程序置于捕获阶段(最后一个参数为 true)
  form.addEventListener("focus", () => form.classList.add('focused'), true);
  form.addEventListener("blur", () => form.classList.remove('focused'), true);
</script>

方案二,可以使用 focusinfocusout 事件 —— 与 focus/blur 事件完全一样,只是它们会冒泡。

值得注意的是,必须使用 elem.addEventListener 来分配它们,而不是 on<event>

所以,这是另一个可行的变体:

<form id="form">
  <input type="text" name="name" value="Name">
  <input type="text" name="surname" value="Surname">
</form>

<style> .focused { outline: 1px solid red; } </style>

<script>
  form.addEventListener("focusin", () => form.classList.add('focused'));
  form.addEventListener("focusout", () => form.classList.remove('focused'));
</script>

总结

在元素获得/失去焦点时会触发 focusblur 事件。

它们的特点是:

  • 它们不会冒泡。但是可以改为在捕获阶段触发,或者使用 focusin/focusout
  • 大多数元素默认不支持聚焦。使用 tabindex 可以使任何元素变成可聚焦的。

可以通过 document.activeElement 来获取当前所聚焦的元素。

任务

重要程度: 5

创建一个 <div>,它在被点击后变成 <textarea>

文本区域(textarea)允许我们编辑 <div> 里的 HTML。

当用户按下 Enter 键,或者 <textarea> 失去焦点时,<textarea> 会变回 <div>,并且 <textarea> 中的内容会变成 <div> 中的 HTML。

在新窗口中演示

打开一个任务沙箱。

重要程度: 5

使单元格在点击时可编辑。

  • 点击时 —— 单元格应该变成“可编辑的”(在里面会出现文本区域),我们修改其中的 HTML。在这不调整单元格大小,所有几何形状保持不变。
  • OK 和 CANCEL 按钮会出现在单元格的下面,用以完成/取消编辑。
  • 同一时刻只有一个单元格可被编辑。当一个 <td> 处于“编辑模式”时,在其它单元格上的点击会被忽略。
  • 该表格可能有很多单元格。请使用事件委托。

示例:

打开一个任务沙箱。

  1. 在点击时 —— 用相同尺寸且无边框的 <textarea> 替换单元格的 innerHTML。可以使用 JavaScript 或 CSS 设置正确的尺寸。
  2. textarea.value 设置为 td.innerHTML
  3. 聚焦在文本区域(textarea)。
  4. 在单元格下方应该显示 OK/CANCEL 按钮,并处理对它们的点击事件。

使用沙箱打开解决方案。

重要程度: 4

聚焦在老鼠上。然后使用键盘的方向键移动它:

在新窗口中演示

P.S. 除了 #mouse 元素外,不要在任何地方放置事件处理程序。

P.P.S. 不要修改 HTML/CSS,这个方法应该是通用的,可以用于任何元素。

打开一个任务沙箱。

我们可以使用 mouse.onclick 来处理点击,并将老鼠设置为 position:fixed,然后使用 mouse.onkeydown 来处理键盘的方向键。

唯一的缺陷是 keydown 仅会在聚焦的元素上触发。因此,我们需要向元素添加 tabindex。因为我们禁止更改 HTML,所以我们可以使用 mouse.tabIndex 属性。

P.S. 我们也可以使用 mouse.onfocus 代替 mouse.onclick

使用沙箱打开解决方案。

教程路线图