Regex JavaScript: cờ, nhóm bắt, lookahead, quay lui thảm họa

Thử biểu thức chính quy JavaScript với cờ g i m s u v y, nhóm bắt và nhóm có tên, lookahead, thay thế bằng $1, và tránh quay lui thảm họa như (a+)+$.

Trong bài này

Để thử một biểu thức chính quy JavaScript, hãy gõ biểu thức không kèm dấu gạch chéo, bật các cờ cần dùng, dán một đoạn văn bản mẫu, rồi xem phần nào khớp và mỗi nhóm bắt đã lấy được gì. Một công cụ thử tốt tô sáng các chuỗi khớp ngay khi bạn gõ, liệt kê các nhóm cùng vị trí và cho xem trước kết quả của phép thay thế. Hướng dẫn này nói về bảy cờ và mỗi cờ đổi điều gì, nhóm bắt và nhóm có tên, lookahead và lookbehind, thay thế bằng $1, những cái bẫy trong mã mà ai cũng vấp một lần, và mẫu (a+)+$ có thể làm một chương trình đứng thật sự. Mọi ví dụ bên dưới đều được chạy trong JavaScript và cho đúng kết quả ghi ra.

Thử một biểu thức từng bước

  1. Mở công cụ thử regex và gõ biểu thức vào ô đầu tiên, không kèm dấu gạch chéo bao quanh. \d{4}-\d{2} là biểu thức; /\d{4}-\d{2}/g là dạng nó xuất hiện trong mã nguồn. Các cờ nằm ở những nút riêng.
  2. Bật các cờ bạn cần. g được bật sẵn để tìm mọi chuỗi khớp chứ không chỉ chuỗi đầu tiên.
  3. Dán văn bản cần tìm. Các chuỗi khớp được tô sáng bằng hai màu xen kẽ để các chuỗi liền kề vẫn phân biệt được.
  4. Đọc bảng kết quả: mỗi chuỗi khớp với vị trí bắt đầu và kết thúc, và một dòng cho mỗi nhóm bắt. Nhóm không tham gia vào lần khớp được ghi rõ như vậy, khác với nhóm khớp ra một chuỗi rỗng.
  5. Để viết lại văn bản, điền vào ô "Thay bằng" và đọc phần xem trước.

Công cụ dùng chính bộ máy regex của trình duyệt bạn, nên đó là phiên bản JavaScript (ECMAScript). Đó là phiên bản đúng cho JavaScript, TypeScript và Node.js. Python, PHP, Java và .NET giống nhau ở hầu hết cú pháp thường dùng nhưng không phải tất cả: ví dụ Python viết nhóm có tên là (?P<name>...) còn JavaScript viết (?<name>...).

Các cờ

CờTênTác dụngVí dụ
gtoàn cụcTìm mọi chuỗi khớp, không dừng ở chuỗi đầu"a-a-a".replace(/a/g, "x") cho x-x-x; không có g cho x-a-a
ikhông phân biệt hoa thườngChữ cái khớp ở cả hai dạng/việt/i khớp VIỆT
mnhiều dòng^ và $ khớp ở mọi chỗ xuống dòng/^b$/m tìm thấy b trong "a\nb"; không có m thì không thấy gì
sdotAllDấu . khớp cả dấu xuống dòng/a.b/s.test("a\nb") là true; không có s là false
uunicodeLàm việc theo điểm mã; cho phép dùng \p{...}/^.$/u.test("😀") là true; không có u là false
vtập unicodeBản kế nhiệm chặt chẽ hơn của u với phép toán trên tập/[\p{L}--[a-z]]/v khớp é nhưng không khớp e
ydính (sticky)Chỉ khớp đúng tại vị trí hiện tại/foo/y thất bại trên "barfoo" cho đến khi lastIndex là 3

Hai lưu ý. Ký hiệu tắt \w chỉ gồm chữ cái ASCII, chữ số và dấu gạch dưới, nên /\w+/.exec("Việt Nam")[0] chỉ ra Vi. Để lấy chữ cái của mọi ngôn ngữ hãy dùng /\p{L}+/u, khớp trọn từ Việt. Và vị trí được đếm theo đơn vị mã UTF-16, đúng cách index của JavaScript hoạt động, nên một emoji không có cờ u chiếm hai vị trí. Cờ v mới hơn; trình duyệt chưa hỗ trợ sẽ hiện nút đó ở trạng thái bị khóa.

