Markdown渲染器完整指南 - 主流解析器对比与实战教程

Markdown 渲染器是干嘛的?说白了就是把 .md 文件里的 Markdown 文本变成浏览器能看懂的 HTML。你在 GitHub 上看到的 README、掘金上的技术博客、Notion 导出的文档——背后都有一个 markdown parser 在干活。

这篇文章会帮你搞清楚三件事:渲染器怎么工作的、市面上主流的几个库各有什么特点、以及在实际项目里怎么选怎么用。

Markdown 渲染器是怎么工作的

不管你用的是 markdown-it、marked 还是别的什么库,核心流程都差不多,分三步走:

第一步,词法分析(Tokenization)。渲染器读入原始的 Markdown 文本,按照 CommonMark 规范把它拆成一个个 token。比如 ## 标题 会被识别为一个 heading token,**粗体** 会被拆成 ** + 文本 + **

第二步,构建中间结构。不同库的做法有差异:markdown-it 会构建一个 token 流然后直接输出 HTML;而 remark/unified 这类库会先构建完整的 AST(抽象语法树),让你可以在中间做各种变换。

第三步,生成 HTML。把中间结构映射成 HTML 标签输出。

Markdown 文本 → Token 流 → HTML 输出

这个流程听起来简单,但魔鬼在细节里。比如下面这段 Markdown:

- 第一层
  - 第二层
    - 第三层

不同渲染器对这种嵌套列表的解析结果可能不一样。有些严格要求缩进必须是空格(不能是 Tab),有些宽松一点。这也是为什么规范兼容性很重要——后面会详细聊。

主流 Markdown 渲染器对比

JavaScript 生态里有不少 markdown 转 html 的库,但真正广泛使用的主要是下面这几个。

markdown-it:功能最全的选择

markdown-it 是目前生态最成熟的选择。它严格遵循 CommonMark 规范,默认就是安全的(不会把用户输入里的 <script> 标签直接渲染出来),而且有非常丰富的插件生态。

const MarkdownIt = require('markdown-it');
const md = new MarkdownIt();

const result = md.render('# Hello Markdown');
// <h1>Hello Markdown</h1>

markdown-it 支持很多配置项,常用的几个:

const md = new MarkdownIt({
  html: false,        // 禁止在 Markdown 中写原生 HTML
  linkify: true,      // 自动把 URL 转成链接
  typographer: true,  // 启用排版优化(比如把 (c) 转成 ©)
  highlight: function (str, lang) {
    // 自定义代码高亮,可以接入 highlight.js
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(str, { language: lang }).value;
    }
    return '';
  }
});

说实话,markdown-it 最大的卖点不是性能(虽然也不差),而是它的插件体系。你想要脚注、数学公式、定义列表、emoji、容器块……都有现成的插件:

const md = new MarkdownIt()
  .use(require('markdown-it-footnote'))    // 脚注支持
  .use(require('markdown-it-mark'))        // ==标记文本==
  .use(require('markdown-it-emoji'));      // :emoji: 支持

md.render('这是一个脚注[^1]\n\n[^1]: 脚注内容');

21k+ GitHub stars,VS Code、Dillinger 这些知名项目都在用它,社区依赖者超过 88 万。如果你需要一个"不会出错"的选择,选它基本不会后悔。

marked:追求速度的首选

marked 的定位很明确:快。它是一个低级别的编译器,不搞 AST 那套复杂的东西,直接把 Markdown 编译成 HTML。

const { marked } = require('marked');

const html = marked.parse('# Hello **Markdown**');
// <h1>Hello <strong>Markdown</strong></h1>

marked 的代码量少、包体积小(压缩后约 19KB),解析速度在基准测试里通常是几个主流库里最快的。它对 CommonMark 0.31 的通过率达到 98%,GFM 0.29 通过率 97%,兼容性也没问题。

但它有一个需要特别注意的点:marked 默认不过滤 HTML。也就是说用户如果输入 <script>alert('xss')</script>,marked 会原样输出。官方文档明确建议配合 DOMPurify 这类库一起用。

我曾经在一个博客系统里用 marked 渲染用户提交的评论,当时没注意到这个问题,结果有人提交了一条包含 <script> 标签的评论,直接在页面上弹了个 alert。后来加了 DOMPurify 做消毒才解决:

