Skip to content

s08 - 内容渲染

模型会写代码、列清单、做表格。但你的界面上,这些全是一坨纯文本。 这一章要做的是:让 Markdown 在聊天气泡里真正活起来。

这一章要解决什么问题

s07 有了流式聊天界面。看起来不错——直到你让模型写一段代码。

它确实会回复代码,但三个反引号原样显示,语言标签是文本,没有高亮,想复制得手动选中再 Ctrl+C。表格也一样,管道符和破折号全部原样输出。

模型的输出是 Markdown,但你的界面把它当纯文本。 这一章修复这个问题。

安装依赖

bash
npm install react-markdown remark-gfm react-syntax-highlighter
npm install -D @types/react-syntax-highlighter

三个包各管一件事:

  • react-markdown:Markdown 字符串转 React 组件
  • remark-gfm:支持表格、任务列表、删除线等 GitHub 扩展语法
  • react-syntax-highlighter:代码块语法高亮

核心思路

模型输出的 Markdown 字符串

MarkdownMessage 组件(解析 Markdown,分发渲染)
  ├─ 普通文本 → <p>, <ul>, <table> ...
  └─ 代码块   → CodeBlock 组件(高亮 + 复制按钮)

react-markdown 把 Markdown 转成 HTML 元素。但它默认用 <pre><code> 渲染代码块,没有高亮也没有复制按钮。所以我们需要自定义代码块的渲染方式。

CodeBlock 组件

tsx
// app/components/CodeBlock.tsx
"use client";

import { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";

export default function CodeBlock({ language, children }: { language: string; children: string }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () =&gt; {
    await navigator.clipboard.writeText(children);
    setCopied(true);
    setTimeout(() =&gt; setCopied(false), 2000);
  };

  return (
    &lt;div className="relative group my-3 rounded-lg overflow-hidden"&gt;
      &lt;div className="flex items-center justify-between bg-gray-800 px-4 py-2 text-xs text-gray-400"&gt;
        &lt;span&gt;{language || "code"}&lt;/span&gt;
        &lt;button onClick={handleCopy} ...&gt;
          {copied ? "已复制" : "复制"}
        &lt;/button&gt;
      &lt;/div&gt;
      &lt;SyntaxHighlighter language={language} style={oneDark} ...&gt;
        {children}
      &lt;/SyntaxHighlighter&gt;
    &lt;/div&gt;
  );
}

做了三件事:

  1. 语法高亮SyntaxHighlighter 接收语言和代码文本,自动高亮。oneDark 是配色,可以换成 vscDarkPlusdracula 等。
  2. 复制按钮navigator.clipboard.writeText() 是浏览器原生剪贴板 API,点击后 2 秒内显示"已复制"。
  3. 行号:代码超过 3 行才显示行号,一两行代码没必要标。

完整代码见 code/s08/app/components/CodeBlock.tsx

MarkdownMessage 组件

有了 CodeBlock,接入 react-markdown

tsx
// app/components/MarkdownMessage.tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";

export default function MarkdownMessage({ content }: { content: string }) {
  return (
    &lt;ReactMarkdown
      remarkPlugins={[remarkGfm]}
      className="markdown-body"
      components={{
        code({ className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || "");
          const codeString = String(children).replace(/\n$/, "");

          if (match) {
            return &lt;CodeBlock language={match[1]}&gt;{codeString}&lt;/CodeBlock&gt;;
          }
          // 行内代码
          return &lt;code className="bg-gray-800 text-emerald-300 px-1.5 py-0.5 rounded text-sm font-mono" {...props}&gt;{children}&lt;/code&gt;;
        },
        a({ children, href, ...props }) {
          return &lt;a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 underline" {...props}&gt;{children}&lt;/a&gt;;
        },
      }}
    &gt;
      {content}
    &lt;/ReactMarkdown&gt;
  );
}

关键在 components prop——一个映射表,告诉 react-markdown:"遇到某个标签时,用我的组件替换默认渲染。"

react-markdown 把行内代码和代码块都渲染成 <code>。区别在于:代码块有 className="language-python",行内代码没有。所以用正则匹配——有类名走 CodeBlock,没有就是行内代码。

remarkGfm 插件让 react-markdown 认识表格、任务列表、删除线。不加的话,模型输出的表格会被当成纯文本。

接入

page.tsx 里,assistant 消息从 <p className="whitespace-pre-wrap">{msg.content}</p> 改成 <MarkdownMessage content={msg.content} />。user 消息保持原样。

route.ts 里的 system prompt 加一句 "请用 Markdown 格式回复,代码块请标明语言"——不是所有模型都会主动用 Markdown。

Markdown 样式

react-markdown 输出标准 HTML 标签,默认样式跟聊天气泡不搭。在 globals.css 里用 .markdown-body 作用域加样式——标题加粗加大、列表有缩进和圆点、引用块有左边框、表格有边框。只作用在聊天气泡内部,不污染其他部分。完整样式见 code/s08/app/globals.css

试试看

bash
cd code/s08
npm install
npm run dev

问这些问题看效果:

  • "写一个 Python 快排" — 带高亮的代码块 + 复制按钮
  • "对比 React 和 Vue" — 表格渲染
  • "解释 HTTP 状态码" — 标题、列表、引用块
  • "用 git log 查看提交历史" — 行内代码特殊样式

试着改改

换配色方案:把 oneDark 换成 vscDarkPlusatomDarkdracula,看看效果。

处理没有语言标签的代码块:有些模型输出的代码块没有语言标签,match 会是 null。加一个 fallback 就行:

tsx
if (match) {
  return &lt;CodeBlock language={match[1]}&gt;{codeString}&lt;/CodeBlock&gt;;
}
return &lt;CodeBlock language="text"&gt;{codeString}&lt;/CodeBlock&gt;;

教学边界

这一章只做一件事:让 Markdown 在聊天气泡里正确渲染。

不涉及:

  • LaTeX 公式渲染(需要 remark-math + rehype-katex
  • Markdown 编辑器(这是渲染,不是编辑)
  • 代码块的"运行"按钮
  • 自定义主题系统

这些都在后面的章节里。

一句话记住

模型输出的是 Markdown,不是纯文本。你要做的不是解析它,而是告诉 React 怎么渲染它。