寫程式的時候,多少都會遇到需要檢查文字格式、從一大段文字裡撈出特定資訊的場景。像是檢查 Email 格式對不對、從 Log 裡面抓 IP 位址、或是把日期格式批次轉換。這些事情如果用一般的字串操作來處理,程式碼會又臭又長,但如果用 Regular Expression(正規表達式,簡稱 Regex),往往一行 pattern 就能搞定。不過很多開發者一看到像 ^(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$ 這樣的 pattern 就頭痛,覺得像在看外星文。其實 Regex 是由一組簡單的規則積木組合而成,只要一塊一塊學,很快就能看懂,甚至自己寫出來。
Regular Expression 是什麼
簡單來說,Regular Expression 是一種描述文字模式的迷你語言。平常我們在文件裡按 Ctrl+F(macOS 是 Cmd+F)搜尋,只能找「固定的文字」,例如搜尋 “error” 就只會找到 error 這個字。但如果想找的是「所有 Email 地址」或「任何 YYYY-MM-DD 格式的日期」,固定文字就做不到了——這時候就需要 Regex 來描述這個模式(pattern),讓程式幫我們比對、擷取、甚至取代符合模式的文字。
Regex 本身不是一種程式語言,而是一種語法規範,被各種程式語言和工具支援。不管是 JavaScript、Python、Java、Go,還是終端機上的 grep、sed,甚至 VS Code 的搜尋功能,都能使用 Regex。
在哪些地方會用到 Regex
| 使用場景 | 範例 | 沒有 Regex | 有 Regex |
|---|---|---|---|
| 表單驗證 | 檢查 Email 格式 | 手動拆字串逐一判斷 | 一行 pattern 搞定 |
| Log 分析 | 從 log 提取 IP 和時間戳 | 寫一堆 split / indexOf | 一個 pattern 精準擷取 |
| 文字取代 | 日期格式 MM/DD/YYYY → YYYY-MM-DD | 複雜的字串操作邏輯 | sed 或 IDE 一次取代 |
| 程式碼搜尋 | 找出所有 TODO 和 FIXME 註解 | grep 純文字搜尋有限 | grep -E 支援 pattern |
在哪裡練習
學 Regex 最好的方式就是邊看邊試。推薦使用 regex101.com ,它可以即時測試 pattern,還會用視覺化的方式解釋每一段的意思,支援 JavaScript、Python、Java、Go 等多種語言的 Regex 引擎。另外 regexr.com 也是不錯的選擇。建議把這些工具打開,跟著文章的範例一起操作。
基本語法入門
Regex 的核心是由一組「積木」組成的,每塊積木代表一個規則。接下來會從最簡單的開始,一個觀念一個觀念往上堆疊。
字面比對(Literal Match)
最簡單的 Regex 就是直接寫出想找的文字。例如 pattern hello 就會比對到字串中的 “hello”。大部分的字元都是直接對應自己,只有少數「特殊字元」有其他意義。
Pattern: hello
文字: say hello world
比對: ^^^^^ (比對到 hello)
特殊字元與跳脫(Metacharacters)
Regex 中有一些字元有特殊含義,稱為特殊字元(metacharacters)。如果想比對這些字元本身,就需要在前面加上反斜線 \ 來跳脫(escape)。
| 特殊字元 | 意義 | 比對字面意思 |
|---|---|---|
. | 任意一個字元(換行除外) | \. |
^ | 行首 | \^ |
$ | 行尾 | \$ |
* | 前一個項目出現 0 次以上 | \* |
+ | 前一個項目出現 1 次以上 | \+ |
? | 前一個項目出現 0 或 1 次 | \? |
{} | 指定出現次數 | \{\} |
[] | 字元類別 | \[\] |
() | 群組 | \(\) |
| | 或(交替) | \| |
\ | 跳脫字元 | \\ |
例如想比對 IP 位址中的 .,就要寫 \.,不然 . 會比對到任何字元。
Character Class 字元類別 […]
用中括號 [] 包起來的是字元類別,表示「比對其中任何一個字元」。
# 比對 a、b 或 c 其中一個
[abc]
# 用範圍表示:比對 a 到 z 的任何小寫英文字母
[a-z]
# 組合多種範圍:小寫、大寫、數字
[a-zA-Z0-9]
# 取反:比對「不是」a、b、c 的任何字元
[^abc]
# 在 [] 裡面,大部分特殊字元會失去特殊意義
# 例如 [.] 就是比對 . 這個字元本身
[.]
預定義字元類別(Shorthand Character Classes)
常用的字元類別有簡寫,省去每次都要寫 [a-zA-Z0-9] 的麻煩。
| 簡寫 | 等同於 | 說明 |
|---|---|---|
\d | [0-9] | 數字 |
\D | [^0-9] | 非數字 |
\w | [a-zA-Z0-9_] | 文字字元(字母、數字、底線) |
\W | [^a-zA-Z0-9_] | 非文字字元 |
\s | [ \t\n\r\f\v] | 空白字元(空格、Tab、換行等) |
\S | [^ \t\n\r\f\v] | 非空白字元 |
大寫版本就是小寫的相反。例如 \d 是數字,\D 就是「不是數字的任何字元」。
Quantifier 量詞
量詞用來指定前面的項目要出現幾次。
| 量詞 | 意義 | 範例 |
|---|---|---|
* | 0 次以上 | ab*c → ac, abc, abbc, abbbc… |
+ | 1 次以上 | ab+c → abc, abbc, abbbc…(不含 ac) |
? | 0 或 1 次 | colou?r → color, colour |
{n} | 剛好 n 次 | \d{4} → 剛好 4 個數字 |
{n,} | 至少 n 次 | \d{2,} → 2 個以上數字 |
{n,m} | n 到 m 次 | \d{2,4} → 2 到 4 個數字 |
預設情況下,量詞是貪婪的(greedy),會盡可能多吃字元。在量詞後面加上 ? 就變成惰性的(lazy),盡可能少吃。這個差異在後面的進階語法會更詳細說明。
# 貪婪模式(預設)
Pattern: <.*>
文字: <b>hello</b>
比對: <b>hello</b> (一次吃到底)
# 惰性模式
Pattern: <.*?>
文字: <b>hello</b>
比對: <b> 和 </b> (盡量少吃)
Anchor 錨點
錨點不比對任何字元,而是比對位置。
| 錨點 | 意義 | 範例 |
|---|---|---|
^ | 行首(或字串開頭) | ^Hello → 只比對行首的 Hello |
$ | 行尾(或字串結尾) | end$ → 只比對行尾的 end |
\b | 單字邊界 | \bcat\b → 比對 cat 但不比對 catch |
\B | 非單字邊界 | \Bcat\B → 比對 concatenate 中的 cat |
錨點為什麼重要?如果驗證 Email 只寫 \w+@\w+\.\w+,像 [email protected]!!! 也會比對成功,因為 pattern 只要在字串中任何位置比對到就算過。加上 ^ 和 $ 之後就能確保整個字串都要符合。
分組與擷取(Groups and Capturing)
學會了基本的比對規則之後,接下來看怎麼把比對到的內容「拆開來用」。
捕獲群組 (…)
用小括號 () 把 pattern 的一部分包起來,就形成一個捕獲群組(capturing group)。比對成功後,可以分別取出每個群組的內容。
# 比對日期並分別擷取年、月、日
Pattern: (\d{4})-(\d{2})-(\d{2})
文字: 2026-04-09
結果: 群組 1 = 2026, 群組 2 = 04, 群組 3 = 09
在各語言中取出群組的方式:
// JavaScript
const match = '2026-04-09'.match(/(\d{4})-(\d{2})-(\d{2})/);
console.log(match[1]); // "2026"
console.log(match[2]); // "04"
console.log(match[3]); // "09"
# Python
import re
match = re.search(r'(\d{4})-(\d{2})-(\d{2})', '2026-04-09')
print(match.group(1)) # "2026"
print(match.group(2)) # "04"
print(match.group(3)) # "09"
// Java
Pattern pattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher matcher = pattern.matcher("2026-04-09");
if (matcher.find()) {
System.out.println(matcher.group(1)); // "2026"
System.out.println(matcher.group(2)); // "04"
System.out.println(matcher.group(3)); // "09"
}
非捕獲群組 (?:…)
有時候需要用小括號來做分組(例如搭配量詞 (?:ab)+),但不需要擷取群組內容。這時候加上 ?: 就變成非捕獲群組,不佔群組編號,效能也稍微好一點。
# 捕獲群組:(?:https?://)會佔一個群組編號
Pattern: (https?://)(\w+\.\w+)
文字: https://klab.tw
結果: 群組 1 = https://, 群組 2 = klab.tw
# 非捕獲群組:(?:https?://) 不佔編號
Pattern: (?:https?://)(\w+\.\w+)
文字: https://klab.tw
結果: 群組 1 = klab.tw
Named Group 命名群組
當 pattern 裡有很多群組,用數字 1, 2, 3... 來記哪個是哪個很容易搞混。命名群組讓我們可以幫每個群組取名字,程式碼更容易閱讀和維護。
| 語言 | 命名群組語法 | 取值方式 |
|---|---|---|
| JavaScript | (?<name>...) | match.groups.name |
| Python | (?P<name>...) | match.group('name') |
| Java | (?<name>...) | matcher.group("name") |
| .NET / C# | (?<name>...) | match.Groups["name"] |
| Go | (?P<name>...) | match[index](需配合 SubexpNames) |
// JavaScript 命名群組範例
const pattern = /(?\d{4})-(?\d{2})-(?\d{2})/;
const match = '2026-04-09'.match(pattern);
console.log(match.groups.year); // "2026"
console.log(match.groups.month); // "04"
console.log(match.groups.day); // "09"
Backreference 反向參照
反向參照可以在 pattern 裡引用前面已經捕獲的群組內容。最經典的用法就是找出重複的單字。
# 找出連續重複的單字
Pattern: \b(\w+)\s+\1\b
文字: the the quick brown fox fox
比對: "the the" 和 "fox fox"
# \1 參照第一個群組捕獲的內容
# 如果群組 1 捕獲了 "the",\1 就只會比對 "the"
命名群組也可以做反向參照:用 \k<name>(JavaScript / .NET)或 (?P=name)(Python)。
進階語法
掌握了基礎之後,接下來這些進階功能會讓 Regex 的能力更上一層。
Alternation 交替 |
| 表示「或」,可以比對左邊或右邊的 pattern。搭配群組使用可以限定「或」的範圍。
# 比對 cat 或 dog
cat|dog
# 搭配群組:比對 cat food 或 dog food
(cat|dog) food
# 不加群組的話,| 的範圍是整個 pattern
# cat food|dog 會比對 "cat food" 或 "dog",不是 "cat food" 或 "dog food"
Lookahead 與 Lookbehind 環視
環視(lookaround)是 Regex 中比較進階的功能。可以把它想成「往前看路或往後看路,但不移動」。環視會檢查某個位置的前面或後面是否符合條件,但不會消耗任何字元。
| 語法 | 名稱 | 說明 |
|---|---|---|
(?=...) | Positive Lookahead | 後面必須符合 |
(?!...) | Negative Lookahead | 後面必須不符合 |
(?<=...) | Positive Lookbehind | 前面必須符合 |
(?<!...) | Negative Lookbehind | 前面必須不符合 |
# Positive Lookahead:找數字,但後面要有 "元"
Pattern: \d+(?=元)
文字: 售價 100元,庫存 50個
比對: 100(50 不會比對到,因為後面不是 "元")
# Negative Lookahead:找數字,但後面不能是 "元"
Pattern: \d+(?!元)
文字: 售價 100元,庫存 50個
比對: 10 和 50
# Positive Lookbehind:找數字,但前面要有 "$"
Pattern: (?<=\$)\d+
文字: Price: $199, Code: A100
比對: 199(100 不會比對到,因為前面不是 $)
# Negative Lookbehind:找數字,但前面不能有 "$"
Pattern: (?
環視最常見的實戰用法就是密碼強度驗證。開頭提到的 ^(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$,就是用多個 lookahead 來同時檢查「包含大寫」和「包含數字」這兩個條件。
要注意的是,不同語言對 lookbehind 的支援程度不一樣:
| 功能 | JavaScript | Python | Java | Go | grep -P |
|---|---|---|---|---|---|
| Lookahead | ✓ | ✓ | ✓ | ✗ | ✓ |
| Lookbehind(固定長度) | ✓ | ✓ | ✓ | ✗ | ✓ |
| Lookbehind(可變長度) | ✓(ES2024+) | ✓ | ✓ | ✗ | ✓ |
Go 的 regexp 套件不支援環視,這是因為 Go 使用 RE2 引擎,保證線性時間複雜度,但犧牲了環視功能。
Greedy vs Lazy vs Possessive 貪婪、惰性與占有量詞
在基本語法提過量詞預設是貪婪的,這裡更深入解釋背後的回溯(backtracking)機制。
| 類型 | 語法 | 行為 |
|---|---|---|
| Greedy(貪婪) | * + ? | 先盡量多吃,比對失敗就一個一個吐回來重試 |
| Lazy(惰性) | *? +? ?? | 先盡量少吃,比對失敗就多吃一個重試 |
| Possessive(占有) | *+ ++ ?+ | 盡量多吃,但不回溯(Java / PCRE 支援) |
# 想比對引號內的字串
文字: He said "hello" and "world"
# Greedy:吃到最後一個引號
Pattern: ".*"
比對: "hello" and "world"
# Lazy:吃到第一個引號就停
Pattern: ".*?"
比對: "hello" 和 "world"
# 更好的做法:用排除字元類別,不需要 lazy
Pattern: "[^"]*"
比對: "hello" 和 "world"
最後那個用 [^"]* 的寫法值得記住,很多時候比 lazy 更直覺也更有效率。
Unicode 與多語言支援
對繁體中文使用者來說,比對中文字元是很常見的需求。傳統做法是用 Unicode 範圍 [\u4e00-\u9fff],但更現代的方式是使用 Unicode Property。
// JavaScript(需要 u flag)
// 比對任何中日韓統一表意文字(CJK)
const pattern = /\p{Script=Han}+/u;
'Hello 世界'.match(pattern); // ["世界"]
// 比對任何 Unicode 字母(含中文、日文假名等)
const letterPattern = /\p{L}+/gu;
'Hello 世界 こんにちは'.match(letterPattern);
// ["Hello", "世界", "こんにちは"]
# Python(使用 regex 套件,非內建 re)
import regex
pattern = regex.compile(r'\p{Han}+')
pattern.findall('Hello 世界') # ['世界']
# 用內建 re 的話,可以用 Unicode 範圍
import re
re.findall(r'[\u4e00-\u9fff]+', 'Hello 世界') # ['世界']
實戰應用
學了這麼多語法,來看看實際開發中怎麼使用。
表單驗證
Email 驗證(簡化版)
# Pattern 拆解:
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
^ # 字串開頭
[a-zA-Z0-9._%+-]+ # 使用者名稱:英數字和一些特殊符號,至少 1 個
@ # @ 符號
[a-zA-Z0-9.-]+ # 域名:英數字、點、連字號
\. # 一個 . (跳脫)
[a-zA-Z]{2,} # 頂級域名:至少 2 個英文字母(com, tw, org...)
$ # 字串結尾
這是簡化版的 Email 驗證,涵蓋絕大多數正常的 Email 地址。真正的 Email 規範(RFC 5322)極其複雜,實務上這個 pattern 已經夠用。
台灣手機號碼
# 台灣手機號碼:09 開頭,後面 8 個數字
^09\d{8}$
# 允許中間有連字號的格式(0912-345-678)
^09\d{2}-?\d{3}-?\d{3}$
台灣身分證字號
# 基本格式驗證:1 個大寫英文字母 + 1 或 2(性別碼)+ 8 個數字
^[A-Z][12]\d{8}$
# 2020 年後新增的居留證號碼格式
^[A-Z][89A-D]\d{8}$
注意:這些 pattern 只驗證「格式」正不正確,不驗證「內容」是否合法(例如身分證字號有檢查碼邏輯,需要額外計算)。
文字擷取與資料清洗
解析 Nginx Access Log
一筆典型的 Nginx access log 長這樣:
203.0.113.50 - - [09/Apr/2026:10:15:32 +0800] "GET /api/users HTTP/1.1" 200 1234
用命名群組一次擷取所有欄位:
import re
log_pattern = re.compile(
r'(?P\d+\.\d+\.\d+\.\d+)' # IP 位址
r' - - ' # 固定分隔
r'\[(?P
從文字中提取所有 URL
import re
text = "參考 https://klab.tw/2026/04/regex-tutorial/ 和 http://example.com/path?q=1"
urls = re.findall(r'https?://\S+', text)
print(urls)
# ['https://klab.tw/2026/04/regex-tutorial/', 'http://example.com/path?q=1']
清除 HTML 標籤
import re
html = '<p>Hello <b>World</b></p>'
clean = re.sub(r'<[^>]+>', '', html)
print(clean) # Hello World
清除 HTML 標籤用 Regex 處理簡單的情境沒問題,但如果是複雜的巢狀 HTML,建議使用專門的 HTML parser(例如 Python 的 BeautifulSoup)。
開發工具整合
Regex 不只用在程式碼裡,日常使用的開發工具也處處用得到。
grep — 在檔案中搜尋
# 從 log 中找出所有 IP 位址
grep -oE '\b[0-9]{1,3}(\.[0-9]{1,3}){3}\b' access.log
# 找出所有 TODO 和 FIXME 註解
grep -rn -E 'TODO|FIXME' src/
# 找出定義函式的行(JavaScript)
grep -rn -E 'function\s+\w+|const\s+\w+\s*=' src/*.js
sed — 批次取代
# 把日期格式從 MM/DD/YYYY 轉成 YYYY-MM-DD
sed -E 's|([0-9]{2})/([0-9]{2})/([0-9]{4})|\3-\1-\2|g' data.csv
# 移除行尾空白
sed -E 's/[[:space:]]+$//' file.txt
# 把 console.log 全部註解掉
sed -E 's/(console\.log\()/\/\/ \1/' app.js
VS Code 搜尋取代
在 VS Code 的搜尋框開啟 Regex 模式(點擊 .* 圖示或按 Alt+R),就可以使用 Regex 搜尋和取代。取代時用 $1、$2 參照群組。
# 把 console.log("...") 改成 logger.info("...")
搜尋: console\.log\(([^)]+)\)
取代: logger.info($1)
Git grep — 搜尋整個 Git 倉庫
# 在整個 Git 倉庫搜尋符合 pattern 的內容
git grep -E 'TODO|FIXME|HACK'
# 搜尋特定語言的檔案
git grep -E 'import\s+.*axios' -- '*.ts' '*.js'
各語言 Regex 差異對照
Regex 語法在各語言大致相同,但建立方式、flag 名稱和一些細節有所不同。以下整理主要差異:
| 功能 | JavaScript | Python | Java | Go | grep -E |
|---|---|---|---|---|---|
| 建立方式 | /pattern/flags | re.compile(r'...') | Pattern.compile("...") | regexp.Compile("...") | CLI 參數 |
| 全域搜尋 | g flag | re.findall() | find() 迴圈 | FindAllString() | 預設行為 |
| 忽略大小寫 | i flag | re.IGNORECASE | CASE_INSENSITIVE | (?i) 前綴 | -i flag |
| 多行模式 | m flag | re.MULTILINE | MULTILINE | (?m) 前綴 | 預設逐行 |
| Named Group | (?<n>...) | (?P<n>...) | (?<n>...) | (?P<n>...) | (?P<n>...) |
| 跳脫字串 | 不需要雙跳脫 | 使用 r'...' | 需要 \\ 雙跳脫 | 使用反引號 `...` | 不需要 |
特別注意 Java 的字串需要對反斜線做雙跳脫,所以 \d 在 Java 裡要寫成 "\\d",Python 用 raw string r'\d' 就不用,Go 用反引號也不用。
常見陷阱與效能考量
Catastrophic Backtracking 災難性回溯
某些 pattern 在特定輸入下會導致引擎瘋狂回溯,執行時間呈指數成長。這不只是效能問題,還可能被惡意利用——這種攻擊叫做 ReDoS(Regular Expression Denial of Service)。
# 危險 pattern 範例
Pattern: (a+)+$
文字: aaaaaaaaaaaaaaaaaaaaX
# 引擎會嘗試所有可能的 a 分組方式,時間複雜度 O(2^n)
# 20 個 a 加一個 X,可能要跑好幾秒甚至更久
避免災難性回溯的原則:
- 避免巢狀量詞(如
(a+)+、(a*)*) - 用具體的字元類別取代
.*(例如[^"]*取代.*?) - 使用 Possessive 量詞或 Atomic Group 防止回溯(如果語言支援)
- 在 regex101.com 上測試,留意步驟數(steps)是否異常高
不要用 Regex 的時候
Regex 很強大,但不是萬能的。以下情境建議用其他工具:
- 解析 HTML/XML:Regex 無法正確處理巢狀標籤,請用 HTML parser(Python BeautifulSoup、JavaScript DOMParser)
- 複雜的巢狀結構:像是 JSON、程式語言語法,Regex 無法處理遞迴巢狀,需要用 parser
- 簡單的字串操作:只是檢查開頭結尾或是否包含某段文字,用
startsWith()、includes()、endsWith()更清楚也更快
常用語法速查表
最後整理一份速查表,方便日後回來查閱。
字元比對
| 語法 | 說明 |
|---|---|
. | 任意一個字元(換行除外) |
\d / \D | 數字 / 非數字 |
\w / \W | 文字字元 / 非文字字元 |
\s / \S | 空白 / 非空白 |
[abc] | a、b 或 c |
[a-z] | a 到 z 的範圍 |
[^abc] | 不是 a、b、c |
量詞
| 語法 | 說明 |
|---|---|
* | 0 次以上(貪婪) |
+ | 1 次以上(貪婪) |
? | 0 或 1 次 |
{n} | 剛好 n 次 |
{n,m} | n 到 m 次 |
*? / +? | 惰性版本 |
錨點與斷言
| 語法 | 說明 |
|---|---|
^ | 行首 / 字串開頭 |
$ | 行尾 / 字串結尾 |
\b | 單字邊界 |
(?=...) | Positive Lookahead |
(?!...) | Negative Lookahead |
(?<=...) | Positive Lookbehind |
(?<!...) | Negative Lookbehind |
群組與參照
| 語法 | 說明 |
|---|---|
(...) | 捕獲群組 |
(?:...) | 非捕獲群組 |
(?<name>...) | 命名群組 |
\1 | 反向參照第 1 個群組 |
a|b | a 或 b |