内容简介:今天,我们将学习PrismJS的源码,看看它是怎么支持CSS与Javascript的语法高亮的。PrismJS是一个前端代码高亮库,支持Markup、CSS、JS等多种语法的高亮显示,其实现简单小巧,扩展语法也非常方便,因此今天决定和大家一起学习一下PrismJS的源码。Prism语法高亮的过程总体而言,分为两个步骤:
今天,我们将学习PrismJS的源码,看看它是怎么支持CSS与Javascript的语法高亮的。
PrismJS是一个前端代码高亮库,支持Markup、CSS、JS等多种语法的高亮显示,其实现简单小巧,扩展语法也非常方便,因此今天决定和大家一起学习一下PrismJS的源码。
代码结构
分词
Prism语法高亮的过程总体而言,分为两个步骤:
- 分词(tokenize):使用选定的语法规则对目标代码进行分词
- 组装代码(stringify):根据分词的结果,组装HTML代码,将单词用带有特定class的标签(默认为span)包裹起来
例如,对于如下的css:
#id .class { color: #ffffff; } 复制代码
通过分词将获得如下的单词列表(包含单词和未匹配为单词的字符串):
[ Token(content: '#id .class', type: 'selector'), ' ', Token(content: '{', type: 'punctuation'), ' ', Token(content: 'color', type: 'property'), Token(content: ':', type: 'punctuation'), ' red', Token(content: ';', type: 'punctuation'), ' ', Token(content: '}', type: 'punctuation') ] 复制代码
并最终生成如下的HTML代码:
<span class="token selector">#id .class</span> <span class="token">{</span> <span class="token property">color</span><span class="token">:</span> red<span class="token punctuation">;</span> <span class="token punctuation">}</span> 复制代码
Prism小巧轻便之处在于,Prism只进行分词,并没有真正意义上的语法分析、构建语法树的过程。例如对于上例而言,Prism只是构建了单词列表,而非构建语法树:
TODO: 图示单词列表和语法树
同时,为了满足更多样化的需求,Prism提供了词法嵌套的功能,不过这和语法分析、构建语法树还是有着本质区别的。
Token类
首先申明Token类,token实例会包含两个属性:type与conent,分别保存单词的类型与内容:
class Token { constructor(type, content) { this.type = type; this.content = content; } } 复制代码
接着,给Token类添加static方法stringify,该方法会递归调用自己,以组装最终的HTML代码:
static stringify(o) { if (typeof o == 'string') { return o; } if (Array.isArray(o)) { return o.map(function(element) { return Token.stringify(element); }).join(''); } const classes = ['token', o.type]; const content = Token.stringify(o.content); return '<span class="' + classes.join(' ') + '">' + content + '</span>'; } 复制代码
传入的参数可能是字符串、token或是数组:若为字符串,直接返回该字符串;若为数组,返回对每一项调用stringify后连接起来的结果;如果是token,则根据返回span标签,type作为class,content为内容。
对该方法的初次调用,将传入tokens数组,数组中包含token实例与没被匹配为特定单词的字符串;token.content可能为字符串,也可能为包含字符串与token的tokens数组,以支持嵌套的分词。
Prism对象
接下来,定义Prism对象:
const _ = { util: { encode(tokens) { // }, }, languages: {}, highlight(text, grammar) { const tokens = _.tokenize(text, grammar); return Token.stringify(_.util.encode(tokens)); }, matchGrammar(text, strarr, grammar) { // }, tokenize: function(text, grammar) { const strarr = [text]; _.matchGrammar(text, strarr, grammar); return strarr; }, Token: Token }; 复制代码
language对象用来存放我们定义的语法。
highlight
方法是Prism的入口,接收2个参数:高亮目标代码text,语法规则grammar。highlight方法会先调用tokenize方法进行分词,随后调用stringify方法组装HTML。
tokenize方法调用matchGrammar方法进行分词,后者接收3个参数:高亮目标代码text;tokens数组strarr,其初始值为[text],并随着分词过程不断变化;语法规则grammar。
encode方法在组装HTML之前先处理编码问题,例如将 &
转化为 &
:
encode(tokens) { if (tokens instanceof Token) { return new Token(tokens.type, _.util.encode(tokens.content)); } else if (Array.isArray(tokens)) { return tokens.map(_.util.encode); } else { return tokens.replace(/&/g, '&').replace(/</g, '<').replace(/\u00a0/g, ' '); } } 复制代码
matchGrammar
接下来让我们来看看matchGrammar方法。
在matchGrammar方法中,我们将依次使用语法中定义的规则进行匹配,需要注意的是,patterns可以是不不是数组:例如定义Javascript语言的注释规则时,我们将使用包含 //
和 /* */
两种模式的数组:
for (const token in grammar) { let patterns = grammar[token]; patterns = Array.isArray(patterns) ? patterns : [patterns]; ... } 复制代码
这里插入一个小知识, for..in
或者 Object.keys
等方法遍历对象键的时候,也是有序的:
Object.keys的遍历顺序: 5分钟彻底理解Object.keys
之后,将使用patterns中的各pattern进行匹配,pattern是一个个正则。匹配时,将遍历strarr数组:如果当前项为字符串,则进行匹配;如果为token,则跳过:
for (let j = 0; j < patterns.length; ++j) { let pattern = patterns[j]; const lookbehind = !!pattern.lookbehind; let lookbehindLength = 0; pattern = pattern.pattern || pattern; for (let i = 0, pos = 0; i < strarr.length; pos += strarr[i].length, ++i) { const str = strarr[i]; if (str instanceof Token) { continue; } pattern.lastIndex = 0; // 重置正则 ... } } 复制代码
这里我们看到,每个规则只会在尚未被匹配的字符串中进行匹配,而语法规则是依次匹配进行匹配的,因此在定义语法规则时的顺序很重要。例如对于 /* #id { background: red } */
,应当整体被匹配为注释,不进一步处理其中的内容,因此注释在语法定义时应该优先级更高,否则中间的内容已经被处理而打断了字符串,注释就没办法再被匹配了。
接下去是匹配的过程。如果正则没匹配到结果,则直接continue;如果匹配到了结果,则把当前字符串分解为3部分:匹配到的内容转换为token,匹配到的内容的前后内容(若非空)。随后用 strarr.splice
将分解后的内容替换原字符串:
const match = pattern.exec(str); if (!match) { continue; } if(lookbehind) { lookbehindLength = match[1] ? match[1].length : 0; } const from = match.index + lookbehindLength; const matched = match[0].slice(lookbehindLength); const to = from + matched.length; const before = str.slice(0, from); const after = str.slice(to); const args = [i, 1]; if (before) { ++i; pos += before.length; args.push(before); } const wrapped = new Token(token, matched); args.push(wrapped); if (after) { args.push(after); } strarr.splice(...args); 复制代码
接下来,定义css语法,添加一条comment规则:
Prism.languages.css = { 'comment': /\/\*[\s\S]*?\*\//, }; 复制代码
测试一下:
const code = ` #id {} /* comment */ .class {} `.trim(); console.log(Prism.highlight(code, Prism.languages.css)); 复制代码
很好,已经能匹配css中的注释了 :)
接下来我们接着看Prism定义的语法高亮,同时再看看Prism的分词逻辑。
定义CSS语法高亮
const string = /("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/; Prism.languages.css = { 'comment': /\/\*[\s\S]*?\*\//, 'atrule': /@[\w-]+[\s\S]*?(?:;|(?=\s*\{))/, 'url': RegExp('url\\((?:' + string.source + '|[^\n\r()]*)\\)', 'i'), 'selector': RegExp('[^{}\\s](?:[^{};"\']|' + string.source + ')*?(?=\\s*\\{)'), 'string': string, 'property': /[-_a-z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/i, 'important': /!important\b/i, 'function': /[-a-z0-9]+(?=\()/i, 'punctuation': /[(){};:,]/ }; 复制代码
上面这些都是比较直观的,就不多说了。随便提一下,这里 property
和 function
里面的 (?=)
,是先行断言(positive lookahead),例如当 property
这里的 (?=\s:)
匹配 color:
这样的字符串,然后只取 color
这部分。
后行断言(lookbehind)的逻辑则相反,例如 /(^|[^\\:])\/\/.*/
匹配 // abc
中 // abc
的部分并返回开头的
。由于 曾经JS中的正则不支持后行断言
,因此Prism为pattern添加了lookbehind属性来处理这样的逻辑。
这时候写一个 background: url(https://www.example.com/1.png)
就能匹配出 url
: <span class="token url">url(https://www.example.com/1.png)</span>
。但是呢,还不够,Prism给CSS定义的 url
规则是这样的:
'url': { pattern: RegExp('url\\((?:' + string.source + '|[^\n\r()]*)\\)', 'i'), inside: { 'function': /^url/i, 'punctuation': /^\(|\)$/ } }, 复制代码
inside
诶,这 inside
又是个什么玩意儿呢?这个意思就是,分出这个词来还不够,对匹配出来的内容,得继续处理。
首先是从当前 pattern
获取inside:
for (let j = 0; j < patterns.length; ++j) { let pattern = patterns[j]; const inside = pattern.inside; ... } 复制代码
然后在创建 token
时,用 inside
作为语法继续分析:
const wrapped = new Token(token, inside ? _.tokenize(match, inside) : match); 复制代码
在 Token.stringify
和 encode
过程中,已经对 token.content
可能是字符串也可能是tokens数组的情况进行了兼容。
而用来匹配 media query
这样规则的 atrule
也用到了inside:
'atrule': { pattern: /@[\w-]+[\s\S]*?(?:;|(?=\s*\{))/, inside: { 'rule': /@[\w-]+/ } }, 复制代码
并在 atrule
中重用了整个CSS的高亮规则:
Prism.languages.css['atrule'].inside.rest = Prism.languages.css; 复制代码
tokenize
方法是这样处理 rest
的:
tokenize: function(text, grammar) { const strarr = [text]; const rest = grammar.rest; if (rest) { for (const token in rest) { grammar[token] = rest[token]; } delete grammar.rest; } _.matchGrammar(text, strarr, grammar); return strarr; }, 复制代码
这样,在对 atrule
进一步分词时,会首先匹配其中定义的 rule
,随后复用整个CSS语法的高亮规则。
用 @media screen and (max-width: 300px)
试一下,得到结果:
<span class="token atrule"><span class="token rule">@media</span> screen and <span class="token punctuation">(</span><span class="token property">max-width</span><span class="token punctuation">:</span> 300px<span class="token punctuation">)</span></span> 复制代码
棒 (๑•̀ㅂ•́)و✧
greedy
目前来说一切都不错,然而...有个坑。
刚才提到在分词时,会依次用规则就行匹配,例如 comment
的优先级高于 string
:
/* let's say 'Hello world' */ 复制代码
这段字符串,将被整体匹配为注释,不会再从里面把 'Hello world'
匹配出来,这个逻辑,没毛病。
但是呢,如果是下面的情况:
content: "a/*b*/c"; 复制代码
在处理过程中, /*b*/
先被作为注释匹配出去了,因此 "a/*b*/c"
也无法作为整体被匹配为字符串了,(⊙o⊙)…
为了处理这个问题,Prism在匹配时,加入了 greedy
这个设置。
为了支持greedy匹配,匹配逻辑将是这样的:
- 以i作为下标循环遍历strarr:如果当前项为token,则跳过;如果是字符串,判断当前模式是否为greedy
- 如果greedy为false,则使用之前的匹配逻辑:用当前模式匹配当前项
-
如果greedy为true,我们则用模式从当前项的起始位置开始匹配 原字符串
,如果能匹配上,我们从当前项开始往后遍历,找到匹配所覆盖的n个项的首位。例如,当字符串
content: "a/*b*/c"
被comment、property和punctuation模式(property和punctuation的优先级应该是低于string的,这里只是举个例子)分解为['content', ':', ' "a', '/*b*/', 'c']
,而string模式将匹配到"a/*b*/c"
,因此将执行strarr.splice(2, 3, ' ', '"a/**/c"')
将结果转换为['content', ':', '' ', 'a/**/c"'']
,同时将遍历的下标i往后移到2(字符串开始处的下标)。
同时需要让token实例记录原始文本长度来方便定位:
class Token { constructor(type, content, matched) { this.type = type; this.content = content; this.length = matched ? matched.length : 0; } ... } 复制代码
然后修改matchGrammar方法,主要是添加了greedy为true时的处理逻辑:
matchGrammar(text, strarr, grammar, index, startPos) { for (const token in grammar) { let patterns = grammar[token]; patterns = Array.isArray(patterns) ? patterns : [patterns]; for (let j = 0; j < patterns.length; ++j) { let pattern = patterns[j]; const inside = pattern.inside; const greedy = !!pattern.greedy; const lookbehind = !!pattern.lookbehind; let lookbehindLength = 0; // 加上'g'标志位,以使用lastIndex来匹配 if (greedy && !pattern.pattern.global) { const flags = pattern.pattern.toString().match(/[imuy]*$/)[0]; pattern.pattern = RegExp(pattern.pattern.source, flags + 'g'); } pattern = pattern.pattern || pattern; for (let i = index, pos = startPos; i < strarr.length; pos += strarr[i].length, ++i) { let str = strarr[i]; if (str instanceof Token) { continue; } let match; let delNum; let from; let to; if (greedy && i != strarr.length - 1) { pattern.lastIndex = pos; match = pattern.exec(text); if (!match) break; // 以下代码用来寻找匹配到的部分在strarr中的位置 from = match.index + (lookbehind ? match[1].length : 0); to = match.index + match[0].length; let k = i; let p = pos; for (const len = strarr.length; k < len && p < to; ++k) { p += strarr[k].length; if (from >= p) { ++i; pos = p; } } // 如果起始点是Token,则认为不合理,不处理 if (strarr[i] instanceof Token) { continue; } delNum = k - i; str = text.slice(pos, p); match.index -= pos; } else { pattern.lastIndex = 0; match = pattern.exec(str); delNum = 1; } if (!match) continue; if(lookbehind) { lookbehindLength = match[1] ? match[1].length : 0; } from = match.index + lookbehindLength; const matched = match[0].slice(lookbehindLength); to = from + matched.length; const before = str.slice(0, from); const after = str.slice(to); const args = [i, delNum]; if (before) { ++i; pos += before.length; args.push(before); } const wrapped = new Token(token, inside ? _.tokenize(matched, inside) : matched, matched); args.push(wrapped); if (after) { args.push(after); } strarr.splice(...args); } } } }, 复制代码
定义JS语法高亮
Prism首先定义了clike高亮规则:
(function (Prism) { Prism.languages.clike = { 'comment': [ { pattern: /(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/, lookbehind: true }, { pattern: /(^|[^\\:])\/\/.*/, lookbehind: true, greedy: true } ], 'string': { pattern: /(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/, greedy: true }, 'class-name': { pattern: /((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[\w.\\]+/i, lookbehind: true, inside: { punctuation: /[.\\]/ } }, 'keyword': /\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/, 'boolean': /\b(?:true|false)\b/, 'function': /\w+(?=\()/, 'number': /\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i, 'operator': /--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/, 'punctuation': /[{}[\];(),.:]/ }; })(Prism); 复制代码
这里涉及到的功能都介绍过了,各个正则的意思大家自己看一下吧 :)
然后在此基础上,扩展了Javascript语法:
Prism.languages.javascript = Prism.languages.extend('clike', { 'class-name': [ Prism.languages.clike['class-name'], { pattern: /(^|[^$\w\xA0-\uFFFF])[_$A-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\.(?:prototype|constructor))/, lookbehind: true } ], 'keyword': [ { pattern: /((?:^|})\s*)(?:catch|finally)\b/, lookbehind: true }, { pattern: /(^|[^.])\b(?:as|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/, lookbehind: true }, ], 'number': /\b(?:(?:0[xX](?:[\dA-Fa-f](?:_[\dA-Fa-f])?)+|0[bB](?:[01](?:_[01])?)+|0[oO](?:[0-7](?:_[0-7])?)+)n?|(?:\d(?:_\d)?)+n|NaN|Infinity)\b|(?:\b(?:\d(?:_\d)?)+\.?(?:\d(?:_\d)?)*|\B\.(?:\d(?:_\d)?)+)(?:[Ee][+-]?(?:\d(?:_\d)?)+)?/, // Allow for all non-ASCII characters (See http://stackoverflow.com/a/2008444) 'function': /[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/, 'operator': /-[-=]?|\+[+=]?|!=?=?|<<?=?|>>?>?=?|=(?:==?|>)?|&[&=]?|\|[|=]?|\*\*?=?|\/=?|~|\^=?|%=?|\?|\.{3}/ }); Prism.languages.javascript['class-name'][0].pattern = /(\b(?:class|interface|extends|implements|instanceof|new)\s+)[\w.\\]+/; Prism.languages.insertBefore('javascript', 'keyword', { 'regex': { pattern: /((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(\[(?:[^\]\\\r\n]|\\.)*]|\\.|[^/\\\[\r\n])+\/[gimyus]{0,6}(?=\s*($|[\r\n,.;})\]]))/, lookbehind: true, greedy: true }, // This must be declared before keyword because we use "function" inside the look-forward 'function-variable': { pattern: /[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/, alias: 'function' }, 'parameter': [ { pattern: /(function(?:\s+[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)?\s*\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\))/, lookbehind: true, inside: Prism.languages.javascript }, { pattern: /[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=>)/i, inside: Prism.languages.javascript }, { pattern: /(\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*=>)/, lookbehind: true, inside: Prism.languages.javascript }, { pattern: /((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*\s*)\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*\{)/, lookbehind: true, inside: Prism.languages.javascript } ], 'constant': /\b[A-Z](?:[A-Z_]|\dx?)*\b/ }); Prism.languages.insertBefore('javascript', 'string', { 'template-string': { pattern: /`(?:\\[\s\S]|\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}|[^\\`])*`/, greedy: true, inside: { 'interpolation': { pattern: /\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}/, inside: { 'interpolation-punctuation': { pattern: /^\${|}$/, alias: 'punctuation' }, rest: Prism.languages.javascript } }, 'string': /[\s\S]+/ } } }); 复制代码
具体语法不讲了,这里介绍下引入的两个方法:insertBefore和extend。
如我们前文所提到的,JS遍历对象的键的过程,也是有序的。对于字符串键,其遍历顺序是键创建的顺序。例如对于 Prism.languages.lang1 = { a: ..., c: ... }
,我们想把 b: ...
插到a和c之间该怎么做呢?
很简单,创建一个空对象 {}
,然后先后给对象设置a、b、c属性,然后 Prism.languages.lang1
赋值为这个新对象就可以啦:
insertBefore: function (inside, before, insert, root) { root = root || _.languages; const grammar = root[inside]; const ret = {}; for (const token in grammar) { if (token == before) { for (const newToken in insert) { ret[newToken] = insert[newToken]; } } ret[token] = grammar[token]; } const old = root[inside]; root[inside] = ret; return ret; }, }, 复制代码
extend则是复制目标语法后,添加新的语法规则:
extend: function (id, redef) { const lang = _.util.clone(_.languages[id]); for (const key in redef) { lang[key] = redef[key]; } return lang; }, 复制代码
util.clone方法:
objId(obj) { if (!obj['__id']) { Object.defineProperty(obj, '__id', { value: ++uniqueId }); } return obj['__id']; }, clone(o, visited) { let clone, id; const type = Object.prototype.toString.call(o).slice(8, -1); visited = visited || {}; switch (type) { case 'Object': id = _.util.objId(o); if (visited[id]) { return visited[id]; } clone = {}; visited[id] = clone; for (var key in o) { if (o.hasOwnProperty(key)) { clone[key] = _.util.clone(o[key], visited); } } return clone; case 'Array': id = _.util.objId(o); if (visited[id]) { return visited[id]; } clone = []; visited[id] = clone; o.forEach(function (v, i) { clone[i] = _.util.clone(v, visited); }); return clone; default: return o; } }, 复制代码
小结
以上,介绍了Prism实现语法高亮的大致流程,以及CSS和JS高亮的定义。除了以上介绍的流程之外,Prism还通过提供各个阶段的hooks,使得功能丰富的插件得以实现。
Prism使用了巧妙的机制满足了许多语法高亮的需求,使用者在定义语法高亮规则时并不必定义完整而复杂的BNF,使得Prism小巧简洁、使用方便。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Pro HTML5 Programming
Peter Lubbers、Brian Albers、Frank Salim / Apress / 2010-9-1 / USD 49.99
HTML5 is here, and with it, web applications take on a power, ease, scalability, and responsiveness like never before. In this book, developers will learn how to use the latest cutting-edge HTML5 web ......一起来看看 《Pro HTML5 Programming》 这本书的介绍吧!