紀錄工作經驗、相關知識,解決技術相關問題。

HTML, 新手 Jamp Holes

HTML input type 的現代原生限制:為什麼不該只依賴 type 屬性做表單驗證?

HTML input type 的現代原生限制:為什麼不該只依賴 type 屬性做表單驗證? 特色圖片

在日常專案開發中,限制使用者輸入格式是最基礎的表單需求。許多剛接觸前端的工程師看到 HTML input type 提供了 number、email、url、tel 與 date 等語意化類型,便以為只要宣告對應的 type 屬性就能完成格式把關。最近在重構金流結帳與會員註冊表單時,正好排查了多起因過度依賴原生 type 屬性而引發的線上資料異常(例如 type="number" 被滑鼠滾輪偷偷改動金額、輸入 12e 導致 input.value 變成空字串、或是 type="url" 放行非 HTTP 協定字串)。這篇筆記將從 WHATWG HTML Standard §4.10.5 (<input> element) 底層規範出發,完整拆解各類型 input type 的真實行為邊界與解法。


⚡ 快速簡單使用(30 秒複製即用範本)

在設計表單時,請先區分欄位是「可加減計算的量化數值」還是「由數字組成的識別碼字串」,並針對原生驗證盲點加上對應防禦:

<form id="checkout-form" novalidate>
  <!-- 情境 A:可加減計算的量化數值(購買數量)➔ 使用 type="number" 並攔截滾輪誤觸 -->
  <div class="form-field">
    <label for="order-qty">購買數量(1 ~ 99 件)</label>
    <input
      type="number"
      id="order-qty"
      name="quantity"
      min="1"
      max="99"
      step="1"
      required
    >
  </div>

  <!-- 情境 B:不可加減的數字識別碼(信用卡號、郵遞區號、驗證碼)➔ 使用 type="text" 保留前導零與 maxlength -->
  <div class="form-field">
    <label for="postal-code">郵遞區號(3 碼、3+2 共 5 碼或 3+3 共 6 碼數字)</label>
    <input
      type="text"
      id="postal-code"
      name="postalCode"
      inputmode="numeric"
      pattern="[0-9]{3}([0-9]{2,3})?"
      maxlength="6"
      autocomplete="postal-code"
      required
    >
  </div>

  <!-- 情境 C:嚴格限制 HTTPS/HTTP 協定之網址欄位 -->
  <div class="form-field">
    <label for="webhook-url">Webhook 通知網址(限 https://)</label>
    <input
      type="url"
      id="webhook-url"
      name="webhookUrl"
      pattern="https://.*"
      placeholder="https://api.example.com/webhook"
      required
    >
  </div>

  <button type="submit">確認送出</button>
</form>

<script>
  // 防護 1:防止使用者捲動頁面時,滑鼠滾輪意外改變聚焦中的 type="number" 數值
  document.addEventListener('wheel', (event) => {
    const active = document.activeElement;
    if (active && active.tagName === 'INPUT' && active.type === 'number') {
      active.blur();
    }
  }, { passive: true });
</script>

一、核心速查:HTML input type 原生行為與隱藏限制對照表

在深入底層規範前,我們先透過以下速查表盤點 6 種最常被誤用的 HTML input type 原生限制與工程應對策略:

input type 屬性開發者常見誤解WHATWG 規範實際原生行為解法與替代方案
type="number"以為只能輸入 0-9 整數,且可用 maxlength 限制長度允許輸入科學記號 e、E、+、- 與小數點;完全忽略 maxlength 與 pattern 屬性;輸入無效數值時 input.value 會回傳空字串 ""僅用於「數量、年齡」等可計算數值;搭配 validity.badInput 檢核與滾輪防護。若為卡號、郵遞區號或驗證碼,改用 type="text" inputmode="numeric"
type="email"以為會嚴格驗證真實頂級網域(TLD)依 WHATWG §4.10.5.1.5 允許無點號網域(如 user@localhost、test@com);規範建議瀏覽器在 value 中以 Punycode(xn--...)保存國際化網域,Chrome 即如此處理前端保留 type="email" 以喚起 @ 專用鍵盤,提交時結合嚴格正規表達式與後端 DNS / 信箱驗證
type="url"以為只會放行 http:// 或 https:// 網址依 WHATWG §4.10.5.1.4 僅檢查值是否為合法的絕對 URL(valid absolute URL),因此 mailto:a@b、ftp://files 甚至 javascript:void(0) 皆能通過原生驗證搭配 pattern="https?://.+" 或使用 JavaScript new URL(val) 明確驗證 url.protocol === 'https:'
type="tel"以為瀏覽器會自動檢查電話號碼格式與字元依 WHATWG §4.10.5.1.3 完全不執行任何格式驗證(僅剔除換行符號 \r\n),允許輸入任意中英文字元定位為「行動端電話鍵盤觸發器」,必須額外搭配 pattern 或前端格式化函式與後端檢核
type="date"以為 input.valueAsDate 取出的時間是使用者本地時區 00:00:00value 格式固定為 YYYY-MM-DD,但 valueAsDate 回傳的是 UTC 午夜 (00:00:00 UTC),跨時區轉換極易產生日期偏移讀取日期字串時優先解析 input.value(YYYY-MM-DD),避免直接對 valueAsDate 呼叫本地時區方法
type="search"以為與 type="text" 行為 100% 相同值淨化演算法會移除所有 \r 與 \n 換行字元,且在 WebKit / Blink 瀏覽器會自動渲染原生清除叉叉按鈕 (::-webkit-search-cancel-button)若需自訂清除按鈕圖示,需透過 CSS 隱藏瀏覽器預設 -webkit-search-cancel-button 偽元素
HTML input type 選型決策樹:量化數值 vs. 識別碼字串 vs. 格式協定
圖:HTML input type 選型決策樹——區分可計算之量化數值、數字識別碼字串與通訊協定格式

二、深度拆解:type="number" 的五大原生限制與 GOV.UK 實作要點

在所有表單類型中,type="number" 是引發最多生產環境問題的屬性。英國政府數位服務團隊(GOV.UK Design System)甚至為此發表了專文〈Why the GOV.UK Design System team changed the input type for numbers〉,將日期輸入元件與銀行帳戶資料等相關模式中的 type="number" 改為 type="text" inputmode="numeric" pattern="[0-9]*",文中提到的原因包含滑鼠滾輪誤改數值,以及 NVDA、Dragon NaturallySpeaking 等輔助科技的相容問題。以下拆解 type="number" 的五大原生限制:

1. 值淨化演算法與無效輸入規則導致 input.value 回傳空字串

根據 WHATWG HTML Standard §4.10.5.1.12 (Number state),<input type="number"> 的值必須是合法的浮點數(Valid floating-point number),因此瀏覽器允許使用者在欄位中輸入科學記號 e、E、正負號 +、- 與小數點 .。

更關鍵的是規範對 value 的兩道限制:瀏覽器不得讓使用者把 value 設成「非空但不是合法浮點數」的字串;透過程式設定非法值時,值淨化演算法(Value Sanitization Algorithm)也會把它改為空字串。因此當使用者在輸入框打入不完整的科學記號(例如 12e 或 10+-2)時,雖然畫面上依然看得到 12e,input.value 卻是空字串 ""(同時 input.valueAsNumber 為 NaN,validity.badInput 為 true;若欄位設有 required,validity.valueMissing 也會是 true)!

瀏覽器解析 input type="number" 之 Value Sanitization 值淨化與驗證生命週期
圖:當使用者於 type="number" 輸入無效浮點數 12e 時,input.value 回傳空字串與 validity.badInput 之判定差異

如果前端驗證程式只寫了 if (input.value === ''),就會把「格式輸入錯誤(12e)」誤判為「使用者尚未填寫此欄位」,向使用者顯示令人困惑的「此欄位為必填」提示!解決思路是先檢查 ValidityState.badInput:

function validateNumberInput(inputEl) {
  // 1. 優先檢查是否有無法解析為浮點數之無效輸入(如 "12e")
  if (inputEl.validity.badInput) {
    return { valid: false, message: '請輸入有效的純數字格式(不含科學記號或特殊符號)。' };
  }

  // 2. 再檢查是否真的未填寫
  if (inputEl.validity.valueMissing || inputEl.value.trim() === '') {
    return { valid: false, message: '此數量欄位為必填。' };
  }

  // 3. 檢查上下限與步進值
  if (inputEl.validity.rangeUnderflow || inputEl.validity.rangeOverflow) {
    return { valid: false, message: `數值必須介於 ${inputEl.min} 到 ${inputEl.max} 之間。` };
  }

  if (inputEl.validity.stepMismatch) {
    return { valid: false, message: '請輸入整數數量。' };
  }

  return { valid: true, value: inputEl.valueAsNumber };
}

2. 原生完全忽略 maxlength 與 pattern 屬性

根據 WHATWG 規範 <input> 各 type 狀態的屬性適用摘要表(bookkeeping details),maxlength、minlength 與 pattern 屬性僅適用於文字類狀態(text、search、url、tel、email、password)。在 <input type="number"> 上設定 maxlength="6" 或 pattern="[0-9]*" 會被瀏覽器完全忽略,毫無作用。

3. 呼叫游標選取 API (setSelectionRange) 會直接拋出 DOMException

當我們實作自動格式化或驗證錯誤自動反白功能時,常會存取 input.selectionStart 或呼叫 input.setSelectionRange(0, 2)。然而根據 WHATWG §4.10.20 APIs for the text control selections,type="number" 與 type="email"、type="date" 均不支援文字選取範圍 API:讀取 selectionStart 只會得到 null,呼叫 setSelectionRange() 則會拋出 InvalidStateError 的 DOMException。以 Chrome 為例,錯誤訊息為:

InvalidStateError: Failed to execute 'setSelectionRange' on 'HTMLInputElement': The input element's type ('number') does not support selection.

4. 自動吃掉前導零(Leading Zeros)與大數 IEEE 754 精度流失

type="number" 將輸入視為數學數值而非字元序列:

  • 若用於台灣手機號碼(0912345678)、郵遞區號(001)或銀行代碼(004),轉為數值處理或步進時會抹除開頭的 0。
  • 若用於 16 位數信用卡號或超長訂單編號,一旦超過 JavaScript Number.MAX_SAFE_INTEGER(2^53 - 1,即 9007199254740991),input.valueAsNumber 就會發生 IEEE 754 雙精度浮點數進位失真。

5. 滑鼠滾輪(Scroll Wheel)誤觸改值與螢幕閱讀器體驗問題

在 Chrome 與 Edge 等桌面瀏覽器中,當使用者點擊 <input type="number"> 輸入完數字後,若焦點仍停留在輸入框內,此時使用者轉動滑鼠滾輪想往下捲動網頁點擊送出按鈕,瀏覽器卻會攔截 wheel 事件並將輸入框內的數字悄悄遞增或遞減!此外,依 HTML-AAM 對應規則,type="number" 在無障礙樹中的角色是 spinbutton(Chrome 即如此對應),螢幕閱讀器會將其播報為微調按鈕,若欄位填寫的是身分證號或信用卡號,會讓視障使用者誤以為該數值需要上下微調。


三、type="email"、type="url" 與 type="date" 的規範邊界

除了 type="number" 之外,其他語意化輸入型態也有必須注意的規範邊界:

1. type="email":故意放寬的 RFC 5322 規則與 Punycode 轉換

許多工程師疑惑為什麼 user@localhost 或 admin@company 這種沒有 .com 頂級網域的字串能通過 <input type="email"> 驗證?
在 WHATWG HTML Standard §4.10.5.1.5 中特別註明了,這個 email 語法定義是對 RFC 5322 的「刻意違反(willful violation)」,理由是 RFC 5322 的語法對實際表單使用來說同時過嚴、過於模糊又過於寬鬆;規範定義的網域部分只要求由合法標籤組成,並不要求一定要有點號。但在一般對外商業網站中,這會導致使用者漏打 .com(例如把 [email protected] 打成 user@gmail)時直接通過前端檢核。

此外,當使用者輸入包含非 ASCII 字元的國際化網域信箱(IDN,例如 @ 後方包含多國語系網域字元)時,規範建議瀏覽器在畫面上顯示 IDN、在 value 中保存 Punycode;以 Chrome 為例,輸入 user@中文.tw 後,input.value 會是 [email protected]。

2. type="url":只驗絕對 URL 語法,不限制 HTTP/HTTPS 協定

依 WHATWG HTML Standard §4.10.5.1.4 (URL state),<input type="url"> 的原生驗證只檢查值是否為 WHATWG URL Standard 定義的合法絕對 URL,不限定 scheme。這意味著:

  • http://localhost(通過)
  • ftp://192.168.1.1/file.zip(通過)
  • mailto:[email protected](通過)
  • custom-app://open(通過)

若你的業務邏輯是讓使用者填寫「個人網站」或「Webhook 回呼網址」,並將該網址渲染至前台 <a href="..."> 中,若未限制協定必須為 https: 或 http:,不僅會產生無效連結,更可能引發資安風險。

3. type="date":valueAsDate 的 UTC 時區偏移陷阱

當使用者在 <input type="date"> 選擇 2026-10-30 時:

  • input.value 永遠回傳純字串 "2026-10-30"。
  • input.valueAsDate 則會回傳一個指向 2026-10-30T00:00:00.000Z(UTC 零時) 的 JavaScript Date 物件。若使用者的電腦位於西半球時區(如美東 UTC-4),直接對 input.valueAsDate.getDate() 取值會得到前一天(29 號)!因此實務上處理日期欄位時,建議直接拆分 input.value 字串或明確指定時區。

四、實作防禦架構:結合 Constraint Validation API 與語意化標籤

為了兼顧「行動裝置最佳虛擬鍵盤體驗」與「嚴謹的商業邏輯驗證」,現代前端表單應採用 HTML5 Constraint Validation API 搭配明確的 <label for> 可標籤化元素綁定規範。

以下示範如何針對「數量(type="number")」、「Email(type="email")」與「網站網址(type="url")」撰寫精準的原生 + 自訂雙重驗證器:

<form id="profile-form" novalidate>
  <!-- 1. 電子信箱:保留 type="email" 喚起專用鍵盤,並補上點號網域檢核 -->
  <div class="field-group">
    <label for="work-email">工作電子信箱</label>
    <input
      type="email"
      id="work-email"
      name="workEmail"
      autocomplete="email"
      autocapitalize="none"
      spellcheck="false"
      required
    >
    <p id="work-email-err" class="field-error" hidden></p>
  </div>

  <!-- 2. 個人網站:使用 type="url" 搭配 URL 建構子檢核 https: 協定 -->
  <div class="field-group">
    <label for="portfolio-url">個人作品集網址(須以 https:// 開頭)</label>
    <input
      type="url"
      id="portfolio-url"
      name="portfolioUrl"
      placeholder="https://example.com"
      required
    >
    <p id="portfolio-url-err" class="field-error" hidden></p>
  </div>

  <button type="submit">儲存設定</button>
</form>

<script>
  const form = document.getElementById('profile-form');
  const emailInput = document.getElementById('work-email');
  const urlInput = document.getElementById('portfolio-url');

  function validateEmailField(el) {
    el.setCustomValidity(''); // 先重置自訂錯誤狀態
    if (el.validity.valueMissing) {
      el.setCustomValidity('請輸入工作電子信箱。');
    } else if (el.validity.typeMismatch) {
      el.setCustomValidity('電子信箱格式不正確,請確認包含 @ 符號。');
    } else {
      // 補強 WHATWG 允許無點號網域 (user@com) 之限制
      const domainPart = el.value.split('@')[1] || '';
      if (!domainPart.includes('.') || domainPart.endsWith('.')) {
        el.setCustomValidity('請輸入包含完整網域名稱的信箱(例如 example.com)。');
      }
    }
    const errEl = document.getElementById(`${el.id}-err`);
    errEl.textContent = el.validationMessage;
    errEl.hidden = el.validity.valid;
    return el.validity.valid;
  }

  function validateHttpsUrlField(el) {
    el.setCustomValidity('');
    if (el.validity.valueMissing) {
      el.setCustomValidity('請輸入作品集網址。');
    } else {
      try {
        const parsed = new URL(el.value);
        if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
          el.setCustomValidity('網址協定僅支援 https:// 或 http://。');
        } else if (!parsed.hostname.includes('.')) {
          el.setCustomValidity('請輸入有效的公開網域名稱。');
        }
      } catch {
        el.setCustomValidity('請輸入包含 https:// 的完整網址。');
      }
    }
    const errEl = document.getElementById(`${el.id}-err`);
    errEl.textContent = el.validationMessage;
    errEl.hidden = el.validity.valid;
    return el.validity.valid;
  }

  form.addEventListener('submit', (e) => {
    const isEmailOk = validateEmailField(emailInput);
    const isUrlOk = validateHttpsUrlField(urlInput);
    if (!isEmailOk || !isUrlOk) {
      e.preventDefault();
    }
  });
</script>

五、常見錯誤與踩坑排查 (Pitfalls & Troubleshooting)

  1. 呼叫 setCustomValidity('錯誤訊息') 後忘記清空,導致表單永遠無法送出:
    根據 MDN HTMLInputElement.setCustomValidity() 說明,只要傳入非空字串,input.validity.customError 就會維持 true(checkValidity() 永遠回傳 false)。因此在每次重新驗證開頭,必須先執行 el.setCustomValidity('') 將錯誤狀態歸零,再執行後續判斷。
  2. 未在 <form> 加上 novalidate 導致自訂錯誤 UI 與瀏覽器原生氣泡衝突:
    若團隊設計了專屬的欄位下方紅字錯誤提示,請務必在 <form> 加上 novalidate 屬性停用瀏覽器預設的提交攔截氣泡,改於 submit 事件中主動呼叫 checkValidity() 控制焦點與錯誤訊息顯示。
  3. 誤以為前端 type 與 pattern 可以取代後端 API 驗證:
    所有 HTML 屬性與客戶端 JavaScript 驗證的目的皆是「即時回饋、改善使用者體驗(UX)」;任何請求皆可透過 curl 或 DevTools 直接繞過瀏覽器發送,因此後端 API 永遠必須執行獨立的 Schema 與型別檢核。

六、總結

理解 HTML input type 的底層規範邊界,能幫助我們在開發表單時做出正確的技術選型:

  • 量化數值(數量、年齡):使用 <input type="number">,並防範 validity.badInput 空字串誤判與滑鼠滾輪誤觸。
  • 數字識別碼(信用卡、郵遞區號、OTP 驗證碼、電話):捨棄 type="number",改用 <input type="text" inputmode="numeric"> 或 <input type="tel"> 以保留前導零並支援 maxlength。
  • Email 與 URL:保留語意化 type="email" 與 type="url" 觸發行動端專屬鍵盤,並透過 Constraint Validation API 補強網域點號與 https: 協定檢核。

參考資料與延伸閱讀

發佈留言