使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑

作者:听澜时间:2026-11-03 08:44:37浏览:657

深入讲解如何使用 marked.js 的 Renderer 自定义代码块语法高亮与超链接渲染逻辑,结合 highlight.js 实现语言检测、防 XSS 转义、外部链接新窗口跳转与防钓鱼安全属性注入。

marked.js highlight.js 前端工程 安全防护 阅读时长约 8 分钟
在基于 marked.js 构建 Markdown 解析系统时,默认生成的 HTML 往往无法满足实际业务需求——代码块缺乏语法高亮与行号结构,超链接无法识别站内外域名且缺少防钓鱼安全属性。marked.js 提供了 Renderer 机制,允许开发者拦截特定语法节点的输出过程。本文将直接讲解如何通过扩展 Renderer 的 code 与 link 方法,实现与 highlight.js 的高亮集成、防 XSS 注入安全转义,以及外链新窗口打开并注入 noopener noreferrer 的完整实现。
使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑
图 1:marked.js 自定义 Renderer 渲染与拦截处理管道概览

一、marked.js 渲染架构与 Renderer 拦截机制

marked.js 的核心工作过程可以清晰地划分为两个阶段:词法解析(Lexing)与HTML 渲染(Rendering)。在词法解析阶段,marked.js 会扫描原始 Markdown 文本,根据通用 CommonMark 与 GFM 规范将其切分为由平铺或嵌套 Token 构成的抽象语法流。进入渲染阶段后,内置的遍历器会依次遍历这些 Token,并根据节点类型分发给 Renderer 实例上的同名方法。

例如,遇到围栏代码块时,引擎会调用 renderer.code();遇到行内超链接时,引擎会调用 renderer.link()。默认情况下,这些方法仅生成最基础的 HTML 标签:代码块被简单包裹进 <pre><code>,超链接仅输出带有 href 的基础 <a> 标签。

重载 Renderer 的本质,就是在引擎生成 HTML 字符串的关口挂载自定义拦截器。拦截器接收当前节点的上下文属性(例如代码文本、语言标识、跳转地址、文本标题等),并在函数内完成高亮着色、属性补充或安全过滤后,将格式化好的最终 HTML 字符串返回给 marked.js 拼装。

使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑
图 2:Marked.js 词法解析与 Renderer 钩子分发链路

二、环境准备与扩展接入规范

在实现具体渲染逻辑前,先在项目中安装 marked 以及主流的语法高亮库 highlight.js。如果是在 Node.js 服务端或现代前端打包工程(如 Vite、Webpack)中,直接执行包管理命令:

# 安装 marked 核心库与 highlight.js 高亮依赖
npm install marked highlight.js

在 marked.js 的不同演进版本中,扩展 Renderer 的语法存在规范演进。在 marked v4 之后的现代版本中,官方推荐使用 marked.use() 插件机制注册自定义渲染方法,这样可以保持模块化隔离,避免直接篡改全局实例:

import { marked } from 'marked';
import hljs from 'highlight.js';

// 现代 marked 插件化扩展骨架
marked.use({
  renderer: {
    code(token) {
      // 自定义代码块逻辑
    },
    link(token) {
      // 自定义链接逻辑
    }
  }
});
入参形态兼容性说明: marked.js 在升级到 v10+ 之后,Renderer 的方法参数逐渐由传统的扁平多参数(如 code(code, lang))向单一 token 对象参数(如 code({ text, lang }))平滑演进。编写健壮的拦截器应当在函数入口处进行兼容解构,确保在各个版本环境中均可无缝执行。
使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑
图 3:自定义 Renderer 落地实施四步流程

三、自定义代码块渲染:语法高亮与安全转义

