原文链接:http://javascript.info/focus-blur,translate with ❤️ by zhangbao.

当用户使用鼠标点击或 Tab 键切换时,会让元素获得焦点。HTML 还提供了 autofocus 特性,在页面加载完毕或者通过其他什么方式,自动让元素获得焦点。

获得焦点,通常表示:“这里准备接收数据了”,这就到了我们可以执行代码,来初始化或加载某些东西的时刻。

失去焦点(“blur”)甚至更重要。当用户点击别处或者按下 Tab 键切换到下一个表单控件时,发生该事件。

失去焦点表示:“数据已经输入了”,因此,我们到了可以执行代码来检查的阶段,甚至可以进行将数据保存到服务器上等操作。

在处理焦点事件时,有一些重要的特性。我们会尽力在本章阐述。

focus/blur 事件

focus 事件称为聚焦,blur 称之为失焦。

下面,我们用这些事件来验证表单框的输入内容。

下例中:

  • blur 处理器检查输入框里是否输入了 Email 地址,如果没有,就显示错误信息。

  • focus 处理器隐藏错误信息(在 blur 的时候还会重新检查):

  1. <style>
  2. .invalid { border-color: red; }
  3. #error { color: red }
  4. </style>
  5. 您的 Email 地址: <input type="email" id="input">
  6. <div id="error"></div>
  7. <script>
  8. input.onblur = function () {
  9. if (!input.value.includes('@')) { // 不是邮箱
  10. input.classList.add('invalid');
  11. error.innerHTML = '请输入正确的 Email 地址';
  12. }
  13. };
  14. input.onfocus = function () {
  15. if (this.classList.contains('invalid')) {
  16. // 删除“错误”指示,因为用户此时要重新输入内容
  17. this.classList.remove('invalid');
  18. error.innerHTML = '';
  19. }
  20. };
  21. </script>

现代 HTML 提供一些表单特进行内容验证:比如 requiredpattern 等,有时正是我们需要的。当我们需要更大的灵活性时,可以借助 JavaScript。如果输入值正确,我们可以自动地将修改后的值发送到服务器。

focus/blur 方法

elem.focus()elem.blur() 方法用于设置/取消元素的聚焦。

我们可以写个例子,如果输入值无效,就不让用户离开输入框:

  1. <style>
  2. .error {
  3. background: red;
  4. }
  5. </style>
  6. 您的 Email 地址: <input type="email" id="input">
  7. <input type="text" style="width:220px" placeholder="确保输入的 Email 地址是有效的,再 focus 这里">
  8. <script>
  9. input.onblur = function () {
  10. if (this.value.includes('@')) {
  11. // 显示错误
  12. this.classList.add('error');
  13. // 然后重新 focus
  14. input.focus();
  15. } else {
  16. this.classList.remove('error');
  17. }
  18. };
  19. </script>

这种方式适用于除火狐之外的所有浏览器(bug)。

如果我们往表单里输入了内容,使用 Tab 或点击离开 <input>,就会触发 onblur,从而导致重新 focus input 的情况发生。

聚焦:focus/blur - 图1JavaScript 触发失焦

失焦的发生有许多场景。

一个场景就是用户点击了别处。但也可以通过 JavaScript 去触发它。例如:

  • 弹出 alert 框时,焦点会自动聚焦在它身上,触发元素的失焦(blur 事件),当 alert 框消失后,焦点又重新回到元素身上(focus 事件)。

  • 如果一个元素从 DOM 中删除,也会引发失焦。如果后来又重新插入进来,就不会触发聚焦了。

这些特性有时会导致 focus/blur 处理程序失误——在意料之外的情况下触发。

最好的方法,是在使用这些事件时要小心。如果我们想要跟踪用户发起的焦点消失,我们应该避免自己无意触发这些事件。

tabindex:让所有元素都支持 focus

默认许多元素都不支持聚焦。

不同浏览器之间的支持列表是不同的,但有一些元素是肯定支持 focus/blur 事件的:像 <button><input><select><a> 等。

另一方面,为格式化内容而存在的元素,像 <div><span><table> 默认是不支持 focus 的。也就是说,elem.focus 方法在这些元素上没有效果,这些元素不会触发 focus/blur 事件。

但在添加 tabindex 特性后,情况就不一样了。

这个特性的目的,是在使用 Tab 键切换元素时,指定它们被 focus 的顺序。

如果我们有两个元素。第一个写了 tabindex="1",第二个写了 tabindex="2",当按下 Tab 键的时候,会首先聚焦 tabindex 值是 1 的元素,然后再聚焦 tabindex 值是 2 的元素。

有两个特殊值:

  • tabindex="0":使元素最后被聚焦。

  • tabindex="-1":使用 Tab 键切换时,会忽略该元素。

如果一个元素有 tabindex 特性,就表示支持聚焦。

