题干与适用场景
实现一个可以调整任务顺序的列表。鼠标和触摸用户可以拖拽条目,但所有排序操作也必须能通过 键盘,以及无需持续按住并移动指针的点击或轻触完成。屏幕阅读器用户需要知道哪一项发生移动及 它的新位置。设计还要保持焦点、支持取消、不丢失或重复条目,并在保存失败时给出明确的可见顺序。
基础题假设列表完整渲染,每项都有稳定且唯一的 ID,每次只移动一项。虚拟列表、多选移动和并发 编辑放在追问中。框架不是主要决策:React 可以负责渲染,但本题考察的是输入建模、无障碍语义、 状态所有权和验证。
拖拽只是增强层,真正的业务操作是“把条目 X 移到最终位置 Y”。指针拖拽、键盘命令和可见移动 控件都应该调用同一个操作,避免三种输入路径产生三套细微不同的顺序。
面试官考察点
第一项是能否区分身份与位置。数组下标只是临时位置,不能作为 React key 或持久条目身份。稳定 ID 决定移动谁,最终下标或相邻稳定 ID 决定移动到哪里。
第二项是无障碍要求是否准确。WCAG 键盘操作要求与 WCAG 2.2 拖曳动作要求彼此相关,但需要 分别满足。只有键盘快捷键,并没有给“可以点击或轻触、却无法持续按住拖动”的用户提供单指针 替代。可见的上移、下移或移到指定位置控件可以同时服务这两种输入。
第三项是交互状态是否严谨。高质量回答会区分空闲、活动排序、提交和取消,保存原始顺序用于 回滚,处理 pointercancel、Escape 和无效放置,并避免把每次悬停位置都保存成业务更新。
第四项是辅助技术行为。原生列表和按钮语义、明确的拖拽手柄、简短操作说明、稳定焦点、礼貌的 状态播报和可见插入指示,比堆叠 ARIA 属性更重要。aria-grabbed 与 aria-dropeffect 已被弃用, 不能把它们当作解决方案。
最后是能否给出基于证据的验证:纯重排测试、鼠标与触摸、纯键盘、屏幕阅读器、焦点断言、减少 动态效果和强制颜色,以及保存失败恢复。自动无障碍扫描无法证明语音排序流程是否容易理解。
回答前需要澄清的问题
- 是在一个列表内排序,还是跨列表移动? 单列表只需要最终位置;跨列表还需要来源和目标
所有权、允许的放置类型及原子更新。
- 顺序是否需要远程保存? 仅本地顺序可以立即提交;远程持久化需要待保存、成功、失败、
重试和版本冲突行为。
- 需要覆盖哪些输入? 如果需要触摸、键盘、开关设备、语音或屏幕阅读器,只有鼠标的 HTML
拖放事件不够。
- 是否有可见的点击或轻触替代? 键盘快捷键解决的是另一项需求。对于拖拽并非本质的排序
列表,应提供无需按住和移动指针的控件。
- 移动时实时预览,还是放下后再改变? 实时预览的视觉反馈更强,但取消必须恢复原顺序,
播报也不能随着每个指针像素连续输出。
- 能否多选条目? 多项移动会把模型从单一 ID 变成有序 ID 集合,还要定义可移动项与锁定项
混合时的规则。
- 列表是否虚拟化? 屏幕外位置没有 DOM 放置目标,命中测试、播报、总数和键盘移动都必须
作用于数据模型。
- 其他客户端会不会并发排序? 若会,保存契约需要列表版本或同等前置条件,以及明确冲突策略。
30 秒回答框架
“我会把排序建模为一个纯命令:将稳定条目 ID 移到最终下标。指针拖拽、键盘操作和可见移动按钮 都调用它。界面使用原生列表与按钮语义,保留被移动手柄的焦点并播报新位置。会话保存原顺序, Escape、pointercancel、无效放置或保存失败时可以恢复。最后验证排列不变量,并覆盖鼠标、触摸、 仅点击、键盘、屏幕阅读器、焦点和失败流程。”
分步骤深入解答
先建立唯一的数据操作。输入是稳定 ID 和从零开始的最终下标,输出是一个新顺序。操作必须保证 每个 ID 恰好出现一次,并且不修改原数组。
interface SortableItem {
id: string
label: string
}
function moveItem(
items: ReadonlyArray<SortableItem>,
itemId: string,
targetIndex: number,
): ReadonlyArray<SortableItem> {
const fromIndex = items.findIndex((item) => item.id === itemId)
if (fromIndex === -1 || items.length < 2) return items
const finalIndex = Math.max(0, Math.min(targetIndex, items.length - 1))
if (fromIndex === finalIndex) return items
const next = [...items]
const [movedItem] = next.splice(fromIndex, 1)
if (!movedItem) return items
next.splice(finalIndex, 0, movedItem)
return next
}数组删除、插入和复制都可能移动元素,因此这个命令的时间复杂度为 O(n),空间复杂度为 O(n)。 对完整渲染的普通任务列表,这个成本合适。超大虚拟列表可以保存可排序秩值,或按相邻 ID 在 服务端移动,但只有规模要求能证明需要时才增加这层复杂度。
React key 必须使用稳定 ID。key={item.id} 让 React 移动已有条目节点,并避免把新下标理解为 新身份,有助于保留手柄焦点和条目内部状态。如果异步更新仍导致焦点丢失,可以按条目 ID 保存 ref,在提交后的渲染中恢复到同一个手柄。不要把焦点送回列表开头,也不要强迫指针用户改变焦点。
即使没有拖拽,基础标记也应容易理解。有序列表和列表项可以暴露集合结构。每项使用原生按钮, 例如命名为“调整评审顺序”。提供可见的上移、下移按钮或“移到指定位置”操作,并在每个无障碍 名称中包含条目名。边界上无法执行的操作应禁用。这些控件可以点击、轻触和用键盘激活,既是 可发现的替代方式,也是在拖拽失败时的简单恢复路径。
两项无障碍要求的区别会改变设计。键盘操作要求完整排序无需指针设备。另一项要求是,当拖曳 属于非本质操作时,必须有无需按住并移动指针的单指针方法。方向键拖拽模式可以满足键盘用户, 却不能自动满足后一项,除非同样的控件还能点击或轻触。可见移动按钮可以同时满足二者。
可选的紧凑键盘拖拽模式能减少重复按 Tab。焦点位于手柄时,按 Enter 或 Space 开始,按 Arrow Up 和 Arrow Down 改变候选位置,再按 Enter 或 Space 提交,Escape 则取消并恢复快照。 把按键说明放在文本中,并由手柄引用,不能要求用户猜测。若条目还支持选择,应把选择与排序 命令分开,避免 Space 在同一焦点目标上有两种含义。
把交互表示为短期会话。开始时保存 itemId、输入类型、原始顺序和当前候选下标。指针或键盘 移动只通过 moveItem 更新候选。提交只产生一次持久化请求和一次播报;取消恢复原顺序并播报 取消。较早的服务端响应不能仅因返回较晚,就覆盖较新的排序会话。
指针输入应使用明确手柄,避免把整行设为可拖动,以免抢占链接、复选框、文字选择或滚动。 若用 Pointer Events 实现页内排序,应设置小幅移动阈值、捕获指针、按条目中点计算候选位置、 显示不只依赖颜色的插入指示,并处理 pointerup、pointercancel、捕获丢失、滚动和列表外放置。 如果需求同时包含触摸传感器、自动滚动、碰撞检测、嵌套滚动容器和辅助技术,优先选择经过验证 的拖拽库。
原生 HTML 拖放适合桌面或跨应用数据传输,但不会自动提供键盘或屏幕阅读器排序流程。它还要求 显式声明有效放置目标,并会在拖动期间抑制其他设备输入事件。选择原生方案后,移动控件、焦点、 播报和触摸验证仍然不能省略。
结果要同时通过视觉与语义表达。候选插入点应使用形状或边框,而不只依赖颜色;保留清晰焦点 指示,并尊重减少动态效果偏好。使用持续存在的礼貌状态区域,在有意的键盘移动、提交、取消和 失败后输出短消息。“已将评审移到第 2 位,共 5 项”包含条目、结果、位置和总数。不要播报每次 指针悬停,也不要在写入消息的同一时刻才创建状态区域。
不要使用过时的拖放语义。WAI-ARIA 1.2 已把 aria-grabbed 和 aria-dropeffect 标记为弃用。 原生控件、无障碍名称、可读操作说明、文字状态、焦点和经过测试的播报更可靠。ARIA 无法补上 缺失的键盘行为,也无法补上不存在的点击替代。
远程保存时,可以发送稳定 ID 顺序,也可以发送移动命令以及客户端开始编辑时的版本。界面可以 乐观预览,在不阻断键盘焦点的前提下显示保存状态,并按选定产品规则决定何时确认播报。网络失败 时,要么保留本地待保存顺序并提供重试,要么回滚快照并播报回滚。版本冲突时读取当前顺序,让 用户重试或执行明确定义的合并;静默覆盖其他客户端顺序不算恢复策略。
验证从纯命令开始。覆盖首项、末项、相邻位置、原位置、未知 ID 和越界下标。生成输入时,断言 输出包含相同 ID、相同数量,并且只发生请求的相对移动。随后执行交互矩阵:
- 鼠标拖拽、触摸拖拽、仅点击控件,以及放到列表外;
- 纯键盘移动、提交、边界操作和 Escape 取消;
- 每次渲染和回滚后,焦点仍在被移动条目的手柄;
- Safari 配合 VoiceOver,以及 Firefox 或 Chrome 配合 NVDA,分别只播报一次条目、说明、
新位置、取消与保存失败;
- 200% 缩放、强制颜色、高对比度、减少动态效果、长标签、滚动和 RTL 布局;
- 延迟成功、请求乱序、离线失败、重试和版本冲突。
自动无障碍工具可以发现名称缺失、属性无效和部分可聚焦问题,但无法判断键盘模型是否易于发现、 语音顺序是否连贯,以及触摸屏幕阅读器能否完成任务。这些必须人工操作。
高质量示范回答
“我会让条目身份独立于数组位置。reducer 接收条目 ID 和最终下标,返回一个新排列,而且只有 这段代码能修改顺序。指针拖拽、上移或下移按钮,以及可选键盘拖拽模式都派发同一命令。
语义基础是有序列表与原生按钮。每行有明确排序手柄和可见移动控件,无障碍名称包含条目标签。 移动控件很重要,因为键盘支持和无需拖动的点击或轻触替代是两项不同要求。我不会依赖 aria-grabbed 或 aria-dropeffect,它们都已弃用。
排序开始时保存原顺序和活动条目 ID。指针命中测试或方向键只更新候选位置。提交时发送一次保存, 并播报‘已将评审移到第 2 位,共 5 项’。Escape、pointercancel、无效放置,或产品选择的保存 失败规则都会恢复快照。稳定 React key 能在 DOM 顺序改变后保持同一手柄焦点。
指针排序使用专用手柄、移动阈值、指针捕获、明确插入指示、自动滚动和取消清理。如果需求包含 触摸、嵌套滚动和跨浏览器碰撞处理,我会在核验拖拽库的键盘与屏幕阅读器行为后再选库;只有鼠标 演示不够。
我会先单元测试排列不变量,再用鼠标、触摸、键盘、仅点击控件、VoiceOver 和 NVDA 人工完成 任务,并覆盖焦点、Escape、列表外放置、减少动态效果、强制颜色、延迟保存、旧响应和版本冲突。 这样覆盖完整交互契约;自动扫描不能单独构成证明。”
常见错误
- 用数组下标作为 key → 排序会改变身份、焦点和条目内部状态 → **所有 key 与命令都使用
稳定条目 ID。**
- 把拖拽设为唯一交互 → 无法持续按住并移动指针的用户不能排序 → **提供结果等价的可见点击
或轻触控件。**
- 加入方向键就声称全部合格 → 键盘等价不一定提供无需拖动的单指针方法 → **分别检查键盘和
拖曳动作要求。**
- 把拖动监听挂在整行 → 链接、选择、复选框和滚动会与排序冲突 → 使用明确且可聚焦的手柄。
- 为每种输入写一套重排算法 → 鼠标、触摸和键盘拥有不同边界错误 → **全部输入调用同一个
稳定 ID 命令。**
- 直接修改数组或 DOM → 渲染状态与保存状态分叉 → 由状态所有者返回新排列。
- 把
aria-grabbed和aria-dropeffect当作无障碍方案 → 属性已弃用,也不会增加交互 →
使用原生控件、说明、状态、焦点和经过测试的播报。
- 播报每次指针移动 → 状态区域变得嘈杂且滞后 → **播报有意键盘步骤和最终结果,不播报指针
像素。**
- 排序后把焦点移到列表开头 → 用户丢失当前位置 → 按稳定条目 ID 保持或恢复焦点。
- 保存每个悬停位置 → 网络竞态会应用过期中间顺序 → **只用版本前置条件持久化一次已提交
移动。**
- 只依赖自动扫描 → 自动工具无法验证语音说明和完整输入流程 → **用真实键盘、触摸和屏幕
阅读器测试。**
追问及应对
追问 1:列表虚拟化后如何调整?
不能把已挂载 DOM 行当作完整集合。移动、总数和候选位置保存在数据模型中。即使目标在屏幕外, 键盘命令也能按逻辑下标移动,再把被移动项滚入视野而不丢失其焦点目标。指针移动需要理解模型 的命中测试与自动滚动。如果虚拟化库不能提供准确集合语义或稳定焦点,在实测规模允许时,关闭 虚拟化的无障碍模式可能更稳妥。
追问 2:如何一次移动多个已选条目?
保存稳定 ID 的有序集合,保持它们的相对顺序,把它们作为一组移除,再插入一个最终边界。播报 需要包含条目数量和目标位置。选择与拖拽手柄必须使用不同命令,尤其是 Space 已经用于切换选择 时。若选择中包含锁定项,应整体拒绝或明确哪些项保留;静默只移动一部分会产生歧义。
追问 3:两个客户端同时排序怎么办?
移动请求携带列表版本或 ETag,服务端只接受版本匹配的移动。冲突时读取当前顺序,保留用户想 移动的条目与目标作为上下文,并提供重试或按既定规则合并。只有产品明确接受丢失另一个用户的 排序决定时,才可使用最后写入获胜。
追问 4:原生 HTML 拖放、Pointer Events 和拖拽库如何选择?
原生拖放适合桌面和外部数据传输,但不会提供完整键盘与辅助技术流程。Pointer Events 对页内 鼠标和触摸排序的控制更强,但要自己实现碰撞检测、捕获、自动滚动和清理。需求已包含这些行为时, 选择经过测试的库更合适,但必须让它的键盘、触摸屏幕阅读器、焦点和 DOM 语义通过产品验证矩阵。 无论选哪种传感器,移动控件与统一重排命令都保留。
追问 5:保存失败时用户已经继续操作怎么办?
每次已提交排序都携带客户端序号和它基于的服务端版本。较晚返回的失败只能回滚由该请求产生的 状态,不能用旧快照覆盖更新的已确认顺序。最简单的安全策略是串行保存,只保留一个明确待保存 顺序,阻止第二次提交但不阻止焦点和阅读。更灵活的队列需要先实现变基与冲突测试,才能证明值得。