Mark Ku's Blog
Podcast 對話本文 AI 對話朗讀版

前言

做過多國市場產品的話,大概都遇過電話號碼驗證的問題。它看起來簡單,實際上規則不少:各國號碼長度不一、格式各異,有些國家還允許變動長度(例如印尼 10–13 位)。傳統做法是手寫正規表達式(Regex)來檢查,但隨著支援的國家變多,這份 Regex 會越來越難維護。這篇記錄我們團隊改用 Google 維護的 libphonenumber 生態系的做法,前後端各用對應的套件。

為什麼不自己維護 Regex

維護成本不低

各國電話號碼的規則差異不小,以手機為例,日本 11 位、馬來西亞 10–11 位、印尼 10–13 位都合法。把這些規則都寫進 Regex 後,行數會很快累積;之後某個國家一旦調整規則,要在這串 Regex 裡正確改對,並不容易。

規則會隨時間變動

各國電信法規的更新比想像中頻繁。光是 2025–2026 年間,英國更新了國家號碼計畫、加拿大實施了千號池(Thousands-Block Pooling)、西班牙也封鎖了偽冒國內號碼的國際來電。這些變更若靠手動維護 Regex,除了耗人力,也容易漏改而導致合法號碼被擋下。

容易忽略的細節

一個常見的例子是「去零(Leading Zero)」。很多國家的國內撥號習慣是開頭帶 0(例如台灣 0912-345-678、日本 090-1234-5678),但轉成國際格式後這個 0 要去掉(+886 912345678+81 9012345678)。自己刻輸入框時,使用者常常不確定「要不要打那個 0」,後台就會收到格式不一致的資料,連帶影響下游系統(簡訊發送、KYC 驗證)。

Google libphonenumber 套件 生態系

核心:libphonenumber

libphonenumber 是 Google 維護的開源電話號碼處理函式庫,涵蓋全球 218 個國家/地區的號碼規則,撰文時最新版本為 9.0.32(2026-06-05 發布)。Google 有團隊追蹤 ITU 各國號碼計畫變更,大約每兩週更新一次元資料;對使用者來說,只要保持套件版本更新,就不必自己追各國規則的變動。

前端:react-phone-number-input

在 React 生態中,react-phone-number-input(v3.4.17)是較常見的多國電話輸入元件,每週下載量約 205 萬次。底層使用的 libphonenumber-js(v1.13.6)是 Google 原版的純 JavaScript 重寫版,體積約 145 KB(原版約 550 KB),原生支援 TypeScript,對一般商業應用的驗證需求大致夠用。

補充:libphonenumber-js 不支援緊急號碼、短碼、字母號碼(如 1-800-GOT-MILK)及號碼地理編碼。不過這些在一般的會員註冊場景通常用不到。

後端:libphonenumber-csharp

libphonenumber-csharp(v9.0.32)是 C# 社群對 Google 原版的移植,NuGet 累計下載量超過 9,230 萬次。它透過 GitHub Actions 排程同步 Google 上游更新,延遲通常在 1 天以內,支援 .NET 8.0 及 .NET Standard 2.0。

注意:官方提醒,若不保持套件更新,IsValidNumber 對較新格式的號碼可能回傳 false,導致誤擋合法用戶。

架構概覽

前後端各自採用同一生態系的套件,形成兩層驗證:前端負責即時回饋,後端負責最終把關:

┌─────────────────────────────────────────────────────┐
│                    使用者瀏覽器                        │
│  ┌───────────────────────────────────────────────┐  │
│  │  react-phone-number-input (v3.4.17)           │  │
│  │  ├─ 國旗選擇 + 國碼自動帶入                      │  │
│  │  ├─ 隨打隨格式化                                │  │
│  │  ├─ 自動去零 (Leading Zero)                    │  │
│  │  └─ 底層:libphonenumber-js (v1.13.6, 145KB)  │  │
│  └───────────────────────────┬───────────────────┘  │
│                              │ E.164 格式            │
│                              │ (+886912345678)       │
└──────────────────────────────┼──────────────────────┘
                               ▼
┌──────────────────────────────┼──────────────────────┐
│                         API Server                   │
│  ┌───────────────────────────┴───────────────────┐  │
│  │  libphonenumber-csharp (v9.0.32)              │  │
│  │  ├─ IsValidNumber() 嚴格驗證                    │  │
│  │  ├─ 解析國碼 + 號碼類型 (手機/市話)               │  │
│  │  └─ 自動同步 Google 上游 (GitHub Actions)       │  │
│  └───────────────────────────┬───────────────────┘  │
│                              │                       │
│                              ▼                       │
│                    資料庫 (E.164 格式儲存)             │
└─────────────────────────────────────────────────────┘

