前言
正則表達式是軟件領域爲數不多的偉大創作。與之相提並論是分組交換網絡、Web、Lisp、哈希算法、UNIX、編譯技術、關係模型、面向對象等。正則自身簡單、優美、功能強大、妙用無窮。
學習正則表達式,語法並不難,稍微看些例子,多可照葫蘆畫瓢。但三兩篇快餐文章,鮮能理解深刻。再遇又需一番查找,竹籃打水一場空。不止正則,其他技術點同樣,需要系統的學習。多讀經典書籍,站在巨人肩膀前行。
這裏涉及的東西太多,我就着重講日常開發中可能會用到的內容,如果像深入理解的話推薦翻閱書籍《精通正則表達式》
(所以簡單來說,學習正則就是投入高,收益低)(起初一看簡單易懂,深入瞭解過後感嘆正則的強大)
全文略長,可以選擇感興趣的部分看
1、介紹正則
正則表達式嚴謹來講,是一種描述字符串結構模式的形式化表達方法。起始於數學領域,流行於 Perl 正則引擎。JavaScript 從 ES 3 引入正則表達式,ES 6 擴展
對正則表達式支持。
正則原理
對於固定字符串的處理,簡單的字符串匹配算法(類KMP
算法)相較更快;但如果進行復雜多變的字符處理,正則表達式速度則更勝一籌。那正則表達式具體匹配原理是什麼?這就涉及到編譯原理的知識(編譯原理着實是我大三裏面最頭疼的課程了)
正則表達式引擎實現採用一種特殊理論模型:有窮自動機(Finite Automata
)也叫有限狀態自動機(finite-state machine
)具體的細節見文章底部的參考文檔
字符組
字符組 | 含義 |
---|---|
[ab] | 匹配 a 或 b |
[0-9] | 匹配 0 或 1 或 2 ... 或 9 |
1 | 匹配 除 a、b 任意字符 |
字符組 | 含義 |
---|---|
d | 表示 [0-9],數字字符 |
D | 表示 [^0-9],非數字字符 |
w | 表示 [_0-9a-zA-Z],單詞字符,注意下劃線 |
W | 表示 [^_0-9a-zA-Z],非單詞字符 |
s | 表示 [ tvnrf],空白符 |
S | 表示 [^ tvnrf],非空白符 |
. | 表示 [^nru2028u2029]。通配符,匹配除換行符、回車符、行分隔符、段分隔符外任意字符 |
量詞
匹配優先量詞 | 忽略優先量詞 | 含義 |
---|---|---|
{m,n} | {m,n}? | 表示至少出現 m 次,至多 n 次 |
{m,} | {m,}? | 表示至少出現 m 次 |
{m} | {m}? | 表示必須出現 m 次,等價 {m,m} |
? | ?? | 等價 {0,1} |
+ | +? | 等價 {1,} |
* | *? | 等價 {0,} |
錨點與斷言
正則表達式中有些結構並不真正匹配文本,只負責判斷在某個位置左/右側的文本是否符合要求,被稱爲錨點。常見錨點有三類:行起始/結束位置、單詞邊界、環視。在 ES5 中共有 6 個錨點。
錨點 | 含義 |
---|---|
^ | 匹配開頭,多行匹配中匹配行開頭 |
$ | 匹配結尾,多行匹配中匹配行結尾 |
b | 單詞邊界,w 與 W 之間位置 |
B | 非單詞邊界 |
(?=p) | 該位置後面字符要匹配 p |
(?!p) | 該位置後面字符不匹配 p |
需要注意,\b
也包括 \w
與 ^
之間的位置,以及 \w
與 $
之間的位置。如圖所示。
修飾符
修飾符是指匹配時使用的模式規則。ES5 中存在三種匹配模式:忽略大小寫模式、多行模式、全局匹配模式,對應修飾符如下。
修飾符 | 含義 |
---|---|
i | 不區分大小寫匹配 |
m | 允許匹配多行 |
g | 執行全局匹配 |
u | Unicode 模式,用來正確處理大於\uFFFF 的 Unicode 字符,處理四個字節的 UTF-16 編碼。 |
y | 粘連模式,和g相似都是全局匹配,但是特點是:後一次匹配都從上一次匹配成功的下一個位置開始,必須從剩餘的第一個位置開始,這就是“粘連”的涵義。 |
s | dotAll 模式,大部分情況是用來處理行終止符的 |
2、正則的方法
字符串對象共有 4 個方法,可以使用正則表達式:match()
、replace()
、search()
和split()
。
ES6 將這 4 個方法,在語言內部全部調用RegExp
的實例方法,從而做到所有與正則相關的方法,全都定義在RegExp
對象上。
String.prototype.match
調用RegExp.prototype[Symbol.match]
String.prototype.replace
調用RegExp.prototype[Symbol.replace]
String.prototype.search
調用RegExp.prototype[Symbol.search]
String.prototype.split
調用RegExp.prototype[Symbol.split]
String.prototype.match
String.prototype.replace
字符串的replace方法,應該是我們最常用的方法之一了,這裏我給詳細的說一下其中的各種使用攻略。
replace
函數的第一個參數可以是一個正則,或者是一個字符串(字符串沒有全局模式,僅匹配一次),用來匹配你想要將替換它掉的文本內容
第二個參數可以是字符串,或者是一個返回字符串的函數。這裏請注意,如果使用的是字符串,JS 引擎會給你一些 tips 來攻略這段文本:
變量名 | 代表的值 |
---|---|
$$ |
插入一個 "$"。 |
$& |
插入匹配的子串。 |
$` | 插入當前匹配的子串左邊的內容。 |
$' | 插入當前匹配的子串右邊的內容。 |
$n | 假如第一個參數是 RegExp 對象,並且 n 是個小於100的非負整數,那麼插入第 n 個括號匹配的字符串。提示:索引是從1開始,注意這裏的捕獲組規則 |
如果你不清楚捕獲組的順序,給你一個簡單的法則:從左到右數 >>> 第幾個 '(' 符號就是第幾個捕獲組
(特別適用於捕獲組裏有捕獲組的情況)(在函數模式裏,解構賦值時會特別好用)
$`:就是相當於正則匹配到的內容的左側文本
$':就是相當於正則匹配到的內容右側文本
$&:正則匹配到的內容
$1 - $n :對應捕獲組
如果參數使用的是函數,則可以對匹配的內容進行一些過濾或者是補充
下面是該函數的參數:
變量名 | 代表的值 |
---|---|
match | 匹配的子串。(對應於上述的$&。) |
p1,p2, ... |
假如replace()方法的第一個參數是一個RegExp 對象,則代表第n個括號匹配的字符串。(對應於上述的$1,$2等。)例如, 如果是用 /(\a+)(\b+)/ 這個來匹配, p1 就是匹配的 \a+ , p2 就是匹配的 \b+。
|
offset |
匹配到的子字符串在原字符串中的偏移量。(比如,如果原字符串是“abcd”,匹配到的子字符串是“bc”,那麼這個參數將是1) |
string | 被匹配的原字符串。 |
一個示例,從富文本里面,匹配到裏面的圖片標籤的地址
可以說,使用函數來替換文本的話,基本上你想幹嘛就幹嘛
String.prototype.search
String.prototype.split
RegExp.prototype.test
和String.prototype.search 的功能很像,但是這個是返回布爾值,search返回的是下標,這個從語義化角度看比較適合校檢
RegExp.prototype.exec
3、正則常見的使用
主要內容是ES6 裏新增的修飾符(u,y,s)(g,m,i 就不說了)、貪婪和非貪婪模式、先行/後行斷言
'u' 修飾符
ES6 對正則表達式添加了u
修飾符,含義爲“Unicode 模式”,用來正確處理大於\uFFFF
的 Unicode 字符。也就是說,會正確處理四個字節的 UTF-16 編碼。少說廢話,看圖
但是很可惜的是 MDN給出的瀏覽器兼容性如下:(截止至2019.01.24),所以離生產環境上使用還是有點時間
'y' 修飾符
除了u
修飾符,ES6 還爲正則表達式添加了y
修飾符,叫做“粘連”(sticky)修飾符。
y
修飾符的作用與g
修飾符類似,也是全局匹配,後一次匹配都從上一次匹配成功的下一個位置開始。不同之處在於,g
修飾符只要剩餘位置中存在匹配就可,而y
修飾符確保匹配必須從剩餘的第一個位置開始,這也就是“粘連”的涵義。
var s = 'aaa_aa_a';
var r1 = /a+/g;
var r2 = /a+/y;
r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]
r1.exec(s) // ["aa"]
r2.exec(s) // null
上面代碼有兩個正則表達式,一個使用g
修飾符,另一個使用y
修飾符。這兩個正則表達式各執行了兩次,第一次執行的時候,兩者行爲相同,剩餘字符串都是_aa_a
。由於g
修飾沒有位置要求,所以第二次執行會返回結果,而y
修飾符要求匹配必須從頭部開始,所以返回null
。
如果改一下正則表達式,保證每次都能頭部匹配,y
修飾符就會返回結果了。
var s = 'aaa_aa_a';
var r = /a+_/y;
r.exec(s) // ["aaa_"]
r.exec(s) // ["aa_"]
上面代碼每次匹配,都是從剩餘字符串的頭部開始。
使用lastIndex
屬性,可以更好地說明y
修飾符。
const REGEX = /a/g;
// 指定從2號位置(y)開始匹配
REGEX.lastIndex = 2;
// 匹配成功
const match = REGEX.exec('xaya');
// 在3號位置匹配成功
match.index // 3
// 下一次匹配從4號位開始
REGEX.lastIndex // 4
// 4號位開始匹配失敗
REGEX.exec('xaya') // null
上面代碼中,lastIndex
屬性指定每次搜索的開始位置,g
修飾符從這個位置開始向後搜索,直到發現匹配爲止。
y
修飾符同樣遵守lastIndex
屬性,但是要求必須在lastIndex
指定的位置發現匹配。
const REGEX = /a/y;
// 指定從2號位置開始匹配
REGEX.lastIndex = 2;
// 不是粘連,匹配失敗
REGEX.exec('xaya') // null
// 指定從3號位置開始匹配
REGEX.lastIndex = 3;
// 3號位置是粘連,匹配成功
const match = REGEX.exec('xaya');
match.index // 3
REGEX.lastIndex // 4
實際上,y
修飾符號隱含了頭部匹配的標誌^
。
/b/y.exec('aba')
// null
上面代碼由於不能保證頭部匹配,所以返回null
。y
修飾符的設計本意,就是讓頭部匹配的標誌^
在全局匹配中都有效。
下面是字符串對象的replace
方法的例子。
const REGEX = /a/gy;
'aaxa'.replace(REGEX, '-') // '--xa'
上面代碼中,最後一個a
因爲不是出現在下一次匹配的頭部,所以不會被替換。
單單一個y
修飾符對match
方法,只能返回第一個匹配,必須與g
修飾符聯用,才能返回所有匹配。
'a1a2a3'.match(/a\d/y) // ["a1"]
'a1a2a3'.match(/a\d/gy) // ["a1", "a2", "a3"]
y
修飾符的一個應用,是從字符串提取 token(詞元),y
修飾符確保了匹配之間不會有漏掉的字符。
const TOKEN_Y = /\s*(\+|[0-9]+)\s*/y;
const TOKEN_G = /\s*(\+|[0-9]+)\s*/g;
tokenize(TOKEN_Y, '3 + 4')
// [ '3', '+', '4' ]
tokenize(TOKEN_G, '3 + 4')
// [ '3', '+', '4' ]
function tokenize(TOKEN_REGEX, str) {
let result = [];
let match;
while (match = TOKEN_REGEX.exec(str)) {
result.push(match[1]);
}
return result;
}
上面代碼中,如果字符串裏面沒有非法字符,y
修飾符與g
修飾符的提取結果是一樣的。但是,一旦出現非法字符,兩者的行爲就不一樣了。
tokenize(TOKEN_Y, '3x + 4')
// [ '3' ]
tokenize(TOKEN_G, '3x + 4')
// [ '3', '+', '4' ]
上面代碼中,g
修飾符會忽略非法字符,而y
修飾符不會,這樣就很容易發現錯誤。
很遺憾,這個的瀏覽器兼容性也不咋地
但是,如果你的項目裏有集成了babel,就可以使用以上的兩個修飾符了,他們分別是
@babel-plugin-transform-es2015-sticky-regex
@babel-plugin-transform-es2015-unicode-regex
's' 修飾符
正則表達式中,點(.
)是一個特殊字符,代表任意的單個字符,但是有兩個例外。一個是四個字節的 UTF-16 字符,這個可以用u
修飾符解決;另一個是行終止符(line terminator character)。
所謂行終止符,就是該字符表示一行的終結。以下四個字符屬於”行終止符“。
- U+000A 換行符(
\n
) - U+000D 回車符(
\r
) - U+2028 行分隔符(line separator)
- U+2029 段分隔符(paragraph separator)
雖然這個瀏覽器兼容性也很差,但是我們有方法來模擬它的效果,只是語義化上有點不友好
/foo.bar/.test('foo\nbar') // false
/foo[^]bar/.test('foo\nbar') // true
/foo[\s\S]bar/.test('foo\nbar') // true 我喜歡這種
貪婪模式和非貪婪模式(惰性模式)
貪婪模式:正則表達式在匹配時會儘可能多地匹配,直到匹配失敗,默認是貪婪模式。
非貪婪模式:讓正則表達式僅僅匹配滿足表達式的內容,即一旦匹配成功就不再繼續往下,這就是非貪婪模式。在量詞後面加?即可。
在某些情況下,我們需要編寫非貪婪模式場景下的正則,比如捕獲一組標籤或者一個自閉合標籤
這時捕獲到了一組很奇怪的標籤,如果我們的目標是隻想捕獲img標籤的話,顯然是不理想的,這時非貪婪模式就可以用在這裏了
只需要在量詞後加 ?
就會啓用非貪婪模式,在特定情況下是特別有效的
先行/後行(否定)斷言
有時候,我們會有些需求,具體是:匹配xxx前面/後面的xxx。很尷尬的是,在很久之前,只支持先行斷言(lookahead)和先行否定斷言(negative lookahead),不支持後行斷言(lookbehind)和後行否定斷言(negative lookbehind),在ES2018 之後才引入後行斷言
名稱 | 正則 | 含義 |
---|---|---|
先行斷言 | /want(?=asset)/ | 匹配在asset前面的內容 |
先行否定斷言 | /want(?!asset)/ | want只有不在asset前面才匹配 |
後行斷言 | /(?<=asset)want/ | 匹配在asset後面的內容 |
後行否定斷言 | /(?<!asset)want/ | want只有不在asset後面才匹配 |
老實說,根據我的經驗,後行斷言的使用場景會更多,因爲js 有很多的數據存儲是名值對的形式保存,所以很多時候我們想要通過"name="來取到後面的值,這時候是後行斷言的使用場景了
先行斷言:只匹配 在/不在 百分號之前的數字
後行斷言:
這裏引例 @玉伯也叫射鵰 的一篇 博文的內容
這裏可以用後行斷言
(?<=^|(第.+[章集])).*?(?=$|(第.+[章集]))
“後行斷言”的實現,需要先匹配/(?<=y)x/
的x
,然後再回到左邊,匹配y
的部分。這種“先右後左”的執行順序,與所有其他正則操作相反,導致了一些不符合預期的行爲。
首先,後行斷言的組匹配,與正常情況下結果是不一樣的。
/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]
/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]
上面代碼中,需要捕捉兩個組匹配。沒有“後行斷言”時,第一個括號是貪婪模式,第二個括號只能捕獲一個字符,所以結果是105
和3
。而“後行斷言”時,由於執行順序是從右到左,第二個括號是貪婪模式,第一個括號只能捕獲一個字符,所以結果是1
和053
。
其次,“後行斷言”的反斜槓引用,也與通常的順序相反,必須放在對應的那個括號之前。
/(?<=(o)d\1)r/.exec('hodor') // null
/(?<=\1d(o))r/.exec('hodor') // ["r", "o"]
上面代碼中,如果後行斷言的反斜槓引用(\1
)放在括號的後面,就不會得到匹配結果,必須放在前面纔可以。因爲後行斷言是先從左到右掃描,發現匹配以後再回過頭,從右到左完成反斜槓引用。
另外,需要提醒的是,斷言部分是不計入返回結果的。
具名組匹配
ES2018 引入了具名組匹配(Named Capture Groups),允許爲每一個組匹配指定一個名字,既便於閱讀代碼,又便於引用。
上面代碼中,“具名組匹配”在圓括號內部,模式的頭部添加“問號 + 尖括號 + 組名”(?<year>
),然後就可以在exec
方法返回結果的groups
屬性上引用該組名。同時,數字序號(matchObj[1]
)依然有效。
具名組匹配等於爲每一組匹配加上了 ID,便於描述匹配的目的。如果組的順序變了,也不用改變匹配後的處理代碼。
如果具名組沒有匹配,那麼對應的groups
對象屬性會是undefined
。
具名組匹配 × 解構賦值
具名組引用
如果要在正則表達式內部引用某個“具名組匹配”,可以使用\k<組名>
的寫法。
4、常用正則
我這裏比較推薦一個正則可視化的網站:https://regexper.com/ 在上面貼上你的正則,會以圖形化的形式展示出你的正則匹配規則,之後我們就可以大致上判斷我們的正則是否符合預期(貌似需要科學上網)
如果想通過字符串來生成正則對象的話,有兩種方式,一種是字面量方式,另一種是構造函數
構造函數:new Regexp('content', 'descriptor')
字面量模式(請做好try-catch處理):
const input = '/123/g'
const regexp = eval(input)
校驗密碼強度
密碼的強度必須是包含大小寫字母和數字的組合,不能使用特殊字符,長度在8-10之間。
^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,10}$
非全數字 全字母的 6-15位密碼 先行否定斷言
/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,15}$/
校驗中文
字符串僅能是中文。
^[\u4e00-\u9fa5]{0,}$
校驗身份證號碼
15位
^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$
18位
^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$
校驗日期
“yyyy-mm-dd“ 格式的日期校驗,已考慮平閏年。
^(?:(?!0000)[0-9]{4}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$
提取URL鏈接
下面的這個表達式可以篩選出一段文本中的URL。
^(f|ht){1}(tp|tps):\/\/([\\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?
提取圖片標籤的地址
假若你想提取網頁中所有圖片信息,可以利用下面的表達式。
/<img [^>]*?src="(.*?)"[^>]*?>/g;
\*[img][^\>]*[src] *= *[\"\']{0,1}([^\"\'\ >]*)
5、注意事項
使用非捕獲型括號
如果不需要引用括號內文本,請使用非捕獲型括號 (?:...)
。這樣不但能夠節省捕獲的時間,而且會減少回溯使用的狀態數量。
消除不必要括號
非必要括號有時會阻止引擎優化。比如,除非需要知道 .*
匹配的最後一個字符,否則請不要使用 (.)*
。
不要濫用字符組
避免單個字符的字符組。例如 [.]
或 [*]
,可以通過轉義轉換爲 \.
和 \*\
。
使用起始錨點
除非特殊情況,否則以 .*
開頭的正則表達式都應該在最前面添加 ^
。如果表達式在字符串的開頭不能匹配,顯然在其他位置也不能匹配。
從量詞中提取必須元素
用 xx*
替代 x+
能夠保留匹配必須的 “x”。同樣道理,-{5,7}
可以寫作 -----{0,2}
。(可讀性可能會差點)
提取多選結構開頭的必須元素
用 th(?:is|at)
替代 (?:this|that)
,就能暴露除必須的 “th”。
忽略優先還是匹配優先?
通常,使用忽略優先量詞(惰性)還是匹配優先量詞(貪婪)取決於正則表達式的具體需求。舉例來說,/^.*:/
不同於 ^.*?:
,因爲前者匹配到最後的冒號,而後者匹配到第一個冒號。總的來說,如果目標字符串很長,冒號會比較接近字符串的開頭,就是用忽略優先。如果在接近字符串末尾位置,就是用匹配優先量詞。
拆分正則表達式
有時候,應用多個小正則表達式的速度比單個正則要快的多。“大而全”的正則表達式必須在目標文本中的每個位置測試所有表達式,效率較爲低下。典型例子可以參考前文, 去除字符串開頭和結尾空白。
將最可能匹配的多選分支放在前頭
多選分支的擺放順序非常重要,上文有提及。總的來說,將常見匹配分支前置,有可能獲得更迅速更常見的匹配。
避免指數級匹配
從正則表達式角度避免指數級匹配,應儘可能減少 + *
量詞疊加,比如 ([^\\"]+)*
。從而減少可能匹配情形,加快匹配速度。
6、小結
正則表達式想要用好,需要一定的經驗,個人經驗來看,需要把你想法中的需要寫出來,然後通過搭積木的形式,把一個個小的匹配寫出來,然後再組合出你想要的功能,這是比較好的一種實現方法。
如果說遇到了晦澀難懂的正則,也可以貼到上面提到的正則可視化網站裏,看下它的匹配機制。
對於前端來說,正則的使用場景主要是用戶輸入的校檢,富文本內容的過濾,或者是對一些url或者src的過濾,還有一些標籤的替換之類的,掌握好了還是大有裨益的,起碼以前雄霸前端的 jQ 的選擇器 sizzle 就是用了大量正則。
最後,如果大家覺得我有哪裏寫錯了,寫得不好,有其它什麼建議(誇獎),非常歡迎大家指出和斧正。也非常歡迎大家跟我一起討論和分享!
寫在最後
感謝以下參考文檔的作者的分享
精通正則表達式(第三版)
前端正則二三事 @代碼君的自白
ES6 入門 -- 正則的拓展 @阮一峯
- ab ↩