> cừn.
HomeBlogNewsProjectsAbout
cừn.
~/blog
article.mdx

Regex

Regular Expression, công cụ tạo mẫu pattern nhằm tìm và xử lí chuỗi hiệu quả.

16/03/2025cừn.29 phút5,731 từ

Regex, một công cụ khá là hay mà tôi đã từng được học. Regex là viết tắt của Regular Expression (Biểu thức chính quy). Là các search pattern dùng thể thay thế, kiểm tra và thao tác với chuỗi văn bản. Một công cụ mạnh cho việc xử lý chuỗi.

Regex được hỗ trợ trên rất nhiều ngôn ngữ lập trình như JavaScript, Python, Java, Go,... Bạn chỉ cần học một lần thì có thể áp dụng nó trên nhiều ngôn ngữ khác (Tuy nhiên với mỗi ngôn ngữ sẽ có Engine Regex xử lý khác nhau nên sẽ có phần giống và khác nhau, nhưng sẽ không mất quá nhiều thời gian để làm quen).

Trong blog này mình sẽ dùng JavaScript để minh họa.

Có lẽ khi bạn review code hoặc đọc code của ai đó thì có thể sẽ nhìn thấy các đoạn mã dạng như sau: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9\s]).{8,}$/. Cái đoạn mã như mã code ngoài hành tinh này chính là Regex.

Regex được thầy dạy OOP của tui (thầy Quang, yêu thầy nhiều lắm vì thầy đã kéo điểm OOP của em lên) giới thiệu trong tiết học OOP và hướng dẫn cách dùng cơ bản.

Bạn có thể xem qua video này trước (đây cũng là video đầu tiên tui tìm về regex lúc học)

0. Regex giải quyết vấn đề gì ?

Giả sử bạn là 1 thằng dev (hoặc không phải dev). Bạn xây dựng chức năng đăng ký tài khoản.

Để đơn giản, form đăng ký của chúng ta chỉ có 2 trường là email và password.

Nếu từng đăng ký tài khoản trên các ứng dụng khác, chắc bạn cũng biết rằng email và password thường phải đáp ứng một số điều kiện nhất định. Ví dụ:

  • Email: Phải chứa ký tự @ và kết thúc bằng .com, .edu, .vn, ...
  • Password: Phải có ít nhất 8 ký tự.

Với 2 case này, bạn có thể sài luôn các hàm có sẵn trong JavaScript như .includes(), .endsWith(), .length,... và kết hợp chúng lại để kiểm tra.

javascript
1
2
3
4
5
6
7
8
const email = "hello@gmail.com";
const password = "hello123";
const validEmail = email.includes("@") && email.endsWith(".com");
const validPassword = password.length >= 8;
console.log(validEmail); // true
console.log(validPassword); // true

yeah, như nhập môn lập trình z.

Tuy nhiên, trong thực tế, với một doanh nghiệp hoặc app production, chắc chắn việc validate format như thế này không thể nào đủ dc vì bảo mật thông tin luôn là vấn đề quan trọng. Password quá đơn giản có thể bị tấn công như bruce force. Vì vậy họ sẽ yêu cầu chặt chẽ hơn về các điều kiện đặt mật khẩu, ví dụ như:

Phải có ít nhất 8 ký tự, bao gồm chữ cái thường, chữ cái hoa, số và ký tự đặc biệt.

Bạn vẫn có thể dùng các hàm chuỗi và vòng lặp để kiểm tra từng điều kiện, tuy nhiên nó sẽ:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function validPassword(password) {
let hasLowercase = false;
let hasUppercase = false;
let hasNumber = false;
let hasSpecialCharacter = false;
for (const char of password) {
if (char >= "a" && char <= "z") {
hasLowercase = true;
} else if (char >= "A" && char <= "Z") {
hasUppercase = true;
} else if (char >= "0" && char <= "9") {
hasNumber = true;
} else if (char !== " " && char !== "\t" && char !== "\n" && char !== "\r") {
hasSpecialCharacter = true;
}
}
return (
password.length >= 8 &&
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecialCharacter
);
}
// Demo
const password = "Hello@123";
console.log(validPassword(password)); // true

Đoạn code vẫn chạy đúng yêu cầu. Tuy nhiên, bạn phải khai báo 4 biến, duyệt qua từng ký tự, kiểm tra hàng loạt điều kiện rồi cuối cùng mới có thể xác định password có hợp lệ hay không. Code lồng if else rất nhiều và rối.

Với cùng điều kiện password, Regex just:

/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9\s]).{8,}$/

javascript
1
2
const valid = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9\s]).{8,}$/.test(password);
console.log(valid); // true

Thay vì tự viết từng câu điều kiện và vòng lặp, bạn chỉ cần định nghĩa một pattern và để Regex Engine thực hiện việc check.

Tất nhiên, Regex không phải lúc nào cũng tốt hơn cách xử lý chuỗi thông thường. Nhưng với những bài toán liên quan đến tìm kiếm, kiểm tra định dạng hay trích xuất dữ liệu theo quy luật, Regex sẽ là một công cụ cực kỳ hữu ích.