實作:前端與後端

前端實作(React)

react-phone-number-input 用起來算直觀,幾行程式碼就能建立一個具備國碼選擇、即時格式化、國旗切換的輸入框:

import PhoneInput, { isValidPhoneNumber } from 'react-phone-number-input'
import 'react-phone-number-input/style.css'

function PhoneForm() {
  const [phone, setPhone] = useState<string | undefined>()

  const handleSubmit = () => {
    if (phone && isValidPhoneNumber(phone)) {
      // phone 已經是 E.164 格式,例如 "+886912345678"
      api.post('/register', { phone })
    }
  }

  return (
    <PhoneInput
      international
      defaultCountry="TW"
      countries={['TW', 'JP', 'US', 'MY', 'ID', 'SG', 'TH']}
      value={phone}
      onChange={setPhone}
      placeholder="輸入電話號碼"
    />
  )
}

這段程式碼帶來的幾個好處:

  • 隨打隨格式化:輸入日本號碼時會自動變成 090 1234 5678,比較好讀
  • 貼上自動偵測:使用者貼上 +62 812 3456 7890,國旗會自動切換成印尼
  • 自動去零:選了台灣後輸入 0912345678,元件會轉成 +886 912345678
  • 可限定國家清單:透過 countries 參數,只顯示業務有覆蓋的國家

線上 Demo:https://catamphetamine.github.io/react-phone-number-input/

