JS正则表达式的骚操作

参考资料

《JS正则表达式的分组匹配》
《正则表达式之捕获组/非捕获组介绍》
《正则表达式中(?:pattern)、(?=pattern)、(?!pattern)、(?<=pattern)和(?<!pattern)》
《正则基础之——NFA引擎匹配原理》

NB的工具网站

《正则在线测试》
《正则测试、学习工具》
《正则测试、学习工具-英文原版》

背景介绍

由来

做Hexo的时候遇到一个问题,因为图片等文件很多已经上传到了腾讯云的COS对象存储,而且COS开启了CDN加速,所以其实每个上传的对象都存在一个对应的CDN加速的域名,比如上传文件的原访问地址为:https://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg,由于Hexo是生成的静态页面,所以我们需要在生成静态页面前将这些静态资源的地址替换为CDN加速的域名地址,比如上面的域名替换为:https://blog.666.top/res/public/test.jpg

由于Hexo使用的是Markdown语法然后渲染为静态HTML页面,所以我们实际上需要针对渲染完成的静态HTML页面中的特殊标签的特殊内容进行替换。比如说有一个<img src="https://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg"></img>这种情况。

需求描述

我们替换不是对所有标签的src进行替换,可能只替换诸如<img><audio><video>这三个标签的src,同时由于src中可能会引入其他外部网站的图片,所以肯定只需要对来自我们特定域名下特定目录下的特定标签中的src进行域名CDNIFY操作。所以我们产生了如下几个必要条件:

  1. HTML文档中的<img><audio><video>标签
  2. 上面这些标签src中的URL的HOST满足bucketname.cos.ap-beijing.myqcloud.com
  3. 这个文件的URL中目录前缀是/res/public/,也就是说/res/private/aaa.jpg这种是不进行替换的

解决方法当然也有很多种,比如Hexo中有专门的第三方插件库(无法满足要求所以放弃),解析HTML后替换(速度慢)等,但介于无法满足我们的要求,最后还是采用将生成的页面通过正则进行替换,所以也引发了这篇对正则的学习笔记。

知识积累

对于正则的使用,一直都是什么时候用什么时候查,用到了再想办法那种。因为觉得脑子根本记不住那些个表达式(蛤蛤蛤蛤)。直到遇到今天这个问题,于是对正则表达式的语法进行了进一步的学习。

一、JS正则表达式的分组匹配

  • 什么是分组

通俗来说,我理解的分组就是在正则表达式中用()包起来的内容代表了一个分组,像这样的:

var reg = /(\d{2})/
reg.test('12');  //true

这里reg中的(/d{2})就表示一个分组,匹配两位数字

  • 分组内容的的形式

一个分组中可以像上面这样有一个具体的表达式,这样可以优雅地表达一个重复的字符串

/hahaha/
/(ha){3}/

这两个表达式是等效的,但有了分组之后可以更急简洁。
体格分组中还可以有多个候选表达式,例如

var reg = /I come from (hunan|hubei|zhejiang)/;
reg.test('I come from hunan');   //true
reg.test('I come from hubei');   //true

也就是说在这个分组中,通过|隔开的几个候选表达式是并列的关系,所以可以把这个|理解为或的意思

  • 捕获组

正则表达式 描述 示例
(pattern) 匹配pattern并捕获结果,自动设置组号。 (abc)+d匹配abcd或者abcabcd
(?<name>pattern)

(?'name'pattern)
匹配pattern并捕获结果,设置name为组名。 经测试JS中支持<>命名
\num 对捕获组的反向引用。其中 num 是一个正整数。 (\w)(\w)\2\1 匹配abba
\k<name>

\k'name'
对命名捕获组的反向引用。其中 name 是捕获组名。 (?<group>\w)abc\k<group>匹配xabcx

使用小括号指定一个子表达式后,匹配这个子表达式的文本(也就是此分组捕获的内容)可以在表达式或其它程序中作进一步的处理。默认情况下,每个捕获组会自动拥有一个组号,规则是:从左向右,以分组的左括号为标志,第一个出现的分组的组号为1,第二个为2,以此类推。

示例一 明名捕获组:

