今天,咱們將學習PrismJS的源碼,看看它是怎麼支持CSS與Javascript的語法高亮的。javascript
PrismJS是一個前端代碼高亮庫,支持Markup、CSS、JS等多種語法的高亮顯示,其實現簡單小巧,擴展語法也很是方便,所以今天決定和你們一塊兒學習一下PrismJS的源碼。css
Prism語法高亮的過程整體而言,分爲兩個步驟:前端
例如,對於以下的css:java
#id .class { color: #ffffff; }
複製代碼
經過分詞將得到以下的單詞列表(包含單詞和未匹配爲單詞的字符串):git
[
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代碼:github
<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: 圖示單詞列表和語法樹bash
同時,爲了知足更多樣化的需求,Prism提供了詞法嵌套的功能,不過這和語法分析、構建語法樹仍是有着本質區別的。app
首先申明Token類,token實例會包含兩個屬性:type與conent,分別保存單詞的類型與內容:async
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對象:
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方法中,咱們將依次使用語法中定義的規則進行匹配,須要注意的是,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的分詞邏輯。
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
又是個什麼玩意兒呢?這個意思就是,分出這個詞來還不夠,對匹配出來的內容,得繼續處理。
首先是從當前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>
複製代碼
棒 (๑•̀ㅂ•́)و✧
目前來講一切都不錯,然而...有個坑。
剛纔提到在分詞時,會依次用規則就行匹配,例如comment
的優先級高於string
:
/* let's say 'Hello world' */ 複製代碼
這段字符串,將被總體匹配爲註釋,不會再從裏面把'Hello world'
匹配出來,這個邏輯,沒毛病。
可是呢,若是是下面的狀況:
content: "a/*b*/c";
複製代碼
在處理過程當中,/*b*/
先被做爲註釋匹配出去了,所以"a/*b*/c"
也沒法做爲總體被匹配爲字符串了,(⊙o⊙)…
爲了處理這個問題,Prism在匹配時,加入了greedy
這個設置。
爲了支持greedy匹配,匹配邏輯將是這樣的:
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);
}
}
}
},
複製代碼
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小巧簡潔、使用方便。