话说我要为技术博客写一个小程序版,我的博客解决方案是 hexo + github-page ,格式当然是技术控们喜欢的 markdown 了 。但小程序使用的却是独有的模版语言 WXML 。我总不能把之前的文章手动转换成小程序的 wxml 格式吧,而网上也没完善的转换库,还是自己写个解析器吧。
解析器最核心的部分就是字符串模式匹配,既然涉及到字符串匹配,那么就离不开正则表达式。幸好,正则表达式是我的优势之一。
正则表达式 JavaScript中的正则表达式 解析器涉及到的 JavaScript 正则表达式知识
RegExp 构造函数属性,其中lastMatch,rightContent在字符串截取时非常有用
长属性名
短属性名
替换标志
说明
input
$_
最近一次要匹配的字符串。Opera未实现此属性
lastMatch
$&
$&
最近一次的匹配项。Opera未实现此属性
lastParen
$+
最近一次匹配的捕获组。Opera未实现此属性
leftContext
$`
$`
input字符串中lastMatch之前的文本
rightContext
$’
$’
Input字符串中lastMatch之后的文本
multiline
$*
布尔值,表示是否所有表达式都使用多行模式。IE和Opera未实现此属性
$n
$n
分组
$$
转义$
test 方法 和 RegExp 构造函数test 方法调用后,上面的属性就会出现在 RegExp 中,不推荐使用短属性名,因为会造成代码可读性的问题,下面就是样例
var text = "this has been a short summer" ;var pattern = /(.)hort/g ;if (pattern.test(text)){ alert(RegExp .input); alert(RegExp .leftContext); alert(RegExp .rightContext); alert(RegExp .lastMatch); alert(RegExp .lastParen); alert(RegExp .multiline); } if (pattern.test(text)){ alert(RegExp .$_); alert(RegExp ["$`" ]); alert(RegExp ["$'" ]); alert(RegExp ["$&" ]); alert(RegExp ["$+" ]); alert(RegExp ["$*" ]); }
replace 方法
一般使用的是没有回调函数的简单版本,而回调函数版本则是个大杀器,及其强大
var regex = /(\d{4})-(\d{2})-(\d{2})/ ;"2011-11-11" .replace(regex, "$2/$3/$1" );"one two three" .replace(/\bt[a-zA-Z]+\b/g , function (match,index,str ) { console .log(match,index,str); return match.toUpperCase(); });
match 方法
全局模式和非全局模式有显著的区别,全局模式和 exec 方法类似。
var str = '1a2b3c4d5e' ;console .log(str.match(/b/ )); var str = '1a2b3c4d5e' ;str.match(/h/g ); str.match(/\d/g ); var pattern = /\d{4}-\d{2}-\d{2}/g ;var str ="2010-11-10 2012-12-12" ;var matchArray = str.match(pattern);for (vari = 0 ; i < matchArray.length; i++) { console .log(matchArray[i]); }
exec 方法
与全局模式下的 match 类似,但 exec 更强大,因为返回结果包含各种匹配信息,而match全局模式是不包含具体匹配信息的。
var pattern = /(\d{4})-(\d{2})-(\d{2})/g ;var str2 = "2011-11-11 2013-13-13" ;while ((matchArray = pattern.exec(str2)) != null ) { console .log( "date: " + matchArray[0 ]+"start at:" + matchArray.index+" ends at:" + pattern.lastIndex); console .log( ",year: " + matchArray[1 ]); console .log( ",month: " + matchArray[2 ]); console .log( ",day: " + matchArray[3 ]); }
search ,split 这两个比较简单的方法则不再介绍
正则表达式高级概念 正常情况下正则是从左向右进行单字符匹配,每匹配到一个字符, 就后移位置, 直到最终消耗完整个字符串, 这就是正则表达式的字符串匹配过程,也就是它会匹配字符,占用字符。相关的基本概念不再讲解,这里要讲的和字符匹配不同的概念 - 断言。
断言
正则中大多数结构都是匹配字符,而断言则不同,它不匹配字符,不占用字符,而只在某个位置判断左/右侧的文本是否符合要求。这类匹配位置的元素,可以称为 “锚点”,主要分为三类:单词边界,开始结束位置,环视。
单词边界 \b 是这样的位置,一边是单词字符,一边不是单词字符,如下字符串样例所示
^ 行开头,多行模式下亦匹配每个换行符后的位置,即行首 $ 行结束,多行模式下亦匹配每个换行符前的位置,即行尾
'hello\nword' .replace(/^|$/g ,'<p>' )"<p>hello" +"word<p>" 'hello\nword\nhi' .replace(/^|$/mg ,'<p>' )"<p>hello<p>" +"<p>word<p>" +"<p>hi<p>"
环视
环视是断言中最强的存在,同样不占用字符也不提取任何字符,只匹配文本中的特定位置,与\b, ^ $ 边界符号相似;但环视更加强大,因为它可以指定位置和在指定位置处添加向前或向后验证的条件。
而环视主要体现在它的不占位(不消耗匹配字符), 因此又被称为零宽断言。所谓不占宽度,可以这样理解:
环视的匹配结果不纳入数据结果;
环视它匹配过的地方,下次还能用它继续匹配。
环视包括顺序环视和逆序环视,javascript 在 ES 2018 才开始支持逆序环视
(?=) 顺序肯定环视 匹配右边
(?!) 顺序否定环视
(?<=) 逆序肯定环视 匹配左边
(?<!) 逆序否定环视
来看一下具体的样例
'asd.exe' .match(/.+(?=\.exe)/ )=> ["asd" , index : 0 , input : "asd.exe" , groups : undefined ] </?(?!p|a|img)([^> /]+)[^>]*/ ?> /(?<=\$)\d+ /.exec ('Benjamin Franklin is on the $100 bill ') // ["100 ",index: 29 ,... ] /(?<!\$)\d+ /.exec ('it ’s is worth about €90 ') // ["90 ", index: 21 ,... ] // 利用环视占位但不匹配的特性 '12345678'.replace(/\B(?=(\d{3})+$)/g , ',') => "12,345,678" //分割数字
解析器的编写 正则表达式相关写得有点多,但磨刀不误砍柴工,开始进入主题
markdown格式 hexo 生成的 markdwon 文件格式如下,解析器就是要把它解析成json格式的输出结果,供小程序输出 wxml
--- title: Haskell学习-functor date: 2018-08-15 21:27:15 tags: [haskell] categories: 技术 banner: https://upload-images.jianshu.io/upload_images/127924-be9013350ffc4b88.jpg --- ## 什么是Functor **functor** 就是可以执行map操作的对象,functor就像是附加了语义的表达式,可以用盒子进行比喻。**functor** 的定义可以这样理解:给出a映射到b的函数和装了a的盒子,结果会返回装了b的盒子。**fmap** 可以看作是一个接受一个function 和一个 **functor** 的函数,它把function 应用到 **functor** 的每一个元素(映射)。```haskell -- Functor的定义 class Functor f where fmap :: (a -> b) -> f a -> f b ```
入口 使用node进行文件操作,然后调用解析器生成json文件
const { readdirSync, readFileSync, writeFile } = require ("fs" );const path = require ("path" );const parse = require ("./parse" );const files = readdirSync(path.join(__dirname, "posts" ));for (let p of files) { let md = readFileSync(path.join(__dirname, "posts" , p)); const objs = parse(md); writeFile(path.join(__dirname, "json" , p.replace('.md' ,'.json' )), JSON .stringify(objs), function ( err ) { err && console .log(err); }); }
来看一下解析器入口部分,主要分为:summary 部分,code代码部分,markdown文本部分。将文本内容的注释和空格过滤掉,但是代码部分的注释要保留。
module .exports = function analyze (str ) { let ret = { summary : {}, lines : [] }; while (str) { if (/^([\s\t\r\n]+)/ .test(str)) { str = RegExp .rightContext; } if (/^(\-{3})[\r\n]?([\s\S]+?)\1[\r\n]?/ .test(str)) { str = RegExp .rightContext; ret.summary = summaryParse(RegExp .$2 ); ret.num = new Date (ret.summary.date).getTime(); } if (/^`{3}(\w+)?([\s\S]+?)`{3}/ .test(str)) { const codeStr = RegExp .$2 || RegExp .$1 ; const fn = (RegExp .$2 && codeParse[RegExp .$1 ]) ? codeParse[RegExp .$1 ] : codeParse.javascript; str = RegExp .rightContext; ret.lines.push({ type : "code" , child : fn(codeStr) }); } if (/^<!--[\s\S]*?-->/ .test(str)) { str = RegExp .rightContext; } if (/^(.+)[\r\n]?/ .test(str)) { str = RegExp .rightContext; ret.lines.push(textParse(RegExp .$1 )); } } return ret; };
文本内容提取 summary 内容块的提取比较简单,不讲叙。还是看 markdown 文本内容的解析吧。这里匹配 markdown 常用类型,比如列表,标题h,链接a,图片img等。而返回结果的数据结构就是一个列表,列表里面可以嵌套子列表。但基本就是正则表达式提取内容,最终消耗完字符行。
function textParse (s ) { const trans = /^\\(\S)/ ; const italy = /^(\*)(.+?)\1/ ; const bold = /^(\*{2})(.+?)\1/ ; const italyBold = /^(\*{3})(.+?)\1/ ; const headLine = /^(\#{1,6})\s+/ ; const unsortList = /^([*\-+])\s+/ ; const sortList = /^(\d+)\.\s+/ ; const link = /^\*?\[(.+)\]\(([^()]+)\)\*?/ ; const img = /^(?:!\[([^\]]+)\]\(([^)]+)\)|<img(\s+)src="([^"]+)")/ ; const text =/^[^\\\s*]+/ ; if (headLine.test(s)) return { type : "h" + RegExp .$1. length, text : RegExp .rightContext }; if (sortList.test(s)) return { type : "sl" , num : RegExp .$1 , child : lineParse(RegExp .rightContext) }; if (unsortList.test(s)) return { type : "ul" , num : RegExp .$1 , child : lineParse(RegExp .rightContext) }; if (img.test(s)) return { type : "img" , src : RegExp .$2 ||RegExp .$4 , alt : RegExp .$1 ||RegExp .$3 }; if (link.test(s)) return { type : "link" , href : RegExp .$2 , text : RegExp .$1 }; return { type : "text" , child : lineParse(s) }; function lineParse (line ) { let ws = []; while (line) { if (/^[\s]+/ .test(line)) { ws.push({ type : "text" , text : " " }); line = RegExp .rightContext; } if (trans.test(line)) { ws.push({ type : "text" , text : RegExp .$1 }); line = RegExp .rightContext; } if (sortList.test(line)) { return { child : lineParse(RegExp .rightContext) }; } if (unsortList.test(line)) { return { child : lineParse(RegExp .rightContext) }; } if (link.test(line)) { ws.push({ type : "link" , href : RegExp .$2 , text : RegExp .$1 }); line = RegExp .rightContext; } if (italyBold.test(line)) { ws.push({ type : "italybold" , text : RegExp .$2 }); line = RegExp .rightContext; } if (bold.test(line)) { ws.push({ type : "bold" , text : RegExp .$2 }); line = RegExp .rightContext; } if (italy.test(line)) { ws.push({ type : "italy" , text : RegExp .$2 }); line = RegExp .rightContext; } if (text.test(line)) { ws.push({ type : "text" , text : RegExp .lastMatch }); line = RegExp .rightContext; } } return ws; } }
代码块显示 如果只是解析文本内容,还是非常简单的,但是技术博客嘛,代码块是少不了的。为了代码关键字符的颜色显示效果,为了方便阅读,还得继续解析。我博客目前使用到的语言,基本写了对应的解析器,其实有些解析器是可以共用的,比如 style方法不仅可应用到 css 上, 还可以应用到类似的预解析器上比如:scss ,less 。html 也一样可应用到类似的标记语言上。
const codeParse = { haskell(str){}, javascript(str){}, html:html, css:style };
来看一下比较有代表性的 JavaScript 解析器,这里没有使用根据换行符(\n)将文本内容切割成字符串数组的方式,因为有些类型需要跨行进行联合推断,比如解析块,方法名称判断就是如此。只能将一整块文本用正则表达式慢慢匹配消耗完。最终的结果类似上面的文本匹配结果 - 嵌套列表,类型就是语法关键字,常用内置方法,字符串,数字,特殊符号等。
其实根据这个解析器可以进一步扩展和抽象一下,将它作为类 C 语言族的基本框架。然后只要传递 对应语言的正则表达式规则,就能解析出不同语言的结果出来,比如 C# ,java ,C++ ,GO 。
javascript(str) { const comReg = /^\/{2,}.*/ ; const keyReg = /^(import|from|extends|new|var|let|const|return|if|else|switch|case|break|continue|of|for|in|Array|Object|Number|Boolean|String|RegExp|Date|Error|undefined|null|true|false|this|alert|console)(?=([\s.,;(]|$))/ ; const typeReg = /^(window|document|location|sessionStorage|localStorage|Math|this)(?=[,.;\s])/ ; const regReg = /^\/\S+\/[gimuys]?/ ; const sysfunReg = /^(forEach|map|filter|reduce|some|every|splice|slice|split|shift|unshift|push|pop|substr|substring|call|apply|bind|match|exec|test|search|replace)(?=[\s\(])/ ; const funReg = /^(function|class)\s+(\w+)(?=[\s({])/ ; const methodReg = /^(\w+?)\s*?(\([^()]*\)\s*?{)/ ; const symbolReg = /^([!><?|\^$&~%*/+\-]+)/ ; const strReg = /^([`'"])([^\1]*?)\1/ ; const numReg = /^(\d+\.\d+|\d+)(?!\w)/ ; const parseComment = s => { const ret = []; const lines = s.split(/[\r\n]/g ); for (let line of lines) { ret.push({ type : "comm" , text : line }); } return ret; }; let ret = []; while (str) { if (/^\s*\/\*([\s\S]+?)\*\// .test(str)) { str = RegExp .rightContext; const coms = parseComment(RegExp .lastMatch); ret = ret.concat(coms); } if (/^(?!\/\*).+/ .test(str)) { str = RegExp .rightContext; ret.push({ type : "text" , child :lineParse(RegExp .lastMatch) }); } if (/^[\r\n]+/ .test(str)){ str=RegExp .rightContext; ret.push({type :'text' ,text :RegExp .lastMatch}); } } return ret; function lineParse (line ) { let ws = []; while (line) { if (/^([\s\t\r\n]+)/ .test(line)) { ws.push({ type : "text" , text : RegExp .$1 }); line = RegExp .rightContext; } if (comReg.test(line)) { ws.push({ type : "comm" , text : line }); break ; } if (regReg.test(line)) { ws.push({ type : "fun" , text : RegExp .lastMatch }); line = RegExp .rightContext; } if (symbolReg.test(line)) { ws.push({ type : "keyword" , text : RegExp .$1 }); line = RegExp .rightContext; } if (keyReg.test(line)) { ws.push({ type : "keyword" , text : RegExp .$1 }); line = RegExp .rightContext; } if (funReg.test(line)) { ws.push({ type : "keyword" , text : RegExp .$1 }); ws.push({ type : "text" , text : " " }); ws.push({ type : "fun" , text : RegExp .$2 }); line = RegExp .rightContext; } if (methodReg.test(line)) { ws.push({ type : "fun" , text : RegExp .$1 }); ws.push({ type : "text" , text : " " }); ws.push({ type : "text" , text : RegExp .$2 }); line = RegExp .rightContext; } if (typeReg.test(line)) { ws.push({ type : "fun" , text : RegExp .$1 }); line = RegExp .rightContext; } if (sysfunReg.test(line)) { ws.push({ type : "var" , text : RegExp .$1 }); line = RegExp .rightContext; } if (strReg.test(line)) { ws.push({ type : "var" , text : RegExp .$1 + RegExp .$2 + RegExp .$1 }); line = RegExp .rightContext; } if (numReg.test(line)) { ws.push({ type : "var" , text : RegExp .$1 }); line = RegExp .rightContext; } if (/^\w+/ .test(line)) { ws.push({ type : "text" , text : RegExp .lastMatch }); line = RegExp .rightContext; } if (/^[^`'"!><?|\^$&~%*/+\-\w]+/ .test(line)) { ws.push({ type : "text" , text : RegExp .lastMatch }); line = RegExp .rightContext; } } return ws; } }
显示WXML 最后只要运行解析器,就能生成 markdown 对应的 json 文件了,然后把json加载到微信小程序的云数据库里面,剩下的显示就交由小程序完成。下面就是使用 taro 编写 jsx 显示部分
<View className='article' > {lines.map(l => ( <Block> <View className='line' > {l.type.search("h" ) == 0 && ( <Text className ={l.type} > {l.text}</Text > )} {l.type == "link" && ( <Navigator className ='link' url ={l.href} > {l.text} </Navigator > )} {l.type == "img" && ( <Image className ='pic' mode ='widthFix' src ={l.src} /> )} {l.type == "sl" && ( <Block> <Text decode className='num'> {l.num}.{" "} </Text> <TextChild list={l.child} /> </Block> )} {l.type == "ul" && ( <Block> <Text decode className='num'> {" "} •{" "} </Text> <TextChild list={l.child} /> </Block> )} {l.type == "text" && l.child.length && ( <TextChild list={l.child} /> )} </View> {l.type == "code" && ( <View className='code'> {l.child.map(c => ( <View className='code-line'> {c.type == 'comm' && <Text decode className='comm'> {c.text} </Text>} {c.type == 'text' && c.child.map(i => ( <Block> {i.type == "comm" && ( <Text decode className='comm'> {i.text} </Text> )} {i.type == "keyword" && ( <Text decode className='keyword'> {i.text} </Text> )} {i.type == "var" && ( <Text decode className='var'> {i.text} </Text> )} {i.type == "fun" && ( <Text decode className='fun'> {i.text} </Text> )} {i.type == "text" && ( <Text decode className='text'> {i.text} </Text> )} </Block> ))} </View> ))} </View> )} </Block> ))} </View >
后记 经过这个项目的磨练,我的正则表达式的能力又上了一个台阶, 连 环视 都已经是信手拈来了😄
小程序预览