後端驗證(C#)

前端驗證主要是為了使用體驗,真正把關的還是後端。使用 libphonenumber-csharp 做驗證:

using PhoneNumbers;

public class PhoneValidator
{
    private static readonly PhoneNumberUtil PhoneUtil = PhoneNumberUtil.GetInstance();

    public static bool ValidatePhone(string phoneNumber)
    {
        try
        {
            // 解析號碼(第二個參數為預設國碼區域,傳 null 表示號碼必須包含 "+" 國碼)
            var number = PhoneUtil.Parse(phoneNumber, null);

            // 嚴格驗證:檢查號碼長度、格式是否符合該國規則
            if (!PhoneUtil.IsValidNumber(number))
                return false;

            // 取得號碼類型(手機、市話、VOIP 等)
            var numberType = PhoneUtil.GetNumberType(number);

            // 可依業務需求限制只接受手機號碼
            return numberType == PhoneNumberType.MOBILE
                || numberType == PhoneNumberType.FIXED_LINE_OR_MOBILE;
        }
        catch (NumberParseException)
        {
            return false;
        }
    }
}

建議:統一用 E.164 格式儲存

不管前端收到什麼格式,存入資料庫前都先轉成 E.164 格式(+國碼號碼,例如 +886912345678),好處是:

面向E.164 的優勢
格式統一全球任何號碼都是同一種格式,不會有「帶零/不帶零」的混亂
下游相容Twilio、AWS SNS 等簡訊服務一律要求 E.164
查詢方便資料庫做唯一性檢查時不用擔心同一號碼有多種寫法
長度限制最多 15 碼(含國碼),欄位設計很單純

驗證嚴謹度:只收手機,還是手機市話都收?

前面的驗證只判斷「號碼合不合法」,但實務上常會再多一個需求:只收手機號,不收市話。畢竟註冊後要發簡訊驗證碼,市話收不到簡訊。這時就要調整驗證的嚴謹度。

libphonenumber 把號碼類型分得很細(手機、市話、免付費、VoIP 等),對應到兩種驗證嚴謹度:

嚴謹度手機市話說明
max(寬鬆)只要格式、長度符合該國規則就通過,不分號碼類型
mobile(嚴格)只接受手機號,合法的市話也會被判 false

後端 libphonenumber-csharp 一律使用完整 metadata,IsValidNumber() 預設就是 max 行為(兩種都收)。如果要做到只收手機,前後端都要額外對齊,不然會出現「前端讓你送、後端把你擋下來」的落差。

前端對齊(這個坑我自己踩過)

整段最容易卡住的就是這一步,我自己也被它繞進去過,所以特別講清楚。

react-phone-number-input(還有它底層的 libphonenumber-js)為了縮小體積,預設載入的是 min 這份精簡版規則表min 只保留「號碼長度」的規則,完全沒有「判斷號碼是手機還是市話」的規則

問題出在它不會報錯。當你呼叫 getType() 想知道號碼類型時,它對大多數國家只會安靜地回傳 undefined。程式不會 crash、不會跳警告,所以很容易以為「我有在判斷類型」,實際上那段判斷根本沒生效:

// ❌ 踩坑:用預設路徑 import,載入的是 min(沒有類型規則)
import { parsePhoneNumber } from 'react-phone-number-input'

const type = parsePhoneNumber('+886912345678')?.getType()
// type 永遠是 undefined
// 於是「type === 'MOBILE'」永遠成立不了 → 連合法手機都被擋光
// 寫了等於沒寫,而且還不會報錯

解法很單純:把 import 路徑改成 /max 子套件,就會載入含「類型規則」的完整版,getType() 才會真正生效:

// ✅ 正確:從 /max import,才有判斷類型的能力
import { parsePhoneNumber } from 'react-phone-number-input/max'

const type = parsePhoneNumber('+886912345678')?.getType()
// type === 'MOBILE',這下判斷才有效

一句話記法:只要你打算用 getType() 分辨手機 / 市話,import 路徑就一定要帶 /max(或 /mobile),千萬別用預設的那個。

知道這點之後,前端的手機驗證就可以這樣寫:

import { parsePhoneNumber } from 'react-phone-number-input/max'

function isValidMobile(value?: string): boolean {
  if (!value) return false
  const phone = parsePhoneNumber(value)
  if (!phone || !phone.isValid()) return false

  const type = phone.getType()
  // 接受手機;號段重疊無法判定的國家(FIXED_LINE_OR_MOBILE / undefined)也放行
  return type === 'MOBILE'
    || type === 'FIXED_LINE_OR_MOBILE'
    || type === undefined
}

如果想要最嚴格的「只收手機」,也可以直接 import react-phone-number-input/mobile,它的 isValidPhoneNumber 會把市話直接判成 false。但這個 bundle 對號段重疊的國家(如美國)也會一律擋掉,使用前要先確認目標國家有獨立的手機號段。

後端對齊

後端因為一律使用完整 metadata,類型判斷本來就準。把前面的 ValidatePhone 改成限制手機(其實就是文章前面後端範例已經在做的事):

var numberType = PhoneUtil.GetNumberType(number);

// 接受手機;號段重疊無法判定的國家放行(這就是「bypass」)
return numberType == PhoneNumberType.MOBILE
    || numberType == PhoneNumberType.FIXED_LINE_OR_MOBILE;

號段重疊的國家怎麼辦

有些國家的手機與市話號段是重疊的,最典型的是美國 , 同一段號碼可能是手機也可能是市話,libphonenumber 無法分辨,於是回傳 FIXED_LINE_OR_MOBILE。這時若嚴格只收 MOBILE,連合法的美國手機都會被擋下來。

libphonenumber 對這種情況留了一個「bypass」:把 FIXED_LINE_OR_MOBILE(以及前端 min metadata 下的 undefined)也視為通過,等於在「無法判定」時選擇放行,避免誤殺合法號碼。要不要開這個 bypass,取決於你支援哪些國家:

國家類型判斷結果嚴格 mobile 驗證
馬來西亞、印尼、中國、泰國、越南有獨立手機號段,回傳 MOBILE✅ 安全,不會誤判
美國、加拿大號段重疊,回傳 FIXED_LINE_OR_MOBILE⚠️ 需要 bypass,否則誤擋

我們團隊主要覆蓋東南亞與中國市場,這些國家大多有獨立的手機號段,類型判斷會穩定回傳 MOBILE,嚴格驗證不太會誤判。但程式碼仍保留 FIXED_LINE_OR_MOBILE 的 bypass,避免日後擴張到美加市場時,才發現合法用戶被擋在門外。

太嚴格會掉註冊率:把驗證強度調得「剛剛好」

前面談的都是「要不要分手機/市話」,但實務上還有一個更現實的問題:驗證太嚴格,會把真實用戶擋在門外,註冊率跟著掉。 我們自己就是因為註冊轉換率被壓低,才回頭把驗證強度放鬆。

問題的根源在於 isValidNumber() 這種「完整驗證」是一把雙面刃。它會比對該國號碼的完整樣式——不只是長度,連號段、開頭數字都要符合 metadata 裡登記的規則。一旦電信商新開了一段號碼、而你的套件版本還沒跟上,這些剛配發出去的合法號碼就會被判 false。使用者覺得「我的號碼明明是對的,為什麼註冊不了」,就直接流失了。

有趣的是,連 libphonenumber-js 的作者都在 README 裡明講:他個人偏好較寬鬆的 isPossible(),因為「它的強處正是它的弱處」——只驗長度,反而不會因為 metadata 過期而誤殺新號碼。

兩種驗證模式:完整驗證 vs 只驗長度

libphonenumber 同時提供兩種驗證強度,前後端都有對應 API:

模式前端(libphonenumber-js)後端(C#)檢查內容特性
完整驗證(嚴格)isValid()IsValidNumber()長度 + 號段樣式資料最乾淨,但 metadata 過期會誤擋新號碼
只驗長度(寬鬆)isPossible()IsPossibleNumber()只看位數是否可能合法較不會誤殺,對新號段更耐放

三段式驗證強度

把「分手機/市話」和「完整驗證/只驗長度」兩個軸交叉,實務上常落在三種設定,依「能接受多少髒資料」與「多在意註冊率」來選:

強度手機市話適合情境
最嚴格完整驗證完整驗證資料品質優先,能接受偶爾誤擋(如金流、KYC)
手機嚴格 + 市話寬鬆完整驗證只驗長度主要靠手機發簡訊,但市話也想盡量收
最寬鬆只驗長度只驗長度衝註冊率優先,先收下來再說(資料後續再清洗)

前端寫法(沿用前面 /max 的 import,getType() 才有作用):

import { parsePhoneNumber } from 'react-phone-number-input/max'

type Strictness = 'strict' | 'mobileStrict' | 'loose'

function validatePhone(value: string | undefined, level: Strictness): boolean {
  if (!value) return false
  const phone = parsePhoneNumber(value)
  if (!phone) return false

  // 任何模式都先做最基本的長度檢查(位數、國碼是否可能合法)
  if (!phone.isPossible()) return false

  const type = phone.getType()
  const isMobile =
    type = 'MOBILE' || type = 'FIXED_LINE_OR_MOBILE' || type === undefined

  switch (level) {
    case 'strict':       // 手機、市話都要過完整 pattern 驗證
      return phone.isValid()
    case 'mobileStrict': // 手機嚴格、市話只驗長度(已通過 isPossible)
      return isMobile ? phone.isValid() : true
    case 'loose':        // 只驗長度
      return true
  }
}

後端 C# 對應寫法:

public enum PhoneStrictness { Strict, MobileStrict, Loose }

public static bool ValidatePhone(string phoneNumber, PhoneStrictness level)
{
    try
    {
        var number = PhoneUtil.Parse(phoneNumber, null);

        // 任何模式都先擋掉「長度離譜 / 國碼錯誤」
        if (!PhoneUtil.IsPossibleNumber(number))
            return false;

        var type = PhoneUtil.GetNumberType(number);
        bool isMobile = type == PhoneNumberType.MOBILE
            || type == PhoneNumberType.FIXED_LINE_OR_MOBILE;

        return level switch
        {
            // 最嚴格:手機、市話都要符合完整號碼樣式
            PhoneStrictness.Strict => PhoneUtil.IsValidNumber(number),

            // 手機嚴格、市話寬鬆:手機過完整驗證,市話只要長度合理就收
            PhoneStrictness.MobileStrict => isMobile
                ? PhoneUtil.IsValidNumber(number)
                : true,

            // 最寬鬆:長度可能合法就收(已通過 IsPossibleNumber)
            PhoneStrictness.Loose => true,

            _ => PhoneUtil.IsValidNumber(number),
        };
    }
    catch (NumberParseException)
    {
        return false;
    }
}

想給更友善的錯誤訊息時,前端可用 validatePhoneNumberLength():長度不對時它會回傳 TOO_SHORT / TOO_LONG,而不是只給一個 false,方便提示「號碼太短」之類的具體原因。

寬鬆模式的坑:短碼會被放行(我踩過)

放寬到「只驗長度」是有代價的,這個坑我自己實際踩到過。isPossible() / IsPossibleNumber() 只看位數落在該國可能的長度區間內,不比對真實號段。問題是很多國家的短碼(Short Code)——例如客服/服務號碼、企業簡碼(台灣的 1651922,或各家電信的三~五碼服務號)——位數剛好也落在「可能合法」的區間裡,於是這些根本不是使用者手機的號碼,在最寬鬆模式下會被判成通過

也就是說,「衝註冊率」把驗證放到最鬆之後,資料庫裡可能混進一批短碼或服務號,之後要發簡訊驗證碼時才發現送不出去。所以純 loose 模式不能只靠 isPossible() 一關把守,至少要再擋掉短碼。

後端 C# 可以用 ShortNumberInfo 補一道防線,把短碼直接踢掉:

using PhoneNumbers;

var shortInfo = ShortNumberInfo.GetInstance();

// loose 模式收下前,先確認它不是短碼
if (shortInfo.IsValidShortNumber(number))
    return false; // 是短碼/服務號,不當成使用者電話

前端因為 libphonenumber-js 本來就不支援短碼解析(前面提過),這些簡碼多半在 parsePhoneNumber() 階段就拿不到有效號碼;真正要小心的是後端——它載入完整 metadata,IsPossibleNumber() 對短碼長度會很寬容。

一句話記法isPossible() 只保證「位數看起來像個電話」,不保證「這是一支能收簡訊的手機」。開最寬鬆模式時,記得對後端補一層 ShortNumberInfo 過濾。

用 example number 當輸入提示,把錯誤擋在輸入前

降低註冊摩擦還有一招:直接告訴使用者「這個國家的號碼長什麼樣」。libphonenumber 內建每個國家的範例號碼,可以拿來當輸入框的 placeholder 或錯誤訊息範例,使用者一看就知道格式,自然少打錯。

前端用 getExampleNumber()

import { getExampleNumber } from 'libphonenumber-js'
import examples from 'libphonenumber-js/mobile/examples'

const example = getExampleNumber('TW', examples)
example?.formatNational()      // '0912 345 678'
example?.formatInternational() // '+886 912 345 678'

拿來當 placeholder,使用者選哪個國家就提示那個國家的範例:

<PhoneInput
  international
  defaultCountry="TW"
  value={phone}
  onChange={setPhone}
  placeholder={getExampleNumber('TW', examples)?.formatNational()}
/>

後端 C# 也有對應 API,適合放進 API 文件或驗證失敗的回應訊息裡:

var example = PhoneUtil.GetExampleNumberForType("TW", PhoneNumberType.MOBILE);

// 國內格式:0912 345 678
var national = PhoneUtil.Format(example, PhoneNumberFormat.NATIONAL);

// 國際格式:+886 912 345 678
var international = PhoneUtil.Format(example, PhoneNumberFormat.INTERNATIONAL);

這樣一來,驗證失敗時不只回「號碼格式錯誤」,還能附上「正確格式範例:0912 345 678」,使用者改起來更直覺,註冊流程也更順。

商業考量:可以免費商用嗎?

套件授權商用閉源傳染性
react-phone-number-inputMIT✅ 完全允許✅ 不需公開原始碼❌ 無
libphonenumber-jsMIT✅ 完全允許✅ 不需公開原始碼❌ 無
libphonenumber-csharpApache 2.0✅ 完全允許✅ 不需公開原始碼❌ 無

簡單說,不管是 MIT 還是 Apache 2.0,對商業與閉源專案都算友善。可以自由使用、修改、分發,也能包裝在付費軟體中,不會有像 GPL 那種「強迫開源」的傳染性問題,導入正式產品時不太需要擔心授權。

結論

整理一下採用 libphonenumber 生態系後的幾個好處:

  • 維護成本降低:Google 大約每兩週更新各國規則,平常只需要保持套件版本更新(npm update / dotnet update
  • 資料品質較穩定:前端即時格式化 + 後端驗證,減少格式錯亂的資料進到資料庫
  • 使用體驗較好:國旗選擇、隨打隨格式化、貼上自動偵測,註冊表單操作起來比較順
  • 驗證強度可調:從「完整驗證」到「只驗長度」(isValid / isPossible),可以在「資料品質」與「註冊轉換率」之間找平衡,不必一刀切;再搭配 example number 當輸入提示,進一步降低填錯率

如果專案裡還留著一大段各國電話的 Regex,可以考慮換成這套做法,把這部分維護交給上游處理。

參考資料

作者

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。閱讀更多

覺得這篇有幫助?

作者做的免費工具、每日 Podcast 與電子報,都在這裡。

Mark Ku · 本文採用 CC BY 4.0 授權,轉載請註明作者並附上原文連結。

留言

訂閱電子報

訂閱後即時收到新文章通知,不錯過任何技術分享。

提交即表示同意接收電子報,隨時可

熱門文章

View all
Mark Ku
··599

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案
Mark Ku
··493

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南
Mark Ku
··333

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略
Mark Ku
··271

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調
Mark Ku
··222

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取
Mark Ku
··217

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1
用 Google libphonenumber 套件 處理多國電話號碼驗證(前端 + 後端) - Mark Ku's Blog