現在的 HTML5 已經可以靠原生語法做掉以前非寫 JavaScript 不可的功能:摺疊選單用 <details>、表單驗證用 required 與 pattern、彈出視窗用 <dialog>、下拉提示用 Popover API、自動完成用 <datalist>。這五個範例合起來,通常可以讓一個典型的行銷頁少載入 jQuery、Popper.js、Select2 這類函式庫,壓縮後省下大約 60 到 100 KB 的傳輸量。更重要的是無障礙行為、鍵盤操作、焦點管理都由瀏覽器內建處理,不必自己重造一次。以下每個範例都附上可以直接貼上去跑的程式碼,以及我認為什麼情況該退回去寫 JS。
先看全貌:五個範例一次比較
先把結論攤開來。這五組語法解決的都是「互動元件」這一類需求,也剛好是過去十幾年網頁設計專案裡最常被外掛塞爆的地方。左邊是你以前寫的東西,右邊是現在可以換掉的寫法。
| 需求 | 過去的做法 | HTML5 原生寫法 | 壓縮後可省下(概估) |
|---|---|---|---|
| 摺疊選單、FAQ 手風琴 | jQuery slideToggle、Bootstrap Collapse | <details> + <summary> + name | 約 20 KB |
| 表單欄位檢查 | jQuery Validate、自寫 regex 檢查 | required、pattern、type、:user-invalid | 約 8 KB |
| 彈出視窗、燈箱 | Bootstrap Modal、Magnific Popup | <dialog> + command / commandfor | 約 15 KB |
| 下拉選單、提示泡泡 | Popper.js、Tippy.js | popover + popovertarget | 約 12 KB |
| 輸入自動完成 | Select2、typeahead.js、Awesomplete | <datalist> + list | 約 25 KB |
數字是概估值,實際情況看你原本裝了什麼。但重點其實不在那幾十 KB——現在光纖跟 4G 都不缺這點頻寬。真正省下的是維護成本:不用再處理 Esc 鍵關不掉、Tab 鍵跑到背景去、螢幕閱讀器讀不出狀態這些鬼問題。
範例一:<details> 與 <summary> 取代手風琴選單
這大概是投資報酬率最高的一個。以前做一組 FAQ 摺疊,要寫 HTML 結構、寫 CSS 的 max-height 過場、綁 click 事件、還要記得加 aria-expanded。現在四行就結束了。
<details> <summary>運費怎麼算?</summary> <p>訂單滿 1,000 元免運,未滿酌收 80 元。</p> </details> 加上 open 屬性可以讓它預設展開。而 2024 年之後全瀏覽器都支援的 name 屬性,讓它直接變成「一次只能開一個」的手風琴——這是以前一定要寫 JS 才做得到的行為:
<details name="faq"> <summary>可以退換貨嗎?</summary> <p>到貨七天內未拆封可退換。</p> </details> <details name="faq"> <summary>可以開統編嗎?</summary> <p>結帳時填寫統編即可,發票寄送至指定 Email。</p> </details> <details name="faq"> <summary>有實體門市嗎?</summary> <p>台北、台中各一間,營業時間 11:00–20:00。</p> </details> 三個 details 共用同一個 name,展開其中一個,另外兩個自動收起。零 JavaScript。
還有一個很多人不知道的好處:使用者按 Ctrl+F 在頁面上搜尋文字時,瀏覽器會自動展開藏在收合區塊裡的內容並捲到那個位置。用 JS 做的手風琴如果把內容設成 display:none,這個功能就死了。搜尋引擎那邊也一樣,<details> 裡的文字是完整被抓取的,不會因為預設收合就被當成隱藏內容。
唯一要自己補的是外觀。原生三角形箭頭長得很陽春,用 summary::marker 或 ::-webkit-details-marker 換掉就好。展開的高度動畫則要靠 interpolate-size: allow-keywords 搭配 transition,目前 Chrome 系列支援得比較完整,Safari 跟 Firefox 上會直接跳出來、沒有滑順過場。如果那個動畫對你的專案是必要的,這裡就是要不要退回 JS 的分界線。
範例二:原生表單驗證取代 jQuery Validate
表單驗證是我看過最常被過度工程化的一塊。一個聯絡表單,五個欄位,結果掛了 jQuery 加 jQuery Validate 加中文語系檔,就為了跳「請輸入正確的 Email」。這些 HTML5 全部內建。
<form> <label for="name">姓名</label> <input id="name" name="name" type="text" required minlength="2"> <label for="email">電子郵件</label> <input id="email" name="email" type="email" required> <label for="phone">手機號碼</label> <input id="phone" name="phone" type="tel" pattern="09[0-9]{8}" title="請輸入 09 開頭的十碼手機號碼" inputmode="numeric" required> <label for="qty">數量</label> <input id="qty" name="qty" type="number" min="1" max="99" step="1"> <button type="submit">送出</button> </form> 幾個實務上的細節值得講清楚:
pattern的正規表示式會自動被前後錨定,所以不用寫^和$。title屬性的內容會出現在驗證失敗的提示氣泡裡,不寫的話使用者只會看到「請符合所要求的格式」這種沒營養的訊息。inputmode="numeric"讓手機跳出數字鍵盤,但仍然保留type="tel"的語意。這組合對行動裝置的填答完成率有實質幫助。- 樣式請用
:user-invalid而不是:invalid。差別在於:invalid在頁面一載入、使用者根本還沒動手時就把空欄位全部標紅,體驗很糟;:user-invalid只在使用者互動過或按了送出之後才生效。
input:user-invalid { border-color: #d9534f; } 需要客製錯誤文案時,用 setCustomValidity() 補一點 JS 就行,但底層的驗證邏輯、必填判斷、送出攔截還是交給瀏覽器。這叫做「用 JS 加強」,不是「用 JS 重寫」。
提醒一句:前端驗證只是體驗優化,絕對不能當作安全機制。任何人打開開發者工具刪掉 required,或是直接用 curl 打你的 API,前端這一層就形同不存在。後端驗證一律照做。
範例三:<dialog> 加上 command 屬性做出零 JS 的燈箱
<dialog> 元素本身從 2022 年就全面可用了,但過去大家覺得它不夠香,因為要打開它得呼叫 showModal()——還是得寫 JS。2025 年之後情況變了:Invoker Commands API 的 command 與 commandfor 屬性讓按鈕可以直接指名要操作哪個對話框。
<button command="show-modal" commandfor="signup">免費註冊</button> <dialog id="signup"> <h2>建立帳號</h2> <p>填寫 Email 即可開始使用,不需要信用卡。</p> <form method="dialog"> <button>關閉</button> </form> </dialog> 整段沒有一行 JavaScript。而且瀏覽器免費附贈這些行為:
- 按 Esc 自動關閉。
- 焦點鎖在對話框內,Tab 不會跑到背景的連結上。
- 關閉後焦點自動還給觸發它的那顆按鈕。
- 背景內容自動變成
inert,螢幕閱讀器不會念到。 - 遮罩層可以用
dialog::backdrop直接設定樣式,不必自己疊一個半透明的 div。
光是第二點跟第三點,自己用 JS 實作的焦點陷阱就很容易寫壞。我看過太多 modal 一關掉,焦點就掉回頁面最上方,鍵盤使用者要重按二十幾次 Tab 才回得到原位。
<form method="dialog"> 是另一個常被忽略的老語法:表單送出時不會發任何請求,只會關閉對話框,並把按鈕的 value 寫進 dialog.returnValue。做「確定 / 取消」的確認框剛好夠用。
要注意 command 與 commandfor 是相對新的東西,各家瀏覽器大約在 2025 年上半年陸續支援。如果你的訪客有一定比例卡在舊版,就補一段五行的 fallback:偵測 'command' in HTMLButtonElement.prototype,不支援時才綁 click 事件呼叫 showModal()。
範例四:Popover API 取代 Popper.js 與 Tippy.js
下拉選單、提示泡泡、通知面板——這類「浮在上面、點旁邊會關掉」的東西,以前是 Popper.js 的地盤。現在 HTML 有 popover 屬性。
<button popovertarget="menu">會員中心</button> <div id="menu" popover> <ul> <li><a href="/orders">我的訂單</a></li> <li><a href="/points">紅利點數</a></li> <li><a href="/logout">登出</a></li> </ul> </div> 預設的 popover(等同 popover="auto")自帶「light dismiss」:點外面關掉、按 Esc 關掉、同時只會開一個。這正是下拉選單該有的行為,而且不用你寫任何監聽器。如果要做那種必須按按鈕才關的通知條,改成 popover="manual"。
Popover 內容會被放進「top layer」,意思是它永遠疊在最上面,不受父層 overflow: hidden 或 z-index 堆疊環境影響。做過側邊欄下拉選單被裁切、然後跟 z-index 大戰三百回合的人,應該懂這件事有多值錢。
把元件的行為交還給瀏覽器,你少寫的不是程式碼,是那些你永遠測不完的邊緣情況。
誠實講一個現階段的缺口:定位。要讓泡泡精準貼在按鈕下方,得靠 CSS Anchor Positioning(anchor-name 與 position-anchor),而這項功能到目前為止在 Chromium 系列跑得最好,Safari 與 Firefox 的進度落後一截。若你的下拉選單就在按鈕正下方、位置固定,用一般的 position: absolute 搭配相對定位的父層就解決了。若你需要「空間不夠時自動翻到上面」這種聰明行為,跨瀏覽器目前還是 Popper.js 比較穩。
範例五:<datalist> 取代自動完成套件
<datalist> 是這五個裡面資歷最老的,IE10 就有了,卻是最沒人用的一個。它讓輸入框在打字時跳出建議清單,同時又允許使用者輸入清單以外的內容——這正是 <select> 做不到、而大家跑去裝 Select2 的原因。
<label for="city">配送縣市</label> <input id="city" name="city" list="city-list" placeholder="輸入或選擇"> <datalist id="city-list"> <option value="臺北市"> <option value="新北市"> <option value="桃園市"> <option value="臺中市"> <option value="臺南市"> <option value="高雄市"> </datalist> 它也能搭配其他 input 型別使用。例如 type="range" 加 list 會在滑桿上顯示刻度標記,type="color" 加 list 會在色票面板加上預設色,這在做設計工具型介面時滿好用的。
但我要把話講明白:<datalist> 有明確的天花板,別勉強它。
- 下拉清單的外觀完全不能用 CSS 控制,每家瀏覽器長得不一樣。要品牌一致的視覺就別想了。
- 比對規則是瀏覽器決定的,沒有模糊搜尋、沒有拼音比對、沒有權重排序。
- 選項要從 API 動態撈,還是得寫 JS 去塞
<option>。 - 沒有多選、沒有標籤(tag)介面。
所以我的分界很簡單:清單是固定的、數量在一百筆以內、選項不需要圖示或副標,就用 <datalist>。縣市、國家、常用標籤、單位名稱都符合。反過來說,商品搜尋、會員名單、需要顯示縮圖跟價格的複合式選項,乖乖用套件。
瀏覽器支援度對照
下表整理各功能大致進入穩定版的時間點。實際規劃時建議還是到 caniuse 或 MDN 的 Baseline 標示確認一次,各家小版號偶爾會有出入。
| 功能 | Chrome / Edge | Safari | Firefox | 現在能不能直接用 |
|---|---|---|---|---|
<details> 基本功能 | 2011 年起 | 2011 年起 | 2015 年起 | 可以,完全沒問題 |
<details name> 互斥手風琴 | 120(2023 年底) | 17.2 | 130(2024 年 9 月) | 可以,舊版只是變成各自獨立收合 |
| 原生表單驗證屬性 | 很早 | 很早 | 很早 | 可以 |
:user-invalid | 119 | 16.5 | 88 | 可以 |
<dialog> | 37 | 15.4(2022 年 3 月) | 98 | 可以 |
command / commandfor | 135(2025 年 4 月) | 18.4 | 138 | 建議加 fallback |
| Popover API | 114(2023 年 5 月) | 17 | 125(2024 年 4 月) | 可以 |
| CSS Anchor Positioning | 125 | 尚未完整 | 尚未完整 | 還不建議當主力方案 |
<datalist> | 很早 | 12.1 | 很早 | 可以 |
還有哪些 JS 可以順手砍掉
既然都在整理了,這幾個也一起換掉吧。它們的支援度都很成熟,改動成本幾乎是零。
| 原本用什麼 | 改用 | 備註 |
|---|---|---|
| lazysizes、各種 lazyload 外掛 | <img loading="lazy" width height> | 務必同時寫上寬高,避免版面位移影響 CLS 分數 |
| JS 產生的進度條 | <progress> 與 <meter> | 前者表示進行中的任務,後者表示區間內的量值,別搞混 |
| 錨點平滑捲動的 JS | CSS scroll-behavior: smooth | 記得搭配 prefers-reduced-motion 尊重使用者設定 |
| 自製影片播放器 | <video controls playsinline> | 只有在需要客製 UI 或 DRM 時才值得裝播放器套件 |
| 手動綁定的複製按鈕 | <input> 搭配 autocomplete 正確值 | 填正確的 autocomplete 名稱,瀏覽器自動填入的成功率差很多 |
| Sticky 導覽列的 scroll 事件 | CSS position: sticky | 不會在捲動時觸發大量 repaint,效能好上一截 |
什麼情況我還是會乖乖寫 JavaScript
寫到這裡要平衡一下。我不主張「零 JS 才是好網頁」,那是另一種形式的教條。以下幾種狀況,硬要用原生語法反而是自找麻煩:
- 需要精準控制動畫時序。
<details>的展開過場跨瀏覽器不一致,如果那個滑順展開是你設計稿的核心體驗,用 JS 加 Web Animations API 會單純得多。 - 複合式選單。選項要顯示縮圖、價格、庫存標籤,還要支援多選跟遠端搜尋——這超出
<datalist>的設計範圍太多。 - 需要在關閉前攔截。「還沒存檔,確定要離開嗎」這種確認流程,原生
<dialog>的 Esc 關閉是攔不住的,要靠cancel事件加preventDefault()。 - 複雜的跨欄位驗證。「結束日期必須晚於開始日期」「兩次密碼要一致」這種相依邏輯,HTML 屬性表達不出來。
- 你的專案已經在用框架。React、Vue 專案裡混用原生 popover 有時會跟框架的狀態管理打架,得評估一下值不值得。
判斷原則其實只有一句話:先用 HTML 做到 80%,剩下 20% 再用 JS 補。而不是一開始就裝一包函式庫把 100% 都自己重寫一遍。
如果只能先換一個,我會從這裡下手
從 <details> 開始。它改動最小、風險最低、立刻就能刪掉一整塊 JS 跟對應的 CSS,而且幾乎所有網站都有 FAQ 或商品規格這種可摺疊的區塊。做完之後你會發現 Lighthouse 的無障礙分數莫名其妙變高了——因為瀏覽器幫你補了一堆你本來會忘記寫的 ARIA 狀態。
第二個換表單驗證。這一步通常能讓聯絡頁直接擺脫 jQuery 依賴,如果那頁只有表單,整個 <script> 標籤都能刪掉。
<dialog> 跟 Popover 我會放在改版時一起處理,因為它們牽涉到既有樣式的重構,零碎地換反而容易出包。<datalist> 則看場合,遇到固定清單的欄位就順手換掉。
下次接到新案子,動手裝套件之前,先花三分鐘去 MDN 查一下這個需求有沒有原生解法。這幾年網頁標準推進的速度比多數人以為的快,很多我們習以為常「一定要寫 JS」的東西,其實早就寫在規格裡了。