const { marked } = require('marked');
const DOMPurify = require('isomorphic-dompurify');

const dirty = marked.parse(userInput);
const clean = DOMPurify.sanitize(dirty);

36k+ stars,依赖者超过 150 万,marked 在社区里使用非常广泛。

showdown:经典的双向转换器

showdown 是个比较有年头的老牌库了。它最大的特点是支持双向转换——不仅能把 Markdown 转成 HTML,还能把 HTML 转回 Markdown。

const showdown = require('showdown');
const converter = new showdown.Converter();

const html = converter.makeHtml('# Hello');
// <h1>Hello</h1>

const md = converter.makeMd('<h1>Hello</h1>');
// # Hello

showdown 的配置选项非常丰富(30 多个),支持 GitHub Flavored Markdown 的表格、任务列表、删除线等特性,还有 Angular、Vue 的封装库(ng-showdown、vue-showdown)。

不过 showdown 和 marked 一样,默认不过滤 HTML,存在 XSS 风险。而且项目更新频率不如 markdown-it 和 marked 活跃。

remark/unified:插件化生态方案

remark 是 unified 生态系统的一部分。它的思路跟前面几个库不太一样:先把 Markdown 解析成 AST,然后你可以通过插件对 AST 做任意变换,最后再输出 HTML。

Markdown → remark AST → 插件变换 → HTML

这种架构的优势在于灵活性极高。你想自动给标题加 ID、检查 Markdown 语法规范、把相对链接转成绝对链接、甚至支持 MDX——都有对应的插件。

const { unified } = require('unified');
const remarkParse = require('remark-parse');
const remarkRehype = require('remark-rehype');
const rehypeStringify = require('rehype-stringify');

const result = await unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process('# Hello Markdown');

Next.js、Astro、Remix 这些现代框架在处理 Markdown 时底层都用的是 remark。如果你的项目本身就在用这些框架,直接用 remark 是最自然的选择。

代价是学习曲线陡一些,包体积也可能更大(取决于你用了哪些插件)。

如何选择 Markdown 渲染器

说了这么多,到底该选哪个?我的建议是根据场景来:

场景推荐理由
通用 Web 项目markdown-it功能全面、插件丰富、默认安全
追求极致性能marked解析速度最快、包体积小
需要双向转换showdown唯一支持 MD↔HTML 的方案
Next.js/Astro 等构建框架remark/unified原生支持、MDX 兼容
超轻量场景marked 或更小的库按需选择最小包

对了,如果你的项目里已经用了某个框架(比如 React 或 Vue),很多时候不需要直接操作底层渲染库,可以用框架封装好的组件——下面会讲到。

在 React 中渲染 Markdown

React 项目里渲染 Markdown,最直接的方案是用 react-markdown 组件。它底层基于 remark/unified,用起来比手动操作 markdown-it 再塞进 dangerouslySetInnerHTML 要安全得多:

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

function MarkdownViewer({ content }) {
  return (
    <ReactMarkdown remarkPlugins={[remarkGfm]}>
      {content}
    </ReactMarkdown>
  );
}

如果你想用 markdown-it 也没问题,但要注意安全问题。我在早期项目里用过这种方式,后来发现 dangerouslySetInnerHTML 这个名字本身就是在提醒你——这里有风险:

import MarkdownIt from 'markdown-it';
import DOMPurify from 'isomorphic-dompurify';

const md = new MarkdownIt({ html: false });