Nhóm bắt

Dấu ngoặc đơn tạo ra một nhóm bắt, được đánh số từ 1 theo vị trí của dấu ngoặc mở. (\w+), (\w+) trên Doe, John bắt Doe ở nhóm 1 và John ở nhóm 2. Thay bằng $2 $1 bạn được John Doe. Dùng (?:...) khi cần gom nhóm mà không bắt, để việc đánh số đơn giản hơn.

Nhóm không tham gia là undefined, không phải chuỗi rỗng: /(a)?b/.exec("b") cho ["b", undefined]. Đó là sự khác biệt mà công cụ hiển thị giữa "không tham gia" và "rỗng".

Nhóm có tên

Tên tốt hơn số khi biểu thức dài ra. Với (?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}) trên 2026-10-13, các nhóm là year = 2026, month = 10 và day = 13. Thay bằng $<day>/$<month>/$<year> thì văn bản thành 13/10/2026. Bên trong biểu thức, \k<name> lặp lại một nhóm có tên: /(?<c>\w)\k<c>/ tìm thấy cặp chữ lặp ll trong hello.

Lookahead và lookbehind

Lookaround kiểm tra những gì nằm cạnh chuỗi khớp mà không đưa chúng vào kết quả.

  • (?=...) nhìn trước dương: phần theo sau phải khớp.
  • (?!...) nhìn trước âm: phần theo sau không được khớp.
  • (?<=...) nhìn sau dương: phần đứng trước phải khớp.
  • (?<!...) nhìn sau âm: phần đứng trước không được khớp.

Hai trường hợp hữu ích. Thêm dấu phân cách hàng nghìn: \B(?=(\d{3})+(?!\d)) với cờ g và phần thay thế là , biến 1234567 thành 1,234,567, và 1234567.891 thành 1,234,567.891. Chỉ lấy số đứng sau dấu đô la: (?<=\$)\d+ tìm thấy 45 trong price: $45, cost: €30 và bỏ qua 30. Nhiều lookahead xếp chồng còn kiểm tra được nhiều điều kiện cùng lúc, như ^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$, chấp nhận Abcdef12 và từ chối abcdef12. Đó chỉ là minh họa, không phải chính sách mật khẩu tốt; độ dài quan trọng hơn loại ký tự.

Thay thế

Trong phần văn bản thay thế, $1 đến $99 là các nhóm đánh số, $<name> là nhóm có tên, $& là toàn bộ chuỗi khớp và $$ là một dấu đô la thật. Ba kết quả khiến nhiều người bất ngờ:

  • "cat".replace(/a/, "[$&]") cho c[a]t.
  • "ab".replace(/(a)/, "$2") cho $2b, vì không có nhóm 2 nên chữ được giữ nguyên.
  • "ab".replace(/(?<x>a)/, "$<y>") cho b, vì tên không tồn tại được thay bằng không gì cả.

Không có cờ g thì chỉ chuỗi khớp đầu tiên được thay, đó là lý do phổ biến nhất khiến phép thay thế "không hoạt động".

Tham lam, lười và chuỗi rỗng

Các lượng từ mặc định là tham lam: lấy nhiều nhất có thể. Trên <b>bold</b> and <i>italic</i>, mẫu <.+> tìm ra một chuỗi khớp, là cả dòng. Thêm dấu hỏi và <.+?> trở nên lười, tìm ra bốn chuỗi: <b>, </b>, <i> và </i>.

Một mẫu có thể khớp với không gì cả, như x*, khớp một chuỗi rỗng ở mọi vị trí. Trên ab với g nó khớp ba lần, ở vị trí 0, 1 và 2, và phép thay bằng - cho -a-b-. Công cụ đánh dấu các chuỗi rỗng bằng một vạch mảnh.

