题干与适用场景
你要实现一个可拖拽的移动端侧边栏。桌面用户期待 Esc 关闭,Android 用户期待返回手势或返回按钮关闭;表单有未保存内容时必须先确认。请使用 CloseWatcher 设计统一关闭流程,并说明 cancel、close、requestClose()、destroy()、焦点、多个 watcher、浏览器不支持和历史回退边界。
MDN 将 CloseWatcher 描述为让自定义组件响应设备特定关闭操作的接口。HTML Standard 还规定了 close watcher 的分组和防止滥用历史操作的边界。本文基于公开资料整理,不声称是公司真题。
面试官考察点
面试官关注你是否把“关闭请求”和“立即关闭”区分开,能否在 cancel 阶段阻止关闭并展示确认,同时在唯一的 close 处理器中清理 UI。强回答会提到用户激活、多个 watcher 的分组、AbortSignal 生命周期、焦点返回和不支持时的普通按钮回退;普通回答只监听 keydown。
回答前需要澄清的问题
- 侧边栏是模态、非模态,还是与页面历史绑定的导航状态?
- 未保存内容需要阻止所有关闭请求,还是只在特定字段脏时阻止?
- 返回手势是关闭当前组件,还是应该返回上一条历史记录?
- 目标浏览器是否支持 CloseWatcher,是否允许关闭能力退化为显式按钮?
30 秒回答框架
“我把所有关闭入口统一为 close request。打开侧边栏时创建一个带 AbortSignal 的 CloseWatcher,cancel 检查表单是否脏;脏时阻止默认关闭并显示确认,干净时让流程继续。close 负责隐藏组件、恢复焦点和销毁资源,显式关闭按钮也走同一套逻辑。能力不支持时保留按钮和 Esc 监听,返回手势交给浏览器或历史处理,不能阻断核心导航。”
分步骤深入解答
先区分两个动作:requestClose() 模拟设备关闭请求,会先触发 cancel,没有被阻止才触发 close;close() 立即触发 close,跳过 cancel;destroy() 只停用 watcher。保存成功、组件卸载或路由离开时,应明确选择请求关闭还是强制清理,不能把两个语义混用。
function openDrawer() {
const controller = new AbortController();
const watcher = new CloseWatcher({ signal: controller.signal });
watcher.addEventListener("cancel", (event) => {
if (!formIsDirty()) return;
event.preventDefault();
showDiscardConfirmation(() => watcher.close());
});
watcher.addEventListener("close", () => {
hideDrawer();
restoreFocusToTrigger();
controller.abort();
});
return { watcher, controller };
}确认对话框本身不能递归制造无法关闭的 watcher。确认放弃时可以调用当前 watcher 的 close(),因为用户已经完成明确确认;取消确认则保持侧边栏打开。保存成功后清除脏状态,再调用 requestClose(),让同一套清理路径继续执行。
焦点策略取决于组件类型。打开模态侧边栏时把焦点放到可解释的标题或第一个控件,关闭后返回触发按钮;非模态面板不应强行抢焦点,但仍要有可达的关闭按钮和可见状态。关闭请求不能只改变 CSS,还要更新可访问名称、遮罩、滚动锁和键盘顺序。
多个 watcher 有特殊边界。没有用户激活创建多个 watcher 时,规范允许它们被分组,一次 close request 可能同时关闭多个 watcher;不要为每个小弹层都无条件创建实例。优先让一个顶层组件拥有 watcher,子组件通过事件请求父组件关闭,并在组件卸载时调用 destroy() 或中止关联信号。
返回手势不能被简单当成普通点击。平台可能把返回动作作为历史遍历或 close request,浏览器会依据 close watcher 管理器决定目标。应用只在确实能拦截且组件处于打开状态时执行确认;若没有可关闭组件,应让浏览器继续历史返回。不要全局阻止 popstate 或返回手势来保护一个局部表单。
能力检测和回退要保住核心路径。检查 window.CloseWatcher 后再创建实例;不支持时保留显式关闭按钮,必要时增加受限的 Esc 监听,并把返回手势交给现有路由。不要宣称自定义监听能完全复制 Android 返回行为。记录支持率、关闭请求阻止率、确认后放弃率和焦点回退错误。
高质量示范回答
我会把侧边栏的所有关闭入口统一为 close request。组件打开时创建带 AbortSignal 的 CloseWatcher,cancel 只负责判断未保存状态:脏时 preventDefault() 并显示确认,干净时不阻止。确认放弃后调用 close(),保存成功后清除脏状态并调用 requestClose()。close 是唯一的 UI 清理点,负责隐藏面板、恢复触发按钮焦点、解除滚动锁并终止 watcher。
我会限制 watcher 数量,避免无用户激活的多个实例被分组关闭;组件卸载或路由切换时销毁它。模态和非模态组件分别处理焦点和遮罩,返回手势没有打开组件时交给浏览器历史。没有 CloseWatcher 的浏览器保留显式按钮和有限的键盘回退,不阻断核心导航,并用指标验证关闭、确认和可访问性行为。
常见错误
- 错误表现 → 用
close()处理所有入口;失败原因 → 跳过了未保存确认的cancel阶段;修正方法 → 用户请求走requestClose(),强制清理才用close()。 - 错误表现 → 只监听 Esc;失败原因 → 无法覆盖 Android 返回手势和其他设备关闭动作;修正方法 → 使用 CloseWatcher,并保留显式按钮。
- 错误表现 → 每个子弹层都创建 watcher;失败原因 → 未激活创建的 watcher 可能被分组关闭;修正方法 → 由顶层可关闭组件统一拥有实例。
- 错误表现 → 关闭后焦点留在已隐藏节点;失败原因 → 键盘和辅助技术失去当前位置;修正方法 → 记录触发器并在
close中恢复焦点。 - 错误表现 → 全局阻止返回事件;失败原因 → 破坏没有打开组件时的历史导航;修正方法 → 只在打开且确需确认时阻止关闭请求。
追问及应对
requestClose() 和 close() 什么时候分别使用?
用户或平台发起的关闭意图使用 requestClose(),它给 cancel 一个阻止机会;用户确认放弃、组件已卸载或清理流程必须立即结束时使用 close()。两者最终都应汇入同一个 close 清理处理器。
未保存确认对话框也要创建 CloseWatcher 吗?
不一定。确认对话框应有自己的明确关闭按钮和可访问焦点路径,避免与父 watcher 形成递归或分组关闭。父 watcher 在确认完成后执行 close(),子对话框关闭只改变确认状态。
如何处理多个打开的面板?
定义栈或顶层所有权:一次关闭请求只关闭最上层、确实可关闭的组件,其余保持打开。不要依赖多个无用户激活 watcher 的隐式分组行为来实现业务栈,应用层应记录顺序和返回焦点。
CloseWatcher 不支持时怎样回退?
保留显式关闭按钮和基础 Esc 处理,继续执行同一份脏状态确认、焦点恢复和清理函数。返回手势及历史导航不做全局拦截;通过能力分组监控退化比例,逐步决定是否扩大增强范围。