基于 html-react-parser 的 Markdown 渲染管线改造
告别 dangerouslySetInnerHTML,用标签处理器映射表 + html-react-parser 构建一套可扩展的 Markdown → React 组件渲染管线。
背景:原有方案的局限
在一个 Next.js + Markdown 驱动的博客 / 作品展示站点中,内容渲染通常经过这样一条链路:
1Markdown 文件 → marked.parse() → HTML 字符串 → dangerouslySetInnerHTML → 页面
2这条链路简单直接,但存在几个明显的痛点:
- 无法插入 React 组件。代码块高亮、Mermaid 图表、链接预览等增强功能需要 React 组件渲染,
dangerouslySetInnerHTML无法做到。 - 扩展困难。原有方案用正则把 HTML 拆分为"普通片段"和"代码块",手动调度渲染。每新增一种特殊标签就要改正则和渲染分支,维护成本高。
- 不安全。
dangerouslySetInnerHTML直接注入未经过滤的 HTML,存在 XSS 风险隐患。
我们需要的是一套"标签 → 组件"的声明式映射机制:遇到 <pre><code> 就渲染 CodeBlock,遇到外部 <a> 就渲染 LinkPreview,未来新增 <video-info> 就渲染 VideoCard——而核心解析引擎不需要改动。
设计思路
灵感来自 AI 对话产品中的内容渲染模式。核心流程如下:
关键设计决策:
- 标签处理器映射表:一个
Record<string, TagHandler>对象,键是 HTML 标签名,值是处理函数。新增标签支持只需加一行。 - html-react-parser:它遍历 HTML 的每个 DOM 节点,对每个节点调用我们的
replace回调。如果回调返回 React 元素就替换,返回undefined就保留原节点。 - customHandlers prop:支持页面级覆盖或扩展,不同页面可以有不同的标签行为。
实现
1. 安装依赖
1npm install html-react-parser
2html-react-parser 底层使用 htmlparser2(与 cheerio 同源),解析速度极快,支持 SSR 和 CSR。
2. 核心组件 ProseContent
完整实现位于 src/components/frontend/ProseContent.tsx:
1"use client"
2
3import { useMemo } from "react"
4import parse, {
5 domToReact,
6 type HTMLReactParserOptions,
7 Element,
8 type DOMNode,
9} from "html-react-parser"
10import { CodeBlock } from "@/components/ui/code-block"
11import { MermaidBlock } from "@/components/frontend/MermaidBlock"
12import { LinkPreview } from "@/components/ui/link-preview"
132.1 类型定义
1// 与 html-react-parser 的 replace 签名对齐
2type ReplaceResult = React.JSX.Element | string | null | boolean | object | void
3
4// 标签处理器:接收 DOM 元素,返回 React 节点或 null(保持原样)
5type TagHandler = (node: Element, options: HTMLReactParserOptions) => ReplaceResult
6踩坑提醒:
replace回调的返回类型不是React.ReactNode!React.ReactNode包含number、bigint、Promise等类型,而replace只接受JSX.Element | string | null | boolean | object | void。如果用React.ReactNode会报 TS 类型错误。
2.2 代码块处理器
1function handlePreCodeBlock(node: Element): ReplaceResult {
2 const codeEl = node.children?.find(
3 (child): child is Element =>
4 child instanceof Element && child.name === "code"
5 )
6 if (!codeEl) return null
7
8 const classAttr = codeEl.attribs?.class || ""
9 const langMatch = classAttr.match(/language-(\S+)/)
10 const language = langMatch ? langMatch[1] : "text"
11
12 const rawCode = extractText(codeEl)
13 const code = decodeHtmlEntities(rawCode).trim()
14
15 if (language === "mermaid") {
16 return (
17 <div className="not-prose my-6">
18 <MermaidBlock code={code} />
19 </div>
20 )
21 }
22
23 return (
24 <div className="not-prose my-6">
25 <CodeBlock language={language} code={code} filename="" />
26 </div>
27 )
28}
29这个处理器做了三件事:
- 从
<pre>中找到<code>子元素 - 从
class="language-xxx"中提取语言标识 - 根据语言分流到
MermaidBlock(图表)或CodeBlock(语法高亮)
2.3 链接处理器
1function handleLink(
2 node: Element,
3 options: HTMLReactParserOptions
4): ReplaceResult {
5 const href = node.attribs?.href
6 if (!href) return null
7
8 const isExternal =
9 href.startsWith("http://") || href.startsWith("https://")
10 if (!isExternal) return null
11
12 const children = domToReact(node.children as DOMNode[], options)
13
14 return <LinkPreview url={href}>{children}</LinkPreview>
15}
16只对外部链接启用 LinkPreview(hover 时展示目标页截图预览),内部链接保持原样。注意 domToReact 的使用——它将子节点递归转为 React 元素,保证链接内的加粗、图片等嵌套内容正常渲染。
2.4 标签处理器映射表
1const TAG_HANDLERS: Record<string, TagHandler> = {
2 pre: (node) => handlePreCodeBlock(node),
3 a: (node, opts) => handleLink(node, opts),
4 img: (node) => handleImage(node), // 预留扩展点
5}
6这就是整套方案的"注册中心"。扩展时只需在此添加一行,无需改动解析引擎。
2.5 解析引擎
1export function ProseContent({
2 html,
3 className,
4 customHandlers,
5}: ProseContentProps) {
6 const parsedContent = useMemo(() => {
7 const mergedHandlers = { ...TAG_HANDLERS, ...customHandlers }
8
9 const options: HTMLReactParserOptions = {
10 replace: (domNode) => {
11 if (!(domNode instanceof Element)) return
12
13 const handler = mergedHandlers[domNode.name]
14 if (handler) {
15 const result = handler(domNode, options)
16 if (result !== null) return result
17 }
18 },
19 }
20
21 return parse(html, options)
22 }, [html, customHandlers])
23
24 return <div className={className}>{parsedContent}</div>
25}
26核心逻辑只有 5 行:
- 合并内置和自定义处理器
- 遍历每个 DOM 节点,按标签名查找处理器
- 有处理器就执行替换,没有就保留原节点
useMemo 确保只在 html 或 customHandlers 变化时重新解析。
3. 页面接入
博客详情页
1// src/app/(frontend)/blog/[slug]/page.tsx
2import { ProseContent } from "@/components/frontend/ProseContent"
3
4// 在 JSX 中:
5{contentHtml ? (
6 <ProseContent html={contentHtml} />
7) : (
8 content.split("\n").map((line, i) => (
9 <p key={i}>{line || "\u00A0"}</p>
10 ))
11)}
12作品详情页
1// src/app/(frontend)/works/[slug]/page.tsx
2import { ProseContent } from "@/components/frontend/ProseContent"
3
4// 替换原来的 dangerouslySetInnerHTML:
5{contentHtml ? (
6 <ProseContent html={contentHtml} />
7) : (
8 content.split("\n").map((line, i) => (
9 <p key={i}>{line || "\u00A0"}</p>
10 ))
11)}
12作品详情页之前只能渲染纯 HTML,现在也自动获得了代码高亮、Mermaid 图表、链接预览等全部能力。
4. 工具函数
1/** 递归提取 DOM 元素中的纯文本 */
2function extractText(el: Element): string {
3 let text = ""
4 for (const child of el.children ?? []) {
5 if (child.type === "text") {
6 text += (child as unknown as { data: string }).data
7 } else if (child instanceof Element) {
8 text += extractText(child)
9 }
10 }
11 return text
12}
13
14/** 反转义 marked 输出的 HTML 实体 */
15function decodeHtmlEntities(str: string): string {
16 return str
17 .replace(/</g, "<")
18 .replace(/>/g, ">")
19 .replace(/&/g, "&")
20 .replace(/"/g, '"')
21 .replace(/'/g, "'")
22}
23extractText 递归收集文本节点内容,decodeHtmlEntities 反转义 marked 输出中的 HTML 实体。两者配合从 <code> 元素中还原出原始代码文本。
改造前后对比
旧方案(ProseCodeBlock)
1HTML 字符串
2 ↓ 正则匹配 <pre><code>
3 ↓ 手动拆分为 segments[]
4 ├── type: "code" → CodeBlock / MermaidBlock
5 └── type: "html" → dangerouslySetInnerHTML
6问题:只能处理代码块这一种标签。新增其他特殊标签需要增加正则和分支逻辑。
新方案(ProseContent)
1HTML 字符串
2 ↓ html-react-parser 遍历 DOM 树
3 ↓ 对每个节点查询 TAG_HANDLERS 映射表
4 ├── 命中 → 返回 React 组件
5 └── 未命中 → 保留原节点
6优势:
| 维度 | 旧方案 | 新方案 |
|---|---|---|
| 扩展性 | 改正则 + 改渲染逻辑 | 映射表加一行 |
| 支持标签数 | 仅 <pre><code> | 任意标签 |
| 嵌套处理 | 不支持 | domToReact 递归处理 |
| dangerouslySetInnerHTML | 大量使用 | 仅 parser 内部安全处理 |
| 页面级定制 | 不支持 | customHandlers prop |
扩展示例
假设未来需要在 Markdown 中嵌入视频卡片,只需:
1. 创建处理函数
1function handleVideoInfo(node: Element): ReplaceResult {
2 const rawJson = extractText(node)
3 try {
4 const data = JSON.parse(rawJson)
5 return <ContentVideo content={data} />
6 } catch {
7 return null
8 }
9}
102. 注册到映射表
1const TAG_HANDLERS: Record<string, TagHandler> = {
2 pre: (node) => handlePreCodeBlock(node),
3 a: (node, opts) => handleLink(node, opts),
4 img: (node) => handleImage(node),
5 'video-info': (node) => handleVideoInfo(node), // 新增
6}
73. 在 Markdown 中使用
1这是一段介绍文字。
2
3<video-info>{"title":"演示视频","url":"https://example.com/video.mp4"}</video-info>
4
5继续其他内容。
6html-react-parser 会自动将 <video-info> 标签路由到我们的处理函数,无需修改任何解析逻辑。
或者,你也可以只在某个特定页面启用:
1<ProseContent
2 html={contentHtml}
3 customHandlers={{
4 'video-info': (node) => handleVideoInfo(node),
5 }}
6/>
7总结
这次改造的核心思想是将 HTML 标签与 React 组件的映射关系从"硬编码在渲染逻辑中"提升为"声明式的配置表"。
实际变更非常小:新增 1 个文件(ProseContent.tsx),修改 2 个页面的 import 和 JSX 引用。但架构上从"只能处理代码块"变成了"可处理任意自定义标签",为未来的内容增强(实体卡片、广告组件、交互式组件等)奠定了基础。