🚀 在浏览器里跑 DeepSeek-R1?WebGPU 端侧推理实战(六)—— 完结篇:消息渲染与流式收尾

📌 上篇回顾 :我们打通了推理全链路------中断控制、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 元素都有的属性集合classNameonClickwidth 等),这样 {...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 属性valueHTMLTextAreaElement 特有的。

属性 类型 有 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 ✅ 完整能力清单

一个完整的浏览器本地推理聊天应用,具备:

  1. ✅ WebGPU 环境检测
  2. ✅ 模型下载进度条(多文件并发)
  3. ✅ 模型预热(着色器预编译)
  4. ✅ 流式生成(打字机效果)
  5. ✅ 思考过程展示(可折叠)
  6. ✅ 中断生成
  7. ✅ 重置上下文
  8. ✅ KV 缓存加速追问
  9. ✅ Markdown + 数学公式渲染
  10. ✅ 自适应输入框 + 粘性滚动
  11. ✅ 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 dynamickey 避免 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 端侧推理的门槛,自己动手跑起来。有问题欢迎评论区交流!🚀

相关推荐
sunly_1 小时前
TypeScript:3、类型声明与类型推断
javascript·ubuntu·typescript
YIAN2 小时前
从 Hash 底层原理到 React Router v6 实战:我学会了什么?
前端·react.js·vue-router
To_OC3 小时前
后端接口还没交付,前端如何独立把整套业务跑通
前端·react.js·全栈
AndrewHZ4 小时前
【LLM技术全景】RAG 从原理到实战——检索增强生成完整指南
人工智能·深度学习·算法·llm·检索增强·生成式模型·rag
程序员跑路5 小时前
Barrel Export、Jest、Babel 与依赖图
react.js
l1m0_5 小时前
告别反复修改Prompt:AI生成React应用并落地开发的实战复盘
前端·react.js·ui·ai·设计
xexpertS6 小时前
前端工程转型实践:从 Ember 迁移到 React,提升构建速度与研发效能
前端·react.js·前端框架
张元清7 小时前
React useFocus Hook:追踪并控制元素焦点状态 (2026)
javascript·react.js