03工程笔记
2026 · 02 · 28
9 分钟
← 返回列表

基于 html-react-parser 的 Markdown 渲染管线改造

告别 dangerouslySetInnerHTML,用标签处理器映射表 + html-react-parser 构建一套可扩展的 Markdown → React 组件渲染管线。

背景:原有方案的局限

在一个 Next.js + Markdown 驱动的博客 / 作品展示站点中,内容渲染通常经过这样一条链路:

text
1Markdown 文件 → marked.parse() → HTML 字符串 → dangerouslySetInnerHTML → 页面 2

这条链路简单直接,但存在几个明显的痛点:

  1. 无法插入 React 组件。代码块高亮、Mermaid 图表、链接预览等增强功能需要 React 组件渲染,dangerouslySetInnerHTML 无法做到。
  2. 扩展困难。原有方案用正则把 HTML 拆分为"普通片段"和"代码块",手动调度渲染。每新增一种特殊标签就要改正则和渲染分支,维护成本高。
  3. 不安全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. 安装依赖

bash
1npm install html-react-parser 2

html-react-parser 底层使用 htmlparser2(与 cheerio 同源),解析速度极快,支持 SSR 和 CSR。

2. 核心组件 ProseContent

完整实现位于 src/components/frontend/ProseContent.tsx

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" 13

2.1 类型定义

tsx
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.ReactNodeReact.ReactNode 包含 numberbigintPromise 等类型,而 replace 只接受 JSX.Element | string | null | boolean | object | void。如果用 React.ReactNode 会报 TS 类型错误。

2.2 代码块处理器

tsx
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

这个处理器做了三件事:

  1. <pre> 中找到 <code> 子元素
  2. class="language-xxx" 中提取语言标识
  3. 根据语言分流到 MermaidBlock(图表)或 CodeBlock(语法高亮)

2.3 链接处理器

tsx
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 标签处理器映射表

tsx
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 解析引擎

tsx
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 行:

  1. 合并内置和自定义处理器
  2. 遍历每个 DOM 节点,按标签名查找处理器
  3. 有处理器就执行替换,没有就保留原节点

useMemo 确保只在 htmlcustomHandlers 变化时重新解析。

3. 页面接入

博客详情页

tsx
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

作品详情页

tsx
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. 工具函数

tsx
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(/&lt;/g, "<") 18 .replace(/&gt;/g, ">") 19 .replace(/&amp;/g, "&") 20 .replace(/&quot;/g, '"') 21 .replace(/&#39;/g, "'") 22} 23

extractText 递归收集文本节点内容,decodeHtmlEntities 反转义 marked 输出中的 HTML 实体。两者配合从 <code> 元素中还原出原始代码文本。

改造前后对比

旧方案(ProseCodeBlock)

text
1HTML 字符串 2 ↓ 正则匹配 <pre><code> 3 ↓ 手动拆分为 segments[] 4 ├── type: "code" → CodeBlock / MermaidBlock 5 └── type: "html" → dangerouslySetInnerHTML 6

问题:只能处理代码块这一种标签。新增其他特殊标签需要增加正则和分支逻辑。

新方案(ProseContent)

text
1HTML 字符串 2 ↓ html-react-parser 遍历 DOM 树 3 ↓ 对每个节点查询 TAG_HANDLERS 映射表 4 ├── 命中 → 返回 React 组件 5 └── 未命中 → 保留原节点 6

优势

维度旧方案新方案
扩展性改正则 + 改渲染逻辑映射表加一行
支持标签数<pre><code>任意标签
嵌套处理不支持domToReact 递归处理
dangerouslySetInnerHTML大量使用仅 parser 内部安全处理
页面级定制不支持customHandlers prop

扩展示例

假设未来需要在 Markdown 中嵌入视频卡片,只需:

1. 创建处理函数

tsx
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} 10

2. 注册到映射表

tsx
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} 7

3. 在 Markdown 中使用

html
1这是一段介绍文字。 2 3<video-info>{"title":"演示视频","url":"https://example.com/video.mp4"}</video-info> 4 5继续其他内容。 6

html-react-parser 会自动将 <video-info> 标签路由到我们的处理函数,无需修改任何解析逻辑。

或者,你也可以只在某个特定页面启用:

tsx
1<ProseContent 2 html={contentHtml} 3 customHandlers={{ 4 'video-info': (node) => handleVideoInfo(node), 5 }} 6/> 7

总结

这次改造的核心思想是将 HTML 标签与 React 组件的映射关系从"硬编码在渲染逻辑中"提升为"声明式的配置表"

实际变更非常小:新增 1 个文件(ProseContent.tsx),修改 2 个页面的 import 和 JSX 引用。但架构上从"只能处理代码块"变成了"可处理任意自定义标签",为未来的内容增强(实体卡片、广告组件、交互式组件等)奠定了基础。