s08 - 内容渲染
模型会写代码、列清单、做表格。但你的界面上,这些全是一坨纯文本。 这一章要做的是:让 Markdown 在聊天气泡里真正活起来。
这一章要解决什么问题
s07 有了流式聊天界面。看起来不错——直到你让模型写一段代码。
它确实会回复代码,但三个反引号原样显示,语言标签是文本,没有高亮,想复制得手动选中再 Ctrl+C。表格也一样,管道符和破折号全部原样输出。
模型的输出是 Markdown,但你的界面把它当纯文本。 这一章修复这个问题。
安装依赖
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 组件
// 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 () => {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group my-3 rounded-lg overflow-hidden">
<div className="flex items-center justify-between bg-gray-800 px-4 py-2 text-xs text-gray-400">
<span>{language || "code"}</span>
<button onClick={handleCopy} ...>
{copied ? "已复制" : "复制"}
</button>
</div>
<SyntaxHighlighter language={language} style={oneDark} ...>
{children}
</SyntaxHighlighter>
</div>
);
}做了三件事:
- 语法高亮:
SyntaxHighlighter接收语言和代码文本,自动高亮。oneDark是配色,可以换成vscDarkPlus、dracula等。 - 复制按钮:
navigator.clipboard.writeText()是浏览器原生剪贴板 API,点击后 2 秒内显示"已复制"。 - 行号:代码超过 3 行才显示行号,一两行代码没必要标。
完整代码见 code/s08/app/components/CodeBlock.tsx。
MarkdownMessage 组件
有了 CodeBlock,接入 react-markdown:
// 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 (
<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 <CodeBlock language={match[1]}>{codeString}</CodeBlock>;
}
// 行内代码
return <code className="bg-gray-800 text-emerald-300 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>{children}</code>;
},
a({ children, href, ...props }) {
return <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 underline" {...props}>{children}</a>;
},
}}
>
{content}
</ReactMarkdown>
);
}关键在 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。
试试看
cd code/s08
npm install
npm run dev问这些问题看效果:
- "写一个 Python 快排" — 带高亮的代码块 + 复制按钮
- "对比 React 和 Vue" — 表格渲染
- "解释 HTTP 状态码" — 标题、列表、引用块
- "用
git log查看提交历史" — 行内代码特殊样式
试着改改
换配色方案:把 oneDark 换成 vscDarkPlus、atomDark、dracula,看看效果。
处理没有语言标签的代码块:有些模型输出的代码块没有语言标签,match 会是 null。加一个 fallback 就行:
if (match) {
return <CodeBlock language={match[1]}>{codeString}</CodeBlock>;
}
return <CodeBlock language="text">{codeString}</CodeBlock>;教学边界
这一章只做一件事:让 Markdown 在聊天气泡里正确渲染。
不涉及:
- LaTeX 公式渲染(需要
remark-math+rehype-katex) - Markdown 编辑器(这是渲染,不是编辑)
- 代码块的"运行"按钮
- 自定义主题系统
这些都在后面的章节里。
一句话记住
模型输出的是 Markdown,不是纯文本。你要做的不是解析它,而是告诉 React 怎么渲染它。