Đoạn regex trên nhìn cũng có vẻ chẳng dễ hiểu hơn là bao =)). Mỗi kí hiệu bên trong đó đều có ý nghĩa riêng, và khi kết hợp lại sẽ tạo thành một pattern hoàn chỉnh. Vậy để hiểu từng kí tự trong đó là gì thì đi tiếp nhé.

1. Cấu trúc của 1 biểu thức Regex

Trước hết để đi vào regex thì bạn cần biết qua cấu trúc của 1 biểu thức regex, có dạng:

zsh
1
/pattern/flags

Pattern: nằm giữa 2 dấu /, là chuỗi kí tự mình muốn tìm hoặc kiểm tra.

Flags: nằm sau dấu / thứ hai, là cờ báo hiệu cho engine cách tìm kiếm.

// note
Ghi chú
Flags không nhất thiết phải có (optional), ví dụ /apple/ vẫn là một regex hợp lệ.

Các flags thường dùng

Trong regex sẽ có các flags với các kí hiệu riêng cho từng loại để báo cho engine biết cách search của mẫu regex này.

FlagHiểu nôm na là
gTìm hết những chỗ khớp, không dừng ở chỗ đầu tiên. Ví dụ có 3 chữ apple thì lấy cả 3.
iKhông phân biệt viết hoa hay viết thường. Tìm apple thì Apple cũng khớp.
mKhi văn bản có nhiều dòng, cho ^ (đầu) và $ (cuối) xét từng dòng, thay vì chỉ xét đầu/cuối cả văn bản.
sBình thường dấu . khớp gần như mọi ký tự, trừ ký tự xuống dòng. Bật s thì nó khớp cả ký tự xuống dòng.
uGiúp regex hiểu ký tự Unicode như emoji đúng hơn. Những cú pháp như \p{L} cũng giúp tìm chữ cái ở nhiều ngôn ngữ.
// tip
nên biết
2 flags g và i là 2 flags hay dùng nhất trong các pattern regex. Bạn nên tập trung vào 2 flags này.

Với flags g, regex sẽ tìm tất cả những từ khớp với pattern. Còn không có flags g, regex sẽ dừng lại ở kết quả khớp đầu tiên.

javascript
1
2
3
4
5
6
const text = "apple apple and apple are delicious.";
const result = text.match(/apple/i);
console.log(result); // ['apple'] (chỉ 1 từ apple đầu tiên)
const allResult = text.match(/apple/g);
console.log(allResult); // ['apple', 'apple', 'apple']

Với flags i, khi tìm kiếm sẽ không phân biệt chữ hoa hay chữ thường. Ví dụ /apple/i sẽ khớp với Apple, apple, APPLE,...

javascript
1
2
3
4
const text = "Apple apple and APPLE are delicious.";
console.log(text.match(/apple/g)); // ['apple']
console.log(text.match(/apple/gi)); // ['Apple', 'apple', 'APPLE']

Regex sẽ không phân biệt thứ tự của flags khi bạn kết hợp chúng. Bạn viết /apple/gi hay /apple/ig đều giống nhau (Tính chất giao hoán).

Bạn có thể đọc đoạn regex /apple/gi thành lời là: tìm mọi chỗ trong câu có pattern là “apple” và không phân biệt hoa thường.

Demo trên web với regex `/apple/gi`Demo trên web với regex `/apple/gi`

2. Character Classes

Đi qua Flags nhẹ nhàng vậy thì giờ đi vào phần nặng hơn là Pattern. Pattern là phần quan trọng nhất của regex, nơi bạn định nghĩa các quy tắc để tìm kiếm và xác định những chuỗi ký tự cần khớp (matching).

// note
Dễ hiểu
Nếu Flags quyết định Regex sẽ tìm kiếm như thế nào, thì Pattern quyết định Regex sẽ tìm kiếm cái gì.

Nếu pattern chỉ là 1 từ đơn giản như apple thì quá đơn giản cho regex. Nhưng thực tế, không phải lúc nào chúng ta cũng biết chính xác chuỗi cần tìm là gì. Đôi khi bạn sẽ phải tìm kiếm những chuỗi có chung một đặc điểm nào đó, ví dụ:

  • Tìm tất cả các chữ số trong một chuỗi
  • Tìm tất cả các chữ cái thường trong một chuỗi
  • Tìm tất cả các ký tự đặc biệt trong một chuỗi

Vậy bạn sẽ viết pattern như thế nào khi những gì bạn biết mới chỉ là các ký tự cố định? Bạn sẽ liệt kê từng ký tự một? =)))

Vì vậy Regex cung cấp cho bạn các kí hiệu đặc biệt để bạn biến các pattern bình thường trở nên linh hoạt hơn khi tìm kiếm.

2.1 Character Set: Matches any character in the set.

  • Syntax: [abc]:

  • Mô tả: Gồm 1 cặp ngoặc vuông. Các kí tự nằm trong cặp ngoặc vuông này [abc] được xem là 1 ký tự riêng biệt. ([a, b, c]). Khi tìm kiếm, chỉ cần 1 ký tự trong tập hợp này xuất hiện là có thể thỏa mãn.

Với đoạn regex /b[eo]r/g thì ta có tập hợp là [eo]. Các kí tự trong set được xem là 1 ký tự nên ta xem set này chứa 2 kí tự e và o. Vì vậy những từ ber và bor sẽ thỏa mãn tập hợp.

