这次收尾一个三栏作品页时,页面表面上已经差不多了:列表能分页,卡片能打开抽屉,鼠标点击也能完成浏览。真正让我停下来的,是一个只在键盘操作时出现的跳转。翻到第 2 页,页面上明明已经换成了另一组作品,按一下方向键或 J,焦点却像突然失忆一样回到第 1 页。
这类问题很容易被当成“小地方不顺”。但我当时很快意识到,键盘用户依赖的不是视觉上哪张卡片亮着,而是一条连续的状态链:当前页、当前页中的位置、是否打开抽屉,以及关闭后焦点应该回到哪里。只要其中一层仍然使用全量列表的旧坐标,分页就只是视觉切片,导航实际上还活在另一套坐标系里。
一个看似偶然的跳回
当时的场景很具体。我先把列表翻到第 2 页,没有选中作品,然后按下方向键。预期应该是从当前页的首项开始移动;实际结果却落到了整个列表的第 0 项,并触发了页面回到第 1 页的行为。按 J 也会复现同样的方向错误,只是入口不同,用户感受到的结果一样:自己已经翻页,界面却替自己撤销了这个决定。
我一开始以为是分页按钮没有同步某个状态,或者是渲染完成后焦点被重新挂载。继续看操作链以后,问题没有那么复杂,却更值得警惕:未选中作品时,选中索引是 -1。后面的键盘逻辑没有把这个状态当成“当前页还没有起点”,而是把无效位置继续交给了全列表导航。于是边界处理把它推到了第 0 项,自动翻页逻辑再把第 1 页当成了合法结果。
分页并没有改变列表本身,但它改变了键盘导航应该使用的坐标。
如果不处理,用户每次翻页后都可能被拉回第 1 条,尤其在用 J/K 或上下方向键连续浏览时,视觉焦点和实际操作会反复分离。对鼠标用户来说,这个缺陷可能要经过几次尝试才被发现;对依赖键盘的人来说,它直接破坏了“我正在这里继续看”的基本预期。
表面是索引,实际是状态边界
这件事让我重新意识到,索引错误很少只是一个数字写错。数字背后通常藏着一个没有说清楚的状态边界:这里的索引到底是全量数组里的位置,还是当前页可见卡片里的位置?它代表数据排序,还是代表用户正在操作的焦点?如果这些问题没有分开,代码即使能在第 1 页顺利运行,也不代表分页以后仍然成立。
这次至少有两种位置不能混用。全量索引用于识别作品在完整数据中的位置;当前页索引则用于方向键在可见卡片之间移动。页码变化时,全量数据仍然存在,但键盘起点必须先由当前页和当前焦点决定。没有选中作品时,起点不能被解释成“全量第 0 项”,而应该解释成“当前页的首项”,或者在已有焦点卡片时从那张卡片继续。
最容易误判的是,给 -1 加一个默认值看起来足够修复问题。把它统一改成 0,确实能让表达式继续运行,却没有回答“这个 0 属于哪一页”。无效索引不是一个应该被悄悄吞掉的数字,它是在提醒我们当前状态还没有建立导航起点。
我选择先拆开全量索引和当前页索引,是因为这比在原有条件里不断加例外更容易验证。先确定页码,再确定页内起点,最后才计算上下移动后的目标位置。这样做没有增加新的产品能力,却把原本隐含在默认值里的判断写了出来:用户在哪一页,就从哪一页的可见范围开始操作。
抽屉让问题变成一条状态链
如果只修列表页,问题还没有真正结束。作品打开抽屉以后,键盘焦点进入了另一个容器;此时 Tab 应该在抽屉内部循环,J 仍然要有明确的切换行为,Esc 要关闭抽屉,关闭以后焦点还要回到触发抽屉的那张卡片。它们不是互不相关的快捷键,而是同一条状态链上的几个转折点。
我把这几个动作放在一起看,才发现“焦点恢复”其实是分页问题的延伸。打开前如果只记住一个全量位置,翻页后这个位置可能已经不在当前可见范围;如果只记住当前页中的序号,又可能在数据变化后找不到对应卡片。因此恢复焦点时,优先依据仍然存在的当前卡片,找不到时才退回当前页首项。这个退回不是为了方便实现,而是为了让用户仍然留在当前浏览上下文里。
当时新增的回归断言覆盖了翻页后的上下移动,也覆盖了抽屉的 Tab 循环、J 切换、Esc 关闭和焦点恢复。这样检查的重点就不再是“某个按键有没有触发函数”,而是按键前后的状态有没有连续变化。比如翻到第 2 页后按下方向键,断言要确认选中的是当前页中的下一项,并且页码仍然是第 2 页;关闭抽屉后,则要确认焦点没有漂到页面开头。
键盘回归测试真正要断言的不是事件被调用,而是页码、选中项、抽屉和焦点在动作前后仍然相互一致。
真实按键比静态检查更诚实
这次不能只看代码或手动点几下。已知缺陷本来就是交互顺序造成的:先翻页,再在未选中状态下按键,最后还要观察焦点落点。静态检查可以告诉我条件分支存在,却不能证明浏览器真的把 ArrowDown 和 J 解释成预期的导航动作。
我因此用真实按键做回归,并在 1440、1024、375 这 3 个 CSS 宽度下检查页面。宽屏时重点看三栏布局和第 2 页的导航保持;中等宽度时看侧边抽屉打开后,焦点是否仍在正确的容器里;窄屏时则看单栏布局有没有横向溢出,以及 Tab 和 Esc 是否还能完成完整闭环。3 种宽度都通过,说明修复没有只对某一种布局成立。
第 2 页的验证还遇到了一个现实边界:当时真实数据只有 12 个工程,不足以稳定展示需要的分页场景。于是我在浏览器里临时生成样本,只用于验证第 2 页的真实 ArrowDown 行为,验证后恢复真实数据,并在报告里注明这一点。这个处理很保守,因为它没有把测试用数据混进作品数据,也没有把一张临时截图伪装成真实内容。
我没有把这次结果扩大成“全站键盘体验已经验证”。这次只验证了目标页面、指定宽度和相关动作。验证的可信度来自边界写清楚,而不是把一次局部通过说成全部可靠。
错误捷径会把分页重新藏起来
最容易走的错误捷径,是继续把键盘事件绑定到全量数组位置,然后遇到索引无效就默认回到第 1 条。它的好处是改动很小,坏处是它把分页状态重新藏在默认分支里。只要用户从第 2 页开始操作,默认值就不再是安全的起点,而是一次无声的跳转。
另一个捷径是只补一条“按键后索引正确”的断言。这样的断言可能在第 1 页通过,却没有证明翻页后仍然留在当前页;也可能只检查了列表,没有检查抽屉关闭后的焦点恢复。对这类交互问题,我更愿意把动作链完整走一遍,因为真正的错误往往发生在两个状态之间,而不是某个函数的孤立输出里。
我选择不扩展共享组件、不重启其他服务,也不跑无关的全量检查,是因为任务的风险边界已经清楚:目标是当前页面的键盘定位和回归验证。缩小范围不是少做检查,而是让每一项检查都能回答一个具体问题。页面逻辑改了什么,回归断言守住什么,浏览器验证覆盖什么,都应该能够对应起来。
下次我会怎么做
下次遇到分页列表的键盘问题,我会先画出最小状态表:当前页、页内焦点、全量位置、抽屉是否打开,以及关闭后的恢复目标。先问“这个索引属于哪个坐标系”,再看默认值和边界分支,而不是先给 -1 找一个看似合理的替代数字。
接着我会按一条完整路径验证:翻到非第 1 页,在未选中状态下按方向键和 J;从已有焦点卡片继续上下移动;打开抽屉,检查 Tab、J、Esc;关闭后确认焦点回到原来的上下文。每一步都同时观察页码、选中卡片和焦点,不把其中一个通过误认为整条状态链正确。
最后再用实际浏览器按键覆盖主要宽度,并把临时数据、未覆盖的范围和未跑的检查写清楚。一个流程成熟的标志,不是它能在第 1 页跑通一次,而是它能在第 2 页、抽屉打开和布局变化以后,仍然让用户知道自己在哪里。
这件事表面上是在修一个方向键,实际上是在修复界面和用户意图之间的连续性。分页只改变了可见范围,不应该抹掉用户已经做出的选择。把全量索引、当前页索引和焦点恢复分开,配合真实按键回归,才是这次问题真正可复用的方法。