wui
组件

Markdown 渲染器 Markdown

将 CommonMark 与 GFM 格式文本解析为安全、可深度定制与支持流式羽化边缘的 React 内容组件。

第三方依赖 · react-markdown第三方依赖 · remark-gfm

基础用法

解析完整的 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.tsx
components/ui/markdown.tsx
import * 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 的核心配置项,并提供以下专有属性:

属性类型默认值说明
childrenstring—待解析渲染的原始 Markdown 字符串文本。
isStreamingbooleanfalse是否处于流式接收中。设为 true 时自动挂载 aria-busy、aria-live,末尾文本应用羽化效果,新出现的段落、列表与代码块会轻柔淡入上移。
featherLengthnumber18流式模式下末尾字符应用动态透明度羽化(AiStreamEdge)的字符长度。
componentsComponents—覆盖特定 HTML 标签渲染逻辑的映射对象(例如自定义 pre、code、a、blockquote 等)。
remarkPluginsPluggableList—自定义 Remark 语法解析插件列表,默认已内置包含 remark-gfm。
rehypePluginsPluggableList—自定义 Rehype 转换插件列表。
classNamestring—应用于外层 Markdown 容器的额外 CSS 类名。

事件 Events

属性类型默认值说明
className / styleReact.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> 等语义化元素。
  • 链接与焦点:渲染的所有链接均带有高对比度焦点环与下划线指示,支持键盘顺畅导航。