zsh
1
/b[eo]r/g
javascript
1
2
3
4
const text = "bar ber bir bor bur"
const result = text.match(/b[eo]r/g)
console.log(result) // [ber, bor]

2.2 Negated Character Set: Matches any character not in the set.

Trường hợp này là trường hợp ngược lại với case Character Set ở trên.

  • Syntax: [^abc]

  • Mô tả: Cũng là 1 cặp ngoặc vuông, tuy nhiên có thêm dấu ^ ở đầu kèm với tập kí tự. Chỉ cần ký tự đó không thuộc tập hợp các kí tự trong [abc] là thỏa mãn.

Kiểu toán học là lấy tập hợp A gồm tất cả các chữ cái, sau đó loại bỏ đi tập hợp B gồm các chữ cái trong set [abc] thì sẽ còn lại tập hợp C = A - B. Tập hợp C này chính là những ký tự thỏa mãn pattern [^abc].

Ở ví dụ trên, regex /b[eo]r/g trong tập hợp [] chỉ chứa e và o, tuy nhiên có dấu ^ nên nó sẽ mang ý nghĩa là không chứa từ e và o nên những từ ber và bor đều bị loại. Chỉ còn lại bar, bir, bur là thỏa mãn pattern.

zsh
1
/b[^eo]r/g
javascript
1
2
3
4
const text = "bar ber bir bor bur"
const result = text.match(/b[^eo]r/g)
console.log(result) // [bar, bir, bur]

2.3 Range: Matches all characters between two characters, including themselves.

Syntax: [a-z] (hoặc [A-Z], [0-9],...)

Thay vì phải liệt kê tất cả ký tự kiểu [abcdefghijklmnopqrstuvwxyz] với Set trong có vẻ dài dòng thì bạn có thể dùng range: [a-z] với kí tự đầu tiên là kí tự bắt đầu của range và kí tự thứ 2 là kí tự kết thúc của range (bao gồm cả 2 kí tự đầu và cuối).

  • [a-z]: Chữ cái thường từ a đến z.
  • [A-Z]: Chữ cái hoa từ A đến Z.
  • [0-9]: Chữ số từ 0 đến 9.

Kết hợp lại ta có: [a-zA-Z0-9] là khớp với các kí tự từ a tới z, A tới Z và chữ số từ 0 đến 9.

Lưu ý là sẽ không hỗ trợ các kí tự tiếng việt như: "ă ư ê" nhé, kể cả khi bạn bật flag u.

[e-i] thì sẽ khớp với các ký tự: e, f, g, h, i.

zsh
1
/[a-z]/g

2.4 Dot: Matches any character except line breaks.

  • Syntax: . (Đơn giản là 1 dấu chấm) đại diện cho 1 ký tự bất kỳ (chữ cái, chữ số, dấu cách, ký tự đặc biệt...), ngoại trừ các kí tự kết thúc dòng (line terminators) \n (trừ khi bật flag s).
zsh
1
/h.h./g # h_h_

Ví dụ với pattern /h.h./g thì dấu . sẽ đại diện cho 1 ký tự bất kỳ, nên những từ có thể thỏa mãn pattern này là: haho, haha, hihi, hoho, huhu, h1h3, ...(Bất kì kí tự nào trừ dấu \n).

2.5 Escape:

Trong regex, các ký tự sau đều có ý nghĩa đặc biệt: . * + ? ( ) [ ] { } ^ $ | \ /

Nếu bạn muốn pattern trong regex lấy được kí tự đặc biệt đó mà regex không nhận chúng là 1 chức năng của expression, hãy đặt \ (escape) trước kí tự đó. Regex sẽ xem đó là 1 kí tự bình thường mà không thực thi chức năng của chúng.

Ví dụ:

javascript
1
2
3
const text = "Visit google.com or github.com";
const result = text.match(/\./g);
console.log(result); // [".", "."]

Nếu không escape ., regex sẽ hiểu . là toán tử Dot (khớp với mọi kí tự trừ \n) chứ không phải ký tự dấu chấm (.) trong văn bản, dẫn đến kết quả không như mong đợi.

2.6 Digit and Not Digit: Matches any digit character and vice versa.

Ký hiệuTương đươngÝ nghĩaPhủ địnhÝ nghĩa phủ định
\d[0-9]Chữ số (Digit)\DKý tự không phải số
zsh
1
2
/[0-9]/g
/\d/g

Với \d thì regex chỉ detect được các ký tự là chữ số như trên hình, hoàn toàn không detect được các ký tự a, b, c,...

Với \D thì ngược lại, các chữ số sẽ bị regex bỏ qua, và chỉ detect được các ký tự a, b, c,... và các ký tự đặc biệt khác.

2.7 Word and Not Word: Matches any alphanumeric character. Including the underline and vice versa.

Ký hiệuTương đươngÝ nghĩaPhủ địnhÝ nghĩa phủ định
\w[a-zA-Z0-9_]Ký tự từ (Word char: chữ, số, _)\WKý tự không phải từ
zsh
1
2
/[a-zA-Z0-9_]/g
/\w/g

Như ví dụ trên, \w sẽ detect được các ký tự chữ cái, chữ số và dấu gạch dưới _. Hoàn toàn bỏ qua dấu cộng (+), trừ (-), dấu nhân (*),... và các kí tự khác.

