使用 marked.js Renderer 自定义代码块高亮与链接渲染逻辑
深入讲解如何使用 marked.js 的 Renderer 自定义代码块语法高亮与超链接渲染逻辑,结合 highlight.js 实现语言检测、防 XSS 转义、外部链接新窗口跳转与防钓鱼安全属性注入。
Renderer 机制,允许开发者拦截特定语法节点的输出过程。本文将直接讲解如何通过扩展 Renderer 的 code 与 link 方法,实现与 highlight.js 的高亮集成、防 XSS 注入安全转义,以及外链新窗口打开并注入 noopener noreferrer 的完整实现。
一、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 以及主流的语法高亮库 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) {
// 自定义链接逻辑
}
}
});
Renderer 的方法参数逐渐由传统的扁平多参数(如 code(code, lang))向单一 token 对象参数(如 code({ text, lang }))平滑演进。编写健壮的拦截器应当在函数入口处进行兼容解构,确保在各个版本环境中均可无缝执行。
三、自定义代码块渲染:语法高亮与安全转义
自定义 code 渲染器需要达成三个关键目标:
- 语言识别与高亮映射:根据代码块后跟随的语言标签(如
```javascript),调用 highlight.js 生成带语义类名的高亮 HTML 标签片段。 - 未知语言与空语言兜底:对于用户未指定语言或输入了 highlight.js 无法识别的语言,必须进行安全的 HTML 字符实体转义,防止潜在的 XSS 攻击或页面结构崩塌。
- 外层结构包装:输出包含语言提示徽标(Language Badge)或方便后续添加一键复制按钮的外层容器。
实现代码块拦截器的具体逻辑如下:
// 基础 HTML 转义函数,处理兜底场景
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 实例完成组合,得到一个在生产环境中可以直接运行的 Markdown 转换工具模块:
import { marked } from 'marked';
import hljs from 'highlight.js';
// HTML 转义工具
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 注册 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。
crypto-js 处理 Base64 和 Hex 编码转换的正确姿势
2026-11-05 16:39:11
ammo.js 内存管理:避免 Wasm 环境下的内存泄漏技巧
2026-11-05 10:21:09
JS几种补零方法的性能对比与选择建议
2026-11-04 21:17:50
JS数组去重最佳实践:Set、filter与reduce实现方案
2026-11-04 10:57:15
基准元素选择策略对 JavaScript 快速排序性能的影响
2026-11-03 22:37:29