下面例子里有一个列表,点击第一项然后按下 Tab 键:

  1. 点击第一项然后按下 Tab 键,跟踪元素聚焦顺序。
  2. <ul>
  3. <li tabindex="1"></li>
  4. <li tabindex="0"></li>
  5. <li tabindex="2"></li>
  6. <li tabindex="-1">负一</li>
  7. </ul>
  8. <style>
  9. li { cursor: pointer; }
  10. :focus { outline: 1px dashed green; }
  11. </style>

聚焦的顺序是这样的:1 -> 2 -> 0(零总是最后一个)。正常情况下,<li> 不支持聚焦,但是在添加 tabindex 之后,就支持聚焦了,我们用 :focus 去添加元素聚焦时的样式。

聚焦:focus/blur - 图2使用 **elem.tabIndex`` 同样有效**

我们也可以使用 JavaScript 添加 tabindex 属性,也就是 elem.tabIndex 的方式,让元素支持聚焦。

委托:focusin/focusout

focusblur 事件不冒泡。

例如,我们在 <form> 上使用 onfocus,让表单在聚焦时高亮:

  1. <!-- 在 <form> 聚焦时,添加类名 focused -->
  2. <form onfocus="this.className = 'focused'">
  3. <input type="text" name="name" value="姓">
  4. <input type="text" name="surname" value="名">
  5. </form>
  6. <style> .focused { outline: 1px solid red; } </style>

我们会发现上面的代码不起作用,因为用户是在 <input> 上聚焦,focus 事件也只发生在这个输入框上,不会冒泡。因此,不会触发 form.onfocus

有两种解决方案:

首先,有一个有趣的历史特性:focus/blur 不冒泡,但是在捕获阶段是向下传播的。

岂不是将事件绑定在捕获阶段不就行了!

  1. <form id="form">
  2. <input type="text" name="name" value="姓">
  3. <input type="text" name="surname" value="名">
  4. </form>
  5. <style> .focused { outline: 1px solid red; } </style>
  6. <script>
  7. // 将事件处理程序放在捕获阶段(最后一个参数值是 true)
  8. form.addEventListener('focus', () => form.classList.add('focused'), true);
  9. form.addEventListener('focus', () => form.classList.remove('focused'), true);
  10. </script>

第二个方案是使用 focusinfocusout 事件,它们等同于 focus/blur,唯一不同的是,这两个事件冒泡。

需要注意的是,必须使用 elem.addEventListener 的方式绑定才有效,使用 on<event> 的方式是行不通的。

这是另一种变体代码:

  1. <form id="form">
  2. <input type="text" name="name" value="姓">
  3. <input type="text" name="surname" value="名">
  4. </form>
  5. <style> .focused { outline: 1px solid red; } </style>
  6. <script>
  7. // 我们使用冒泡的 focusin 和 focusout 事件
  8. form.addEventListener("focusin", () => form.classList.add('focused'));
  9. form.addEventListener("focusout", () => form.classList.remove('focused'));
  10. </script>

总结

focusblur 事件是在元素获取焦点/失去焦点时触发的。

它们的特性如下:

  • 它们不冒泡。这个缺点可以使用捕获阶段的事件处理程序或 focusin/focusout 事件解决。

  • 默认许多元素都不支持聚焦。使用 tabIndex 可以让元素具备聚焦的能力。

使用 document.activeElement 属性可以获取页面当前的聚焦元素。

练习题

问题

一、可编辑的 div

创建一个 <div>,在点击的时候变成 <textarea>

文本框让在 <div> 中编辑 HTML 成为可能。

当用户按下 Enter 或者文本框失去焦点时,<textarea> 变为 <div>,然后文本框的内容变为 <div> 中的 HTML 内容了。

二、在点击时编辑 TD

让表格单元格变成点击后可编辑的。

  • 当点击时——单元格变成“可编辑的”(内部出现文本框),我们可以修改里面的 HTML 结构。单元格不可以调整大小,一直保持原始尺寸大小。

  • 单元格下面出现的 OK 和 CANCEL 按钮点击后表示完成/取消编辑。

  • 一次只能编辑一个单元格内容。当一个 <td> 处于“编辑模式”时,点击其他单元格的操作都会被忽略。

  • 表格可能包含许多单元格。需要使用事件委托。

三、键盘驱动的老鼠

聚焦老鼠,使用方向按键移动位置:

这是 demo

P.S. 只在 #mouse 上绑定事件处理程序。P.P.S 无需修改任何 HTML/CSS,让这个方法也适用于其他元素。

答案

一、可编辑的 div

  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <link type="text/css" rel="stylesheet" href="my.css">
  5. <meta charset="utf-8">
  6. </head>
  7. <body>
  8. <ul>
  9. <li>点击 div 进入编辑模式</li>
  10. <li>Enter 或失去焦时会保存</li>
  11. </ul>
  12. 允许写入 HTML。
  13. <div id="view" class="view">Text</div>
  14. <script>
  15. let area = null;
  16. let view = document.getElementById('view');
  17. view.onclick = function() {
  18. editStart();
  19. };
  20. function editStart() {
  21. area = document.createElement('textarea');
  22. area.className = 'edit';
  23. area.value = view.innerHTML;
  24. area.onkeydown = function(event) {
  25. if (event.key == 'Enter') {
  26. this.blur();
  27. }
  28. };
  29. area.onblur = function() {
  30. editEnd();
  31. };
  32. view.replaceWith(area);
  33. area.focus();
  34. }
  35. function editEnd() {
  36. view.innerHTML = area.value;
  37. area.replaceWith(view);
  38. }
  39. </script>
  40. </body>
  41. </html>

