13

本文引至: please call me hr

因为本人平时写作方式就是使用的markdown, 感觉有些引擎解析快,有些慢. 但又无可奈何. 就像:

我就喜欢你看不惯我,又干不掉我的样子

所以, 这里,相对markdown语法引擎做一个简单分析。或者说,自己动手来写一个micro-markdown-parser.
markdown 引擎其实并不复杂,只要你得到了对应的regexp,然后替换一下HTML tag即可. 目前市面上流行的几种markdown 解析器 无外乎就是: marker,markdown-js.
一开始,markdown是由John Gruber用Perl写出来的语法解析器. 由于md在后面过于火爆,出现了不同的支持引擎. 不过,后面在github上,提出了GFM (Github Flavored Markdown) 这一个标准之后. 大部分引擎的解析规范也得到了统一.
最最基本的一个md引擎,应该需要能够解析: Inline HTML, Automatic paragraphs, headers, blockquotes, lists, code blocks, horizontal rules, links, emphasis, inline code and images 这几种. 详情,可以参考: md features
接下来,我们正式的 make a md parser.

前期准备

关于md parser 最最基本的就是正则和exec方法. 先简单说一下exec方法吧.

正则的exec

exec是用来在特定str中,匹配指定正则的方法. 实际上可以使用String.prototype.match代替.基本使用为:

regexObj.exec(str)

返回值为 array(匹配到) 和 null (没有匹配到)
如果返回array则:

  • [1]...[n]: 正则分组匹配到的内容.

  • index: 正则开始匹配到string的位置

  • input: 原始的string

具体的demo:

var re = /quick\s(brown).+?(jumps)/ig;
var result = re.exec('The Quick Brown Fox Jumps Over The Lazy Dog');

// 结果为
[ 'Quick Brown Fox Jumps',
  'Brown',
  'Jumps',
  index: 4,
  input: 'The Quick Brown Fox Jumps Over The Lazy Dog' ]

然后是基本的正则匹配:

基本正则

