AI 協助揭露:本文由 AI 協助撰寫。所有 JavaScript 範例均已透過搭配的 Node.js 驗證腳本執行;未進行人工編輯審查。

一個正規表達式在獨立使用時可能正確,但在應用程式中卻可能失敗。失敗可能來自旗標、Unicode 表示法、用來使用該表達式的 API,或是共用 RegExp 物件的可變狀態。

這就是為什麼「試另一個模式」通常不是好的除錯策略。更好的做法是將錯誤轉換為一個小型測試矩陣。

這個矩陣有四個有用的軸向:

軸向 要測試的問題
輸入 是否包含確切的失敗字串,包括空白字元、正規化,以及隱藏碼位?
模式與旗標 使用 ugimsy 時,會有什麼變化?
消費者 API 我們呼叫的是 testexecmatchAll 還是 replace
預期結果與狀態 應該產生哪些符合、群組、替換文字,以及 lastIndex 值?

這會將「正規表達式在某處有效,但在另一處無效」這類軼事,轉換為可重現的陳述,例如:

在這個確切的輸入下,/^.$/ 失敗,/^.$/u 成功,且輸入包含一個由兩個 UTF-16 碼元表示的 Unicode 碼位。

從可執行的矩陣開始

最小的實用測試工具就是資料加上斷言:

import assert from "node:assert/strict";

const cases = [
  {
    name: "an emoji needs Unicode code-point mode",
    run: () => /^.$/u.test("💡"),
    expected: true,
  },
  {
    name: "a decomposed character contains two code points",
    run: () => /^.$/u.test("e\u0301"),
    expected: false,
  },
  {
    name: "NFC normalization composes that character",
    run: () => /^.$/u.test("e\u0301".normalize("NFC")),
    expected: true,
  },
];

for (const testCase of cases) {
  assert.deepEqual(testCase.run(), testCase.expected, testCase.name);
}

Enter fullscreen mode Exit fullscreen mode

請保留矩陣中的原始失敗輸入。稍後再加入精簡的範例,但不要用可能無法重現該錯誤的簡潔字串來取代證據。

當輸入可疑時,請同時檢查碼元與碼位:

const input = "💡";

console.log(input.length);            // 2 UTF-16 code units
console.log(Array.from(input).length); // 1 Unicode code point

Enter fullscreen mode Exit fullscreen mode

這兩個長度說明了為什麼不帶 u 旗標的點號不代表「一個可見字元」。

Unicode 不只是加上 u 旗標

u 旗標會改變模式剖析,並讓 . 這類建構在 Unicode 碼位上運作,而非個別的 UTF-16 代理對半:

console.log(/^.$/.test("💡"));  // false
console.log(/^.$/u.test("💡")); // true

Enter fullscreen mode Exit fullscreen mode

它也啟用了 Unicode 屬性跳脫,這通常比手寫的長範圍更清楚:

const lettersOnly = /^\p{Letter}+$/u;

console.log(lettersOnly.test("東京Cafe")); // true
console.log(lettersOnly.test("東京2"));    // false

Enter fullscreen mode Exit fullscreen mode

u 不會正規化文字。預組字串 "é" 與分解字串 "e\u0301" 看起來相似,卻包含不同的碼位序列。請決定正規化是否屬於應用程式合約的一部分,然後明確測試該決定:

const oneCodePoint = /^.$/u;

console.log(oneCodePoint.test("é"));                  // true
console.log(oneCodePoint.test("e\u0301"));            // false
console.log(oneCodePoint.test("e\u0301".normalize("NFC"))); // true

Enter fullscreen mode Exit fullscreen mode

除非規格允許,否則請勿默默正規化識別碼、密碼、簽章或其他協定資料。正規化是一項資料決策,而非通用的正規表達式修復。

測試捕獲形狀,而非僅測試完整符合

成功的完整符合並不能證明下游的捕獲處理是正確的。選擇性捕獲可能為 undefined,而重複群組內的捕獲只會儲存最後一次捕獲的值。

const optional = /(?<word>[A-Za-z]+)(?:-(?<suffix>\d+))?/;
const match = optional.exec("alpha");

console.log(match.groups.word);   // "alpha"
console.log(match.groups.suffix); // undefined

const repeated = /(ab)+/.exec("abab");
console.log(repeated[0]); // "abab"
console.log(repeated[1]); // "ab", not an array of both repetitions

Enter fullscreen mode Exit fullscreen mode

對於每一個有意義的捕獲,請加入斷言。如果應用程式程式碼預期某個值存在,也請測試不存在的分支。

具名群組能讓矩陣更容易閱讀,因為斷言描述的是領域而非位置:

assert.equal(match.groups.word, "alpha");
assert.equal(match.groups.suffix, undefined);

Enter fullscreen mode Exit fullscreen mode

替換是獨立的行為

符合與替換在矩陣中應為不同的列。模式可能找到正確的範圍,但替換字串卻參照到錯誤的群組,或是錯誤處理選擇性群組。

具名捕獲可為替換範本提供有用的自我說明:

const person = /(?<first>\p{Letter}+)\s+(?<last>\p{Letter}+)/gu;
const input = "Ada Lovelace; Grace Hopper";
const output = input.replace(person, "$<last>, $<first>");

