UrsaKit 封面圖:說明正則表達式的群組、前後查找與量詞

正則群組、前後查找與量詞,是多數人還沒搞懂就先遇到的三個特性,而正則表達式的困惑大多就出在這個順序。這篇就講這三個,另外補上決定樣式如何套用的旗標,以及會讓一個能跑得動的樣式變成卡死分頁的回溯行為。

文中每個範例都可以在 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 就停
uUnicode 模式,可用 \p{...}無效跳脫會直接報錯,這通常正是你要的
y黏著模式:只從 lastIndex 精確匹配分詞器與解析器常用

06什麼時候不該用正則

正則很適合平坦、規則的文字:日誌行、ID、分隔符、簡單驗證。對於會巢狀的結構,它就是錯的工具:

任務更好的工具
驗證 JSONJSON 解析器;JSON 驗證工具 會回報行號與欄位
解析 HTML 或 XMLDOM 解析器,不是 <.+>
從含引號欄位的 CSV 取資料CSV 解析器
確認某個電郵存在寄一封確認信
完整驗證網址URL 建構子

07一套能找出錯誤、而不是靠猜的流程

  1. 先貼真實樣本 — 在寫樣式之前,先把實際輸入放進測試區。對著想像出來的文字寫的樣式,只會通過想像出來的測試。
  2. 一個 Token 一個 Token 疊 — 加一點、看高亮、再加下一點。UrsaKit 測試器會解釋每個 Token,(?:\s+)? 就不再是天書,而是「可選的空白群組」。
  3. 刻意決定錨點 — 想清楚你要的是「字串中任何位置」還是「整個字串」,再決定要不要加 ^ / $。
  4. 測那些「差一點就中」的輸入 — 餵進不該匹配的資料:多一個字元、空格換成連字號、結尾多一個換行。bug 都藏在失敗案例裡。
  5. 檢查旗標 — g、m、s 改變結果的頻率比樣式本身還高。
  6. 從參考表點擊插入,不要重打 — 在參考面板點 Token 會插入到光標位置,可以避開從文件複製樣式帶來的跳脫錯誤。

樣式符合預期之後,用 Diff 比對工具 對照處理前後的文字——正則偷偷吃掉一個字元這種事,用比對看得最清楚。

08資料來源

常見問題

捕獲群組與非捕獲群組差在哪?

捕獲群組 (foo) 會把匹配到的內容存起來供你讀取;非捕獲群組 (?:foo) 只是把樣式集合起來給量詞或分支使用,不會出現在結果裡。

前後查找會消耗它檢查的文字嗎?

不會。前後查找只是檢查前方或後方的文字,引擎仍然停在原位,同一批字元之後還能被樣式的其他部分匹配。

後向查找(lookbehind)到處都支援嗎?

JavaScript 從 ES2018 開始支援,現代瀏覽器與 Node.js 都有;但較舊的引擎與部分非 JavaScript 執行環境會拒絕,或限制可變長度的後向查找。

為什麼我的正則會把瀏覽器卡死?

像 (a+)+ 這種巢狀量詞,會讓引擎嘗試指數級的分割方式。改寫樣式,讓每個字元只有唯一一種消耗方式即可。

什麼情況不該用正則?

巢狀或遞迴結構,例如 JSON、HTML 或程式語言本身。那些需要解析器,不是樣式。

g 旗標改變了什麼?

沒有 g 時引擎只回傳第一個匹配,有 g 則持續掃描。匹配行為本身不變,但同一個帶 g 的 RegExp 物件會保留 lastIndex,這就是重複使用時結果出乎意料的原因。

返回文章列表

相關文章

相關工具