自定义 code 渲染器需要达成三个关键目标:

  1. 语言识别与高亮映射:根据代码块后跟随的语言标签(如 ```javascript),调用 highlight.js 生成带语义类名的高亮 HTML 标签片段。
  2. 未知语言与空语言兜底:对于用户未指定语言或输入了 highlight.js 无法识别的语言,必须进行安全的 HTML 字符实体转义,防止潜在的 XSS 攻击或页面结构崩塌。
  3. 外层结构包装:输出包含语言提示徽标(Language Badge)或方便后续添加一键复制按钮的外层容器。

实现代码块拦截器的具体逻辑如下:

// 基础 HTML 转义函数,处理兜底场景
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

const customCodeRenderer = function(token, maybeLang) {
  // 兼顾现代对象参数与旧版平铺参数
  const rawCode = (typeof token === 'object' && token !== null) ? token.text : token;
  const langString = ((typeof token === 'object' && token !== null) ? token.lang : maybeLang) || '';
  
  // 提取首个单词作为语言标识(忽略后面可能跟随的元信息如文件名)
  const language = langString.trim().split(/\s+/)[0].toLowerCase();
  
  let highlightedContent = '';
  let validLanguage = false;

  // 检查 highlight.js 是否支持该语言
  if (language && hljs.getLanguage(language)) {
    try {
      highlightedContent = hljs.highlight(rawCode, { language }).value;
      validLanguage = true;
    } catch (err) {
      // 高亮引擎异常时回退到纯文本转义
      highlightedContent = escapeHtml(rawCode);
    }
  } else {
    // 未指定语言或不支持的语言,执行纯字符转义
    highlightedContent = escapeHtml(rawCode);
  }

  // 格式化输出:外层带有语言属性、代码类名与徽章
  const langClass = validLanguage ? ` language-${language}` : '';
  const langBadge = validLanguage ? `<span class="code-badge">${language}</span>` : '';

  return `
<div class="code-block-wrapper">
  ${langBadge}
  <pre><code class="hljs${langClass}">${highlightedContent}</code></pre>
</div>`;
};

在这段实现中,我们通过 hljs.getLanguage(language) 显式校验语言可用性。若直接调用 hljs.highlightAuto,在面对长代码或大量混合文本时不仅存在性能瓶颈,还极易发生语种误判。

四、自定义超链接渲染:外链分流与安全防御

在网页中渲染用户或外部传入的 Markdown 链接时,面临两个普遍痛点:其一是所有外链默认在当前标签页打开,打断了用户的阅读流;其二是直接渲染未知链接存在反向标签页劫持(Reverse Tabnabbing)安全隐患以及 javascript: 伪协议跨站脚本注入(XSS)。

通过自定义 renderer.link,我们可以对跳转行为进行细粒度控制:

const customLinkRenderer = function(token, maybeTitle, maybeText) {
  // 兼容对象传参与平铺传参
  let href = '';
  let title = '';
  let text = '';

  if (typeof token === 'object' && token !== null) {
    href = token.href || '';
    title = token.title || '';
    text = token.text || '';
  } else {
    href = token || '';
    title = maybeTitle || '';
    text = maybeText || '';
  }

  // 1. 安全协议白名单校验:只允许 http, https, mailto, tel 以及相对路径或锚点
  const isSafeProtocol = /^(https?:|mailto:|tel:|\/|#)/i.test(href.trim());
  if (!isSafeProtocol) {
    // 阻断非安全协议(如 javascript:、vbscript:、data:)
    return `<span class="unsafe-link-text">${escapeHtml(text)}</span>`;
  }

  // 2. 识别是否为站外跨域链接
  const isExternal = /^https?:\/\/\/i.test(href);

  // 3. 构建属性集合
  const attrs = [];
  attrs.push(`href="${escapeHtml(href)}"`);

  if (title) {
    attrs.push(`title="${escapeHtml(title)}"`);
  }

  if (isExternal) {
    // 外链强制新窗口打开,并配置安全隔离属性
    attrs.push('target="_blank"');
    attrs.push('rel="noopener noreferrer"');
    attrs.push('class="external-link"');
  } else {
    // 站内链接或锚点保持普通跳转
    attrs.push('class="internal-link"');
  }

  return `<a ${attrs.join(' ')}>${text}</a>`;
};
为什么必须添加 rel="noopener noreferrer"? 当一个页面通过 target="_blank" 打开新网页且未设置 rel="noopener" 时,新打开的页面可以通过 JavaScript 中的 window.opener 直接读取或重定向父页面 URL。攻击者可借此诱导用户进入假冒的登录页执行钓鱼。noreferrer 则进一步阻断 Referer 请求头的外泄,保护用户隐私。
使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑
图 4:默认 Renderer 与自定义 Renderer 行为及产物对比

五、完整集成示例与运行效果验证

将上述两项自定义逻辑与 marked 实例完成组合,得到一个在生产环境中可以直接运行的 Markdown 转换工具模块:

import { marked } from 'marked';
import hljs from 'highlight.js';

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

// 注册 Renderer 扩展
marked.use({
  renderer: {
    code(token, maybeLang) {
      const rawCode = (typeof token === 'object' && token !== null) ? token.text : token;
      const langString = ((typeof token === 'object' && token !== null) ? token.lang : maybeLang) || '';
      const language = langString.trim().split(/\s+/)[0].toLowerCase();

      let codeHtml = '';
      let isKnownLang = false;

      if (language && hljs.getLanguage(language)) {
        try {
          codeHtml = hljs.highlight(rawCode, { language }).value;
          isKnownLang = true;
        } catch {
          codeHtml = escapeHtml(rawCode);
        }
      } else {
        codeHtml = escapeHtml(rawCode);
      }

      const badgeHtml = isKnownLang ? `<span class="badge">${language}</span>` : '';
      return `<div class="code-container">${badgeHtml}<pre><code class="hljs">${codeHtml}</code></pre></div>`;
    },

    link(token, maybeTitle, maybeText) {
      let href = (typeof token === 'object' && token !== null) ? (token.href || '') : (token || '');
      let title = (typeof token === 'object' && token !== null) ? (token.title || '') : (maybeTitle || '');
      let text = (typeof token === 'object' && token !== null) ? (token.text || '') : (maybeText || '');

      // 协议防御过滤
      if (!/^(https?:|mailto:|tel:|\/|#)/i.test(href.trim())) {
        return `<span class="disabled-link">${escapeHtml(text)}</span>`;
      }

      const isExternal = /^https?:\/\/\/i.test(href);
      const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
      const externalAttrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';

      return `<a href="${escapeHtml(href)}"${titleAttr}${externalAttrs}>${text}</a>`;
    }
  }
});

// 运行测试
const inputMarkdown = `
# 示例文档

访问 [开发者官网](https://marked.js.org "Marked官方文档") 或查看 [本地说明](/docs/intro)。

\`\`\`javascript
const greeting = "Hello marked.js";
console.log(greeting);
\`\`\`
`;

const resultHtml = marked.parse(inputMarkdown);
console.log(resultHtml);

测试输入与输出对照

Markdown 输入节点 原生默认输出 自定义 Renderer 输出
[官网](https://marked.js.org) <a href="https://...">官网</a> <a href="https://..." target="_blank" rel="noopener noreferrer">官网</a>
[注入](javascript:alert(1)) <a href="javascript:alert(1)">注入</a> <span class="disabled-link">注入</span>
```js const x = 1; ``` <pre><code class="language-js">const x = 1;</code></pre> <div class="code-container"><span class="badge">js</span><pre><code class="hljs"><span class="hljs-keyword">const</span> x = 1;</code></pre></div>

六、边界情况与工程注意事项

在实际生产环境中落地自定义 Renderer 时,还需要特别注意以下几个细节问题:

  • 样式表的同步引入:highlight.js 仅负责拆分并注入诸如 hljs-keyword、hljs-string 的类名,具体的文字颜色与背景需要单独引入 highlight.js 的 CSS 样式表(例如 atom-one-dark.css、github.css)。如果页面未加载该 CSS,代码块依然表现为纯文本黑白色。
  • 避免在 Renderer 中执行异步操作:marked.js 的 marked.parse() 默认是同步执行的。如果你的代码高亮库采用异步加载机制(如某些基于 Web Worker 或动态 import 的语法分析器),不能在 renderer.code 中直接使用 await。必须使用 marked.parse(markdown, { async: true }) 并确保版本支持 Promise 管道,或者预先将常用语言分析器同步加载完毕。
  • 同域外链排查:如果你的网站拥有多个外部二级域名(如 api.example.com 与 example.com),单纯依赖 ^https?:\/\/ 判断外链可能将本站绝对地址误判为外链。如有需要,可在判断条件中加入域名白名单匹配:new URL(href, window.location.origin).hostname === window.location.hostname。
使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑
图 5:Renderer 运行时决策流与生产防御清单
作者最新文章
相关文章
更多