组件
Markdown 渲染器 Markdown
将 CommonMark 与 GFM 格式文本解析为安全、可深度定制与支持流式羽化边缘的 React 内容组件。
基础用法
解析完整的 Markdown 文本,自动支持 GFM 表格、代码块、引用、删除线与任务列表:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/markdown安装 Markdown 与 AST 解析插件依赖
pnpm add react-markdown remark-gfm motion radix-ui lucide-react class-variance-authority clsx tailwind-merge添加依赖组件
ai-stream 与 code-block,再复制组件源码到 components/ui/markdown.tsximport * as React from "react"
import ReactMarkdown, {
type Components,
type Options as ReactMarkdownOptions,
} from "react-markdown"
import remarkGfm from "remark-gfm"
import { cn } from "@/lib/utils"
import { AiStreamEdge } from "@/components/ui/ai-stream"
import {
CodeBlock,
CodeBlockActions,
CodeBlockCopy,
CodeBlockHeader,
} from "@/components/ui/code-block"
type MarkdownTreeNode = {
type: string
value?: string
tagName?: string
properties?: Record<string, unknown>
children?: MarkdownTreeNode[]
}
function textContent(node?: MarkdownTreeNode): string {
if (!node) return ""
if (node.type === "text") return node.value ?? ""
return (node.children ?? []).map(textContent).join("")
}
function codeLanguage(node?: MarkdownTreeNode) {
const className = node?.properties?.className
if (!Array.isArray(className)) return undefined
const match = className.find(
(name): name is string =>
typeof name === "string" && name.startsWith("language-")
)
return match?.slice("language-".length)
}
function createStreamFeatherPlugin(featherLength: number) {
return () => (tree: MarkdownTreeNode) => {
function featherLastText(parent: MarkdownTreeNode): boolean {
if (!parent.children) return false
for (let index = parent.children.length - 1; index >= 0; index -= 1) {
const child = parent.children[index]
if (child.children && featherLastText(child)) return true
if (child.type !== "text" || !child.value?.trim()) continue
const tailLength = Math.min(
Math.max(featherLength, 0),
child.value.length
)
if (tailLength === 0) return true
const stableText = child.value.slice(0, -tailLength)
const liveEdge = child.value.slice(-tailLength)
const replacement: MarkdownTreeNode[] = []
if (stableText) replacement.push({ type: "text", value: stableText })
replacement.push({
type: "element",
tagName: "span",
properties: { "data-markdown-stream-edge": "" },
children: [{ type: "text", value: liveEdge }],
})
parent.children.splice(index, 1, ...replacement)
return true
}
return false
}
featherLastText(tree)
}
}
const defaultComponents: Components = {
h1: ({ node: _node, className, ...props }) => (
<h1
className={cn(
"mb-4 mt-8 text-2xl font-semibold tracking-tight first:mt-0",
className
)}
{...props}
/>
),
h2: ({ node: _node, className, ...props }) => (
<h2
className={cn(
"mb-3 mt-8 text-xl font-semibold tracking-tight first:mt-0",
className
)}
{...props}
/>
),
h3: ({ node: _node, className, ...props }) => (
<h3
className={cn("mb-2 mt-6 text-base font-semibold first:mt-0", className)}
{...props}
/>
),
h4: ({ node: _node, className, ...props }) => (
<h4
className={cn("mb-2 mt-5 text-sm font-semibold first:mt-0", className)}
{...props}
/>
),
h5: ({ node: _node, className, ...props }) => (
<h5
className={cn("mb-2 mt-4 text-sm font-medium first:mt-0", className)}
{...props}
/>
),
h6: ({ node: _node, className, ...props }) => (
<h6
className={cn(
"text-muted-foreground mb-2 mt-4 text-xs font-medium first:mt-0",
className
)}
{...props}
/>
),
p: ({ node: _node, className, ...props }) => (
<p
className={cn("my-4 leading-7 first:mt-0 last:mb-0", className)}
{...props}
/>
),
a: ({ node: _node, className, ...props }) => (
<a
className={cn(
"text-primary decoration-primary/40 hover:decoration-primary font-medium underline underline-offset-4 transition-colors",
className
)}
{...props}
/>
),
strong: ({ node: _node, className, ...props }) => (
<strong className={cn("font-semibold", className)} {...props} />
),
span: ({ node: _node, className, style, ...props }) => {
const isStreamEdge = "data-markdown-stream-edge" in props
if (isStreamEdge) {
return <AiStreamEdge className={className} style={style} {...props} />
}
return <span className={className} style={style} {...props} />
},
ul: ({ node: _node, className, ...props }) => (
<ul
className={cn(
"marker:text-muted-foreground my-4 ml-5 list-disc space-y-2 [&.contains-task-list]:ml-0 [&.contains-task-list]:list-none",
className
)}
{...props}
/>
),
ol: ({ node: _node, className, ...props }) => (
<ol
className={cn(
"marker:text-muted-foreground my-4 ml-5 list-decimal space-y-2 [&.contains-task-list]:ml-0 [&.contains-task-list]:list-none",
className
)}
{...props}
/>
),
li: ({ node: _node, className, ...props }) => (
<li
className={cn("pl-1 leading-7 [&.task-list-item]:pl-0", className)}
{...props}
/>
),
blockquote: ({ node: _node, className, ...props }) => (
<blockquote
className={cn(
"border-border text-muted-foreground my-5 border-l-2 pl-4 [&>p]:my-0",
className
)}
{...props}
/>
),
hr: ({ node: _node, className, ...props }) => (
<hr className={cn("border-border my-8", className)} {...props} />
),
code: ({ node: _node, className, ...props }) => (
<code
className={cn(
"bg-muted rounded-sm px-1 py-0.5 font-mono text-[0.875em]",
className
)}
{...props}
/>
),
pre: ({ node, className, children, ...props }) => {
const code = (node as MarkdownTreeNode | undefined)?.children?.[0]
const language = codeLanguage(code)
return (
<CodeBlock data-language={language} className="my-5">
<CodeBlockHeader className="min-h-9 py-1.5">
<span className="text-muted-foreground font-sans text-xs">
{language ?? "text"}
</span>
<CodeBlockActions>
<CodeBlockCopy content={textContent(code).replace(/\n$/, "")} />
</CodeBlockActions>
</CodeBlockHeader>
<pre
className={cn(
"overflow-x-auto px-4 py-3.5 leading-5 [&>code]:rounded-none [&>code]:bg-transparent [&>code]:p-0 [&>code]:text-[1em]",
className
)}
{...props}
>
{children}
</pre>
</CodeBlock>
)
},
table: ({ node: _node, className, ...props }) => (
<div className="my-5 w-full overflow-x-auto border-y">
<table
className={cn("w-full border-collapse text-sm", className)}
{...props}
/>
</div>
),
thead: ({ node: _node, className, ...props }) => (
<thead className={cn("bg-muted/50 border-b", className)} {...props} />
),
tr: ({ node: _node, className, ...props }) => (
<tr className={cn("border-b last:border-b-0", className)} {...props} />
),
th: ({ node: _node, className, ...props }) => (
<th
className={cn("h-10 px-3 text-left align-middle font-medium", className)}
{...props}
/>
),
td: ({ node: _node, className, ...props }) => (
<td className={cn("px-3 py-2.5 align-top", className)} {...props} />
),
input: ({ node: _node, className, ...props }) => (
<input
className={cn("accent-primary mr-2 size-4 translate-y-0.5", className)}
{...props}
/>
),
img: ({ node: _node, className, alt, ...props }) => (
<img
className={cn("my-5 max-w-full border", className)}
alt={alt ?? ""}
{...props}
/>
),
}
export interface MarkdownProps
extends
Omit<React.ComponentProps<"div">, "children">,
Omit<ReactMarkdownOptions, "children" | "components"> {
/** Markdown source to parse and render. */
children: string
/** Whether the source is still receiving content. @default false */
isStreaming?: boolean
/** Number of trailing characters covered by the live feather. @default 18 */
featherLength?: number
/** Override renderers for individual Markdown elements. */
components?: Components
}
/** Parses CommonMark and GFM source into styled, safe React elements. */
function Markdown({
children,
className,
isStreaming = false,
featherLength = 18,
components,
remarkPlugins,
rehypePlugins,
remarkRehypeOptions,
allowElement,
allowedElements,
disallowedElements,
skipHtml,
unwrapDisallowed,
urlTransform,
...props
}: MarkdownProps) {
return (
<div
data-slot="markdown"
data-streaming={isStreaming || undefined}
aria-live={isStreaming ? "polite" : undefined}
aria-busy={isStreaming}
className={cn(
"text-foreground min-w-0 text-sm",
// Blocks mounted while streaming ease in; already-rendered blocks keep their DOM and stay still.
isStreaming &&
"[&>*]:animate-in [&>*]:fade-in-0 [&>*]:slide-in-from-bottom-1 [&>*]:duration-300 [&>*]:ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:[&>*]:animate-none",
className
)}
{...props}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, ...(remarkPlugins ?? [])]}
rehypePlugins={
isStreaming
? [
...(rehypePlugins ?? []),
createStreamFeatherPlugin(featherLength),
]
: rehypePlugins
}
remarkRehypeOptions={remarkRehypeOptions}
allowElement={allowElement}
allowedElements={allowedElements}
disallowedElements={disallowedElements}
skipHtml={skipHtml}
unwrapDisallowed={unwrapDisallowed}
urlTransform={urlTransform}
components={{ ...defaultComponents, ...components }}
>
{children}
</ReactMarkdown>
</div>
)
}
export { Markdown }
属性 Props
Markdown 继承 react-markdown 的核心配置项,并提供以下专有属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | string | — | 待解析渲染的原始 Markdown 字符串文本。 |
| isStreaming | boolean | false | 是否处于流式接收中。设为 true 时自动挂载 aria-busy、aria-live,末尾文本应用羽化效果,新出现的段落、列表与代码块会轻柔淡入上移。 |
| featherLength | number | 18 | 流式模式下末尾字符应用动态透明度羽化(AiStreamEdge)的字符长度。 |
| components | Components | — | 覆盖特定 HTML 标签渲染逻辑的映射对象(例如自定义 pre、code、a、blockquote 等)。 |
| remarkPlugins | PluggableList | — | 自定义 Remark 语法解析插件列表,默认已内置包含 remark-gfm。 |
| rehypePlugins | PluggableList | — | 自定义 Rehype 转换插件列表。 |
| className | string | — | 应用于外层 Markdown 容器的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className / style | React.HTMLAttributes<HTMLDivElement> | — | 继承原生 <div> 容器属性,所有子元素的事件(如链接点击、按钮操作)均可通过 components 自定义重载委托处理。 |
使用场景与设计规范
Markdown 用于AI 对话消息体、技术文档呈现与富文本内容渲染:
- 安全沙箱无 XSS 风险:基于 React 虚拟 DOM 树渲染,不使用
dangerouslySetInnerHTML,默认过滤危险 HTML 注入。 - 流式增量解析容错:在 LLM Token 逐步返回的过程中,即使遇到未闭合的代码块(```)或未完成的表格,也能优雅容错显示,避免界面崩溃闪烁。
- 代码围栏即代码块:代码围栏(```)自动渲染为
CodeBlock,标题栏显示语言并提供带动效反馈的复制按钮;如需自定义可通过components.pre覆盖。 - 任务列表:GFM 任务列表自动去除项目符号,只保留复选框。
- 与设计系统主题无缝对齐:内置样式全部使用语义化 CSS 变量(如
bg-muted、text-foreground、border-border),在深浅色主题下均能呈现完美的排版韵律。
场景示例
流式分片输出与末尾羽化
结合 isStreaming={true},模拟大模型逐字打字机流式输出,并在最后一行文本呈现自然的流光羽化边缘:
Loading…
自定义元素重载
通过 components 属性把 blockquote 渲染为 Alert 提示,并为 a 链接加上外链图标与新窗口打开:
Loading…
无障碍与交互 Accessibility
- 实时区域状态:流式生成期间,组件容器自动挂载
aria-live="polite"与aria-busy="true",提示屏幕阅读器内容处于动态生成阶段。 - 语义化标签结构:精准映射
<h1>~<h6>、<ul>、<ol>、<blockquote>、<table>等语义化元素。 - 链接与焦点:渲染的所有链接均带有高对比度焦点环与下划线指示,支持键盘顺畅导航。