Còn case này thì ngược lại, \W chỉ detect các kí tự không phải chữ cái, chữ số và dấu gạch dưới _ như +, -, *, @, #,...

2.8 Whitespace and Not Whitespace: Matches any whitespace character and vice versa.

Ký hiệuTương đươngÝ nghĩaPhủ địnhÝ nghĩa phủ định
\s\t \r \n \f \v Khoảng trắng (Space, Tab, Newline)\SKý tự không phải khoảng trắng, tab
zsh
1
2
/[ \t\r\n\f\v]/g
/\s/g

Ví dụ với \s thì regex sẽ detect được các ký tự khoảng trắng ở giữa các từ Hello, World, How, are, you. Hoàn toàn ko động tới các ký tự chữ cái.

Còn với \S thì regex sẽ detect được các ký tự chữ cái Hello, World, How, are, you. Hoàn toàn bỏ qua các ký tự khoảng trắng.

Lưu ý:

Mỗi lần viết \w, \d hay \s, regex chỉ khớp một ký tự, không tự gom các ký tự đứng cạnh nhau.

Ví dụ với chuỗi 42, một \d chỉ khớp 4 hoặc 2 ở mỗi lượt tìm, chứ không khớp nguyên 42. Muốn lấy cả 42 thành một kết quả (2 số liên tiếp) thì phải viết \d\d; muốn lấy 3 chữ số liên tiếp thì viết \d\d\d. Tới 10 chữ số (ví dụ kiểm tra SĐT) thì bạn nên đếm lại xem mình đã gõ đủ \d chưa =))).

\d\d\d\d\d\d\d\d\d\d\d

Vì vậy, regex cung cấp các bộ định lượng (Quantifiers) để xác định số lần xuất hiện của một ký tự hoặc một nhóm ký tự liền kề, thay vì phải viết lặp lại nhiều \d hay \w.

3. Quantifiers

Như đã đề cập ở trên, Quantifiers (bộ định lượng) sẽ cho phép bạn quy định số lần xuất hiện của 1 kí tự hoặc 1 nhóm kí tự các ký tự, thay vì phải viết nhiều \d hay \w liên tiếp.

Điểm cần nhớ là bộ định lượng chỉ áp dụng cho kí tự hoặc nhóm kí tự ngay trước nó (ngay trước kí tự định lượng).

// info
Ví dụ
trong be+r, dấu + chỉ xác định số lần lặp cho chữ e, chứ không lặp cả cụm be. Phần đứng trước có thể là một ký tự, một tập ký tự (range) như [a-z], hoặc một nhóm (group) như (ab).
Ký hiệuTênLặp bao nhiêu lần?Ví dụ
*Asterisk0 lần trở lênbe*r khớp br, ber, beer...
+Plus1 lần trở lênbe+r khớp ber, beer... nhưng không khớp br.
?Optional0 hoặc 1 lầncolou?r khớp color và colour.
{n}ExactĐúng n lầnbe{2}r khớp beer.
{min,max}RangeTừ min đến max lầnbe{1,3}r khớp ber, beer, beeer.

3.1 Asterisk: Expression matches zero or more.

Syntax: *

Trong /be*p/g, dấu * nằm ngay sau e, nghĩa là chữ e có thể xuất hiện 0 lần hoặc nhiều lần. Vì vậy bp vẫn khớp, rồi bep, beep, beeep cũng khớp.

3.2 Plus: Expression matches one or more.

Syntax: +

Plus và Asterisk same same nhau, chỉ khác là Plus bắt buộc phải có ít nhất 1 lần xuất hiện, còn Asterisk thì có thể không cần xuất hiện.

zsh
1
/be+p/g

Lúc này bep, beep, beeep đều khớp. Riêng bp thì bị loại vì thiếu e.

3.3 Optional: Makes the expression optional.

Syntax: ?

Với Optional, bạn có thể cho phép ký tự đứng trước có hoặc không có, nghĩa là ký tự đó có thể xuất hiện 0 hoặc 1 lần.

Ví dụ với case tìm cả color lẫn colour:

zsh
1
/colou?r/g

Dấu ? đứng sau u, nên chữ u có thể xuất hiện 0 hoặc 1 lần. Có u thì ra colour, không có thì ra color.

3.4 Range Quantifiers: Expression matches within specified ranges.

Với +, * và ? đều có range cố định là O hoặc nhiều lần, 1 hoặc nhiều lần và 0 đến 1 lần.

Range Quantifiers cho phép bạn xác định số lần lặp của một ký tự hoặc nhóm ký tự trong một khoảng nhất định.

Syntax: {min,max} để chỉ rõ khoảng lặp:

zsh
1
/be{1,2}p/g

Ở đây, e phải xuất hiện từ 1 đến 2 lần, nên bep, beep đều khớp. bp và beeep thì không đủ vì chứa nhiều chữ e hơn range cho phép.

  • Muốn đúng 3 lần thì viết {3}: /be{3}r/g khớp beeer.
  • Muốn ít nhất 2 lần và không giới hạn tối đa thì viết {2,}: /be{2,}r/g.
  • Còn muốn tối đa 10 lần thì ghi rõ điểm bắt đầu là 0 và cho max là 10: /be{0,10}r/g.