const str = 'http://reg-test-server:8080/download/file1.html#'
const reg = /(?<protocol>\w+):\/\/(?<host>[^/:]+)(?<port>:\d+)?(?<path>[^# :]*)/
console.log(reg.exec(str))

输出结果:

[ 'http://reg-test-server:8080/download/file1.html',
  'http',
  'reg-test-server',
  ':8080',
  '/download/file1.html',
  index: 0,
  input: 'http://reg-test-server:8080/download/file1.html#',
  groups: [Object: null prototype] {
    protocol: 'http',
    host: 'reg-test-server',
    port: ':8080',
    path: '/download/file1.html' } ]

可以看到URL中各个分组已经在groups中命名了。如果不命名,则groups为空

示例二 明名分组的引用:

const str = 'http://reg-test-server:8080/download/file1.html#'
const reg = /(?<protocol>\w+):\/\/(?<host>[^/:]+)(?<port>:\d+)?(?<path>[^# :]*)/

console.log('Before', str)
console.log('After ', str.replace(reg, '$<protocol>://www.baidu.com$<port>$<path>'))

输出结果为:

Before http://reg-test-server:8080/download/file1.html#
After  http://www.baidu.com:8080/download/file1.html#

当然上面只是示例了一下明名分组的引用,实际可能无需这么麻烦。

示例三 反向引用:

const str1 = 'https://www.baidu.com?method=https'
const str2 = 'https://www.baidu.com?method=http'

const reg = /(\w+):\/\/[^/:]+\?method=\1/

console.log(reg.test(str1), reg.test(str2)) //true false

首先通过分组(\w+)捕获了https这个协议串,然后在最后的method=\1中通过反向引用,引用了之前捕获的https这个串,所以在验证的时候,method=https返回为true,而method=http则会返回false

还有个常用的反向引用示例如下:

var reg = /(\w{3}) is \1/
reg.test('kid is kid') // true
reg.test('dik is dik') // true
reg.test('kid is dik') // false
reg.test('dik is kid') // false

需要注意的是,如果引用了越界或者不存在的编号的话,就被被解析为普通的表达式

var reg = /(\w{3}) is \6/;
reg.test( 'kid is kid' ); // false
reg.test( 'kid is \6' );  // true

二、正则表达式的非捕获型分组

字符 描述 示例
                              (?:pattern)
                        
匹配pattern,但不捕获匹配结果。 'industr(?:y|ies)' 匹配'industry'或'industries'。
(?=pattern) 正向肯定预查,匹配pattern前面的位置。不捕获匹配结果。 'Windows(?=95|98|NT|2000)' 匹配 "Windows2000" 中的 "Windows" 不匹配 "Windows3.1" 中的 "Windows"。

※ 简单说,以 xxx(?=pattern)为例,就是捕获以pattern结尾的内容xxx
(?!pattern) 正向否定预查,在任何不匹配pattern的字符串开始处匹配查找字符串。不捕获匹配结果。 'Windows(?!95|98|NT|2000)' 匹配 "Windows3.1" 中的 "Windows" 不匹配 "Windows2000" 中的 "Windows"。

※ 简单说,以 xxx(?!pattern)为例,就是捕获不以pattern结尾的内容xxx
(?<=pattern) 反向肯定预查,与正向肯定预查类似,只是方向相反。不捕获匹配结果。 '2000(?<=Office|Word|Excel)' 匹配 " Office2000" 中的 "2000" 不匹配 "Windows2000" 中的 "2000"。

※ 简单说,以(?<=pattern)xxx为例,就是捕获以pattern开头的内容xxx。
(?<!pattern) 反向否定预查,与正向否定预查类似,只是方向相反。不捕获匹配结果。 '2000(?<!Office|Word|Excel)' 匹配 " Windows2000" 中的 "2000" 不匹配 " Office2000" 中的 "2000"。

※ 简单说,以(?<!pattern)xxx为例,就是捕获不以pattern开头的内容xxx。

非捕获组只匹配结果,但不捕获结果,也不会分配组号,当然也不能在表达式和程序中做进一步处理。
首先(?:pattern)(pattern)不同之处只是在于不捕获结果。
接下来的四个非捕获组用于匹配pattern(或者不匹配pattern)位置之前(或之后)的内容。匹配的结果不包括pattern。

应用举例

(?<=<(\w+)>).*(?=<\/\1>)匹配不包含属性的简单HTML标签内的内容。如:<div>hello</div>之中的hello,匹配结果不包括前缀<div>和后缀</div>。
下面是程序中非捕获组的示例,用来提取数字。 可以看到反向回查和反向预查都没有被捕获。

const str = '有下面几组数字:010001,100,21000,4100011,510002,310000,把6位数且开头不是0的数挑出来。'
const reg1 = /([1-9]\d{5})/g
console.log(str.match(reg1)) // [ '410001', '510002', '310000' ]

const reg2 = /(?<!\d)([1-9]\d{5})/g
console.log(str.match(reg2)) // [ '410001', '510002', '310000' ]

const reg3 = /(?<!\d)([1-9]\d{5})(?!\d)/g
console.log(str.match(reg3)) // [ '510002', '310000' ]

我们看到,只有第三个正则才是输出的正确的结果。首先我们捋一下,什么样的数字如何我们的要求呢?

  1. 开头不为1
  2. 连续的6个数字
  3. 第一个数字前面不是数字
  4. 最后一个数字后面不是数字

首先中间的分组([1-9]\d{5})实现了开头不是0且6位数的作用,满足了1,2两条要求,但是对于4100011这个数字,也都符合要求但是是7位的。
然后我们通过(?<!\d)这个表达式,实现了反向否定预查,就是说我们要捕获的内容不以数字开头,从而满足了第3条要求。
最后我们通过(?!\d)这个表达式,实现了正向否定预查,就是说我们要捕获的内容不以数字结尾,从而剔除了4100011这个数字,实现了我们最终的要求。

问题解决

好啦,前面补充了这么多营(ji)养(chu)快(zhi)线(shi),我们回到本次的主题。怎么进行有效的替换呢?同样先来捋一捋,我们需要满足什么要求:

  1. 只替换HTML文档中的<img><audio><video>标签,其他标签不做处理
  2. 满足上面条件下,标签的SRC中,域名是bucketname.cos.ap-beijing.myqcloud.com
  3. 满足上面条件下,标签的SRC中,路径是/res/public/开头的
  4. 满足上面条件下,保留原来的http或者https协议

当然上面的这个条件并不一定满足所有的场景,因为我们实现可以知道对于这三种标签,没有什么复杂的嵌套关系,所以说根据实际的场景制定了上面的几个约束条件。那么我们就来看看该如何来制定正则呢:
/((\<(img|video|audio)+.*\s+src\=.*\/\/)|(\!\[.*\]\(.*\/\/))bucketname.cos.ap-beijing.myqcloud.com(?=\/res\/public\/)/gm

让我们一起来解释一下上面的这个正则:

  1. (\<(img|video|audio)+.*\s+src\=.*\/\/)这是第一个捕获分组,用于捕获HTML语法中的指定标签
    • \<(img|video|audio)+,这个捕获分组规定了匹配那三类标签的开头,如<img字符
    • .*\s+src\=.*\/\/,这部分用来匹配<img ..... src="http://这部分,当然img和src中间可能存在其他属性定义,一并捕获,例如:<img width="100%" alt="aae" src="http://
    • 我们通过\s+src\=来进一步限制捕获的src=前面必须有至少一个空格
  2. |(\!\[.*\]\(.*\/\/))这是第二个捕获分组,中间用了|来分割,用来捕获MARKDOWN语法中的图片标签,如![图片描述](http://
  3. 前面两个捕获分组合并成一个大的捕获分组:((\<(img|video|audio)+.*\s(src)\=.*\/\/)|(\!\[.*\]\(.*\/\/)),至此实现了指定域名下指定标签对象的内容的前部分的捕获,但是条件3要求我们只替换/res/public/路径下的内容,所以我们在后面加入了(?=\/res\/public\/)这个正向肯定查询分组
    • (?=\/res\/public\/) 正如上面所说,它只会限制捕获以/res/public/结尾的字符串而不会将其捕获,为何呢?因为我们这里不需要将其捕获
  4. 最后我们通过/gm模式,启用全局和多行模式。

搞定上面的正则表达式后,我们来考虑替换的问题。替换自然用的是JS中的replace函数,直接上代码:

const reg = /((\<(img|video|audio)+.*\s+src\=.*\/\/)|(\!\[.*\]\(.*\/\/))bucketname.cos.ap-beijing.myqcloud.com(?=\/res\/public\/)/gm

const str = `
<img src="http://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg" />
<img width="100%" height="100%" alt="上海鲜花港 - 郁金香" src="https://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg" />
<img width="100%" height="100%" alt="上海鲜花港 - 郁金香"
     src="http://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg" />
![上海鲜花港 - 郁金香](https://bucketname.cos.ap-beijing.myqcloud.com/res/public/markdown.md)

<img src="http://bucketname.cos.ap-beijing.myqcloud.com/public/test.jpg" width=100% height=100% alt="上海鲜花港 - 郁金香"/>
<img alt="上海鲜花港 - 郁金香" src="http://bucketname.cos.ap-beijing.myqcloud.com/res/private/test.jpg" width=100% height=100% />
`

console.log(str.replace(reg, '$1blog.666.top'))

输出结果:

<img src="http://blog.666.top/res/public/test.jpg" />
<img width="100%" height="100%" alt="上海鲜花港 - 郁金香" src="https://blog.666.top/res/public/test.jpg" />
<img width="100%" height="100%" alt="上海鲜花港 - 郁金香"
     src="http://blog.666.top/res/public/test.jpg" />
![上海鲜花港 - 郁金香](https://blog.666.top/res/public/markdown.md)

<img src="http://bucketname.cos.ap-beijing.myqcloud.com/public/test.jpg" width=100% height=100% alt="上海鲜花港 - 郁金香"/>
<img alt="上海鲜花港 - 郁金香" src="http://bucketname.cos.ap-beijing.myqcloud.com/res/private/test.jpg" width=100% height=100% />

由此,之前提出的问题就已经解决啦!但是如果要匹配的内容如下:

<img src="http://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg" />
<img width="100%" height="100%"
     alt="上海鲜花港 - 郁金香"
     src="http://bucketname.cos.ap-beijing.myqcloud.com/res/public/test.jpg" />

运用上面的正则是无法匹配成功的,这个问题留给你自己探索吧!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342