从零写一个 Markdown 解析器:够用就好
本站的 Markdown 解析是自己写的,三百多行。完整实现 Markdown 规范很复杂,但支持自己会用到的语法并不难。
一、先想清楚要支持什么
写笔记真正用到的:
| 类型 | 语法 |
|---|---|
| 标题 | # ~ ###### |
| 段落 | 普通文本 |
| 代码块 | ``` `lang ``` 围栏 |
| 行内代码 | ` code ` |
| 强调 | **粗** *斜* ~~删~~ |
| 列表 | - / 1. ,支持嵌套 |
| 任务清单 | - [ ] / - [x] |
| 引用 | > |
| 表格 | 管道符 |
| 分隔线 | --- |
| 链接/图片 | []() ![]() |
不支持的(我也用不到):脚注、定义列表、HTML 混排、复杂嵌套引用。
明确「不支持什么」比追求完整更重要。 半吊子的完整实现会到处出 bug。
二、整体思路
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;
}核心原则:块级从上往下扫描,行内单独处理。
三、必须先转义
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
// 行内解析:先转义,再套 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> 就会被真的执行。
顺便处理链接的协议白名单:
function safeUrl(u) {
var t = String(u).trim();
return /^(https?:|mailto:|#|\/|\.)/i.test(t) ? t : "#";
}
// 挡掉 javascript: 伪协议四、代码块:优先保护
代码块里的 *、` `、#` 都是字面量,不能被解析。所以遇到围栏就整块吞掉:
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;
}五、列表的递归处理
嵌套列表是最麻烦的部分。做法是先收集所有列表项带缩进量,再递归构建:
// 收集
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 };
}任务清单只是一个变体:
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>";
}六、表格
检测「当前行含 |,且下一行是分隔行」:
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());
}对齐方式从分隔行的冒号判断:
var align = cell.trim();
var a = /^:-+:$/.test(align) ? "center"
: /^-+:$/.test(align) ? "right"
: /^:-+/.test(align) ? "left" : "";七、标题生成 id
目录跳转需要锚点,所以渲染标题时顺手生成 id:
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 里连续的非空行属于同一段落:
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>";九、验证:标签闭合平衡
写完解析器,最重要的一步是批量验证所有文章都能正确渲染。本站写了个检查脚本:
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 到底是怎么被解析的。
