上个月我开源了一个项目:DeepSeek-R1 WebGPU------1.5B 参数的推理模型,完全跑在浏览器里,断网也能用。
今天不讲模型推理,讲点更落地的:这个项目里的加载进度条和聊天对话框是怎么从零写出来的。
全程 React + TypeScript + TailwindCSS,没有第三方 UI 库,每一个 div 都是手写的。
一、项目长什么样?
先看一眼最终效果------一个典型的端侧 AI 应用,就三个核心区域:
scss
┌──────────────────────────────────┐
│ │
│ DeepSeek-R1 WebGPU │ ← 标题区
│ (模型介绍文字) │
│ │
│ [ Load Model ] ← 加载按钮 │
│ │
│ ┌──────────────────────────────┐│
│ │ model.onnx 45.2% of 1.2GB ││ ← 文件下载进度条(多个文件)
│ │ ████████████░░░░░░░░░░░░░░░░ ││
│ └──────────────────────────────┘│
│ ┌──────────────────────────────┐│
│ │ model2.onnx 78.1% of 856MB ││
│ │ ██████████████████████░░░░░░ ││
│ └──────────────────────────────┘│
│ │
│ ┌──────────────────────────────┐│
│ │ Type your message here... ││ ← 聊天输入框
│ └──────────────────────────────┘│
└──────────────────────────────────┘
两个硬需求:
- 加载时:模型文件可能有好几个,每个都要显示文件名 + 百分比 + 文件大小
- 加载完:输入框才能用,支持 Shift+Enter 发送,Enter 换行
下面逐个拆解。
二、进度条组件:从 0 到 1
2.1 数据长什么样?
模型下载时会拿到这样的数据:
js
const progressItems = [
{ text: 'model.onnx', percentage: 45.2, total: 1288490188 },
{ text: 'model2.onnx', percentage: 78.1, total: 897581056 },
]
三个字段:
| 字段 | 含义 | 示例 |
|---|---|---|
text |
文件名 | model.onnx |
percentage |
当前下载进度 | 45.2(0~100 的浮点数) |
total |
文件总大小(字节) | 1288490188 |
2.2 文件大小格式化:formatBytes
total 是原始的字节数------1288490188,谁能一眼看出是多大?
需要一个工具函数把它转成人类可读的格式。
typescript
function formatBytes(size) {
// 确定用哪个单位:0=B, 1=kB, 2=MB, 3=GB, 4=TB
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
// 除以对应进率 → 保留两位小数 → 拼接单位
return +(size / Math.pow(1024, i)).toFixed(2) * 1 + ['B', 'kB', 'MB', 'GB', 'TB'][i];
}
逐步拆解:
js
// 第一步:定单位
// 1,288,490,188 字节 → log(size) / log(1024) ≈ 3.26 → Math.floor = 3 → i = 3
// ['B', 'kB', 'MB', 'GB', 'TB'][3] → "GB"
// 第二步:换算
// 1288490188 / 1024³ = 1288490188 / 1073741824 = 1.199999...
// .toFixed(2) = "1.20"
// 第三步:拼接
// "1.20" + "GB" → "1.20GB"
这里有个小技巧:
+(...).toFixed(2) * 1------ 先toFixed(2)转字符串再* 1转回数字,去掉多余的小数位。
效果对比:
| 原始值 | 格式化后 |
|---|---|
0 |
0B |
1024 |
1kB |
1288490188 |
1.2GB |
897581056 |
856MB |
2.3 组件本身:最简单的进度条
tsx
const Progress = ({ text, percentage, total }) => {
percentage ??= 0; // 空值合并:没传就默认 0
return (
<div className="w-full bg-gray-100 rounded-lg overflow-hidden mb-0.5">
<div
style={{ width: `${percentage}%` }}
className="bg-blue-400 whitespace-nowrap px-1 text-xs"
>
{text}
{percentage.toFixed(2)}%
{isNaN(total) ? '' : ` of ${formatBytes(total)}`}
</div>
</div>
);
};
三层结构:
erlang
┌──────────────────────────────────────────────┐
│ 外层 div:灰色底,100% 宽度 = 进度条轨道 │ ← bg-gray-100
│ ┌──────────────────────────────────────┐ │
│ │ 内层 div:蓝色,width 跟着百分比变 │ │ ← bg-blue-400
│ │ model.onnx 45.20% of 1.20GB │ │ ← 文字信息
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
四种情况全部兜底:
js
percentage ??= 0; // ① 没传百分比 → 默认 0
{isNaN(total) ? '' : ` of ${formatBytes(total)}`} // ② 没传文件大小 → 不显示 "of xxx"
// ③ 传了 → 显示 "of 1.20GB"
// ④ percentage 正常传 → 用传入的值
这叫**"封装者多考虑,使用者用着爽"**------组件不假定数据一定完整,各种边界都兜住。
三、父组件怎么用:progressItems 驱动的列表渲染
3.1 数据状态
tsx
const [loadingMessage, setLoadingMessage] = useState('开始加载');
const [progressItems, setProgressItems] = useState([]);
progressItems 是一个数组,模型下载过程中会动态往里 push 进度数据。
3.2 条件渲染 + 列表渲染
tsx
{
status === 'loading' && ( // 只有 loading 状态才显示
<div className="w-full max-w-[500px] mx-auto p-4">
<p className="text-center mb-1">{loadingMessage}</p>
{progressItems.map(({ text, percentage, total }, index) => (
<Progress
key={index}
text={text}
percentage={percentage}
total={total}
/>
))}
</div>
)
}
数据流向:
scss
App (父组件)
│
│ progressItems = [ │ { text: 'model.onnx', percentage: 45.2, total: ... }, │ { text: 'model2.onnx', percentage: 78.1, total: ... }, │ ]
│
│ .map() 遍历 ──→ 每个 item 变成一个 <Progress /> 组件
│
▼
Progress (子组件)
props: { text, percentage, total }
→ 纯展示,不管数据从哪来
这里的命名别学我------生产环境请用稳定的
key(比如文件名),不要用index,否则列表重新排序时 React 会搞混 DOM。
四、聊天输入框:受控组件 + 合成事件 + 键盘快捷键
4.1 受控组件:React 怎么接管 textarea
原生 HTML 里,<textarea> 自己管自己的值------用户敲什么,它就显示什么。
React 不干:"所有状态都得我来管,textarea 也不例外。"
tsx
const [input, setInput] = useState(''); // input 的值归 React 管
<textarea
value={input} // textarea 显示什么,由 React 决定
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
setInput(target.value); // 用户敲了什么,告诉 React 更新
}}
/>
这就是"受控组件":
css
用户敲键盘
→ 浏览器触发 input 事件
→ e.target.value 拿到新内容
→ setInput() 更新 state
→ React 用新 state 重新渲染 textarea
→ 你看到的文字是你刚敲的
很简单的一个循环,但每个 <input> / <textarea> / <select> 都得这么写。Vue 里一个 v-model 搞定,React 偏不------这叫"显式优于隐式",React 社区的哲学。
4.2 as HTMLTextAreaElement:TypeScript 的"相信我"断言
tsx
const target = e.target as HTMLTextAreaElement;
为什么需要 as?
TypeScript 看到的:
markdown
e.target → EventTarget(所有 DOM 事件的爸爸)
→ 只有 addEventListener、removeEventListener
→ 没有 .value ❌
实际运行时:
css
e.target → HTMLTextAreaElement(就是那个 <textarea>)
→ 有 .value、.rows、.placeholder... ✅
所以 as HTMLTextAreaElement 就是在说:"TS 你别怕,我确定这就是个 textarea,.value 不会出错的。"
jsx
// 不用 as 的话 TS 会报错:
e.target.value // ❌ Property 'value' does not exist on type 'EventTarget'
// 用了 as 断言:
const target = e.target as HTMLTextAreaElement;
target.value // ✅ 没问题了
4.3 Shift+Enter 发送:键盘事件组合技
需求:Enter 换行,Shift+Enter 发送消息。
tsx
onKeyDown={(e) => {
if (input.length > 0 && e.key === 'Enter' && e.shiftKey) {
e.preventDefault(); // 阻止默认的换行行为
onEnter(); // 执行发送逻辑
}
}}
// 发送函数
const onEnter = () => {
if (input.length === 0) return; // 空消息不发
console.log('发送消息:', input);
setInput(''); // 发完清空
};
三个条件缺一不可:
| 条件 | 含义 |
|---|---|
input.length > 0 |
有内容才发,空的别浪费 token |
e.key === 'Enter' |
按的是回车 |
e.shiftKey |
Shift 也按住了 |
e.key vs e.shiftKey:
js
// e.key → 你按了哪个键 → 返回值如 'Enter', 'a', 'Escape'
// e.shiftKey → Shift 有没有同时被按住 → true / false
// e.ctrlKey → Ctrl 有没有同时被按住
// e.metaKey → Win / Mac ⌘ 有没有同时被按住
五、完整数据流:一张图串起来
scss
┌────────────────────────────────────────────────────────────┐
│ App.tsx │
│ │
│ state: │
│ input ← 聊天框的内容 │
│ status ← null | 'loading' | 'ready' │
│ progressItems ← [{text, percentage, total}, ...] │
│ loadingMessage ← "开始加载" │
│ │
│ ─────────────────────────────────────────────────────── │
│ │
│ [Load Model] 按钮 │
│ │ onClick → setStatus('loading') │
│ │ │
│ ▼ 模型下载中... │
│ progressItems 数组更新 │
│ │ │
│ │ .map() 遍历 │
│ ▼ │
│ <Progress text="model.onnx" percentage={45} total={...}/> │
│ <Progress text="model2.onnx" percentage={78} total={...}/>│
│ │ │
│ ▼ 下载完成 → setStatus('ready') │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ <textarea> 被激活 (disabled=false) │ │
│ │ │ │
│ │ 用户输入 → onInput → setInput(target.value) │
│ │ 用户按 Shift+Enter → onKeyDown → onEnter() 发送 │
│ └─────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
六、核心知识点清单
这篇文章里你接触到的概念:
| 分类 | 知识点 | 在哪用到的 |
|---|---|---|
| React | 受控组件 | textarea 的 value={input} + onInput |
| React | Props 单向传递 | App → Progress 传 text/percentage/total |
| React | 条件渲染 | status === 'loading' && (...) |
| React | 列表渲染 | progressItems.map() |
| React | 合成事件 | onInput、onKeyDown |
| TS | as 类型断言 |
e.target as HTMLTextAreaElement |
| TS | 泛型 useState | `useState<string |
| JS | 空值合并 ??= |
percentage ??= 0 |
| JS | Math.log 单位换算 | formatBytes |
| JS | 键盘事件 e.key / e.shiftKey | onKeyDown |
| CSS | TailwindCSS 原子类 | 所有 className |
| CSS | 内联 style 动态宽度 | style={{ width: \${percentage}%` }}` |
七、如果面试官问你"这个进度条你怎么设计的?"
你可以这么答:
- 首先我把它拆成独立组件
Progress,接收三个 props------文件名、百分比、文件大小。- 内部做了边界兜底:
??=给百分比设默认值 0,isNaN判断文件大小是否存在。- 文件大小用
formatBytes工具函数自动换算单位(B → kB → MB → GB),让数字可读。- 父组件
App用progressItems数组驱动,.map()遍历渲染多个进度条,数据变化时 React 自动更新。- 外层灰色 div 做轨道,内层蓝色 div 的
width用百分比动态设置,CSS transition 可以加平滑过渡动画。
一套话说下来,React 组件化、数据流、边界处理、TypeScript 全都秀了一遍。
八、总结
一个进度条一个输入框,看起来简单,但背后涉及了 React 的半壁江山:
- 组件化思想:一个功能一个组件,搭积木
- 受控组件:React 接管 DOM,数据驱动 UI
- TypeScript:用类型让代码更安全
- TailwindCSS:原子类让 CSS 不再是效率瓶颈
下次写 Vue 的时候,你是 v-model 一把梭;写 React 的时候,你是 value + onInput + setState 三件套------看起来麻烦一点,但每一步都清清楚楚,出问题也好排查。
这就是 React 的哲学:EXPLICIT OVER MAGIC.
觉得有帮助?点个赞让更多人看到。有不同意见或者更好的写法?评论区等你 👇
日拱一阶,持续学习,终身成长。