[源碼學習]PrismJS

今天,咱們將學習PrismJS的源碼,看看它是怎麼支持CSS與Javascript的語法高亮的。javascript

PrismJS是一個前端代碼高亮庫,支持Markup、CSS、JS等多種語法的高亮顯示,其實現簡單小巧,擴展語法也很是方便,所以今天決定和你們一塊兒學習一下PrismJS的源碼。css

代碼結構

分詞

Prism語法高亮的過程整體而言,分爲兩個步驟:前端

  • 分詞(tokenize):使用選定的語法規則對目標代碼進行分詞
  • 組裝代碼(stringify):根據分詞的結果,組裝HTML代碼,將單詞用帶有特定class的標籤(默認爲span)包裹起來

例如,對於以下的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類,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對象

接下來,定義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以前先處理編碼問題,例如將&轉化爲&amp;

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, '&amp;').replace(/</g, '&lt;').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': /[(){};:,]/
};
複製代碼

上面這些都是比較直觀的,就很少說了。隨便提一下,這裏propertyfunction裏面的(?=),是先行斷言(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.stringifyencode過程當中,已經對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小巧簡潔、使用方便。

參考

相關文章
相關標籤/搜索