今天,我们将学习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小巧简洁、使用方便。