function MarkdownViewer({ content }) {
  const html = DOMPurify.sanitize(md.render(content));
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

如果还需要代码高亮,可以配合 rehype-highlight 插件(react-markdown 方案)或者手动在 markdown-it 里配置 highlight.js。

在 Vue 中渲染 Markdown

Vue 项目里,选择也不少。

方案一:markdown-it + v-html

<template>
  <div class="markdown-body" v-html="renderedHtml"></div>
</template>

<script setup>
import { ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';

const md = new MarkdownIt({ html: false, linkify: true });

const props = defineProps({ source: String });
const renderedHtml = ref('');

watch(() => props.source, (val) => {
  renderedHtml.value = md.render(val || '');
}, { immediate: true });
</script>

方案二:vue-showdown

如果你喜欢 showdown 的双向转换能力,可以直接用 vue-showdown 组件:

<template>
  <VueShowdown :markdown="source" flavor="github" />
</template>

两种方案各有优劣。markdown-it 更灵活,插件更多;vue-showdown 开箱即用更省事。

安全性:渲染 Markdown 时必须注意的事

这个话题值得单独拿出来说,因为很多教程都一笔带过了。

当你把用户提交的 Markdown 内容渲染成 HTML 时,必须考虑 XSS(跨站脚本攻击)。三种情况:

情况一:内容来自可信来源(比如你自己写的文档)。这种情况下安全性问题不大,但最好还是把 html 选项设为 false,避免意外的 HTML 注入。

情况二:内容来自用户提交。这是最危险的情况。用户可以轻易在 Markdown 中嵌入 <script> 标签、javascript: 协议链接、或者 onerror 事件。markdown-it 默认会过滤这些,但 marked 和 showdown 不会。

情况三:需要在 Markdown 中使用 HTML。有时候你确实需要写 HTML(比如嵌入视频),这时就需要一个 HTML 消毒库。

无论哪种情况,只要你把 Markdown 渲染成 HTML 塞进页面,加一层 DOMPurify 都是好的习惯:

import DOMPurify from 'isomorphic-dompurify';

// 不管用的什么解析器,输出都过一遍消毒
const safeHtml = DOMPurify.sanitize(parser.render(userInput));

CommonMark 规范为什么重要

你可能看到过 CommonMark 这个词。它是一个努力让 Markdown 语法标准化的项目——因为 Markdown 的发明者 John Gruber 最初并没有给出一个精确的规范,导致不同的 markdown parser 对同一段文本的解析结果可能不一致。

举个例子,下面这段 Markdown:

**bold *italic***

**bold *italic** text*

不同的渲染器可能给出不同的 HTML 输出。CommonMark 规范就是为了消除这种歧义。

markdown-it 是 CommonMark 规范的忠实实现者,这也是为什么很多人在正式项目里选它——行为可预测,不会出现"换个渲染器效果就不一样"的问题。marked 也做到了 98% 的 CommonMark 兼容性,日常使用差别不大,但在一些边缘情况下可能会和 markdown-it 的输出不同。

代码高亮集成

代码高亮是 Markdown 渲染器最常见的搭配功能。不管你选哪个解析库,接入代码高亮的方式都差不多。

markdown-it + highlight.js

const hljs = require('highlight.js');
const md = require('markdown-it')({
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return '<pre class="hljs"><code>' +
          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
          '</code></pre>';
      } catch (__) {}
    }
    return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
  }
});

marked + highlight.js

marked 的配置方式类似,通过 marked.setOptions 设置:

const { marked } = require('marked');
const hljs = require('highlight.js');

marked.setOptions({
  highlight: function(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value;
    }
    return code;
  }
});

记得在页面中引入 highlight.js 的 CSS 主题文件,不然只有代码标记没有颜色。

自定义渲染行为

有时候你需要改变渲染器的默认输出,比如给所有链接加 target="_blank"、给图片加 lazy loading、或者自定义标题的 ID 生成规则。

markdown-it 自定义渲染规则

const md = require('markdown-it')();

// 给所有链接加 target="_blank"
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
  return self.renderToken(tokens, idx, options);
};

md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
  tokens[idx].attrSet('target', '_blank');
  tokens[idx].attrSet('rel', 'noopener noreferrer');
  return defaultRender(tokens, idx, options, env, self);
};

marked 自定义 renderer

const { marked } = require('marked');

const renderer = {
  heading(text, depth) {
    const slug = text.toLowerCase().replace(/[^\w]+/g, '-');
    return `<h${depth} id="${slug}">${text}</h${depth}>`;
  },
  image(href, title, text) {
    return `<img src="${href}" alt="${text}" loading="lazy" />`;
  }
};

marked.use({ renderer });

两种方式都不复杂,但 markdown-it 的 token 操作更灵活一些,适合需要精细控制的场景。

参考来源

  1. CommonMark Spec — Markdown 语法标准化规范
  2. markdown-it GitHub — markdown-it 官方仓库及文档
  3. Marked.js 官方文档 — marked 渲染器规范兼容性报告
  4. MDN: innerHTML 安全性 — 关于 innerHTML 和 XSS 安全说明
  5. DOMPurify — HTML 消毒库,用于防范 XSS 攻击