4. Greedy vs Lazy Matching

Nếu bạn ghép hai thứ vừa học: \d đại diện cho một chữ số, còn định lượng + yêu cầu phần đứng ngay trước nó lặp ít nhất 1 lần. Ghép lại thành /\d+/, ta sẽ tìm được một dãy chữ số đứng liền nhau. Ví dụ 123 thì khớp cả dãy; còn trong 12-3, dấu - ngắt dãy số ra làm hai (12 và 3).

Nhưng nếu có một dãy 9 chữ số liên tiếp thì /\d+/ nên lấy 1 số, hay lấy cả 9? Cả hai đều thỏa “ít nhất 1 lần” của toán tử +.

Greedy và Lazy là cách regex quyết định sẽ ưu tiên lấy nhiều hay ít trước.

4.1 Greedy

Mặc định, các bộ định lượng như *, +, {2,5} đều greedy: chúng thử lấy nhiều ký tự nhất có thể. Nếu phần regex phía sau không khớp, chúng mới nhả bớt để thử lại.

  • Greedy: Ưu tiên khớp nhiều nhất có thể, nhưng vẫn phải đảm bảo toàn bộ pattern khớp thành công.
javascript
1
2
3
const text = "Mã đơn hàng: 123456789";
console.log(text.match(/\d+/)?.[0]); // "123456789"

Ở đây, \d+ gặp dãy số liền nhau nên lấy luôn cả 123456789. Có thể hình dung nó kiểu “cứ lấy hết đã, thiếu gì tính sau” =))).

4.2 Lazy

Thêm ? sau các bộ định lượng sẽ đổi nó sang Lazy: *?, +?, {2,5}?... Lúc này regex thử lấy ít nhất có thể; nếu phần phía sau chưa khớp thì mới lấy thêm. Dấu ? khi đặt ở đây sẽ khác với dấu ? định lượng (Optional) ở phần 3 nhé.

  • Lazy: Ưu tiên khớp ít nhất có thể, nhưng vẫn phải đảm bảo toàn bộ pattern khớp thành công.
javascript
1
2
3
const text = "Mã đơn hàng: 123456789";
console.log(text.match(/\d+?/)?.[0]); // "1"

\d+? chỉ cần 1 chữ số là đã đủ điều kiện “ít nhất 1”, nên nó dừng ở 1. Đến đây Lazy nghe có vẻ hơi... lười thật. Nhưng thử một trường hợp khác sẽ thấy nó có ích.

Giả sử bạn có một chuỗi chứa hai từ được đặt trong dấu ngoặc kép và muốn lấy riêng từng từ:

javascript
1
2
3
4
5
6
7
const text = '"Hello" "World"'; // "Hello" "World"
console.log(text.match(/".+"/g));
// ['"Hello" "World"']
console.log(text.match(/".+?"/g));
// ['"Hello"', '"World"']

Greedy .+: Cố gắng khớp nhiều ký tự nhất có thể. Vì vậy, nó lấy từ dấu " đầu tiên đến dấu " cuối cùng, khiến "Hello" "World" trở thành một kết quả duy nhất.

Lazy .+?): Cố gắng khớp ít ký tự nhất có thể, miễn là pattern vẫn hợp lệ. Vì vậy, nó dừng lại ngay khi gặp dấu " đóng đầu tiên. Kết hợp với flag g, Regex tiếp tục tìm và lấy từ còn lại là "World".

Nói đơn giản, Greedy thì tham lam, muốn lấy càng nhiều càng tốt, còn Lazy thì tiết kiệm, chỉ lấy vừa đủ để thỏa mãn pattern. =)))

5. Anchors & Boundaries

Tới đây bạn đã biết viết pattern như thế nào, tìm ký tự nào và pattern đó lặp bao nhiêu lần. Nhưng bạn muốn check một từ nào đó xem có ở đầu câu, cuối câu, hay đứng riêng thành một từ thì sao? Lúc này cần thêm mấy ký hiệu mà Regex hỗ trợ để chỉ vị trí.

Đặc biệt là các kí hiệu hỗ trợ này sẽ không kiểm tra xem pattern có khớp thêm chữ nào không, mà nó sẽ kiểm tra xem regex đang đứng đúng chỗ chưa.

5.1 Anchors: ^ & $

^ đánh dấu đầu chuỗi, còn $ đánh dấu cuối chuỗi. Đặt chúng ở đâu thì pattern phải khớp tại vị trí đó:

javascript
1
2
3
4
5
console.log("cat naps".match(/^cat/)?.[0]); // "cat"
console.log("my cat".match(/^cat/)); // null: cat không ở đầu
console.log("my cat".match(/cat$/)?.[0]); // "cat"
console.log("cat naps".match(/cat$/)); // null: cat không ở cuối

Đọc^cat/ là “tìm cat ngay đầu chuỗi”, còn /cat$/ là “tìm cat ngay cuối chuỗi”.

Muốn cả chuỗi là đúng chữ cat thì kẹp cả hai bên: /^cat$/. Không có chuyện ^ hay $ xuất hiện trong kết quả; chúng chỉ đứng canh cửa để kiểm tra vị trí thôi.

Tuy nhiên, nếu bạn có một chuỗi gồm 5 dòng như sau:

text
1
2
3
4
5
dog
cat
bird
cat
whale

