[源码学习]PrismJS

2,487 阅读9分钟

今天,我们将学习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之前先处理编码问题,例如将&转化为&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小巧简洁、使用方便。

参考