焦点与键盘导航
前四节的主角是鼠标和手指:点、划、捏、长按。这一节把指针请下场,换一位更古老也更苛刻的演员——键盘。把鼠标拔掉、触摸屏关掉,只剩 Tab、方向键和回车,你的界面还能不能用?答案全部押在一个概念上:焦点(focus)。它是整个界面的「现在时」:同一时刻只有一个元素持有它,键盘事件只发给它,屏幕阅读器的目光也只落在它身上。这一节讲清楚六件事:焦点到底是什么、谁有资格拿到它、Tab 按什么顺序走、模态框怎么用「焦点陷阱」圈住用户、快捷键怎么不闯祸、删掉一个元素之后焦点该交给谁。学完你会发现:键盘可用不是无障碍的赠品,而是界面工程质量的一面照妖镜——鼠标用户的宽容,掩盖不了 DOM 顺序的混乱。
想象一座小剧场,全场只有一盏追光灯,灯往哪儿打,观众的目光就在哪儿——同一时刻,它只能照亮一位演员。这就是焦点:整个舞台上唯一的「现在时」。
谁能被灯照到,不是看谁演得好,而是看台口贴没贴站位标记:贴了标记的位子(按钮、输入框、带地址的链接)灯扫过去就能停;没贴标记的角落(普通的 div、span),灯从来不看一眼。
灯从一个演员移到下一个,走的是导演手里的出场名单——名单在排戏时就写死了,跟今晚演员站在舞台左边还是右边无关。这份名单就是 DOM 顺序。
有的演员手里握着插队卡(正数 tabindex):不管他在名单第几,持卡的人先上场,卡号小的走在前。
到了座谈会环节,主持人把话筒收进一个圈:问题在圈内来回传,传到头就绕回第一位,谁也别想把话筒带进观众席乱窜——这是焦点陷阱,模态框的看家本领。散场铃(Esc)一响,灯回到报幕员身上——还焦:从哪来回哪去。键盘导航的整门手艺,就是这座剧场的管理制度:一盏灯、一份名单、几张插队卡、一个话筒圈。
术语对照:先把这一节的行话翻译成人话
| 术语 | 翻译成人话 | 剧场里的对应物 |
|---|---|---|
| 焦点(focus) | 界面的「现在时」,同一时刻只有一个元素持有 | 那盏全场唯一的追光灯 |
| document.activeElement | 随时可查的「灯现在照着谁」登记簿 | 舞台监督手里的灯位记录 |
| 可聚焦(focusable) | 有资格被灯照到的元素 | 台口贴了站位标记的位子 |
| Tab 顺序(tab order) | 按 Tab 键时灯依次走过的路线 | 导演手里的出场名单 |
| tabindex | 给元素发的号码牌:0 入队、-1 暗桩、正数插队 | 站位标记与插队卡 |
| 焦点陷阱(focus trap) | 把灯圈在模态框里,到头绕回,不许漏出去 | 座谈会的话筒圈 |
| roving tabindex | 组件内选项间「传话筒」,Tab 键只负责进出组件 | 话筒在一排嘉宾手里传 |
| focusin / focusout | 会冒泡版本的获焦/失焦公告(focus 不冒泡) | 全楼广播版的灯位变更通知 |
| :focus-visible | 只在键盘导航时才画的焦点圈 | 观众举了望远镜,才给演员打亮圈 |
| 还焦(restore focus) | 弹窗关闭后,把焦点还给打开它的人 | 散场铃响,灯回报幕员 |
| inert | 把一块界面整块「拉闸」:不可点、不可聚焦、对读屏隐身 | 后台区域直接熄灯清场 |
| 快捷键(hotkey) | 不经过鼠标直达功能的按键组合 | 后台的暗号:喊一嗓子就换布景 |
焦点是什么:整个界面的「现在时」
先给焦点下一个可操作的定义:焦点是键盘世界的收件地址。你在页面上随便按一个键,这个 keydown 事件并不是撒胡椒面一样撒给所有元素,而是精准地投递给当前持有焦点的那个元素,再从它开始沿 DOM 树往上冒泡(§ 4.2 的传播规则原样适用,只是起点固定)。焦点落在搜索框里,你敲的每个字都进搜索框;焦点落在某个按钮上,空格键就按下这个按钮。说白了,焦点在哪儿,键盘的世界就在哪儿——这就是为什么把鼠标拔掉之后,界面的可用性完全取决于「灯能不能走到每一个该走的地方」。
想知道灯现在照着谁,随时可以查:document.activeElement 就是那本登记簿,页面上任何时刻它都指向当前焦点元素;一个都没聚焦时,它指向 <body>。所有焦点管理的代码都从这本登记簿出发:打开弹窗前先记下它(准备还焦),关掉菜单后查它(确认焦点没摔到 body 上)。
顺手厘清三个容易混淆的「当前」:焦点是键盘的现在时;悬停(hover)是鼠标的现在时,鼠标挪走就失效;选中(selection)是数据的现在时,比如表格里高亮的那一行、输入框里刷黑的那段文字。三者互相独立:一个元素可以被选中但不聚焦(后台标签页里你之前刷黑的文字还在),也可以聚焦但不选中。就像图书馆里你用书签夹着的那页(选中)、你眼睛正看着的那行(焦点)、你手指正点着的那本(悬停)——三样东西,三个系统。键盘导航只关心中间那个。
谁能拿到灯:天生的焦点名单
浏览器出厂就带一份「天生可聚焦」名单,其实就是所有「原生交互控件」:button、input(除 type="hidden" 外)、select、textarea、带 href 的 a、summary、[contenteditable]、带 controls 的 audio/video。它们不写任何 tabindex,就天然在 Tab 队列里,而且键盘激活是免费的:按钮自动响应回车和空格,链接自动响应回车,复选框自动响应空格——这套行为浏览器替你写好了。
名单之外的一切——div、span、img、p、没写 href 的 a——天生不可聚焦:Tab 扫过去直接跳过,灯不看一眼。另外有两种「在名单上但被临时除名」的状态要分清:disabled 的控件(取消了资格,也不响应点击,事件不发);display:none / visibility:hidden / hidden 属性的元素(整个从舞台上撤掉了,连存在感都没有)。好比安检口的两道规矩:证件没带(没资格)和今天航班取消(不在场),都是过不去,但原因完全不同——排查「为什么 Tab 到不了」时,先分清是哪一种。
这里埋着 Web 开发最经典的陷阱之一:div 陷阱。设计师画了一个漂亮的卡片,开发者顺手写了 <div onclick="...">——鼠标用户点得好好的,键盘用户 Tab 到天荒地老也停不进来。这个元素就是「半残控件」:能被鼠标点,却不在键盘的世界里存在。修法有两档:正解是换成 button,重新做样式(button 的默认样式全可以用 CSS 抹掉),聚焦、键盘激活、读屏播报全套免费送;将就的解法是加 tabindex="0" 让它入队,再手写 keydown 监听回车和空格——但你马上会发现这是在亲手复刻浏览器送过的东西,漏一处(比如忘了空格)就是新的坑。能用原生控件就用原生控件,这是本节出现频率最高的一句忠告。
Tab 顺序:导演手里的出场名单
按 Tab 键,灯从当前元素跳到「下一个可聚焦元素」——这个「下一个」由谁决定?答案朴素得让人意外:DOM 顺序,也就是 HTML 源码里元素出现的先后。不是视觉上的左右,不是 z-index 的高低,不是哪个元素看起来更重要。简单说:排戏时名单就写死了,演出当晚演员站哪儿不影响出场次序。
麻烦在于,CSS 有的是办法让视觉顺序背叛 DOM 顺序:flex-direction: row-reverse 让一排按钮在屏幕上左右颠倒,Tab 顺序纹丝不动;order 属性同理;float、Grid 的自动放置、绝对定位,全都能把视觉和源码拆成两个宇宙。于是同一个页面,鼠标用户看到按钮从左到右是「保存、取消、帮助」,键盘用户的 Tab 顺序却可能是「取消、帮助、保存」——两拨人进了同一家超市,拿到的却是两张货架排布图完全不同的地图。想象一下按视觉顺序读表格填完一屏,一按 Tab,灯却蹦到了页面顶部某个你根本看不见的隐藏链接上——这不是假想,是几乎每个用 Tab 走过网页的人都遭遇过的真实体验。
工程纪律由此而来:让 DOM 顺序 = 视觉顺序 = 阅读顺序,三位一体。布局需要换序时,优先改 HTML 结构而不是用 CSS 硬掰;实在要视觉反转(比如聊天界面),先想清楚键盘用户按名单走时会不会迷路。验收办法土而有效:把鼠标拔了,用 Tab 从头走到尾,你的眼睛跟着焦点圈走一遍——顺序乱不乱、有没有被跳过的功能、有没有看不见的暗桩,一目了然。这一招在后面的 Checklist 里还会出现,它值回全部票价。
tabindex:三种号码牌,只有两种该用
tabindex 属性给任何元素发号码牌,但三种取值的命运天差地别,值得逐一过堂:
tabindex="0":入队卡。元素加入自然 Tab 队列,位置由 DOM 顺序决定。什么时候用:让一个非原生控件(比如自绘的卡片、可交互的表格行)进入键盘世界——但记住配齐 keydown 的回车/空格处理,否则就是只有站位没有戏份的演员。
tabindex="-1":暗桩。元素从 Tab 队列里除名,但可以用 JS 手动聚焦(el.focus())。这是焦点管理的瑞士军刀:页面路由切换后聚焦大标题、删除列表项后接管焦点的容器、roving tabindex 里的非活跃选项、跳转链接(skip link)的目标——全靠它。「灯不自动来,但导演喊得动」。
tabindex="1" 及以上:插队卡。持卡者先于所有自然队列上场,卡号小的先走。听起来很好用,实际上它是 Web 前端公认的脚趾枪:第一,它把整个页面的 Tab 顺序劈成两套——持卡人一套、平民一套,键盘用户的心智模型直接碎掉;第二,它是全文档范围的:你在页脚小部件里发了一张 1 号卡,它会抢在页头所有按钮之前——你以为的局部优先,实际是全局插队;第三,维护是灾难:后来者想插更前面就得发更小的卡,最后满页都是卡,没人说得清顺序。
不妨这样想:插队卡是 CSS 还管不了布局的远古时代(上个世纪九十年代末)留下的遗产,当时开发者靠它修正糟糕的表格布局导致的顺序错乱。今天顺序问题正解是改 DOM,正数 tabindex 在主流代码规范(包括 Google、MDN 的指引)里一律劝退。全站纪律一句话:tabindex 只用 0 和 -1,正数一个不碰。
灯亮灯灭的公告:focus、blur 与会冒泡的 focusin
灯移动时,浏览器会发公告:元素获得焦点时触发 focus,失去时触发 blur。但这对事件有个著名特性:它们不冒泡。这在当年是有意为之的设计,却让 § 4.2 学过的事件委托直接失效——你想在列表容器上挂一个监听器接收所有子项的获焦事件?挂 focus 是收不到的,因为公告只在元素自家门口贴,不上楼。解法是它的冒泡版本:focusin / focusout,在获得/失去焦点之前沿树冒泡。做「输入框聚焦时给整行加高亮」这类全局统计或样式联动,监听 focusin 就够了,一份监听器管一整个表单。
事件对象里还藏着一条有用情报:e.relatedTarget——焦点的「前一任」或「下一任」。focusin 时它是即将失焦的元素,focusout 时它是即将接棒的元素;穿透跳转时可能为 null。做「用户从哪个字段离开」的校验提示、或者「焦点是否已经逃出表单」的判断,查它比自己记账干净。
再看一个时序细节,和 § 4.3 的鼠标三连击连起来看:mousedown → focus → mouseup → click。焦点在按下和抬起之间就已经转移,click 最后才来。这个顺序偶尔会咬人:比如 mousedown 里就改了 DOM(提前移除了目标元素),focus 和 click 可能就没了着落。另外一个平台冷知识值得记下:macOS 上点击按钮默认不聚焦——Safari 和 Chrome 都遵循苹果的界面惯例,鼠标点完按钮焦点仍在原处(Windows 和 Linux 则聚焦),只有按 Tab 走过去才给。所以「点完按钮立刻看 activeElement」的测试代码在不同平台会给出不同答案,不是 bug,是两套剧场的管理制度。
最后把镜头拉远一层:元素之上还有窗口级焦点。切到别的标签页或别的应用,当前页面的 window 会触发 blur(切回来触发 focus)——想统计「用户还盯着页面没有」、想在视频网站里实现「切走自动暂停」,监听的就是它(连同 visibilitychange,§ 4.1 讲过它和事件循环的配合)。而嵌了 iframe 的页面则是两座相邻剧场:每个文档各有一盏自己的灯、各有一份名单,键盘事件只在各自楼里巡游;外层页面聚焦到 iframe 时,灯其实交给了 iframe 这栋楼内部的元素。排查「Tab 进了内嵌地图/播放器就出不来了」这类怪象时,先想到这一层——不是 Tab 坏了,是你已经走进了另一栋楼。
:focus 与 :focus-visible:灯该亮给谁看
焦点必须看得见——这是键盘可用性的生命线。焦点圈(outline)就是灯打在演员身上的亮边:键盘用户全靠它知道「我现在在哪儿」。历史包袱在于:早年的浏览器不区分输入方式,鼠标点击也会画上焦点圈,设计师嫌它丑,于是满世界出现了 outline: none——这一行 CSS 是 Web 可用性史上最著名的一场事故:鼠标用户看着清爽了,键盘用户瞬间「失明」,灯照着谁,全场没人知道。
现代解法是 :focus-visible 伪类(2022 年起四大浏览器全部支持):浏览器自己判断输入方式——刚用过键盘(比如按过 Tab),就显示焦点样式;纯鼠标点击,就不显示。判断是启发式的,但效果出奇地合心意。正确写法只有三句话:永远别写裸的 outline: none;要改样式就改在 :focus-visible 上,用 box-shadow 或加粗的 outline 画一个对比度足够的圈;圈要贴着元素、别被 overflow: hidden 裁掉。打个比方,:focus-visible 就像剧场只在观众举起望远镜时才给演员打亮圈——需要看的人看得清清楚楚,不需要的人不被打扰。
顺带认识它的兄弟 :focus-within:当自身或任意后代获得焦点时命中——搜索框里的输入框一聚焦,整个搜索条(含放大镜图标)一起亮起来,这种「整组点亮」的优雅全靠它。最后提醒一句正反都成立的话:焦点样式必须可见是底线,但「可见」不等于「扎眼」——别用 outline: none 一刀切,也别留着浏览器默认蓝圈硬顶设计稿;给 :focus-visible 配一个跟品牌色同源、对比度不低于 3:1 的圈,两边都体面:
/* 反面教材:这行 CSS 是 Web 可用性史上最著名的事故 */
button { outline: none; } /* 键盘用户当场「失明」 */
/* 正面教材:只在键盘导航时显示,样式跟着品牌色走 */
:focus-visible {
outline: 2px solid var(--brand); /* 颜色对比度 ≥ 3:1 */
outline-offset: 2px; /* 和元素边缘留一口气 */
}
:focus:not(:focus-visible) { outline: none; } /* 鼠标点击不画圈 */
这套写法还有个易漏的细节:焦点圈画在元素外面一圈(outline 不占布局、也不怕被圆角裁切),而 box-shadow 方案在父容器设了 overflow: hidden 时会被裁掉一截——圆角卡片、滚动容器里的列表项是重灾区。验收时专门拿这两类元素按 Tab 看一眼,圈是不是完整。好比给每间诊室门口挂的科室牌:牌子被门框挡掉一半,挂号单写得再清楚,患者照样走错门。
开演第一盏灯:autofocus、路由切换与跳转链接
页面刚加载完,灯照着谁?默认是 body——键盘用户得从页面最顶端开始 Tab,把导航栏每个链接都走一遍才够得着正文。三个场景各有正解:
表单页:给第一个输入框写 autofocus 属性,加载完灯直接照在正题上——搜索页、登录页的标准操作。注意它是「整页第一名」:文档里多个 autofocus 只有第一个生效;SPA 里路由切换不会重新触发它,别指望老属性干新活。
SPA 路由切换:这是键盘和读屏用户被坑得最惨的地方。点击导航链接后 DOM 换了整屏内容,但焦点还停在原来那个导航链接上(它可能已经被销毁了——焦点于是摔到 body),读屏用户的「目光」根本不知道页面变了。正解是在路由切换完成后,把新页面的主标题(h1)加上 tabindex="-1" 并 focus()——灯移到新页面的「报幕员」上,读屏随即播报新标题,用户的注意力被正式交接。这招相当于搬家后第一件事是把新家门牌钉上:内容没多一个字,但「你到哪儿了」这件事被正式宣告了。
长页面:页头放一条「跳到主内容」(skip link),平时视觉隐藏,Tab 第一下聚焦时才显现——它是全页 Tab 队列的第一站,让键盘用户一键跨过几十个导航链接。目标主容器记得加 tabindex="-1",否则部分浏览器只滚动不聚焦。这个二十多年历史的老模式,至今是各大网站无障碍合规检查的第一项。
这组场景里还藏着一个通用细节:el.focus() 默认会把元素滚动进视口——多数时候这正是你要的(聚焦谁就让谁可见),但也会闯祸:路由切换聚焦新页面标题时,如果用户本来停在页面中部,会被猛地拽回顶部;轮播图自动播放时每张切到的新幻灯片都抢走焦点并滚动页面,是经典翻车现场。解法是 el.focus({ preventScroll: true }):灯移过去,但椅子不许动。通俗地说,focus 自带「顺手把人请到台前」的服务,preventScroll 就是取消这项增值服务——要不要,取决于你的场景里滚动是帮忙还是捣乱。
焦点陷阱:模态框的话筒圈
模态框(modal)打开后,如果不做任何处理,Tab 会老老实实按全页名单走:走完弹窗里的按钮,漏到弹窗背后的页面上——用户看得见弹窗,灯却在黑着的后台里乱逛,方向键和回车全打在看不见的元素上。这体验好比座谈会的观众拿到话筒直接跑进了后台休息室。所以模态框必须圈一个「话筒圈」:焦点进圈后只能在圈内循环(尾部绕回首部,Shift+Tab 反向同理),唯一出口是关闭。
完整的圈有三个部件,一个都不能少:
① 开门时聚焦:打开后灯必须先进圈——聚焦弹窗内第一个可交互控件(或加了 tabindex="-1" 的标题);
② 圈内循环:在弹窗容器上监听 keydown,Tab 在最后一个元素上按下时 preventDefault() 并聚焦第一个,反向同理。清点「圈内有哪些可聚焦元素」必须在按键当下进行(DOM 可能动态变化),选择器串 a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"]) 过滤掉隐藏项;
③ 关门时还焦:记住打开前的 document.activeElement(多半是那个打开按钮),关闭后 focus() 还回去。缺了这步,关掉弹窗后灯摔到 body 上,键盘用户「失忆」——刚才填到哪儿了?只能从页面顶端重走一遍。从哪来回哪去,是焦点管理的基本礼貌。
手写这套循环是每个前端必做的练习,但产线代码今天有两条更省心的路:其一,inert 属性——把弹窗之外的所有顶层元素(页头、侧栏、主内容)统统设为 inert,浏览器会把它们整块「拉闸」:不可聚焦、不可点击、对读屏隐身,Tab 自然只能在弹窗里打转,一个属性顶半个陷阱,还顺手解决了读屏用户的「背景乱入」问题;其二,原生 <dialog> 元素——调用 showModal(),浏览器自动把它抬到最顶层、把背景变 inert、圈住焦点、Esc 关闭、autofocus 生效:1980 年代 Windows 对话框就有的全套待遇(当年由系统的 IsDialogMessage 免费提供),Web 等了三十年,如今一个方法全数奉还。能用 dialog 就用 dialog——手写陷阱留着当教学练习。
roving tabindex:组件内部的独轮车
菜单、单选按钮组、选项卡、树形控件——这些「一组选项」的键盘交互有个标准模式问题:焦点在选项之间怎么走?如果每个选项都 tabindex="0",Tab 会把用户困在这一组里——按八次 Tab 才能离开一个八项的菜单,谁受得了?正解叫 roving tabindex(游走式 tabindex):整组里只有当前选中项是 0,其余全是 -1。Tab 一步就能跳过整组(进出组件),组件内部改用方向键移动焦点——按向下键,JS 把上一项的 tabindex 改回 -1、下一项改成 0 并 focus(),话筒就这样在嘉宾之间传,而「进出会议室」永远留给 Tab。配套按键是一整套行业惯例:上下(或左右)方向键逐项移动、Home/End 跳到头尾、输入字母直接跳到匹配项(typeahead)、Esc 收起并把焦点还给触发按钮。
对照方案叫 aria-activedescendant:焦点全程停在容器上不动,容器上挂一个「当前高亮项 id」的属性,方向键改这个属性,读屏跟着属性走。它省去了真实移焦的滚动开销,适合几千项的超长列表;但 ARIA 接线更多、浏览器一致性要求更高。普通组件首选 roving tabindex——真实焦点,行为最诚实。两种方案摆在一起看:
| 维度 | roving tabindex(传话筒) | aria-activedescendant(点名册) |
|---|---|---|
| 焦点位置 | 真实移动到当前选项上 | 始终停在容器,高亮项只是登记在册 |
| Tab 进出 | 天然正确(当前项 tabindex=0) | 容器自己承担进出 |
| 滚动跟随 | 浏览器自动滚到聚焦项 | 要自己 scrollIntoView 高亮项 |
| 接线成本 | 低,JS 换 tabindex 即可 | 高,整套 ARIA 属性 + 读屏兼容测试 |
| 适用场景 | 菜单、选项卡、单选组等常规组件 | 几千项的超长虚拟列表 |
选型口诀:听着玄的方案(登记在册)只在性能真的成为问题时才值得上——先把话筒传起来,跑不动了再换点名册。
有个容易错过的彩蛋:原生 radio 组免费送了整套 roving 行为——方向键在同组单选钮之间移动并选中,Tab 直接跳出整组。这又是「原生控件白送」论的一个论据:你用 div 自绘单选组,这套行为就得一行行手写;用原生 radio,一个 name 属性全有了。原生世界也有对应物:Android 有 focusable、nextFocusDown 一族属性和 requestFocus();Qt 桌面程序用 setTabOrder 排名单;Win32 对话框的 Tab/方向键由系统消息循环代管——不同剧场,同一套管理制度。
遥控器的世界:焦点导航的极端形态
把键盘导航推到极限,就到了电视盒子、游戏主机这类只有方向键的设备:没有鼠标指针,没有触屏,D-pad 的上下左右是唯一输入。这种界面里焦点不再是「第二套导航」,而是唯一导航——界面上必须始终有一个醒目的高亮项,每次按键它跳到「该方向上最近的下一个可聚焦项」。这个「最近」怎么算?系统内置一个焦点搜索算法:按几何位置找指定方向上距离最短(兼顾主轴投影)的候选元素。Android TV 的 FocusFinder、tvOS 的 UIFocusEngine、游戏主机 UI 全是这个路数;游戏手柄在主机界面上的十字键走的就是同一套。
对开发者的启示有两条:一,电视端布局的纪律比网页严格得多——焦点跳转要有几何规律,网格就按网格走,乱摆的元素会让「向右」跳出一道没人预料到的弧线;二,Web 标准圈一直在推进的「空间导航」(spatial navigation,方向键按几何位置移焦)提案,思路正是从遥控器世界回流的。哪怕你永远不做电视端,理解这个极端形态也有价值:它把「焦点是导航系统的地基」这件事拍在了最显眼的位置——本质上就是:任何界面都可以退化成「一盏灯 + 一份名单 + 几个方向」,鼠标和触屏只是在这幅骨架上加了肉。
快捷键:键盘的幕间加演
先讲一小段历史,因为它解释了快捷键的「气质」。图形界面普及之前,软件的世界是纯键盘的:1980 年代的 WordStar 用 Ctrl 加字母组合干活,Lotus 1-2-3 的老用户至今记得斜杠键呼出的命令菜单,WordPerfect 干脆随软件附赠一张「功能键贴纸」贴在键盘上。鼠标把大众从背快捷键里解放出来的同时,把这套「一步到位」的效率传统留给了专业用户——今天的 Photoshop、Excel、VS Code、Gmail 里效率最高的人群,仍然活在快捷键的世界里。Web 应用承接这套传统时多了一层规矩:你的 keydown 监听器跟用户打字、跟输入法、跟浏览器抢同一个键盘,礼让的边界画在哪儿,全看本节下面这几条军规。
焦点导航解决「走到哪儿」,快捷键解决「一步到位」。全局快捷键的实现姿势是统一的:在 document 上挂一个 keydown 监听器(还记得 § 4.2 吗——挂在祖先节点上,事件自会冒上来),按修饰键和键名分发:
修饰键:e.metaKey(Mac 的 ⌘)、e.ctrlKey、e.altKey、e.shiftKey,可以组合判断。同一个功能在 Mac 用 ⌘K、在 Windows 用 Ctrl+K 是行业惯例,判断就写 if ((e.metaKey || e.ctrlKey) && e.key === 'k')。
单键快捷键的输入框陷阱:j/k 翻页、/ 聚焦搜索框这类单键快捷键好用,但用户正在输入框里打字时按了 j——你必须检查事件来源:e.target.closest('input, textarea, select, [contenteditable]') 命中就放行,别抢用户正在打的字。这是全局快捷键的头号事故来源,邮箱类网站早年都踩过。
输入法:§ 4.3 讲过 IME 组词期间 keydown 会带 e.isComposing === true,快捷键分发开头先 if (e.isComposing) return;,否则中文用户选词的每个字母都会触发你的快捷键——又一个「只在本机测过英文」的经典翻车。
序列快捷键:Gmail 式的「g 然后 i」需要一个小状态机:记住上一个键和它的时间戳,后续键在约一秒半内接上才算数,超时清零。就像接头暗号得一口气对完,隔了一晚上再喊下半句,谁也不认。
保留键别碰:Ctrl/Cmd+W(关标签页)、Ctrl+T(新标签页)、Ctrl+N 这些浏览器保留键拦不下来(部分根本到不了你的代码),F5、Ctrl+F 拦了就是跟用户为敌。设计快捷键先查一遍各大平台的保留清单,避开所有雷区。
Esc 要有栈:弹窗可以叠着开——弹窗里再开确认框,确认框里再开日期选择器。此时按 Esc 该关哪一层?答案必须是最上面那层。工程实现是一个全局「关闭栈」:每打开一层浮层就压入它的关闭函数,Esc 一按弹出栈顶执行;浮层正常关闭时记得把自己出栈。没有栈的 Esc 是灾难:按一下连关三层,用户刚刚填了一半的内容跟着一起消失。说白了,这又是排队叫号的老规矩——后到的先办,办完一个叫下一个,谁也不许越过窗口插手别人的单子。
最后一条软纪律:快捷键要可发现。藏在代码里的快捷键等于不存在——菜单里标注(「保存 Ctrl+S」)、加一个「?」呼出的快捷键面板,是 Gmail 和 GitHub 的共同做法。快捷键是幕间加演,不是正片:每个快捷键功能必须有鼠标可达的等价入口,正如 § 4.4 说每个手势都得有按钮备份——同一条军规,两个战场。
案例:一个键盘可用的自定义下拉菜单
把这一节的知识串成一件完整的作品:自绘下拉菜单(原生 select 样式不够用时的常见需求)。键盘行为的完整验收标准先列出来——按钮上回车/空格/向下键都能展开;展开后方向键逐项移动(roving)、Home/End 跳头尾、字母直达;回车选中并收起;Esc 收起并把焦点还给按钮;点外面也收起。看实现:
<div class="combobox">
<button id="fruit-btn" aria-haspopup="listbox" aria-expanded="false">选水果 ▾</button>
<ul id="fruit-list" role="listbox" hidden>
<li role="option" tabindex="-1">苹果</li>
<li role="option" tabindex="-1">香蕉</li>
<li role="option" tabindex="-1">樱桃</li>
</ul>
</div>
const btn = document.querySelector('#fruit-btn');
const list = document.querySelector('#fruit-list');
const opts = [...list.children];
let open = false, active = -1;
function setOpen(v) {
open = v;
btn.setAttribute('aria-expanded', v); // 顺便告诉读屏:开了/关了
list.hidden = !v;
if (v) move(1); // 开门:灯先进圈
else btn.focus(); // 关门:还焦给按钮——从哪来回哪去
}
function move(d) { // roving:话筒在选项间传
active = (active + d + opts.length) % opts.length;
opts.forEach((o, i) => o.tabIndex = i === active ? 0 : -1);
opts[active].focus();
}
btn.addEventListener('click', () => setOpen(!open));
btn.addEventListener('keydown', e => { // 焦点还在按钮上时,向下/回车/空格都能开门
if (['ArrowDown', 'Enter', ' '].includes(e.key)) { e.preventDefault(); setOpen(true); }
});
list.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
if (e.key === 'Home') { e.preventDefault(); active = -1; move(1); }
if (e.key === 'End') { e.preventDefault(); active = 0; move(-1); }
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
if (e.key === 'Enter') { choose(opts[active]); setOpen(false); }
});
document.addEventListener('click', e => { // 鼠标世界的礼貌:点外面关门
if (!e.target.closest('.combobox')) setOpen(false);
});
逐条对照本节所学:初始 tabindex="-1" 的选项是暗桩,roving 逻辑在 move 里逐项换牌;setOpen(false) 里那句 btn.focus() 是还焦;Esc 是散场铃;aria 属性是给下一节(§ 4.6)读屏用户预留的接口。四十行代码,六个知识点各就各位——这就是键盘可用组件的最小完整形态。验收标准里还有一条「字母直达」(typeahead)没写进代码,补一句实现思路:菜单展开时记一个「上次按键 + 时间戳」,keydown 收到字母就在选项里找以该字母开头的下一项并 move 过去;连续快按多个字母则拼成前缀再找(「苹」按 p、「葡萄」按 p 第二次跳到后者)。超时约半秒清空前缀——跟序列快捷键是同一台小状态机,两处复用。再啰嗦一句:如果这个菜单没有特殊视觉需求,<select> 原生自带上面全部行为(外加输入法联动、平台原生观感),白送的。
焦点去哪儿了:删除元素与动态界面的还焦
最后一个场景,最隐蔽也最致命:你删掉了一个正被聚焦的元素——比如待办列表里的一项,用户刚用键盘走到它上面按了删除。元素从 DOM 里移除,灯没了着落,焦点默认摔到 body上。键盘用户瞬间「断片」:不知道自己在哪,不知道刚删的是第几项,只能从页面顶端重新 Tab 进来。说白了,这就是排队叫号系统突然烧掉了当前号:下一个号没人叫,整个窗口原地下班。
正解是一套「先安排接班人,再撤走现任」的手续:删除前先看它旁边——通常是下一个兄弟节点,没有就看上一个;两个都没有(删的是最后一项),就聚焦列表容器(加 tabindex="-1",再配一句给读屏的「列表已空」播报)。代码只有三行:
function removeItem(li) {
const next = li.nextElementSibling || li.previousElementSibling; // 先找好接班人
li.remove();
(next ?? listContainer).focus(); // 焦点交给邻居,容器兜底——别让它摔到 body
}
同一个原则适用于一切「动态改变界面」的操作:切换标签页后焦点去新面板、提交后焦点去确认提示、上传完成焦点去结果区。相当于搬家公司的规矩:旧家具可以搬走,但门牌必须先钉到新门上。判断标准永远是一句话:每次 DOM 大变动之后,问一句「灯现在照着谁」——答不上来,键盘用户和读屏用户就一起断片。这条军规在 § 4.6 会升级成无障碍测试的硬性条目。
排错手册:焦点六案
- 案情一:Tab 顺序上蹿下跳,和视觉顺序对不上。三查:HTML 源码顺序(多半是布局 CSS 反转了视觉);有没有人埋了正数 tabindex(全文档搜索
tabindex="[0-9]);DevTools 里看元素在 DOM 树里的真实位置。正解是改 DOM 结构,不是继续发卡。 - 案情二:鼠标点有反应,Tab 怎么也到不了;或反过来。div 陷阱:能点的元素不在天生名单上。换成
button/a;必须自绘就加tabindex="0"并手写回车/空格激活。 - 案情三:键盘走到哪儿都看不见焦点圈。全局搜
outline: none和outline:0——多半是设计师的「美观」误伤。改用:focus-visible配自定义样式,删掉裸 outline 覆盖。 - 案情四:关掉弹窗后「失忆」,焦点掉回页首。没做还焦:打开前存
document.activeElement,关闭后focus()还回去。用原生<dialog>的话关闭时浏览器自动还焦。 - 案情五:弹窗开着,Tab 却跑到背景页面去了。没圈话筒圈:检查焦点陷阱的 keydown 循环(元素清单是否按键当下重算、隐藏项是否过滤);更省心的修法是给背景元素加
inert或整体换showModal()。 - 案情六:全局快捷键把用户正在打的字吞了,或中文输入法选词触发乱跳。两道闸没装:输入框检查(
closest('input, textarea, select, [contenteditable]')命中放行)和e.isComposing早退。
破案工具箱配齐三样:自动化扫描——Lighthouse 的无障碍审计和 axe 工具能机器查出「可点元素不可聚焦」「正数 tabindex」「裸 outline:none」这类硬伤,CI 里挂着跑,新债当场拦下;手动巡检——拔掉鼠标用 Tab 走主流程,这招机器替代不了,因为「顺序合不合理、焦点圈看不看得清」最终是人说了算;现场勘验——控制台里敲 document.activeElement 随时问一句「灯照着谁」,配合 Elements 面板高亮,断片、丢焦一眼现形。三样各管一段:简单说,机器管「有没有」,人管「顺不顺」——考试机改卷再快,作文的好坏还得老师读。
整个界面是一座只有一盏追光灯的剧场。焦点就是那盏灯:全场唯一、逐位巡游、观众(键盘事件)只听得见灯下演员说话(
activeElement 登记簿随时可查)。天生名单是台口的站位标记——button、input、a[href] 生来贴着标记,div 和 span 想上灯得先去领牌:tabindex=0 是入队卡,-1 是只听导演调遣的暗桩,正数插队卡是上个世纪留下的危险遗物。灯的路线听出场名单(DOM 顺序)的——演出当晚 CSS 把布景挪了位(row-reverse、order、绝对定位),名单不改一个字,于是视觉和键盘两拨观众拿到两张地图;好剧场让名单、布景、节目单三位一体。灯亮不亮给观众看由 :focus-visible 裁量:举了望远镜(用过键盘)才打亮圈,纯看戏的(鼠标用户)不刺眼。
座谈会(模态框)开幕有一整套礼仪:灯先进场(开门聚焦)、话筒圈里循环(Tab 尾绕首)、散场铃一响灯回原位(Esc + 还焦);今天的大剧场干脆把后台整层拉闸(inert),或者直接请出持证主持人(
<dialog>.showModal(),Windows 剧场三十年前就有的全套服务)。嘉宾座谈用话筒传递(roving tabindex:Tab 只管进出会议室,方向键管传话筒);遥控器剧场(电视盒子)干脆只剩灯和四个方向,焦点成了唯一导航。后台暗号(快捷键)要守三条:不抢演员的台词(输入框放行、输入法组词让路)、不冒充警笛(浏览器保留键不碰)、暗号要贴在墙上让人看得见(可发现性)。最后是这座剧场的总章程:布景可以千变万化,但每一刻都必须答得出「灯现在照着谁」——删节目先安排接班人,换舞台先钉好门牌;答不出的那一刻,一半观众(键盘用户)和一位特殊的观众(屏幕阅读器)就在黑暗里迷了路。
交付前拔掉鼠标过一遍:
① 全页只用 Tab / Shift+Tab / 回车 / 空格 / Esc 完成主流程,能走通才算合格;
② Tab 顺序 = 视觉顺序 = 阅读顺序,布局换序靠改 DOM,不靠 CSS 硬掰;
③ tabindex 只用 0 和 -1,全文档搜一遍确保没有正数;
④ 所有可点击元素用原生控件(button/a),自绘控件补齐 tabindex + 回车/空格激活;
⑤ 焦点圈全程可见(:focus-visible 自定义样式),对比度 ≥ 3:1,无裸 outline: none;
⑥ 模态框四件套齐备:开门聚焦、圈内循环(或 inert/dialog)、Esc 关闭、关门还焦;
⑦ 组内导航用 roving tabindex(方向键/Home/End/typeahead),Tab 只管进出组件;
⑧ SPA 路由切换后聚焦新页面 h1(tabindex=-1);删除聚焦元素前先安排焦点去向;
⑨ 全局快捷键三道闸:输入框检查、isComposing 早退、避开浏览器保留键;
⑩ 快捷键在界面上可见可查,且每个快捷键都有鼠标可达的等价入口。
焦点一句话:焦点是界面的「现在时」——一盏全场唯一的追光灯,键盘事件只发给灯下的人,屏幕阅读器只看灯照的地方。灯的路线听 DOM 名单的,不在天生名单上的元素(div)得领牌:tabindex=0 入队、-1 做只听调遣的暗桩、正数是全文档插队的危险遗物——全站只用前两种。Tab 顺序必须与视觉顺序三位一体,CSS 反转布局会劈出两个宇宙,验收的唯一土办法是拔掉鼠标亲手走一遍。焦点事件不冒泡(focus/blur),委托用它们的冒泡版(focusin/focusout);焦点圈用 :focus-visible 按「举没举望远镜」裁量,裸 outline:none 是可用性史上最著名事故。模态框的话筒圈三件套:开门聚焦、Tab 尾绕首(或 inert 拉闸、或 dialog.showModal() 一站式)、关门还焦;组件内部用 roving tabindex 传话筒,Tab 只管进出;遥控器剧场把这套制度推到极限——焦点是唯一导航。快捷键守三道闸:输入框放行、isComposing 让路、保留键不碰,且每个快捷键都要有鼠标等价入口和可发现性。最后一条总章程:每次 DOM 大变动后都要答得出「灯照着谁」——删除元素先安排接班人,路由切换先钉新门牌。下一节把镜头拉到一位特殊的观众身上——屏幕阅读器如何靠着这盏灯「看」界面,ARIA 怎么给灯下的人配音,对比度怎么让色弱用户分得清——无障碍 A11y,是焦点这门手艺的毕业考场。