📌 上篇回顾 :我们打通了推理全链路------中断控制、KV 缓存加速、双回调流式输出、思考标签识别,Worker 侧已经把文字推到了主线程门口。
🎯 本篇目标 :把文字变成真正的聊天气泡 ------消息渲染、思考折叠、流式填充、自适应输入框、粘性滚动、TS 类型化,以及一路踩过的坑。
📌 系列完结:六篇文章,从项目骨架到完整聊天应用,一篇不多,一篇不少。本篇是终点。
📖 目录
- [一、🏗️ 架构总览](#一、🏗️ 架构总览 "#%E4%B8%80%E6%9E%B6%E6%9E%84%E6%80%BB%E8%A7%88")
- [二、📥 数据到达界面:App.tsx 推理收尾](#二、📥 数据到达界面:App.tsx 推理收尾 "#%E4%BA%8C%E6%95%B0%E6%8D%AE%E5%88%B0%E8%BE%BE%E7%95%8C%E9%9D%A2apptsx-%E6%8E%A8%E7%90%86%E6%94%B6%E5%B0%BE")
- [三、💬 界面渲染:Chat.tsx](#三、💬 界面渲染:Chat.tsx "#%E4%B8%89%E7%95%8C%E9%9D%A2%E6%B8%B2%E6%9F%93chattsx")
- [四、🎨 图标组件层](#四、🎨 图标组件层 "#%E5%9B%9B%E5%9B%BE%E6%A0%87%E7%BB%84%E4%BB%B6%E5%B1%82")
- [五、🎨 贯穿的样式:index.css](#五、🎨 贯穿的样式:index.css "#%E4%BA%94%E8%B4%AF%E7%A9%BF%E7%9A%84%E6%A0%B7%E5%BC%8Findexcss")
- [六、📐 贯穿的规范:TS 类型化](#六、📐 贯穿的规范:TS 类型化 "#%E5%85%AD%E8%B4%AF%E7%A9%BF%E7%9A%84%E8%A7%84%E8%8C%83ts-%E7%B1%BB%E5%9E%8B%E5%8C%96")
- [七、🐛 踩坑记录](#七、🐛 踩坑记录 "#%E4%B8%83%E8%B8%A9%E5%9D%91%E8%AE%B0%E5%BD%95")
- [八、⏱️ 完整数据流时间线(收尾完整版)](#八、⏱️ 完整数据流时间线(收尾完整版) "#%E5%85%AB%E5%AE%8C%E6%95%B4%E6%95%B0%E6%8D%AE%E6%B5%81%E6%97%B6%E9%97%B4%E7%BA%BF%E6%94%B6%E5%B0%BE%E5%AE%8C%E6%95%B4%E7%89%88")
- [九、📦 项目完成总结](#九、📦 项目完成总结 "#%E4%B9%9D%E9%A1%B9%E7%9B%AE%E5%AE%8C%E6%88%90%E6%80%BB%E7%BB%93")
- [十、📝 小结](#十、📝 小结 "#%E5%8D%81%E5%B0%8F%E7%BB%93")
一、🏗️ 架构总览
接续第五篇。这是最后一篇 。第五篇结尾停在 Worker 侧
generate()全貌------它通过 postMessage 把文字推到主线程。本文从"文字到了主线程之后"开始讲:数据怎么进messages状态,又怎么被渲染成聊天气泡。全篇只有一条主线:数据到达 → 界面渲染。前半段(App.tsx)讲"数据怎么来",后半段(Chat.tsx)讲"界面怎么画",中间穿插图标、样式、TS 规范这些"渲染辅助",最后是踩坑和时间线。
1.1 📁 本篇新增文件
sql
src/
├── components/
│ ├── Chat.tsx ← 新增:聊天消息渲染(思考折叠 + Markdown + 公式)
│ ├── Chat.css ← 新增:markdown 文本样式
│ └── icons/
│ ├── BotIcon.tsx ← 新增:机器人图标
│ ├── BrainIcon.tsx ← 新增:大脑图标(思考用)
│ └── UserIcon.tsx ← 新增:用户图标
├── App.tsx ← 补全:start/update/complete 三个 case + 自适应高度 + 自动滚动 + TS 化
├── index.css ← 补全:滚动条 / 动画延迟 / 文本换行 三组自定义样式
└── main.tsx ← 无改动
1.2 📍 本篇主线:一条消息怎么变成屏幕上的气泡
前五篇讲完了"数据怎么从用户输入流到 Worker、Worker 怎么生成文字"。本篇补上最后一段,并沿着这条链路组织章节:
java
① 数据到达(第二章 · App.tsx)
Worker postMessage("update")
→ case "update" 更新 messages + 记录 answerIndex
│
▼
② 界面渲染(第三章 · Chat.tsx)
Chat 组件遍历 messages → Message 组件
→ 用 answerIndex 切分思考/回答
→ render() 转 HTML → MathJax 公式 + Chat.css 样式
│
▼
③ 渲染辅助(第四~六章)
图标(SVG)→ 自定义样式(index.css)→ TS 类型规范
│
▼
④ 踩坑(第七章)+ 完整时间线(第八章)
读任何一章前,先回想它在上面这条主线的哪一环,就不会觉得"各讲各的"了。
二、📥 数据到达界面:App.tsx 推理收尾
承接第五篇:Worker 的
generate()通过 postMessage 把start/update/complete三个信号发回主线程。这一章讲主线程怎么接住这些信号、把文字推进messages状态------这是「数据怎么到达界面」。第五篇只讲了onEnter骨架版和触发推理的 useEffect,这里补上剩余部分。
2.1 ✍️ onEnter 完整版 --- 四个 setState
tsx
function onEnter(message: string) {
// ① 追加用户消息到对话历史末尾
setMessages((prev) => [...prev, { role: "user", content: message }]);
// ② 清空上一次的生成速率(新一轮要重新统计)
setTps(null);
// ③ 立即切"正在生成"状态,按钮变"停止",防止重复发送(乐观更新)
setIsRunning(true);
// ④ 清空输入框(受控组件,state 变空,输入框自动空)
setInput("");
}
第③步 setIsRunning(true) 是乐观更新 ("先斩后奏"),原理见第四篇第十节:不等 Worker 回 start 先切停止按钮,防止这几十毫秒里用户连点两次、造成两路推理并行。
onEnter 里没有直接 postMessage ,而是改 messages 间接触发 useEffect 推理------数据驱动,双守卫逻辑见第五篇 4.2。
2.2 🪹 case "start" --- 空壳占位
tsx
case "start":
setMessages((prev) => [
...prev,
{ role: "assistant", content: "" }, // 空壳消息,后续 update 流式往里填
]);
break;
为什么要加空壳? 流式输出是"边生成边追加",必须先有占位,后面才能往里填:
css
start 时: [用户消息, { role: "assistant", content: "" }] ← 空壳
update 时: [用户消息, { role: "assistant", content: "今" }] ← 往里填
update 时: [用户消息, { role: "assistant", content: "今天" }] ← 继续填
没有空壳,update 就不知道该往哪条消息里追加内容。
空壳还有个副作用:content: "" 会让 Chat 组件渲染三个跳动的小圆点(见 3.3),正好是"模型还在想"的视觉反馈。
2.3 🔄 case "update" --- 流式填充 + 记录 answerIndex
这是整个推理链路最关键的逻辑:
tsx
case "update":
const { output, tps, numTokens, state } = e.data;
setTps(tps);
setNumTokens(numTokens);
setMessages((prev) => {
const cloned = [...prev]; // ① 浅拷贝(不可变原则)
const last = cloned.at(-1); // ② 取最后一条 = start 时加的空壳
const data = {
...last, // ③ 保留旧字段
content: last.content + output, // ④ 追加增量文字
};
// ⑤ 记录 answerIndex(关键!)
if (data.answerIndex === undefined && state === "answering") {
data.answerIndex = last.content.length;
}
cloned[cloned.length - 1] = data; // ⑥ 放回
return cloned; // ⑦ 返回新数组
});
break;
🔑 核心难点:answerIndex 的时机判断
两个条件同时满足才记录:
| 条件 | 含义 |
|---|---|
answerIndex === undefined |
还没记录过(只记录一次) |
state === "answering" |
Worker 检测到了 </think>,思考结束 |
关键:last.content.length 在那一刻是什么?
perl
时间线(content 逐步累积):
"嗯,用户问天气" ← 思考中,state = "thinking"
"嗯,用户问天气,需要查" ← 思考中,state = "thinking"
"嗯,用户问天气,需要查一下。" ← 这一刻检测到 </think>,state 变成 "answering"
↑ 此时 last.content.length = 15 → answerIndex = 15
"嗯,用户问天气,需要查一下。今天晴朗" ← 回答中,往 15 后面继续追加
所以 answerIndex = 15 标记的是:思考段在第 15 个字符处结束,回答段从第 15 个字符开始。这个索引会随消息一起传给 Chat 组件,下一章(3.1/3.3)用它切分思考和回答。
🔑 一句话:answerIndex 是"思考和回答的分界线",在状态切换的那一刻,用当时的文字长度记录下来。
2.4 ✅ case "complete"
tsx
case "complete":
setIsRunning(false); // 生成完成,按钮切回"发送"
break;
注意:不在 onInterrupt 里本地改 isRunning (见第四篇 4.3),而是等 Worker 发 complete 才恢复。这样能保证 Worker 真正停下来了,界面才切回发送键,避免两路推理并行。
2.5 🖥️ 聊天界面的三块"收尾" UI
status === "ready" 的界面里,除了消息列表和输入框,还有三块本次新增的内容。
① 📌 示例问题(EXAMPLES)
模型加载完、还没开始对话时,显示几条预设问题,点一下直接发送:
tsx
const EXAMPLES = [
"Solve the equation x^2 - 3x + 2 = 0",
"Lily is three times older than her son. In 15 years, ...",
"Write python code to compute the nth fibonacci number.",
];
// JSX 中:
{messages.length === 0 && (
<div>
{EXAMPLES.map((msg, i) => (
<div key={i} onClick={() => onEnter(msg)}>{msg}</div>
))}
</div>
)}
messages.length === 0:只在"一条消息都没有"时显示,一旦开始对话就消失- 点击示例 → 直接调用
onEnter(msg)→ 和手动输入一模一样走完整推理链路
② 📊 生成速率与 token 统计
流式输出时实时显示 tps(token/秒),结束后再补一句总结:
tsx
{tps && messages.length > 0 && (
<>
{!isRunning && (
<span>Generated {numTokens} tokens in {(numTokens / tps).toFixed(2)} seconds (</span>
)}
<span>{tps.toFixed(2)}</span>
<span>tokens/second</span>
{!isRunning && (
<>
<span>)</span>
<span onClick={() => { worker.current.postMessage({ type: "reset" }); setMessages([]); }}>
Reset
</span>
</>
)}
</>
)}
拆解这坨条件渲染:
| 时机 | 显示 |
|---|---|
生成中(isRunning=true) |
只显示实时 12.34 tokens/second |
生成完(isRunning=false) |
Generated 150 tokens in 3.21 seconds (12.34 tokens/second) + Reset |
关键点:
tps && ...:tps 是 null(还没生成过)时,整块不显示numTokens / tps:用"总数 ÷ 速率"反推总耗时(秒),.toFixed(2)保留两位小数
③ 🔄 Reset 按钮 --- 重置上下文
tsx
onClick={() => {
worker.current.postMessage({ type: "reset" }); // ① 通知 Worker 清空 KV 缓存
setMessages([]); // ② 本地清空消息列表
}}
点击 Reset 要两边都清:
- Worker 侧:
past_key_values_cache = null(忘掉对话上下文,见第五篇二) - 主线程侧:
setMessages([])(界面回到"Ready!"空状态)
只清一边会出问题:只清 Worker → 界面还有旧消息但模型已失忆;只清界面 → 模型还带着旧上下文继续跑。
这就是
case "reset"在主线程侧的"另一半"------Worker 收到后清缓存,主线程这里清 UI,两边对齐才是一次完整的重置。
2.6 📏 resizeInput --- 自适应高度输入框
tsx
useEffect(() => {
resizeInput();
}, [input]); // input 每变一次,重新算一次高度
function resizeInput() {
if (!textareaRef.current) return; // 安全守卫
const target = textareaRef.current;
target.style.height = "auto"; // ① 先归零
const newHeight = Math.min(Math.max(target.scrollHeight, 24), 200); // ② 夹逼
target.style.height = `${newHeight}px`; // ③ 写回
}
🔑 三个关键点
① 为什么先设 height = "auto"? scrollHeight 是"内容的完整高度"。但如果你之前设了固定高度,读出来的 scrollHeight 可能不准。先归零成 auto,让浏览器算出内容真实需要多高,再读。
② Math.min(Math.max(x, 24), 200) 是"夹逼" :把高度限制在 [24, 200] 区间内(最小一行,最大约 8 行)。
③ 为什么直接改 DOM 不用 state? 改输入框高度只需改一个 DOM 属性,不需要重跑整个组件渲染(第二篇讲过的 useRef vs useState)。
2.7 📜 自动滚动 --- 粘性滚动算法
tsx
useEffect(() => {
if (!chatContainerRef.current || !isRunning) return;
const element = chatContainerRef.current;
if (
element.scrollHeight - element.scrollTop - element.clientHeight <
STICKY_SCROLL_THRESHOLD // 120,粘性滚动阈值
) {
element.scrollTop = element.scrollHeight; // 滚到底
}
}, [messages, isRunning]);
STICKY_SCROLL_THRESHOLD是 App.tsx 顶部定义的常量(值 = 120)。它一开始标注"预留",到本篇自动滚动才真正用上------这就是把"魔法数字"提出来命名的好处:语义清晰,改一处即可。
📐 核心公式
距离底部 = scrollHeight(总内容高)- scrollTop(已滚动距离)- clientHeight(可见区高)
🔄 "粘性滚动"是什么
不是无脑滚到底,而是判断用户是不是已经在看最新内容:
| 用户状态 | 距离底部 | 行为 |
|---|---|---|
| 盯着最新输出 | < 120px | 自动滚到底,紧跟输出 |
| 往上翻看历史 | > 120px | 不打扰,让他继续看 |
📱 类比:微信看聊天,如果你正在翻旧记录,新消息不会强行把你拽回底部;但如果你本来就在底部,新消息来了会自动跟着往下滚。
三、💬 界面渲染:Chat.tsx
上一章把
messages状态更新好了,React 会自动重新渲染。这一章讲 Chat 组件怎么把这些消息画成气泡------这是「数据怎么变成界面」。读完会发现:answerIndex在上一章 2.3 被记录,在这里 3.1/3.3 被消费。
3.1 📋 数据契约:ChatMessage 类型
消息对象长什么样,用 TypeScript 接口定义清楚:
ts
export interface ChatMessage {
role: "user" | "assistant"; // 角色:只能是这两个值之一
content: string; // 内容(assistant 时 = 思考段 + 回答段拼在一起)
answerIndex?: number; // 回答段从第几个字符开始(只有 assistant 有)
}
🔑 核心难点:answerIndex 是什么
模型输出是一长串文字,但分两段------<think> 思考 + </think> 回答。存进 content 时是拼在一起的,所以需要一个"分界线"标记回答从哪开始:
ini
content = "嗯,用户问天气...需要查一下。今天晴朗,25度。"
└──────── 思考段 ────────┘└── 回答段 ──┘
answerIndex = 15(第 15 个字符开始是正式回答)
这个索引是上一章 2.3(case "update")在状态切换的那一刻记录下来的 。拿到它之后,组件就能用 slice 把两段切开(见 3.3)。
export关键字:让 App.tsx 也能import type { ChatMessage },两处共用一份定义,不重复写。
3.2 🔄 render 函数 --- Markdown 转安全 HTML
模型输出的是 Markdown(**加粗**、# 标题、公式等),浏览器不认识,要转成 HTML 才能渲染:
tsx
function render(text: string): string {
// ① 转义反斜杠:修复 marked 解析括号的一个 bug
text = text.replace(/\\([\[\]\(\)])/g, "\\\\$1");
// ② marked 转 HTML → ③ DOMPurify 消毒
const result = DOMPurify.sanitize(
marked.parse(text, {
async: false, // 同步解析
breaks: true, // 单个换行也转 <br>
}),
);
return result;
}
三步流水线:
css
原始 Markdown → 转义反斜杠 → marked 转 HTML → DOMPurify 消毒 → 安全 HTML
"**加粗**" (修 bug) "<strong>加粗</strong>" (防 XSS)
🛡️ 为什么要 DOMPurify 消毒?
模型输出理论上可能包含 <script> 恶意代码,直接塞进页面会被执行(XSS 攻击)。消毒就是把危险标签过滤掉,只留安全的。
这正是下面
dangerouslySetInnerHTML里 "dangerous" 的含义------React 默认不让你直接插 HTML,你必须自己保证安全(这里用 DOMPurify 保证了)。
3.3 💬 Message 组件 --- 单条消息
三个核心变量
tsx
const thinking = answerIndex ? content.slice(0, answerIndex) : content; // 思考段
const answer = answerIndex ? content.slice(answerIndex) : ""; // 回答段
const doneThinking = answer.length > 0; // 思考完了吗?
| 场景 | thinking | answer | doneThinking |
|---|---|---|---|
还没检测到 </think> |
全部内容 | "" |
false |
检测到 </think>,正在回答 |
思考段 | 回答段 | true |
三条渲染分支
ini
Message 组件
├─ role === "assistant" → 灰气泡
│ ├─ thinking 为空 → 三个跳动的点(等待中)
│ └─ thinking 有内容 → 思考折叠按钮 + 回答
│
└─ role === "user" → 蓝气泡(纯文本,不解析 Markdown)
🧠 思考折叠逻辑
- 默认
showThinking = false→ 思考内容收起(只显示 "View reasoning." 按钮) - 点按钮 →
showThinking = true→ 思考内容展开 - 大脑图标:
doneThinking为 false 时加animate-pulse(闪烁动画),表示"还在想"
⏳ 空内容的加载动画
content: "" 时,thinking 也是空 → 渲染三个跳动的小圆点:
tsx
<span className="... animate-pulse"></span> // 第1个点:立即跳
<span className="... animate-pulse animation-delay-200"></span> // 第2个点:延迟200ms
<span className="... animate-pulse animation-delay-400"></span> // 第3个点:延迟400ms
三个点错开时间跳动,形成"波浪"效果。这正是上一章 2.2 里 case "start" 加空壳消息后的视觉反馈------空壳渲染成"正在思考",等第一个 token 来了才变成真正的思考内容。
3.4 📋 Chat 组件 --- 消息列表
tsx
export default function Chat({ messages }: ChatProps) {
const empty = messages.length === 0;
return (
<div className="...">
<MathJaxContext>
{empty ? <div>Ready!</div> : messages.map((msg, i) => <Message key={`message-${i}`} {...msg} />)}
</MathJaxContext>
</div>
);
}
外层套 <MathJaxContext> 是因为:模型回答可能包含数学公式(LaTeX),MathJax 负责把 $x^2$ 渲染成真正的公式。Context 必须包在所有 <MathJax> 外面,提供公式渲染的环境。
3.5 🎨 Chat.css --- Markdown 内容的样式表
render() 输出的 HTML(标题、代码块、列表、表格)默认是"裸"的,没有排版。Chat.css 负责给 .markdown 容器里的这些元素补上样式------所以 Chat.tsx 里每个 dangerouslySetInnerHTML 的 <span> 都带了 className="markdown"。
🔍 @scope (.markdown) --- CSS 作用域(新特性)
文件开头这个写法是本篇最值得注意的新知识点:
css
@scope (.markdown) {
h1 { font-size: 2em; }
pre { background-color: #f2f2f2; }
...
}
@scope 是 CSS 的作用域 语法:花括号里的所有选择器,只在 .markdown 这个容器内部才生效。
less
没有 @scope:h1 { ... } → 整页所有 h1 都被改(可能误伤页面自己的标题)
有 @scope: 只在 .markdown 内的 h1 被改(精准命中,不污染外面)
🏠 类比 :只装修一栋楼(
.markdown)的内部,不碰楼外的公共区域。
📊 它管哪些内容
| 选择器 | 作用 |
|---|---|
pre / code |
代码块底色 + 等宽字体(Consolas/Monaco) |
h1~h6 |
标题字号、加粗、行高 |
ul / ol |
列表符号、缩进 |
table/th/td |
表格边框 |
@media (prefers-color-scheme: dark) |
暗色模式换深色底 |
模型输出为什么需要这些样式?因为 render() 把 **加粗** 转成了 <strong>、把 Markdown 代码块语法转成了 <pre><code>------这些标签浏览器"认识",但默认长得丑(代码块没底色、标题跟正文一样大),Chat.css 让它们看起来像正常文档。
四、🎨 图标组件层
渲染聊天界面需要几个小图标(机器人、大脑、用户、发送、停止)。这一章很短,只讲它们的 TS 写法和一个隐藏坑。
4.1 📐 SVGProps --- 透传 props 的类型
5 个图标组件(ArrowRight / Stop / Bot / Brain / User)都统一成这种写法:
tsx
import type { SVGProps } from "react";
export default function BotIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} width="24" height="24" viewBox="0 0 24 24" ...>
...
</svg>
);
}
SVGProps<SVGSVGElement> 的含义:props 是所有 SVG 元素都有的属性集合 (className、onClick、width 等),这样 {...props} 透传时就有类型检查了。
4.2 🐛 一个隐藏 bug:export default 不检查函数名
BrainIcon.tsx 补充时函数名写错了:
tsx
// ❌ 文件名是 BrainIcon.tsx,但函数名却写成了 BotIcon
export default function BotIcon(props) { ... }
// ✅ 修正为
export default function BrainIcon(props: SVGProps<SVGSVGElement>) { ... }
为什么之前能"蒙混过关"? 因为 export default 默认导出的名字不影响导入:
tsx
import BrainIcon from "./icons/BrainIcon"; // 拿到的是那个函数本身,不管它内部叫啥
两个文件都叫 BotIcon 会非常混乱,所以必须修正。这是一个典型的"名字和实际不符"的坑------export default 匿名了,命名全靠自觉。
五、🎨 贯穿的样式:index.css
前两章讲完了数据流和渲染逻辑,但界面上还有几个 Tailwind 没内置的样式(滚动条、动画延迟、换行)。这一章补上它们,和上一章的 Chat.css 一起构成"渲染的最后一道工序"。
5.1 📜 细滚动条:scrollbar-thin
Tailwind 不提供滚动条美化,得用 ::-webkit-scrollbar 伪元素手写:
css
.scrollbar-thin::-webkit-scrollbar { width: 0.5rem; } /* 滚动条整体宽度 */
.scrollbar-thin::-webkit-scrollbar-track { ... } /* 轨道(凹槽) */
.scrollbar-thin::-webkit-scrollbar-thumb { ... } /* 滑块(能拖动的) */
.scrollbar-thin::-webkit-scrollbar-thumb:hover { ... } /* 滑块悬停 */
要点:
- 分三段控制:整体宽度 → 轨道 → 滑块,用
::-webkit-scrollbar伪元素家族。 - 圆角
9999px= 全圆角(对应 Tailwind 的rounded-full)。 - 颜色对应 Tailwind 的灰阶(如
#f3f4f6= gray-100)。 - 注意:这套写法只对 Chrome / Edge / Safari 生效,Firefox 不支持。
5.2 ⏳ 动画延迟:animation-delay-200/400
Tailwind 的 animate-pulse 自带跳动动画,但没有延迟。给三个点错开时间,形成波浪效果:
css
.animation-delay-200 { animation-delay: 200ms; }
.animation-delay-400 { animation-delay: 400ms; }
配合 Chat.tsx 里三个点:第一个点立即跳,第二个延迟 200ms,第三个延迟 400ms(见 3.3)。
5.3 📝 文本强制换行:overflow-wrap-anywhere
css
.overflow-wrap-anywhere { overflow-wrap: anywhere; }
防止超长英文单词 / 网址撑破聊天气泡。overflow-wrap: anywhere 表示"任何地方都能断行",即使是一个超长单词,也会在气泡边缘强行断行,不会溢出。
🤔 为什么这三个类要单独写?
Tailwind 是"按需生成"的工具类库,遇到源码里没写过的 class 不会自动生成。这三类样式属于它没内置的,所以放进 index.css 手动补上。
六、📐 贯穿的规范:TS 类型化
前面几章都涉及 TypeScript 写法(接口、泛型、事件类型)。这一章把本项目用到的 TS 关键点集中梳理一遍,是贯穿全篇的"规范"。
6.1 🤔 为什么要 TS 化
项目里 worker.js 是 JS,其余是 TS。之前 App.tsx 里存在大量"隐式 any"------参数不标类型,等于放弃类型检查。TS 化的目标就是消除隐式 any,让编译器能帮你抓错。
6.2 📝 关键类型写法
tsx
// ① 联合类型:status 只能是这三个值之一,写错会报错
useState<null | "loading" | "ready">(null)
// ② 可空类型:要么是数字,要么是 null(还没数据)
useState<number | null>(null)
// ③ 泛型数组:元素必须是 ChatMessage 类型
useState<ChatMessage[]>([])
| 类型写法 | 含义 | 用在哪 |
|---|---|---|
| `null | "loading" | "ready"` |
| `number | null` | 数字或空 |
ChatMessage[] |
消息对象数组 | messages |
| `Worker | null` | Worker 实例或空 |
自定义接口:ProgressItem
进度条条目字段很多(file/progress/loaded/total/...),用一个 interface 描述它的"形状":
tsx
interface ProgressItem {
file: string; // 文件名(如 "model.onnx")
progress?: number; // 可选:下载百分比(progress 阶段才有)
loaded?: number; // 可选:已下载字节数
total?: number; // 可选:文件总字节数
name?: string; // 可选:模型仓库名
status?: string; // 可选:事件类型 initiate / progress / done
}
带 ? 的字段是"可选"。下载过程中不同阶段字段不全------initiate 阶段只有 file/name,progress 阶段才有 progress/loaded/total------用可选字段正好匹配。
事件类型:MessageEvent / ErrorEvent
给 Worker 的回调参数标类型,消除隐式 any:
tsx
const onMessageReceived = (e: MessageEvent) => { ... }; // 收到 "message" 事件
const onErrorReceived = (e: ErrorEvent) => { ... }; // Worker 报错 "error" 事件
MessageEvent 就是 addEventListener("message", ...) 回调里事件对象的类型;ErrorEvent 对应 "error" 事件。
import type:只导入类型
tsx
import Chat from "./components/Chat"; // 普通导入:引入组件(运行时代码)
import type { ChatMessage } from "./components/Chat"; // 只导入类型(编译期用)
import type 的关键区别:类型只在编译期存在,编译后会被完全擦除,不产生任何运行时代码 。App.tsx 只是拿 ChatMessage 来标注 state,并不真正"用"它,所以用 import type,打包更干净。
6.3 🐛 一个坑:e.target vs e.currentTarget
取输入框的值时,e.target.value 会报错:
arduino
类型"EventTarget"上不存在属性"value"。
原因:React 里 e.target 的类型是 EventTarget(通用类型),没有 value 属性 。value 是 HTMLTextAreaElement 特有的。
| 属性 | 类型 | 有 value 吗 |
|---|---|---|
e.target |
EventTarget |
❌ 没有 |
e.currentTarget |
HTMLTextAreaElement |
✅ 有 |
target= 实际触发事件的元素(可能被子元素触发),React 只给通用类型currentTarget= 绑定事件处理器的元素(就是 textarea 本身),类型精确
解决 :用 e.currentTarget.value,不用断言。这是 React 事件里取输入框值的标准写法。
七、🐛 踩坑记录
开发过程中踩了两个坑,单独记在这里,方便以后排查。
7.1 📐 MathJax 的 dynamic 模式和 React 冲突
症状
运行时报错:
lua
Uncaught NotFoundError: Failed to execute 'insertBefore' on 'Node'
An error occurred in the <Text> component.
🔍 根因
better-react-mathjax 的 <MathJax dynamic> 在流式输出时,和 React 打架了:
csharp
① 模型每吐一个 token → messages 更新 → React 更新 DOM
② 同时 MathJax 的 dynamic 模式也在监听内容变化,自己重写 DOM 渲染公式
③ 两方同时改同一块 DOM → React 找不到参考节点 → insertBefore 报错
dynamic 属性让 MathJax 自己动手改 DOM,破坏了 React 管理的结构。
✅ 修复
去掉 dynamic,改用 key 让 React 在内容变化时整个重建:
tsx
// ❌ 改前:dynamic 让 MathJax 自己改 DOM
<MathJax dynamic>...</MathJax>
// ✅ 改后:key 变化 → React 卸载旧组件、挂载新组件
<MathJax key={answer}>...</MathJax>
vbnet
改前(dynamic):React 改 DOM ⇄ MathJax 也改 DOM → 冲突
改后(key): content 变 → key 变 → React 卸载旧 MathJax → 挂载新的
↑ 只有 React 一个人改 DOM,MathJax 只在挂载时渲染一次
7.2 📝 textarea 的 type 属性
给 <textarea> 写了 type="text" 会标红:
tsx
<textarea type="text" /> // ❌ textarea 没有 type 属性
<input type="text" /> // ✅ input 才有 type(区分 text/password/checkbox)
textarea 天生就是"多行文本输入框",没有类型可切换。type 是 <input> 的专属属性,直接删掉即可。
八、⏱️ 完整数据流时间线(收尾完整版)
前面各章是"拆开讲",这一章把整条链路串起来看一遍,形成全局印象。
scss
主线程
│ 用户输入"你好" → onEnter
│ ├─ setMessages 追加用户消息
│ ├─ setTps(null)
│ ├─ setIsRunning(true) ← 乐观更新,按钮切停止
│ └─ setInput("") ← 清空输入框
│
│ useEffect 两个守卫通过 → postMessage("generate", messages)
│
▼
Worker generate()
│ ①~④ 分词 + 双回调 + streamer(详见第五篇)
│ ⑤ postMessage("start")
│ ⑥ await model.generate(...)
│ └─ 内部循环逐 token → streamer → postMessage("update", output, tps, numTokens, state)
│ ⑦ 存 KV 缓存
│ ⑧ postMessage("complete")
│
▼
主线程 onMessageReceived
├─ case "start" → 追加空壳 assistant 消息(content: "")
├─ case "update" → 把 output 追加到空壳 + 状态切 answering 时记录 answerIndex
├─ case "complete" → setIsRunning(false)
└─ case "error" → setError
▼
Chat 组件渲染
├─ 空壳消息(content="")→ 三个跳动点
├─ 有思考内容 → "Thinking..." 折叠按钮 + 大脑图标闪烁
├─ 状态切 answering → 记录 answerIndex → 思考段/回答段切开
├─ 思考完成 → 显示回答(Markdown + 公式渲染)
└─ 每来一段新文字 → 粘性滚动自动滚到底
九、📦 项目完成总结
9.1 📚 六篇文章回顾
| 篇目 | 标题 | 核心内容 |
|---|---|---|
| 一 | 选型与基础搭建 | React + TS + Tailwind、WebGPU 检测、Hooks 基础 |
| 二 | 事件、组件与状态驱动 | 事件系统、受控组件、组件化、状态驱动 UI |
| 三 | Worker 架构与模型加载 | Worker 通信、单例模式、TextGenerationPipeline、进度回调 |
| 四 | 下载进度条与聊天界面 | 预热机制、三种下载事件闭环、三态按钮联动 |
| 五 | 中断、重置、缓存与流式生成 | 中断控制、KV 缓存、双回调、流式输出、思考标签 |
| 六 | 消息渲染与流式收尾 | Chat 组件、流式填充、TS 类型化、踩坑记录 |
从零到一,完整走完浏览器本地推理聊天应用的全部核心模块。
9.2 📁 完整项目结构
css
deepseek-r1-WebGPU/
├── src/
│ ├── main.tsx ← 入口:挂载 React 应用
│ ├── App.tsx ← 主组件:状态管理 + 通信 + UI 三态
│ ├── worker.js ← Worker:模型下载 + 推理生成
│ ├── index.css ← 全局样式(滚动条/动画/换行)
│ └── components/
│ ├── Chat.tsx ← 聊天消息渲染
│ ├── Chat.css ← markdown 样式
│ ├── Progress.tsx ← 下载进度条
│ └── icons/ ← 5 个 SVG 图标
└── package.json
9.3 🛠️ 技术栈回顾
| 层 | 技术 | 作用 |
|---|---|---|
| 推理 | Transformers.js + ONNX Runtime Web + WebGPU | 浏览器本地跑大模型 |
| Worker | Web Worker | 推理不阻塞主线程 |
| UI | React + TypeScript + Tailwind CSS | 界面 + 类型安全 |
| 渲染 | marked + DOMPurify + better-react-mathjax | Markdown 安全渲染 + 公式 |
| 通信 | postMessage | 主线程 ↔ Worker 双向消息 |
9.4 ✅ 完整能力清单
一个完整的浏览器本地推理聊天应用,具备:
- ✅ WebGPU 环境检测
- ✅ 模型下载进度条(多文件并发)
- ✅ 模型预热(着色器预编译)
- ✅ 流式生成(打字机效果)
- ✅ 思考过程展示(可折叠)
- ✅ 中断生成
- ✅ 重置上下文
- ✅ KV 缓存加速追问
- ✅ Markdown + 数学公式渲染
- ✅ 自适应输入框 + 粘性滚动
- ✅ TypeScript 类型安全
十、📝 小结
| 模块 | 核心知识点 |
|---|---|
| 数据到达 App.tsx | onEnter 四 setState / start 空壳 / update 流式 + answerIndex 记录 / complete |
| 界面渲染 Chat.tsx | ChatMessage 契约 / 切分思考回答 / render 三步 / 三点动画 / 思考折叠 |
| 图标组件 | SVGProps 透传 / export default 不检查函数名 |
| index.css | 滚动条 / 动画延迟 / 换行 三组自定义样式 |
| TS 类型化 | 联合/可空/泛型 / ProgressItem / MessageEvent / import type / e.currentTarget |
| 踩坑 | MathJax dynamic→key / textarea 无 type |
📋 代码职能回顾
| 代码块 | 职能 | 对应章节 |
|---|---|---|
onEnter 完整版 |
四 setState + 乐观更新 | 二 |
case "start" |
追加空壳 assistant 消息 | 二 |
case "update" |
流式追加 + answerIndex 记录 | 二 |
resizeInput |
自适应高度(先 auto 再读 scrollHeight) | 二 |
| 粘性滚动 | 距离底部 < 120px 才自动滚 | 二 |
ChatMessage 接口 |
定义消息数据结构 | 三 |
render 函数 |
Markdown → 安全 HTML(转义+marked+DOMPurify) | 三 |
Message 组件 |
单条消息渲染(思考折叠/三点动画/气泡) | 三 |
Chat 组件 |
消息列表 + MathJaxContext | 三 |
@scope (.markdown) |
CSS 作用域,只装修 markdown 容器内部 | 三 |
| 图标组件 | SVG 图标 + SVGProps | 四 |
scrollbar-thin |
细滚动条(webkit 伪元素) | 五 |
animation-delay-* |
波浪加载动画 | 五 |
overflow-wrap-anywhere |
超长单词强制断行 | 五 |
| TS 类型化 | 联合类型 / 可空类型 / 泛型 / import type | 六 |
MathJax dynamic→key |
避免 React 与 MathJax 同时改 DOM | 七 |
💼 面试要点总结
面试中:个人介绍 → 聊项目(WebGPU-deepseek)→ 顺势展开:
1. answerIndex 是什么?为什么需要它?
"DeepSeek-R1 输出分思考段(<think>)和回答段(</think>),但存进 content 时是拼在一起的。answerIndex 记录回答段从第几个字符开始,让 Chat 组件能用 slice 切开,分别渲染------思考段可折叠,回答段正常显示。它在 Worker 检测到 </think> 的那一刻用 last.content.length 记录。"
2. 流式输出时,文字怎么从 Worker 走到界面?
"Worker 每生成一个 token,TextStreamer 触发双回调:先记账(TPS、状态切换),再解码成文字推给主线程。主线程 case "update" 把增量文字追加到空壳消息的 content 末尾,messages 更新触发 React 重新渲染,Chat 组件拿到新的完整内容重新切片渲染。complete 只发信号,不带文字------因为文字已经在 update 里发完了。"
3. 空壳消息解决了什么问题?
"start 时先加一条 { role: 'assistant', content: '' } 占位。没有它,update 不知道该往哪条消息追加内容。空壳还附带视觉反馈------Chat 组件检测到 content: '' 会渲染三个跳动点,表示'模型正在思考',体验比空白好得多。"
4. answerIndex 的时机判断为什么是 state === 'answering' 而不是检测 </think> 本身?
"</think> 只在 Worker 的 token 回调里能检测,主线程拿不到原始 token ID,只能拿到 state 字段。Worker 检测到 </think> 后把 state 从 'thinking' 切到 'answering',随 update 消息传给主线程。主线程在 case "update" 里看到 state === 'answering' 且 answerIndex 还没记录过,就用当时的 content.length 记录分界线。"
5. 粘性滚动怎么判断用户是否在看最新内容?
"每次 messages 变化时计算 scrollHeight - scrollTop - clientHeight------这是距离底部的像素值。小于 120px(阈值)说明用户盯着最新输出,自动滚到底;大于 120px 说明用户在往上翻历史,不打扰,让他继续看。阈值提成常量,改一处生效。"
6. 为什么要把 Chat.css 写在 Chat.tsx 旁边而不是 index.css?
"关注点分离。Chat.css 只管 markdown 内容的样式(标题、代码块、列表),index.css 只管全局通用样式(滚动条、动画延迟、换行)。Chat.css 用 @scope (.markdown) 限定作用域,只影响聊天区,不污染页面其他地方。"
7. 为什么用 DOMPurify?
"模型输出的 Markdown 转成 HTML 后,用 dangerouslySetInnerHTML 插入页面。如果模型输出包含 <script>alert('xss')</script>,未消毒直接插入会被执行。DOMPurify 过滤掉所有危险标签,只保留安全的。'dangerous' 的命名正是提醒开发者必须自己保证安全。"
8. 自适应输入框的核心技巧是什么?
"先用 height: auto 让浏览器算出内容的真实高度,再从 scrollHeight 读取,最后用 Math.min/Math.max 限制在 24~200px 区间。直接改 DOM 不触发重渲染(用 useRef 而不是 useState)。"
9. @scope 是什么?解决了什么问题?
"CSS 新特性,作用域语法。@scope (.markdown) { h1 { ... } } 让 h1 样式只在 .markdown 容器内生效,不污染页面其他 h1。相比于 BEM 命名(.markdown h1),@scope 语义更清晰,分组更自然。"
10. 项目从零到一的完整学习路径是什么?
"六篇文章覆盖全部:第一篇搭骨架(React+TS+Tailwind),第二篇做交互(事件+组件+状态),第三篇加载模型(Worker+单例+进度),第四篇做下载 UI(进度条+预热),第五篇打通推理(中断+缓存+流式),第六篇收尾渲染(Chat 组件+TS 化+踩坑)。前后端全部在浏览器本地完成,数据不出设备。"
💡 学习方法
看《你不知道的 JavaScript》、掘金等社区,关注 AI 博主,读 GitHub 源码,学习高质量开源代码,输出内容到社区------六篇文章就是最好的证明。
🏁 系列完结。 六篇文章,从
npm create vite到完整的浏览器本地推理聊天应用,一篇不多,一篇不少。希望这个系列能帮你迈过 WebGPU 端侧推理的门槛,自己动手跑起来。有问题欢迎评论区交流!🚀