
內容摘要
群組決定匹配會回傳什麼,前後查找在不消耗字元的前提下加條件,而貪婪量詞正是把一個慢樣式變成卡死分頁的元凶。
正則群組、前後查找與量詞,是多數人還沒搞懂就先遇到的三個特性,而正則表達式的困惑大多就出在這個順序。這篇就講這三個,另外補上決定樣式如何套用的旗標,以及會讓一個能跑得動的樣式變成卡死分頁的回溯行為。
文中每個範例都可以在 UrsaKit 的 正則表達式測試器 上跑,它會邊打字邊高亮匹配,並逐 Token 解釋樣式。
01正則表達式就是一支小程式
先講清楚引擎實際做的事:它從輸入的每個位置依序嘗試匹配樣式,可能成功、可能失敗,也可能回溯去試別條路。所有出乎意料的行為都來自「回溯試別條路」這一步。
/^\d{4}-\d{2}-\d{2}$/ 四位數字、連字號、兩位數字、連字號、兩位數字
^ $ 錨點:輸入的開頭與結尾
錨點比其他任何字元都重要。少了 ^ 與 $,一條「匹配日期」的樣式也會匹配任何以日期開頭的長字串的前十個字元。
02捕獲群組決定你拿回什麼
括號有兩個作用:把子樣式集合起來給量詞或分支使用,以及把匹配到的文字存起來。本文的寫法對應 JavaScript 的定義,MDN 的群組與反向參照說明把每一種都列了出來。
;/^(\d{4})-(\d{2})-(\d{2})$/.exec('2026-10-04')
// [ '2026-10-04', '2026', '10', '04' ]
// 索引 0 是整個匹配;1 之後是各個群組
編號群組很脆弱:在樣式中間插入一個群組,後面全部重新編號。命名群組把意圖寫清楚,也能撐過後續修改:
;/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/.exec('2026-10-04')
// groups: { year: '2026', month: '10', day: '04' }
如果某個群組只是為了對多個字元套用量詞或分支,就把它改成非捕獲群組。結果更乾淨,也不必為一個永遠不讀的捕獲付出成本。
| 語法 | 名稱 | 效果 |
|---|---|---|
(abc) | 捕獲群組 | 集合並儲存匹配 |
(?:abc) | 非捕獲群組 | 只集合 |
(?<name>abc) | 命名群組 | 以 groups.name 儲存匹配 |
(?=abc) | 正向先行 | 對後方文字加條件 |
(?!abc) | 負向先行 | 要求後方不能是某段文字 |
(?<=abc) | 正向後顧 | 對前方文字加條件 |
(?<!abc) | 負向後顧 | 要求前方不能是某段文字 |
03前後查找在不消耗字元的前提下加條件
先行查找會檢查前方文字,然後把光標留在原位,所以樣式其他部分還能匹配同一批字元。這正是它被稱為「條件」而不是「匹配」的原因。這種斷言有先行與後顧兩種,其中後顧較新,是 ES2018 才加進 JavaScript 的。
/\d+(?= dollars)/.exec('42 dollars') // 匹配 '42',不是 '42 dollars'
/(?<=\$)\d+/.exec('$42') // 匹配 '42';$ 沒有被消耗
最經典的用法是密碼規則:同時要求多個條件,但不限定順序。
;/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test('UrsaKit2026')
// true —— 至少一個小寫、一個大寫、一個數字,長度 8 以上
想保留分隔符又要切開字串,就用先行查找。
'a1b2c3'.split(/(?=\d)/)會回傳['a', '1b', '2c', '3'],因為先行查找只標記位置、不移除字元。若寫成split(/(\d)/),數字本身會變成陣列中的獨立項目。
04貪婪、惰性,以及回溯的代價
量詞預設是貪婪的:先吃下最多字元,再一個一個吐回來,直到樣式其餘部分能匹配。加上 ? 就變成惰性,先吃最少,再逐步增加。
"<p>hi</p>" /<.+>/ -> "<p>hi</p>" 貪婪:最後一個 ">"
"<p>hi</p>" /<.+?>/ -> "<p>" 惰性:第一個 ">"
惰性不代表一定更快。它只是換了一種搜尋順序,遇到失敗的匹配時引擎還是得探索各種可能。真正昂貴的是巢狀結構:量詞裡面還有量詞,而且兩者能匹配同一批字元。
(a+)+$ 對 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaab"
-> 引擎嘗試 2^n 種分割 a 的方式,然後失敗
這就是教科書級的災難性回溯:30 個字元可能意味著十億次嘗試。改寫之後就沒有歧義,因為每個 a 只剩唯一一種消耗方式:
a+$ 對 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaab" -> 立刻失敗
不要在主線程上拿不受信任的樣式去跑不受信任的輸入。
帶巢狀量詞的樣式可以在 30 個字元的字串上卡住事件循環好幾分鐘。在瀏覽器裡是分頁凍結,在伺服器上就是一個永遠不回傳的請求。
05旗標決定樣式如何被套用
旗標不改變樣式,改變的是引擎行為。MDN 的旗標一覽列出全部旗標,其中像 \p{Script=Greek} 這類 Unicode 屬性跳脫,只有在 Unicode 模式下才能用。
| 旗標 | 意義 | 陷阱 |
|---|---|---|
g | 找出所有匹配,而非第一個 | 重複使用帶 g 的樣式會保留 lastIndex,第二次呼叫可能從字串中間開始 |
i | 忽略大小寫 | 搭配 Unicode 時要一起用 iu,否則大小寫折疊不一致 |
m | ^ 與 $ 在換行處也算邊界 | 複製樣式時很容易不小心打開 |
s | . 也匹配換行 | 沒開的時候 . 遇到 \n 就停 |
u | Unicode 模式,可用 \p{...} | 無效跳脫會直接報錯,這通常正是你要的 |
y | 黏著模式:只從 lastIndex 精確匹配 | 分詞器與解析器常用 |
06什麼時候不該用正則
正則很適合平坦、規則的文字:日誌行、ID、分隔符、簡單驗證。對於會巢狀的結構,它就是錯的工具:
| 任務 | 更好的工具 |
|---|---|
| 驗證 JSON | JSON 解析器;JSON 驗證工具 會回報行號與欄位 |
| 解析 HTML 或 XML | DOM 解析器,不是 <.+> |
| 從含引號欄位的 CSV 取資料 | CSV 解析器 |
| 確認某個電郵存在 | 寄一封確認信 |
| 完整驗證網址 | URL 建構子 |
07一套能找出錯誤、而不是靠猜的流程
- 先貼真實樣本 — 在寫樣式之前,先把實際輸入放進測試區。對著想像出來的文字寫的樣式,只會通過想像出來的測試。
- 一個 Token 一個 Token 疊 — 加一點、看高亮、再加下一點。UrsaKit 測試器會解釋每個 Token,
(?:\s+)?就不再是天書,而是「可選的空白群組」。 - 刻意決定錨點 — 想清楚你要的是「字串中任何位置」還是「整個字串」,再決定要不要加
^/$。 - 測那些「差一點就中」的輸入 — 餵進不該匹配的資料:多一個字元、空格換成連字號、結尾多一個換行。bug 都藏在失敗案例裡。
- 檢查旗標 —
g、m、s改變結果的頻率比樣式本身還高。 - 從參考表點擊插入,不要重打 — 在參考面板點 Token 會插入到光標位置,可以避開從文件複製樣式帶來的跳脫錯誤。
樣式符合預期之後,用 Diff 比對工具 對照處理前後的文字——正則偷偷吃掉一個字元這種事,用比對看得最清楚。
08資料來源
- Groups and backreferences — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Groups_and_backreferences (查閱日期:2026-10-09)
- Lookahead assertion — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Lookahead_assertion (查閱日期:2026-10-09)
- Lookbehind assertion — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Lookbehind_assertion (查閱日期:2026-10-09)
- RegExp.prototype.flags — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/flags (查閱日期:2026-10-09)
- Character class escape (\p{...}) — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Character_class_escape (查閱日期:2026-10-09)
- Regular expression Denial of Service (ReDoS) — https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS (查閱日期:2026-10-09)
即時高亮匹配、逐 Token 解釋,還有點擊即插入的參考表,全部在瀏覽器裡完成。
正則表達式測試器常見問題
捕獲群組與非捕獲群組差在哪?
捕獲群組 (foo) 會把匹配到的內容存起來供你讀取;非捕獲群組 (?:foo) 只是把樣式集合起來給量詞或分支使用,不會出現在結果裡。
前後查找會消耗它檢查的文字嗎?
不會。前後查找只是檢查前方或後方的文字,引擎仍然停在原位,同一批字元之後還能被樣式的其他部分匹配。
後向查找(lookbehind)到處都支援嗎?
JavaScript 從 ES2018 開始支援,現代瀏覽器與 Node.js 都有;但較舊的引擎與部分非 JavaScript 執行環境會拒絕,或限制可變長度的後向查找。
為什麼我的正則會把瀏覽器卡死?
像 (a+)+ 這種巢狀量詞,會讓引擎嘗試指數級的分割方式。改寫樣式,讓每個字元只有唯一一種消耗方式即可。
什麼情況不該用正則?
巢狀或遞迴結構,例如 JSON、HTML 或程式語言本身。那些需要解析器,不是樣式。
g 旗標改變了什麼?
沒有 g 時引擎只回傳第一個匹配,有 g 則持續掃描。匹配行為本身不變,但同一個帶 g 的 RegExp 物件會保留 lastIndex,這就是重複使用時結果出乎意料的原因。
相關文章
- JSON 格式化、驗證與壓縮
為什麼正則不是驗證 JSON 的好工具,以及該用什麼。
- 用 Diff 比對兩段文字
看清楚正則到底抓到哪些字元、跳過哪些字元。
相關工具
- 正則表達式測試器
高亮匹配並逐 Token 解說樣式。
- Diff 文字比對
比對你用來開發樣式的那份文字。
- JavaScript 網上執行器
在沙箱主控台裡跑同一條樣式。
- Base64 編解碼工具
把樣式要匹配的字串先解碼出來。