二、在点击时编辑 TD

解决思路:

  1. 点击的时候,使用 <textarea> 接受单元格里的 innerHTML 内容,而且文本框具有与单元格一样的尺寸,且没有边框。可以借助 JavaScript 和 CSS 来完成正确尺寸的设置。

  2. textarea.value 的值设置成 td.innerHTML

  3. 聚焦文本框。

  4. 在单元格下面,显示 OK/CANCEL 按钮,处理发生在按钮上的点击事件。

核心 JavaScript 代码如下:

  1. let table = document.getElementById('bagua-table');
  2. let editingTd;
  3. table.onclick = function(event) {
  4. // 3 possible targets
  5. let target = event.target.closest('.edit-cancel,.edit-ok,td');
  6. if (!table.contains(target)) return;
  7. if (target.className == 'edit-cancel') {
  8. finishTdEdit(editingTd.elem, false);
  9. } else if (target.className == 'edit-ok') {
  10. finishTdEdit(editingTd.elem, true);
  11. } else if (target.nodeName == 'TD') {
  12. if (editingTd) return; // already editing
  13. makeTdEditable(target);
  14. }
  15. };
  16. function makeTdEditable(td) {
  17. editingTd = {
  18. elem: td,
  19. data: td.innerHTML
  20. };
  21. td.classList.add('edit-td'); // td is in edit state, CSS also styles the area inside
  22. let textArea = document.createElement('textarea');
  23. textArea.style.width = td.clientWidth + 'px';
  24. textArea.style.height = td.clientHeight + 'px';
  25. textArea.className = 'edit-area';
  26. textArea.value = td.innerHTML;
  27. td.innerHTML = '';
  28. td.appendChild(textArea);
  29. textArea.focus();
  30. td.insertAdjacentHTML("beforeEnd",
  31. '<div class="edit-controls"><button class="edit-ok">OK</button><button class="edit-cancel">CANCEL</button></div>'
  32. );
  33. }
  34. function finishTdEdit(td, isOk) {
  35. if (isOk) {
  36. td.innerHTML = td.firstChild.value;
  37. } else {
  38. td.innerHTML = editingTd.data;
  39. }
  40. td.classList.remove('edit-td');
  41. editingTd = null;
  42. }

完整代码查看这里

三、键盘驱动的老鼠

我可以使用 mouse.onclick 处理点击,使用 position: fixed 让老鼠“可移动”,然后使用 mouse.onkeydown 处理方向按键的操作。

唯一需要注意的地方是,keydown 事件只在支持聚焦的元素上触发,因此我们需要给元素添加 tabindex 特性以支持此事件。因为我们不能修改 HTML,所以我们要使用 mouse.tabIndex 来实现。

P.S. 我们也可以将 mouse.onclick 替换成 mouse.onfocus

  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <style>
  6. #mouse {
  7. display: inline-block;
  8. cursor: pointer;
  9. margin: 0;
  10. }
  11. #mouse:focus {
  12. outline: 1px dashed black;
  13. }
  14. </style>
  15. </head>
  16. <body>
  17. <p>点击老鼠,使用方向按键就可以移动它!</p>
  18. <pre id="mouse">
  19. _ _
  20. (q\_/p)
  21. /. .\
  22. =\_t_/= __
  23. / \ (
  24. (( )) )
  25. /\) (/\ /
  26. \ Y /-'
  27. nn^nn
  28. </pre>
  29. <script>
  30. mouse.tabIndex = 0;
  31. mouse.onclick = function() {
  32. this.style.left = this.getBoundingClientRect().left + 'px';
  33. this.style.top = this.getBoundingClientRect().top + 'px';
  34. this.style.position = 'fixed';
  35. };
  36. mouse.onkeydown = function(e) {
  37. switch (e.key) {
  38. case 'ArrowLeft':
  39. this.style.left = parseInt(this.style.left) - this.offsetWidth + 'px';
  40. return false;
  41. case 'ArrowUp':
  42. this.style.top = parseInt(this.style.top) - this.offsetHeight + 'px';
  43. return false;
  44. case 'ArrowRight':
  45. this.style.left = parseInt(this.style.left) + this.offsetWidth + 'px';
  46. return false;
  47. case 'ArrowDown':
  48. this.style.top = parseInt(this.style.top) + this.offsetHeight + 'px';
  49. return false;
  50. }
  51. };
  52. </script>
  53. </body>
  54. </html>

在线例子

(完)