Bây giờ bạn muốn tìm từ cat nằm ở đầu một dòng bất kỳ, thay vì bắt buộc phải nằm ở đầu toàn bộ chuỗi.

Bạn sẽ viết là /^cat/g? Nó sẽ không ra gì hết, vì ^ mặc định chỉ xét vị trí đầu của toàn bộ chuỗi (trong trường hợp này là đầu dòng đầu tiên). Ở case này nó chỉ đang xét cho từ dog. ở đầu chuỗi thôi, và nó thấy không phải từ cat nên ko match. Những dòng dưới nó không xem đâu.

Tương tự như vậy cho $, nó chỉ xét vị trí cuối của toàn bộ chuỗi, chứ không xét vị trí cuối của từng dòng.

Vậy làm thế nào để Regex có thể xét vị trí đầu và cuối của từng dòng, thay vì chỉ xét toàn bộ chuỗi?

Đây là lúc cần quay lại với Flags một tí, để tìm hiểu kỹ hơn về một flag đã được nhắc đến ở phần 1: flag m (Multiline).

5.2 Flag m - Multiline:

Vì sao tới tận đây thì mới giới thiệu flag m thay vì giới thiệu chi tiết luôn ở phần 1 chung với 2 flag i và g? Lý do là vì flag m có liên quan trực tiếp đến cách hoạt động của hai Anchors ^ và $ mà bạn vừa đọc qua.

Thông thường, ^ và $ được sử dụng để xác định vị trí đầu và cuối của toàn bộ chuỗi. Nhưng với các đoạn văn bản nhiều dòng, Anchors không thể nhận diện được vị trí đầu/cuối của từng dòng, mà chỉ nhận diện được đầu/cuối của toàn bộ đoạn văn/chuỗi đó. Nhưng khi bật flag m và kết hợp với 2 Anchors trên, flag m sẽ thay đổi cách hoạt động của chúng, cho phép chúng khớp tại đầu và cuối của từng dòng, thay vì chỉ xét đầu và cuối toàn bộ chuỗi.

Ví dụ: Với case cat ở dòng đầu tiên, nó sẽ hoạt động đúng vì nó vẫn là đầu chuỗi văn bản (Vẫn hoạt động với nguyên tắc của toán tử ^). Tuy nhiên từ cat ở đầu dòng thứ tư sẽ không được nhận diện.

Bây giờ, khi bạn dời cat ở dòng đầu tiên xuống đầu dòng dưới, nó sẽ không còn hoạt động khi không có flag m.

Khi bạn thêm flag m, regex sẽ nhận diện được từ cat ở đầu của tất cả các dòng.

Tóm lại là:

  • Không có flag m thì anchors ^ và $ chỉ xét đầu/cuối toàn bộ chuỗi.
  • Có flag m thì anchors ^ và $ có thể xét cả đầu/cuối từng dòng.

5.3 Boundary:

\b dùng để xác định ranh giới của một từ, thường là vị trí giữa một từ với khoảng trắng, dấu câu hoặc mép chuỗi.

Còn \B thì ngược lại, nó xác định những vị trí không phải ranh giới từ, chẳng hạn vị trí nằm giữa các ký tự trong cùng một từ.

Ví dụ, bạn muốn tìm từ cat đứng riêng, chứ không phải những chữ cat nằm trong catch hay caterpillar:

javascript
1
2
3
4
const text = "The cat catches a caterpillar";
console.log(text.match(/cat/g)); // ["cat", "cat", "cat"]
console.log(text.match(/\bcat\b/g)); // ["cat"]

Hai dấu \b ở hai bên đảm bảo rằng trước và sau cat đều là ranh giới từ. Vì vậy, Regex chỉ tìm được từ cat đứng riêng, thay vì lấy cả những phần nằm trong từ khác.

Vậy còn \B thì sao?

Ngược lại với \b, \B dùng để kiểm tra vị trí không phải ranh giới từ. Ví dụ, nếu muốn tìm cat nằm bên trong một từ khác:

javascript
1
console.log("cat bobcat".match(/\Bcat/g)); // ["cat"]

Ở bobcat, ngay trước chữ cat là chữ b. Vì cả hai đều là ký tự thuộc từ nên vị trí giữa chúng không phải ranh giới từ, do đó \Bcat khớp.

Còn từ cat đứng riêng ở đầu chuỗi thì không khớp, vì ngay trước nó là ranh giới từ.

Tóm lại là,

  • \b để tìm ở mép từ (đứng cạnh khoảng trắng, dấu câu)
  • \B để tìm ở phần không phải mép từ.

6. Capturing Group, Alternation & Backreference

Ở phần định lượng, mình đã dùng \d+ để tìm một dãy số: dấu + lặp \d, mỗi lần thêm một chữ số. Nhưng + chỉ lặp phần đứng ngay trước nó. Nếu muốn lặp nguyên cụm ha để tìm hahaha thì sao? Nếu bạn viết ha+ là chưa đúng, vì lúc này chỉ chữ a được lặp (theo nguyên tắc bộ định lượng chỉ áp dụng cho ký tự ngay trước nó).

Đây là lúc bạn phải dùng đến dấu () (Gọi là Capturing Group) để gom ha lại thành một nhóm: (ha)+. Regex sẽ xem cả cụm như một đơn vị.