Những cái bẫy trong mã

  • `lastIndex` và cờ `g` hoặc `y`. Một đối tượng regex có g nhớ chỗ nó đã dừng. const re = /a/g; re.test("a"); re.test("a"); cho true rồi false. Hãy tạo regex mới, đặt lại re.lastIndex = 0, hoặc bỏ g khi bạn chỉ cần câu trả lời có hay không.
  • `matchAll` yêu cầu `g`. "aa".matchAll(/a/) ném TypeError. Với g, [..."a1b22".matchAll(/\d+/g)] cho 1 ở vị trí 1 và 22 ở vị trí 3.
  • Quên thoát ký tự. . khớp mọi ký tự, nên hãy viết \. cho dấu chấm thật. Tương tự với ( ) [ ] { } + ? ^ $ | \.
  • Dán cả dấu gạch chéo và cờ vào biểu thức. Trong công cụ chỉ gõ phần nằm giữa hai dấu gạch chéo.

Quay lui thảm họa

Có những mẫu mà mỗi ký tự thêm vào làm thời gian chạy tăng theo cấp số nhân. Kinh điển là ^(a+)+$. Khi thử trên một chuỗi chữ a kết thúc bằng b, bộ máy thử mọi cách chia các chữ a giữa phép lặp trong và phép lặp ngoài rồi mới chịu bỏ cuộc. Đây là thời gian đo trong Node.js 22, dùng cùng bộ máy V8 như Chrome, trên một máy tính xách tay:

Mẫu, với văn bản aaa…ab24 ký tự26 ký tự28 ký tự
^(a+)+$83 ms334 ms1.320 ms
(a+)+$ (không có ^)166 ms647 ms2.632 ms
^a+$dưới 0,1 msdưới 0,1 msdưới 0,1 ms

Thời gian trên máy bạn sẽ khác, nhưng quy luật thì không: mỗi ký tự thêm vào làm khối lượng việc tăng gần gấp đôi. Điều tương tự xảy ra với ^(a|a)+$ và ^(a*)*$ trên chuỗi a kết thúc bằng b, và với ^(\w+\s?)+$ trên một từ dài theo sau là dấu chấm than; ở 26 ký tự chúng mất từ 330 đến 650 ms. Trên máy chủ, đây là một lỗ hổng từ chối dịch vụ có thật, gọi là ReDoS, và chỉ cần người dùng gửi lên văn bản hoặc biểu thức là đủ.

Cách tránh:

  • Bỏ phép lặp lồng nhau. ^a+$ cho cùng kết quả với ^(a+)+$ trong vài micro giây.
  • Đừng lặp các lựa chọn chồng lấn. (a|a)+, (a|aa)+ và (\w|\d)+ buộc bộ máy chọn giữa các phương án cùng khớp một đoạn.
  • Giới hạn số lần lặp, như {1,20}, và giới hạn độ dài văn bản bạn thử.
  • Mô phỏng nhóm nguyên tử. ^(?=(a+))\1$ lấy các chữ a một lần, không có đường trả lại, và thất bại trên 5.000 chữ a theo sau là b trong chưa đến một mili giây.
  • Với biểu thức không đáng tin, dùng bộ máy thời gian tuyến tính như RE2, đừng dùng bộ máy quay lui.

Công cụ chạy mỗi lần tìm trong một worker nền và dừng nó sau 2 giây kèm thông báo, nên thẻ trình duyệt vẫn mượt và bạn phát hiện được vấn đề trước khi nó lên production.

Những lỗi nên tránh

  • Dùng regex để phân tích HTML hoặc kiểm tra mọi địa chỉ email hợp lệ. Một mẫu dùng để tìm ứng viên thì ổn; thẻ lồng nhau và đầy đủ cú pháp email cần một trình phân tích thật.
  • Mong ^ và $ làm việc theo từng dòng mà không có m.
  • Quên g khi thay thế.
  • Cho rằng \d và \w bao trùm mọi ngôn ngữ. Không phải vậy.

Để xem chính xác phép thay thế đã đổi gì, hãy dán bản trước và bản sau vào công cụ so sánh văn bản; bài so sánh hai văn bản giải thích cách đọc kết quả. Với việc dọn dẹp đơn giản không cần mẫu, bộ công cụ văn bản gọn hơn.

Danh sách kiểm tra ngắn

Gõ biểu thức không kèm dấu gạch chéo, bật g để lấy mọi chuỗi khớp, dùng u cho văn bản không phải tiếng Anh, đặt tên nhóm khi biểu thức có hơn hai nhóm, kiểm tra lastIndex trong mã, và thử mọi mẫu có phép lặp lồng nhau trên một chuỗi dài không khớp trước khi tin nó.

Công cụ được nhắc trong bài

Về trang blog