从零写一个 Markdown 解析器:够用就好

本站的 Markdown 解析是自己写的,三百多行。完整实现 Markdown 规范很复杂,但支持自己会用到的语法并不难。

一、先想清楚要支持什么

写笔记真正用到的:

类型语法
标题# ~ ######
段落普通文本
代码块``` `lang ``` 围栏
行内代码` code `
强调**粗** *斜* ~~删~~
列表- / 1. ,支持嵌套
任务清单- [ ] / - [x]
引用>
表格管道符
分隔线---
链接/图片[]() ![]()

不支持的(我也用不到):脚注、定义列表、HTML 混排、复杂嵌套引用。

明确「不支持什么」比追求完整更重要。 半吊子的完整实现会到处出 bug。

二、整体思路

js
function render(src) {
  var lines = src.replace(/\r\n/g, "\n").split("\n");
  var out = "";
  var i = 0;

  while (i < lines.length) {
    // 1. 围栏代码块:整块吞掉,内部不再解析
    // 2. 标题
    // 3. 引用块
    // 4. 表格
    // 5. 列表(递归处理缩进)
    // 6. 分隔线
    // 7. 空行:结束当前段落
    // 8. 其他:普通段落
  }
  return out;
}

核心原则:块级从上往下扫描,行内单独处理。

三、必须先转义

js
function escapeHtml(s) {
  return String(s)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

// 行内解析:先转义,再套 Markdown 语法
function inline(text) {
  var s = escapeHtml(text);
  s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
  s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  return s;
}

顺序很重要:先转义用户输入,再生成自己的标签。反过来做的话,用户写 <script> 就会被真的执行。

顺便处理链接的协议白名单:

js
function safeUrl(u) {
  var t = String(u).trim();
  return /^(https?:|mailto:|#|\/|\.)/i.test(t) ? t : "#";
}
// 挡掉 javascript: 伪协议

四、代码块:优先保护

代码块里的 *、` `#` 都是字面量,不能被解析。所以遇到围栏就整块吞掉

js
if (/^\s*```/.test(lines[i])) {
  var lang = lines[i].replace(/^\s*```/, "").trim();
  var buf = [];
  i++;
  while (i < lines.length && !/^\s*```/.test(lines[i])) {
    buf.push(lines[i]);
    i++;
  }
  i++;   // 跳过结束围栏
  out += codeBlock(buf.join("\n"), lang);
  continue;
}

五、列表的递归处理

嵌套列表是最麻烦的部分。做法是先收集所有列表项带缩进量,再递归构建:

js
// 收集
var items = [];
while (i < lines.length) {
  var m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.+)$/);
  if (!m) break;
  items.push({
    indent: m[1].length,
    ordered: /\d/.test(m[2]),
    text: m[3]
  });
  i++;
}

// 递归构建
function buildList(items, idx, indent) {
  var ordered = items[idx].ordered;
  var html = ordered ? "<ol>" : "<ul>";
  while (idx < items.length) {
    var it = items[idx];
    if (it.indent < indent) break;
    if (it.indent > indent) {
      // 更深的缩进:递归,把结果塞进上一个 <li>
      var sub = buildList(items, idx, it.indent);
      html = html.replace(/<\/li>$/, sub.html + "</li>");
      idx = sub.idx;
      continue;
    }
    html += "<li>" + inline(it.text) + "</li>";
    idx++;
  }
  return { html: html + (ordered ? "</ol>" : "</ul>"), idx: idx };
}

任务清单只是一个变体:

js
var m = text.match(/^\[([ xX])\]\s+([\s\S]*)$/);
if (m) {
  var done = m[1] !== " ";
  var cls = "task-box" + (done ? " done" : "");
  return "<li class=\"task\"><span class=\"" + cls + "\"></span>"
       + "<span class=\"task-text\">" + inline(m[2]) + "</span></li>";
}

六、表格

检测「当前行含 |,且下一行是分隔行」:

js
function isTableDelim(line) {
  return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
}

function splitRow(line) {
  return line.replace(/^\s*\|/, "").replace(/\|\s*$/, "")
             .split("|").map(s => s.trim());
}

对齐方式从分隔行的冒号判断:

js
var align = cell.trim();
var a = /^:-+:$/.test(align) ? "center"
      : /^-+:$/.test(align) ? "right"
      : /^:-+/.test(align)  ? "left" : "";

七、标题生成 id

目录跳转需要锚点,所以渲染标题时顺手生成 id:

js
function slug(text) {
  return String(text).trim().toLowerCase()
    .replace(/[\s\/]+/g, "-")
    .replace(/[^\w\u4e00-\u9fa5-]/g, "")   // 保留中文、字母、数字、连字符
    .replace(/-+/g, "-")
    .replace(/^-|-$/g, "") || "section";
}

var id = slug(text);
out += "<h" + level + " id=\"" + id + "\">" + inline(text) + "</h" + level + ">";

保留中文很重要:如果只保留 w,中文标题会被清空,导致所有标题 id 相同。

八、段落:合并连续行

Markdown 里连续的非空行属于同一段落:

js
var buf = [];
while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) {
  buf.push(lines[i].trim());
  i++;
}
if (buf.length) out += "<p>" + inline(buf.join(" ")) + "</p>";

九、验证:标签闭合平衡

写完解析器,最重要的一步是批量验证所有文章都能正确渲染。本站写了个检查脚本:

bash
node -e "
global.window = {};
require(\"./assets/js/markdown.js\");
require(\"./assets/js/posts.js\");
const MD = window.Markdown;
let bad = 0;
window.POSTS.forEach(p => {
  const h = MD.render(p.content);
  const c = re => (h.match(re) || []).length;
  const errs = [];
  if (c(/<div\\b/g) !== c(/<\\/div>/g)) errs.push(\"div 不配对\");
  if (c(/<pre>/g) !== c(/<\\/pre>/g)) errs.push(\"pre 不配对\");
  if (/undefined/.test(h)) errs.push(\"含 undefined\");
  if (errs.length) { bad++; console.log(p.id, errs); }
});
console.log(bad ? bad + \" 篇异常\" : \"全部通过\");
"

这个检查在加每批文章时都会跑一遍,抓出过好几次问题。

十、值得用现成库吗

方案体积特点
自写~10KB只支持需要的语法,完全可控
marked~40KB功能全,生态好
markdown-it~100KB最完整,插件多

本站的原则是零依赖,所以自己写。如果你的项目已经有一堆依赖,直接装 marked 更实际——不要为了「练手」在生产项目里造轮子


自己写解析器最大的收获不是省了多少 KB,而是彻底搞清了 Markdown 到底是怎么被解析的。