Nhưng ngoài việc lặp lại một nhóm ký tự, đôi khi bạn muốn Regex khớp với một trong nhiều pattern khác nhau.

Ví dụ, nếu muốn tìm từ cat hoặc dog trong một chuỗi thì sao? Bạn sẽ phải viết hai Regex riêng biệt?

Regex cung cấp một toán tử khá quen thuộc trong lập trình, đó chính là OR | (hay có cách gọi khác là Alternation), cho phép lựa chọn giữa nhiều pattern khác nhau. Toán tử này rất hay được sử dụng để kết hợp với Group.

6.1 Alternation (OR):

Dấu | có thể gọi là OR hoặc Alternation.

Khi bạn viết /dog|cat/g tức là bạn muốn tìm dog hoặc cat. Nếu câu có cả hai, flag g sẽ lấy cả hai kết quả:

javascript
1
2
console.log("I have a cat and a dog".match(/dog|cat/g));
// ["cat", "dog"]

Nhưng nếu chỉ muốn thay đổi một phần của từ thì sao? Ví dụ gray và grey chỉ khác chữ ở giữa. Mình đặt hai option đó vào () (Capturing Group) để dấu | chỉ áp dụng cho a và e:

zsh
1
/gr(a|e)y/g

Như này, regex sẽ khớp được với gray và grey, nhưng không khớp với gruy hay grby.

// warning
Bẫy nhỏ
nếu hai lựa chọn cùng khớp ở cùng một vị trí, regex sẽ thử từ trái sang phải và lấy lựa chọn khớp đầu tiên. Nó không tự chọn từ dài hơn đâu
javascript
1
2
console.log("apples".match(/app|apple/)?.[0]); // "app"
console.log("apples".match(/apple|app/)?.[0]); // "apple"

Muốn ưu tiên apple thì đặt nó trước app nhé.

6.2 Group (...)

Ở phần trên cũng đã có đề cập đến việc sử dụng Capturing Group. Kí hiệu () có khả năng gom nhiều ký tự thành một nhóm, để bộ định lượng áp dụng cho cả nhóm thay vì chỉ ký tự ngay trước nó:

javascript
1
2
3
const text = "hahaha ha h haha";
console.log(text.match(/(ha)+/g)); // ["hahaha", "ha", "haha"]

(ha)+ lặp cả cụm ha ít nhất một lần, nên khớp được hahaha. Nếu bạn viết ha+ thì dấu + chỉ lặp chữ a (tức là haaaaa thì mới dc chứ ko phải haha);

Và cũng có khả năng giữ lại phần mỗi nhóm đã khớp, để lấy riêng dữ liệu cần dùng. Ví dụ với một email đơn giản, để lấy phần trước và sau dấu @:

javascript
1
2
3
4
5
6
const email = "hello@gmail.com";
const result = email.match(/(\w+)@(\w+\.\w+)/);
console.log(result?.[0]); // "hello@gmail.com" (toàn bộ phần khớp)
console.log(result?.[1]); // "hello" (nhóm 1)
console.log(result?.[2]); // "gmail.com" (nhóm 2)

Hai cặp () được đánh số từ trái sang phải: 1 là hello, 2 là gmail.com. Còn [0] luôn là toàn bộ phần khớp, không phải nhóm đầu tiên. Thế là lấy được username và domain mà không cần tách chuỗi thêm lần nữa.

6.3 Backreference

Một trong những tính năng thú vị của Regex là Backreference (tham chiếu ngược). Thay vì chỉ kiểm tra xem một chuỗi có khớp với một mẫu nào đó hay không, Backreference còn cho phép Regex ghi nhớ dữ liệu đã khớp trước đó và yêu cầu dữ liệu đó phải xuất hiện lại chính xác ở vị trí tiếp theo.

// note
Dễ hiểu
Capturing group () sẽ bắt cái kí tự thỏa điều kiện của pattern lại, sau đó nếu có backreference \1, nó sẽ bắt buộc từ kế tiếp thõa mãn expression phải khớp với cái từ thỏa mãn trong capturing group.

Đây là kỹ thuật rất hữu ích khi cần phát hiện từ hoặc cụm từ bị lặp lại liên tiếp.

Ví dụ:

javascript
1
2
3
4
const str = "<h1>Tiêu đề</h1>";
const regex = /<(\w+)>.*?<\/\1>/;
console.log(regex.test(str)); // true

Đầu tiên: (\w+) sẽ tạo thành 1 Capturing Group mà khớp với ký tự, chữ số hoặc dấu gạch dưới ([A-Za-z0-9_]) và regex sẽ lưu lại phần vừa khớp đó. Ở đây sẽ là h1.

zsh
1
(\w+) # Capturing Group 1: khớp với h1

.*? là bất kỳ kí tự nào có thể xuất hiện 0 hoặc nhiều và nó sẽ ngắn nhất (Lazy matching). (khúc này cũng chả cần care lắm :v)

Tiếp theo, \1 được gọi ra -> lúc này regex mở group 1 ra và lúc này mình đang có thằng h1 đã lưu ở trên (\w+). Và group 1 yêu cầu vị trí nó đang đứng phải bắt buộc là h1.

Vì thỏa điều kiện nên câu regex trả về true, còn nếu không khớp sẽ trả về false.

