前言
做過多國市場產品的話,大概都遇過電話號碼驗證的問題。它看起來簡單,實際上規則不少:各國號碼長度不一、格式各異,有些國家還允許變動長度(例如印尼 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)——例如客服/服務號碼、企業簡碼(台灣的 165、1922,或各家電信的三~五碼服務號)——位數剛好也落在「可能合法」的區間裡,於是這些根本不是使用者手機的號碼,在最寬鬆模式下會被判成通過。
也就是說,「衝註冊率」把驗證放到最鬆之後,資料庫裡可能混進一批短碼或服務號,之後要發簡訊驗證碼時才發現送不出去。所以純 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-input | MIT | ✅ 完全允許 | ✅ 不需公開原始碼 | ❌ 無 |
libphonenumber-js | MIT | ✅ 完全允許 | ✅ 不需公開原始碼 | ❌ 無 |
libphonenumber-csharp | Apache 2.0 | ✅ 完全允許 | ✅ 不需公開原始碼 | ❌ 無 |
簡單說,不管是 MIT 還是 Apache 2.0,對商業與閉源專案都算友善。可以自由使用、修改、分發,也能包裝在付費軟體中,不會有像 GPL 那種「強迫開源」的傳染性問題,導入正式產品時不太需要擔心授權。
結論
整理一下採用 libphonenumber 生態系後的幾個好處:
- 維護成本降低:Google 大約每兩週更新各國規則,平常只需要保持套件版本更新(
npm update/dotnet update) - 資料品質較穩定:前端即時格式化 + 後端驗證,減少格式錯亂的資料進到資料庫
- 使用體驗較好:國旗選擇、隨打隨格式化、貼上自動偵測,註冊表單操作起來比較順
- 驗證強度可調:從「完整驗證」到「只驗長度」(
isValid/isPossible),可以在「資料品質」與「註冊轉換率」之間找平衡,不必一刀切;再搭配 example number 當輸入提示,進一步降低填錯率
如果專案裡還留著一大段各國電話的 Regex,可以考慮換成這套做法,把這部分維護交給上游處理。



























留言