正则表达式很容易去源码里翻一翻就找到了.

  regexobject: {
    headline: /^(\#{1,6})([^\#\n]+)$/m,
    code: /\s\`\`\`\n?([^`]+)\`\`\`/g,
    hr: /^(?:([\*\-_] ?)+)\1\1$/gm,
    lists: /^((\s*((\*|\-)|\d(\.|\))) [^\n]+)\n)+/gm,
    bolditalic: /(?:([\*_~]{1,3}))([^\*_~\n]+[^\*_~\s])\1/g,
    links: /!?\[([^\]<>]+)\]\(([^ \)<>]+)( "[^\(\)\"]+")?\)/g,
    reflinks: /\[([^\]]+)\]\[([^\]]+)\]/g,
    smlinks: /\@([a-z0-9]{3,})\@(t|gh|fb|gp|adn)/gi,
    mail: /<(([a-z0-9_\-\.])+\@([a-z0-9_\-\.])+\.([a-z]{2,7}))>/gmi,
    tables: /\n(([^|\n]+ *\| *)+([^|\n]+\n))((:?\-+:?\|)+(:?\-+:?)*\n)((([^|\n]+ *\| *)+([^|\n]+)\n)+)/g,
    include: /[\[<]include (\S+) from (https?:\/\/[a-z0-9\.\-]+\.[a-z]{2,9}[a-z0-9\.\-\?\&\/]+)[\]>]/gi,
    url: /<([a-zA-Z0-9@:%_\+.~#?&\/=]{2,256}\.[a-z]{2,4}\b(\/[\-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)?)>/g
  }

本文参考的是一个教学用的markdown语法parser.github源码 有兴趣,可以查看一下. 读起来非常简单.没有过多的逻辑处理. 所以,这里也是基于这个来进行讲解的.

简单匹配

最简单的匹配应该算headline. 他的正则表达式为: /^(\#{1,6})([^\#\n]+)$/m. 后面的m非常重要. 因为,所有的标题应该是写在首行的,如:

# abc
## sub_abc

使用m flag 来作为首行匹配标识符.完美~
然后,只需要进行一个循环即可.

var headling = /^(\#{1,6})([^\#\n]+)$/m
while ((stra = headline.exec(str)) !== null) {
      count = stra[1].length;
      str = str.replace(stra[0], '<h' + count + '>' + stra[2].trim() + '</h' + count + '>').trim();
    }

当然,这里并不涉及到完全性的处理. 最简单的方式就是过滤字符串,不过过滤字符串也有很多方法. 最直接的就是replace直接替换.

function escape(html, encode) {
  return html
    .replace(!encode ? /&(?!#?\w+;)/g : /&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

这就算一个简单的替换. 另外,还有一种是使用textNode内置的替换方案

// 使用textNode内置的替换引擎,将 < > $等字符替换. 但不会替换' 和 "
var escape = function(str) {
    'use strict';
    var div = document.createElement('div');
    div.appendChild(document.createTextNode(str));
    str = div.innerHTML;
    div = undefined;
    return str;
}

则上面的内容则可以写为:

var headling = /^(\#{1,6})([^\#\n]+)$/m
while ((stra = headline.exec(str)) !== null) {
      count = stra[1].length;
      str = str.replace(stra[0], '<h' + count + '>' + escape(stra[2].trim()) + '</h' + count + '>').trim();
    }

实际上基于这点,我们就可以进行简单的发散. 比如marked.js 根据正则提出了自定义化的匹配模式.

marked.js feature

一些正则细节和匹配细节,我们这里就不过多探讨了, 因为处理的内容主要是\r\n ' ". 我们这里,简单的来看一下marked.js 里面的一些精华部分. 特别是他提出来的可自定义化的正则样式. 即,Renderer方法.

// 官方提出的demo
var marked = require('marked');
var renderer = new marked.Renderer();

renderer.heading = function (text, level) {
  var escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');

  return '<h' + level + '><a name="' +
                escapedText +
                 '" class="anchor" href="#' +
                 escapedText +
                 '"><span class="header-link"></span></a>' +
                  text + '</h' + level + '>';
},

console.log(marked('# heading+', { renderer: renderer }));

我们可以看一下他源码里面的思路:
首先,他有一个Renderer的构造函数:

function Renderer(options) {
  this.options = options || {};
}

接着就是绑定在prototype上面的方法:

Renderer.prototype.blockquote = function(quote) {
  return '<blockquote>\n' + quote + '</blockquote>\n';
};

可能有的童鞋会想,这里他并没有做什么语法解析呢?
亲, 请注意他的参数quote. 然后再看他的渲染内容,就一目了然. quote 是已经转义过后匹配的内容.
我们接着,来看一下调用方法:

// url (gfm)
 if (!this.inLink && (cap = this.rules.url.exec(src))) {
   src = src.substring(cap[0].length);
   text = escape(cap[1]);
   href = text;
   // out 这里是指全部输出的结果.
   out += this.renderer.link(href, null, text);
   continue;
 }

有童鞋可能又会疑问了, 你正则不是全部匹配的吗? 这样做不会遗漏信息吗?
所以说, marked.js为了实现自定义话的模式,牺牲了性能.我们看一下他的正则表达式即可:

var block = {
  newline: /^\n+/,
  code: /^( {4}[^\n]+\n*)+/,
  fences: noop,
  hr: /^( *[-*_]){3,} *(?:\n+|$)/,
  heading: /^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)/,
  lheading: /^([^\n]+)\n *(=|-){2,} *(?:\n+|$)/,
  blockquote: /^( *>[^\n]+(\n(?!def)[^\n]+)*\n*)+/,
  list: /^( *)(bull) [\s\S]+?(?:hr|def|\n{2,}(?! )(?!\1bull )\n*|\s*$)/,
  html: /^ *(?:comment *(?:\n|\s*$)|closed *(?:\n{2,}|\s*$)|closing *(?:\n{2,}|\s*$))/,
  def: /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/,
  paragraph: /^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/,
  text: /^[^\n]+/
};

可以看出,他没有添加任何的pattern... 这就是marked.js精妙的地方. 所以, 上面的out 看起来,也并没有什么神奇的地方了:

out += this.renderer.link(href, null, text);

因此, 通过将renderer对象中方法的override. 造成自定义的效果. 这也是灰常好的. 另外,还有一点需要讲解一下,就是marked.js构造的注释替换的方法.

function replace(regex, opt) {
  regex = regex.source;
  opt = opt || '';
  return function self(name, val) {
    if (!name) return new RegExp(regex, opt);
    val = val.source || val;
    val = val.replace(/(^|[^\[])\^/g, '$1');
    regex = regex.replace(name, val);
    return self;
  };
}
// 看一下他的调用方法
// 相面的block.xxx 都是正则表达式,我这里就不赘述了
block.paragraph = replace(block.paragraph)
  ('hr', block.hr)
  ('heading', block.heading)
  ('lheading', block.lheading)
  ('blockquote', block.blockquote)
  ('tag', '<' + block._tag)
  ('def', block.def)
  ();
// 实际上,这个方法运行的结果是生成一个新的正则表达式. 即,把上面用单词的地方替换为指定的正则
// 例如 paragraph 里面的hr, heading
 paragraph: /^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/

当然,也有其他的实现方式。 只是marked.js在这里做的比较完美.

marked 实际解析顺序

前面提到了使用out+=的方式进行解析. 当然,可能会想到下列问题:
段落嵌套语法怎么解析的呢?
这实际上,他在嵌套的语法层里,并没有做out+= 可以看下列源码:

// code
if (cap = this.rules.code.exec(src)) {
  src = src.substring(cap[0].length);
  cap = cap[0].replace(/^ {4}/gm, '');
  this.tokens.push({
    type: 'code',
    text: !this.options.pedantic
      ? cap.replace(/\n+$/, '')
      : cap
  });
  continue;
}

他在这里传了一个tokens, 然后 传到外层这里再次进行的解析.

Parser.prototype.tok = function() {
  switch (this.token.type) {
    case 'space': {
      return '';
    }
    case 'hr': {
      return this.renderer.hr();
    }
    ...
} 

所以, marked.js为了完成自定义化的解析真的是挖了一个很大的坑. 但相对于全局匹配在替换的模式来说, 这样灵活性大一点。

flexibility + speed = const

ok, 现在我们已经简单的了解了大局方面的marked.js解析原理. 接下来,我们来看一下比较难的code解析。

code 解析原理

如果只是表层的code解析,非常简单. 使用下面的正则表达式即可

code: /\s?\`\`\`\n?([^`]+)\`\`\`/g

但是,这样仅仅只是替换出下列格式.

<pre>
    <code>
        ....
    </code>
<pre>

并没有像下面这样,带上颜色的匹配.

var a =1;
var b =2;

简单的替换原理也很好解释.就是给指定的span添加上不同的class即可.

// 替换:

's'

// 生成span
<span class="str">'abc'</span>

它里面的解析机制,主要就是根据不同的语法正则来添加不同的className.
具体,我们可以参照 highlight.js里面的源码:

  function highlightBlock(block) {
    var node, originalStream, result, resultNode, text;
    var language = blockLanguage(block);
    text = node.textContent;
    ...
    result = language ? highlight(language, text, true) : highlightAuto(text);
    ...
  }

通过blockLanguage 找出指定的code的编程语言. 查找细节有一个方法比较重要:

function registerLanguage(name, language) {
  var lang = languages[name] = language(hljs);
  if (lang.aliases) {
  lang.aliases.forEach(function(alias) {aliases[alias] = name;});
  }
}

该方法用来手动将language的配置文件挂载到里面。 我们看一看js的配置文件

/*
Language: JavaScript
Category: common, scripting
*/

function(hljs) {
  return {
    aliases: ['js', 'jsx'],
    keywords: {
      keyword:
        'in of if for while finally var new function do return void else break catch ' +
        'instanceof with throw case default try this switch continue typeof delete ' +
        'let yield const export super debugger as async await static ' +
        // ECMAScript 6 modules import
        'import from as'
      ,
      literal:
        'true false null undefined NaN Infinity',
      built_in:
        'eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent ' +
    ...
}

然后通过指定的正则来进行匹配和替换. 所以, 一般的md parser引擎解析并不会自带code解析, 因为是在太复杂了... 编程语言这么多.. 这么搞的玩. 所以, highlight 自己自定义了一套 common 机制. 一方, 没有传入指定language的情况.

hljs.COMMENT = function (begin, end, inherits) {
    var mode = hljs.inherit(
      {
        className: 'comment',
        begin: begin, end: end,
        contains: []
      },
      inherits || {}
    );
    mode.contains.push(hljs.PHRASAL_WORDS_MODE);
    mode.contains.push({
      className: 'doctag',
      begin: "(?:TODO|FIXME|NOTE|BUG|XXX):",
      relevance: 0
    });
    return mode;
  };
  hljs.C_LINE_COMMENT_MODE = hljs.COMMENT('//', '$');
  hljs.C_BLOCK_COMMENT_MODE = hljs.COMMENT('/\\*', '\\*/');
  hljs.HASH_COMMENT_MODE = hljs.COMMENT('#', '$');
  hljs.NUMBER_MODE = {
    className: 'number',
    begin: hljs.NUMBER_RE,
    relevance: 0
  };
  hljs.C_NUMBER_MODE = {
    className: 'number',
    begin: hljs.C_NUMBER_RE,
    relevance: 0
  };
  hljs.BINARY_NUMBER_MODE = {
    className: 'number',
    begin: hljs.BINARY_NUMBER_RE,
    relevance: 0
  };
  hljs.CSS_NUMBER_MODE = {
    className: 'number',
    begin: hljs.NUMBER_RE + '(' +
      '%|em|ex|ch|rem'  +
      '|vw|vh|vmin|vmax' +
      '|cm|mm|in|pt|pc|px' +
      '|deg|grad|rad|turn' +
      '|s|ms' +
      '|Hz|kHz' +
      '|dpi|dpcm|dppx' +
      ')?',
    relevance: 0
  };

不说了,最近被面试官调戏,心情比较差... 在博客最后放一个鸡汤.

mdzz, 说好约定的时间呢? 连时间都不遵守的面试官...请自重


villainhr
7.8k 声望2.2k 粉丝