Lưu ý về số thứ tự của Capturing Group

Backreference sử dụng số thứ tự của Capturing Group (\1, \2, \3, ...). Các nhóm này được đánh số theo thứ tự xuất hiện của dấu ( từ trái sang phải, kể cả khi chúng được lồng bên trong nhóm khác.

Ví dụ:

regex
1
((a)(b))

Regex sẽ đánh số như sau:

  • Group 1 → ((a)(b))
  • Group 2 → (a)
  • Group 3 → (b)

Điều này khiến các biểu thức Regex phức tạp trở nên khó đọc và rất dễ nhầm lẫn khi sử dụng Backreference.

Chính vì vậy, trong thực tế người ta thường sử dụng:

  • Non-capturing Group (?:...) khi chỉ cần nhóm các biểu thức mà không cần lưu kết quả.
  • Named Group (?<name>...) để đặt tên cho từng nhóm, giúp Regex dễ đọc và dễ bảo trì hơn so với việc phải nhớ \1, \2, \3,...

...

6.4. Non-capturing Group (?:...)

...

6.5. Named Capturing Group (?<name>...)

...

7. Lookaround (Lookahead & Lookbehind)

...

8. Các pattern thông dụng:

1. Validate username (chữ, số, _, dài 3-15 kí tự)

zsh
1
/^[a-zA-Z0-9_]{3,15}$/g

2. Validate Số Điện Thoại Việt Nam

zsh
1
/^(?:\+84|0)[35789]\d{8}$/g

3. Định Dạng Số Tiền Dấu Phẩy Hàng Nghìn (1000000 -> 1,000,000)

zsh
1
/\B(?=(\d{3})+(?!\d))/g

4. Parse Tham Số URL Query String Thành Object

zsh
1
/[?&]([^=&#]+)=([^&#]*)/g

5. Validate Mã Màu Hex (#FFFFFF)

zsh
1
/^#[0-9a-fA-F]{6}$/g

6. Validate password Mạnh (ít nhất 8 ký tự, có chữ hoa, chữ thường, số và ký tự đặc biệt)

zsh
1
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9\s]).{8,}$/

7. Validate Ngày tháng năm theo dạng dd/mm/yyyy

zsh
1
/^\d{2}\/\d{2}\/\d{4}$/g
// warning
Lưu ý

Các Regex trên chỉ dùng để kiểm tra cấu trúc (định dạng) của dữ liệu, không đảm bảo dữ liệu đó thực sự hợp lệ. Ví dụ, một email đúng định dạng chưa chắc đã tồn tại, hoặc một ngày tháng khớp với pattern chưa chắc là ngày hợp lệ (như 31/02/2026).

Vì vậy, bạn vẫn cần kết hợp thêm các bước xác thực khác cho ứng dụng.

9. Khi nào sử dụng Regex ?

Không phải lúc nào cũng luôn luôn sử dụng Regex. Nếu team bạn chả ai biết regex là gì, tụi nó sẽ stuck. Tụi nó sẽ phải đi ngồi giải mã đống ký tự ngoài hành tinh mà bạn vừa viết. Nếu đề yêu cầu tìm 1 chuỗi đơn giản, dùng regex sẽ phức tạp hóa vấn đề và làm bạn over-engineering.

Trong thực tế nếu cần maintain dự án, đôi khi sử dụng các function quen thuộc để xử lý sẽ giúp họ dễ đọc code và sửa chữa hơn (tuy nhiên giờ có AI rồi nên việc đọc code đôi khi không còn khó khăn nữa :v).

Vì vậy, lựa chọn công cụ để sử dụng đúng thời điểm là 1 điều quan trọng:

  • Tìm kiếm những pattern phức tạp: Khi cần tìm kiếm hoặc trích xuất những chuỗi có chung một quy luật, chẳng hạn số điện thoại, email, URL hay ngày tháng.

  • Validate dữ liệu nhiều điều kiện: Khi cần kiểm tra các ràng buộc về định dạng, chẳng hạn mật khẩu phải có chữ hoa, chữ thường, chữ số và ký tự đặc biệt.

  • Tìm kiếm và thay thế hàng loạt (Search & Replace): Khi cần tìm và thay thế những phần của văn bản khớp với một pattern nhất định, thay vì phải xử lý thủ công từng trường hợp.

  • Xử lý và làm sạch văn bản (Text Processing): Khi cần loại bỏ khoảng trắng dư thừa, chuẩn hóa định dạng, trích xuất số liệu hoặc tách những thành phần trong văn bản theo quy luật.

  • Trích xuất dữ liệu có cấu trúc đơn giản: Khi cần lấy thông tin từ log, tên file, mã định danh hoặc những chuỗi có định dạng cố định mà không cần sử dụng một thư viện xử lý riêng.

Cuối cùng, regex chỉ là 1 công cụ hỗ trợ bạn thôi, không phải là giải pháp cho mọi vấn đề. Hãy cân nhắc sử dụng regex khi nó thực sự mang lại lợi ích, và đừng quên rằng đôi khi những phương pháp truyền thống cũng có thể hiệu quả hơn.

Thanks for your reading!

16/03/2025

Cừn.

Chia sẻ bài viết

FacebookLinkedInX
$cd ..