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>
  );
}

如果你想在 React 裡用 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 攻擊