题干与适用场景
在前端 UI 编码面试中实现一个可复用的模态对话框。调用方传入标题、可选的简短说明、正文、 打开状态,以及确认和关闭回调。对话框必须支持鼠标、触控、键盘与辅助技术用户。视觉居中和 半透明遮罩是必要条件,但核心是行为:当前对话框之外的内容不可操作;对话框有清楚的名称; 焦点进入并留在对话框内;所有用户都有明确的关闭方式;关闭后焦点回到符合工作流的位置。
假设当前常青浏览器支持原生 dialog API。同时说明三种例外:面试官明确禁止使用原生元素、 嵌入式 WebView 不支持,或现有组件库已经提供经过测试的自定义基础组件。可复用组件不能把 初始焦点固定到同一个控件,因为表单、长篇说明和不可逆删除确认需要不同选择。
本题不包含业务数据请求、动画视觉设计或完整的多对话框管理器,这些作为追问。验收条件必须 可观察:键盘打开、焦点落点、焦点限制、无障碍命名、关闭行为、焦点恢复、层级,以及重复渲染 与卸载过程中的稳定性。
面试官考察点
第一项信号是能否把“模态”定义为行为契约。居中的高层级面板只是浮层。真正的模态会让背景 文档不可交互,限制 Tab 顺序,提供 dialog 语义,并管理完整焦点生命周期。只添加 ARIA 属性 却不阻止背景操作,会让无障碍树承诺一种实际不存在的行为。
第二项信号是平台判断。用 showModal() 打开的原生 dialog 元素会进入浏览器 top layer,生成 遮罩,使同一文档的其他元素 inert,并提供模态键盘行为。用 show() 或直接设置 open 属性 打开的是非模态对话框。强回答会在浏览器范围允许时复用原生能力,也能说清自定义实现必须补齐 的所有不变量。
第三项信号是焦点策略,而不是背诵“聚焦第一个按钮”。短表单可以聚焦第一个无效字段或首个 主要字段;长篇文档式对话框通常应聚焦带程序化焦点能力的静态标题,让开头和语义结构保持 可读;不可逆确认应先聚焦破坏性最低的操作。关闭后一般回到打开按钮;若按钮已经被移除, 调用方必须指定工作流中的下一个合理位置。
第四项信号是框架生命周期正确性。命令式浏览器状态与声明式 UI 状态不能漂移。重复调用 showModal()、未同步状态就移除已打开对话框、关闭后不更新父组件,或把焦点还给过期节点, 都会产生真实缺陷。事件监听需要清理,标签 ID 要稳定,服务端渲染期间不能调用浏览器 API。
最后一项信号是验证质量。自动无障碍检查可以发现缺少名称或属性错误,却无法证明当前工作流 选对了初始焦点、Tab 与 Shift+Tab 没有逃到背景、Escape 符合产品策略,或打开按钮消失后焦点 回到了合理位置。这些需要键盘和屏幕阅读器检查。
回答前需要澄清的问题
- 这真的需要模态吗? 如果用户仍需操作页面,应该用非模态对话框、popover 或行内面板;
不能因为组件浮在上方就标成模态。
- 可以使用原生 dialog 元素吗? 可以时使用
showModal()并保留默认行为;不可以时显式实现
语义、背景 inert、焦点限制、Escape 与恢复。
- 里面是什么内容? 简单表单、长篇结构化说明和警告需要不同的描述与初始焦点。
- 操作能撤销吗? 删除或支付应先聚焦取消或其他低风险操作;普通继续流程可以聚焦最可能的
下一步。
- 允许怎样关闭? 始终提供可见的关闭或取消控件,并确认 Escape、点击遮罩、提交成功及未保存
输入的策略。
- 关闭后聚焦哪里? 通常回到打开按钮;如果创建操作会删除或替换它,写组件前先确定稳定的
逻辑后继元素。
- 对话框会堆叠吗? 优先只保留一个活动模态;必须堆叠时,只有顶层处理关闭,关闭后把焦点
恢复到下层对话框内。
- 浏览器和辅助技术范围是什么? 这决定原生元素是否足够、是否需要兼容层,或是否应使用
已有的成熟基础组件。
30 秒回答框架
“我会定义六个不变量:无障碍名称、背景 inert、明确的初始焦点、受限的 Tab 顺序、可见控件与 Escape 关闭,以及合理的焦点恢复。当前浏览器优先使用原生 dialog 和 showModal(),复用 top layer、遮罩、inert 与核心焦点行为。初始焦点按任务选择:长内容聚焦标题,表单聚焦相关字段, 不可逆操作聚焦取消。在 React 中用带状态判断的开关 effect 同步受控状态,直接监听 cancel, 并验证键盘打开、正反向 Tab、关闭、屏幕阅读器命名、焦点恢复、重复渲染和堆叠对话框。”
分步骤深入解答
先写出与 React、CSS 或原生元素无关的不变量:
打开
只有一个活动模态拥有交互权
背景内容在视觉上被遮挡、行为上 inert
对话框通过可见标题获得无障碍名称
焦点进入对话框内经过选择的目标
打开期间
Tab 与 Shift+Tab 不能进入背景文档
可见的关闭或取消控件可到达
只有顶层模态处理关闭请求
关闭
父组件 open 状态与浏览器 dialog 状态一致
打开按钮仍存在时把焦点还给它
否则把焦点移到预先确定的逻辑后继接着选择基础能力。对当前浏览器范围,原生 dialog 是默认方案。调用 showModal() 后,它会 进入 top layer、获得遮罩,并使所在文档的其他内容 inert。这样可以避免手动遍历所有可聚焦 元素、跨多个应用根节点设置 aria-hidden,以及处理 stacking context。调用 show() 或设置 open 得到的是非模态对话框,不能当成等价捷径。
当面试明确禁止原生元素,或产品已有成熟基础组件时,自定义方案仍然成立。它必须渲染具有 dialog 语义和无障碍名称的容器;只有实际阻止外部交互时才设置模态语义;让全部背景根节点 inert;限制焦点;处理 Escape;恢复焦点;并越过应用自身的层叠上下文。把面板 portal 到 document body 有助于避免裁剪和层级问题,但 portal 本身不会提供任何无障碍行为。
用决策表选择初始焦点:
- 短表单聚焦用户应该先处理的字段,验证失败后尤其应聚焦第一个无效字段。
- 长文本、列表或表格聚焦标题或开头的静态元素,并让它可被程序化聚焦;不要把丰富结构压成
一段无障碍描述。
- 不可逆操作聚焦取消或破坏性最低的操作。
- 简单确认或继续流程可以聚焦最常用操作,前提是不会因误触造成损害。
无障碍名称通常引用可见标题。简短纯文本说明可以单独被引用;正文包含多段、列表或表格时, 不要把全部内容设为一个描述,让辅助技术用户按原有结构浏览。图标关闭按钮仍需无障碍名称; 即使支持 Escape,也必须有可见的关闭或取消控件。
下面的 React 示例把原生浏览器状态与受控应用状态保持一致。description 故意只接受简短 字符串;复杂正文放在 children,不会被压成一个描述。
'use client'
import { useEffect, useId, useRef, type ReactNode } from 'react'
interface AccessibleModalProps {
open: boolean
title: string
description?: string
initialFocus: 'heading' | 'cancel' | 'confirm'
children: ReactNode
onConfirm: () => void
onOpenChange: (open: boolean) => void
}
export function AccessibleModal({
open,
title,
description,
initialFocus,
children,
onConfirm,
onOpenChange,
}: AccessibleModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const headingRef = useRef<HTMLHeadingElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const descriptionId = useId()
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open && !dialog.open) {
dialog.showModal()
const target =
initialFocus === 'confirm'
? confirmRef.current
: initialFocus === 'cancel'
? cancelRef.current
: headingRef.current
target?.focus()
} else if (!open && dialog.open) {
dialog.close()
}
}, [initialFocus, open])
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const handleCancel = (event: Event) => {
event.preventDefault()
onOpenChange(false)
}
const handleClose = () => {
if (open) onOpenChange(false)
}
dialog.addEventListener('cancel', handleCancel)
dialog.addEventListener('close', handleClose)
return () => {
dialog.removeEventListener('cancel', handleCancel)
dialog.removeEventListener('close', handleClose)
}
}, [onOpenChange, open])
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<h2 ref={headingRef} id={titleId} tabIndex={-1}>
{title}
</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
{children}
<div>
<button ref={cancelRef} type="button" onClick={() => onOpenChange(false)}>
Cancel
</button>
<button ref={confirmRef} type="button" onClick={onConfirm}>
Confirm
</button>
</div>
</dialog>
)
}对 dialog.open 的判断可避免重复渲染时再次执行命令式打开。cancel 事件可取消且不冒泡,所以 要在元素上直接监听。阻止它的默认关闭后,先改变受控状态,再由下一次 effect 关闭原生 dialog。 close 监听还能在其他原生关闭路径发生时修正状态。浏览器 API 全放在 effect 内,服务端只输出 标记,不会在渲染时调用 DOM。
关闭策略需要明确。点击遮罩不必自动等于取消。含未保存内容的表单可以忽略遮罩点击,轻量 选择器可以允许,破坏性确认不应因意外指针事件消失。如果产品允许点击遮罩关闭,应使用经过 测试的遮罩区域命中判断,并走同一条受控关闭路径;只检查事件目标会把对话框内边距误认成 遮罩。不要用覆盖全屏的隐形关闭层抢走原本属于对话框的点击。
原生焦点恢复通常回到打开元素。只有工作流原因才覆盖它。例如对话框创建新行并移除了“新增 一行”按钮,第一行新单元格才是合理目标。这个策略属于调用方,因为通用对话框无法知道外部 页面发生了什么变化。
对于堆叠对话框,优先在一个对话框内切换步骤。如果第二层不可避免,就维护栈:只有顶层可以 响应 Escape 或遮罩,背景对话框继续 inert;顶层关闭后,焦点回到下层中打开它的控件。一个 全局布尔值无法表达这种关系。
验证行为,不只检查渲染属性:
1. 用 Enter 和 Space 打开,确认焦点进入预期目标。
2. 从最后一个控件按 Tab、从第一个控件按 Shift+Tab,确认背景不可到达。
3. 按 Escape,确认只关闭一个顶层对话框,受控状态变为 false。
4. 分别用键盘、指针和触控操作可见的关闭与取消控件。
5. 正常关闭,确认焦点回到打开按钮。
6. 完成操作时移除打开按钮,确认焦点转到预定逻辑后继。
7. 用屏幕阅读器检查:只读出一个有意义标题,不把丰富正文压成一段描述。
8. 打开破坏性确认,确认初始焦点位于低风险操作。
9. 打开期间重复渲染,确认没有重复打开异常或焦点重置。
10. 导航时卸载,确认没有监听泄漏或焦点跳到 document body。
11. 在 200% 缩放和小视口下,确认标题、控件和可滚动正文都可到达。
12. 先跑自动无障碍检查,再重复自动规则无法证明的手动焦点流程。高质量示范回答
“我会把模态框当成焦点与交互状态机,而不是一个 z-index 很大的面板。打开时,背景文档必须 inert;对话框要通过可见标题获得名称;焦点根据任务进入一个明确目标。打开期间键盘导航留在 内部,而且始终有可见的关闭或取消控件。关闭后焦点回到打开按钮;如果工作流已经替换它, 调用方提供逻辑后继。
当前浏览器里我会使用原生 dialog 元素并调用 showModal(),从而获得 top layer、遮罩、背景 inert 和核心模态焦点行为。设置 open 或调用 show() 都是非模态,我不会拿它们替代。如果 面试禁止原生元素,我会用 dialog 语义、无障碍名称、真实背景 inert、焦点限制、Escape、 portal 和焦点恢复复现同一组不变量,并优先使用已有成熟组件,而不是临时写一套焦点陷阱。
初始焦点取决于内容:短表单聚焦相关字段,长篇结构化内容聚焦静态标题,不可逆操作聚焦取消。 只有简短说明才作为描述引用;列表和多段正文保留可导航结构。
在 React 中,我会在 effect 内同步受控状态与 showModal()、close(),防止重复调用,直接监听 不会冒泡的 cancel 事件,并在 cleanup 移除监听。点击遮罩关闭是明确的产品策略。验证包含键盘 打开、正反向 Tab、Escape、明确关闭、屏幕阅读器命名、打开按钮存在或消失时的焦点恢复、重复 渲染、堆叠对话框、缩放和小视口。自动检查只作补充,不能替代这条流程。”
常见错误
- 只画居中浮层就称为模态 → 键盘和辅助技术仍能到达背景控件 → **定义并测试背景 inert 与
完整焦点生命周期。**
- 只加模态语义却不阻止背景操作 → 无障碍树与指针用户经历不一致 → **只有行为真正模态时
才设置模态语义。**
- 切换原生
open属性 → 对话框显示了,却没有showModal()的行为 → **调用正确的模态方法
并与应用状态同步。**
- 永远聚焦第一个控件 → 长内容开头可能移出视口,破坏性操作还可能聚焦危险按钮 → **按内容
结构和后果选择初始焦点。**
- 把多段内容全部设为无障碍描述 → 屏幕阅读器读出无结构长段 → **只引用简短描述,丰富正文
保留导航结构。**
- 只依赖 Escape → 触控或开关设备用户没有清楚关闭路径 → **提供可见且有名称的关闭或取消
控件。**
- 所有遮罩点击都关闭 → 意外指针操作会丢失输入 → **把 light-dismiss 设为明确、可测试的
产品策略。**
- 没检查平台或组件库就手写焦点陷阱 → 禁用控件、DOM 变化、portal 与嵌套对话框边界迅速
增多 → 优先原生元素或成熟基础组件。
- 在渲染中或每次 effect 都调用
showModal()→ 服务端渲染失败,或浏览器抛错并重置焦点 →
在有状态判断的 effect 中调用命令式方法。
- 把焦点还给已移除按钮 → 焦点退到 document body,键盘上下文丢失 → **页面变化时由调用方
指定逻辑后继。**
- 把自动扫描当成完整证明 → 它不能判断焦点顺序与工作流意图 → **执行完整键盘与屏幕阅读器
流程。**
追问及应对
追问 1:如果禁止使用原生 dialog 元素,需要改变什么?
通过 portal 渲染带 dialog 语义的容器,引用可见标题并设置模态语义。让所有背景应用根节点 inert,不能只把画面调暗。保存打开元素,选择并设置初始焦点,随可聚焦后代变化限制正反向 Tab,在活动对话框处理 Escape,恢复焦点,并清理所有属性与监听。还要说明:使用维护良好的 基础组件,比每个业务对话框重新实现这套跨浏览器行为更安全。
追问 2:表单出现验证错误时怎么处理?
保持对话框打开。把焦点移到第一个无效字段,或能链接到各无效字段的错误摘要;每条错误通过 对应字段的无障碍描述暴露,并保留输入值。不能把全部字段错误合成对话框的一段描述。提交成功 后,只有应用结果已经落地才关闭,然后把焦点移到代表结果的元素。
追问 3:点击遮罩应该关闭吗?
根据数据丢失风险决定。轻量选择器可以允许,长表单或破坏性确认通常要求明确选择。允许时, 只有指针按下和抬起都位于遮罩区域才关闭,复用取消状态迁移,并测试“指针在内部按下、外部 抬起”,避免把拖动误认为关闭意图。
追问 4:怎样做关闭动画又不破坏焦点恢复?
区分“请求关闭”和“从 DOM 移除”。先标记 closing、停止新操作并播放退出过渡,完成后走原生 close 路径、更新状态,再卸载。遵守减少动态效果偏好,并给动画完成设置有界兜底。焦点恢复 发生在真正关闭边界,不能在透明度刚变化时发生。
追问 5:第一层对话框又打开第二层怎么办?
优先把它改成一个对话框内的多步骤流程。如果确实需要,保存有序栈及每层打开元素。只有顶层 响应 Escape 或遮罩,下层保持阻塞;子层关闭后回到父层内的打开控件,父层再关闭时才回到页面 打开控件。
追问 6:怎样阻止页面滚动和布局跳动?
把滚动锁定视为独立于语义 inert 的视觉与输入策略。首个模态打开时记录滚动位置并统一加锁; 必要时补偿滚动条消失的宽度;最后一个模态关闭后才释放。测试移动端虚拟键盘、嵌套滚动区、 缩放与路由变化。引用计数可以避免一层对话框关闭时误解锁另一层背后的页面。
追问 7:如何在 CI 中测试?
组件测试覆盖受控开关、标签、三种初始焦点分支、cancel 处理与 cleanup。浏览器测试操作真实 打开按钮,正反向移动焦点,按 Escape,移除打开按钮并测试堆叠对话框。自动无障碍规则用于 发现结构回归,同时保留代表性屏幕阅读器、键盘、缩放、触控与高对比度手动矩阵,因为 CI 断言无法判断所有播报与工作流选择。