題目與適用情境
在前端 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 斷言無法判斷所有播報與工作流程選擇。