console.log(output);
// Lovelace, Ada; Hopper, Grace

Enter fullscreen mode Exit fullscreen mode

當替換需要條件時,請使用函式並斷言最終字串,而非僅測試正規表達式:

const version = /v(?<major>\d+)(?:\.(?<minor>\d+))?/g;

const output = "v2 v3.7".replace(version, (...args) => {
  const groups = args.at(-1);
  return groups.minor === undefined
    ? `${groups.major}.0`
    : `${groups.major}.${groups.minor}`;
});

console.log(output); // 2.0 3.7

Enter fullscreen mode Exit fullscreen mode

回呼引數清單較為不便,因此如果在正式環境程式碼中出現,請將該行為隔離在具名函式之後。

將全域正規表達式視為可變物件

gy 旗標會讓 RegExp 成為有狀態的。呼叫 testexec 會讀取並寫入 lastIndex

const shared = /\d+/g;
const input = "a1 b2";

console.log(shared.test(input), shared.lastIndex); // true, 2
console.log(shared.test(input), shared.lastIndex); // true, 5
console.log(shared.test(input), shared.lastIndex); // false, 0

Enter fullscreen mode Exit fullscreen mode

這常導致在模組層級的正規表達式跨請求或驗證呼叫重複使用時,產生交替的測試結果:

const hasNumber = /\d/g;

hasNumber.test("item 7"); // true
hasNumber.test("item 7"); // false: the same object starts from its old lastIndex

Enter fullscreen mode Exit fullscreen mode

三個可靠的修正方式是:

  1. 當操作是是非測試時,移除 g
  2. 為每次獨立的掃描建立新的 RegExp
  3. 刻意重設 lastIndex 並斷言狀態轉換。

若要收集所有符合,matchAll 通常更容易理解。它使用複製的正規表達式狀態進行迭代,並保持原始物件的 lastIndex 不變:

const digits = /\d+/g;
const values = [..."a1 b22".matchAll(digits)].map((match) => match[0]);

console.log(values);           // ["1", "22"]
console.log(digits.lastIndex); // 0

Enter fullscreen mode Exit fullscreen mode

還有一個值得測試的狀態邊界:零長度符合。若表達式在未消耗輸入的情況下成功,手動的 exec 迴圈可能會停滯。此時請在空符合後自行前進 lastIndex,或使用已在該情況下定義迭代行為的 API:

const positions = [..."ab".matchAll(/(?=\w)/g)].map((match) => match.index);
console.log(positions); // [0, 1]

Enter fullscreen mode Exit fullscreen mode

刻意比較 API

JavaScript 提供多種正規表達式消費者,因為它們回答不同的問題:

API 有用結果 狀態考量
re.test(input) 布林存在檢查 使用 gy 時會變更 lastIndex
re.exec(input) 帶有捕獲與索引的一次符合 使用 gy 時會變更 lastIndex
input.matchAll(re) 所有詳細符合的可迭代物件 需要 g;不會變更原始正規表達式
input.replace(re, value) 轉換後的字串 替換語意需要各自的斷言

當兩個環境出現歧異時,請先確認它們使用相同的輸入、旗標與 API。將一處的 test 與另一處的 matchAll 進行比較,並不是受控實驗。

若要在建置矩陣時使用瀏覽器本機的草稿區,RegexRegex JavaScript 指南與即時檢查器 可同時顯示符合、捕獲群組、替換預覽與錯誤。這是選用工具:本文中的每個範例皆可僅使用 Node.js 重現。

實用的除錯序列

以下是我用來處理正規表達式錯誤的工作流程:

  1. 擷取確切的輸入。保留跳脫字元、行結尾、正規化,以及周圍字元。
  2. 記錄確切的建構方式。字面量與 new RegExp(string) 有不同的跳脫層級。
  3. 凍結消費者 API。記錄正式環境呼叫的是 testexecmatchAll 還是 replace
  4. 建立最小的相關旗標矩陣。一次變更一個旗標,並說明該列存在的原因。
  5. 斷言完整符合與捕獲。包含選擇性與重複群組分支。
  6. 另外斷言替換輸出。正確的符合仍可能產生錯誤的文字。
  7. 針對 gy 斷言 lastIndex對同一個物件執行多次。
  8. 加入 Unicode 對抗案例。在領域允許時,加入星體字元、結合標記,以及非 ASCII 字母。
  9. 在重現後再最小化。保留原始案例作為永久的回歸測試。
  10. 在支援的執行環境中執行。引擎功能與 Unicode 資料在已部署的瀏覽器與 Node 版本之間可能有所不同。

目標不是測試每一個理論上的字串,而是讓每一個假設都變得可見。一旦輸入表示法、旗標、API 語意、捕獲、替換輸出與可變狀態成為獨立的列,大多數「神祕」的正規表達式失敗就會變成普通的失敗斷言。

保持矩陣可執行

將應用程式依賴的案例放入一個小型的 regex-matrix.mjs 檔案,使用 node:assert/strict,並讓程序在第一個未達預期的結果時以非零值退出。執行方式如下:

node regex-matrix.mjs

Enter fullscreen mode Exit fullscreen mode

請將預期結果與程式碼一起儲存,如此一來,稍後的正規表達式編輯就不會在未變更可執行預期的情況下悄悄改變行為。