記錄一次JavaScript正則詭異經歷

語言: CN / TW / HK

事情是這樣的,最近在寫一個Node功能的時候,遇到了一個正則的問題,覺得挺有意思的,就記錄一下經歷和最終問題原因,希望也能幫助到同樣遇到的同學。

背景

我有一個Node服務,希望對訪問進來的請求進行標記,如果請求進來的 path 是我定義的路由,那麼將標記一個 REQ ,否則標記一個 IVL ,用於對於整個服務的日誌記錄進行輸出。那麼我通過服務啟動時,根據定義的路由,生成一個 RouterMap ,通過訪問進入時,判斷 path 是否命中 RouterMap 來判斷是否預期訪問。

大概的程式碼如下:

export function getSourceMak(
  routerMap: AppRouterMap[],
  req: http.IncomingMessage,
): SourceMark.REQ | SourceMark.IVL | SourceMark.TST {
  const { url, method, headers } = req;
  const pathname = url.split('?')[0];
  const userAgent = headers['user-agent'];
  // 安全掃描
  if (userAgent?.includes('TST(Tencent') && userAgent.includes('Team)')) {
    return SourceMark.TST;
  }
  for (const item of routerMap) {
    const { reg } = item;
    if (reg.test(pathname) && item.method === method.toLocaleLowerCase()) {
      return SourceMark.REQ;
    }
  }

  return SourceMark.IVL;
}

因為涉及到一些動態路由的原因,不能直接通過 path 進行相等判斷,需要對相應的路由規則生成一個對應的正則表示式,並且在服務啟動時生成,儲存在記憶體中進行復用。

生成正常程式碼如下:

export function createRouterRegexp(url) {
  const urlBlock = url.split('/');
  const regBlock = urlBlock.map((block) => {
    if (block[0] === ':') {
      return '((?!/).)*';
    }
    return block;
  });
  return new RegExp(`^${regBlock.join('/')}$`, 'ig');
}

問題

然後在進行除錯的時候發現一個奇怪的現象,假設我有一個路由為 GET /cats/find 的路由,通過打點發現對應的正則表示式, /^\/cats\/find$/gi/cats/find 進行匹配的時候,第一次為true,第二次為false,第三次為true,第四次為false,以此類推。

經過反覆驗證,node程式碼並沒有存在問題,正則表示式也沒有問題,那麼我在瀏覽器中嘗試復現一下,也是得出同樣的問題。至此我很確定,一定是有一些正則相關的坑是我以前沒有注意到。於是我反查了一下JavaScript的文件,終於被我找到原因。

原因

通過查詢MDN正則相關的文件,被查到以下說明

"nolink">當設定全域性標誌的正則使用test()

如果正則表示式設定了全域性標誌, test() 的執行會改變正則表示式 lastIndex 屬性。連續的執行 test() 方法,後續的執行將會從 lastIndex 處開始匹配字串,( exec() 同樣改變正則本身的 lastIndex屬性值 ).

下面的例項表現了這種行為:

var regex = /foo/g; 
// regex.lastIndex is at 0 
regex.test('foo'); // true 
// regex.lastIndex is now at 3 
regex.test('foo'); // false

這不就是我遇到的問題嗎?

通過文件說明得知,當我們正則表示式帶有 g 標識進行全域性匹配時,匹配成功後, regex 例項中會有一個 lastIndex 屬性去記錄本次命中正則的最後一位的下標+1,用於在下一次呼叫 test 的時候,從 lastIndex 開始進行匹配。 以前我沒有遇到過大概率是因為以下原因:

每次進行正則校驗時,都重新生成正則例項: /^\/cats\/find$/gi.test('/cats/find')

但是因為這次我將正則例項儲存,並反覆使用。從而導致問題。

並且通過驗證得出,當匹配成功後, lastIndex 會記錄下一次開始的位置,但是當匹配失敗, lastIndex 會歸零從頭開始。

至此這一次被坑經歷耗時60分鐘左右,耽誤了吃飯最佳時間,導致飯堂菜都快沒有。但是同時也收穫到JavaScript在正則上一個容易被忽略的坑。好像也不虧。