本文记录了一个真实 React + TypeScript 项目的开发过程,涵盖进度条组件设计、State 与 Props 的区别、TypeScript 类型断言、CSS 盒模型踩坑等知识点。
前言
最近在做一个在浏览器里运行大模型的 Demo------DeepSeek-R1 WebGPU。模型文件动辄几个 GB,下载过程中的进度展示就成了第一个要解决的问题。这篇文章把开发进度条组件时遇到的坑和学到的知识点完整记录下来。
一、进度条组件设计
1.1 需求分析
进度条需要展示三个信息:
- 文件名:正在下载哪个模型文件
- 百分比:下载进度 0%~100%
- 文件大小:总大小(字节数得转成 GB/MB 才看得懂)
结构上就是两层 div:外层灰色容器 100% 宽度,内层蓝色条宽由 percentage 决定。
1.2 容器 + 子元素布局
tsx
<div className="w-full bg-gray-100 text-left rounded-lg overflow-hidden mb-0.5">
<div
style={{ width: `${percentage}%` }}
className="bg-blue-400 whitespace-nowrap rounded-lg px-1"
>
{text} {percentage.toFixed(2)}% {isNaN(total) ? '' : `of ${formatBytes(total)}`}
</div>
</div>
外层 w-full 占满父容器,内层蓝色条的 width 用行内 style 动态控制。
1.3 为什么进度宽度要用 style 而不是 Tailwind 类名?
这是一个很容易踩的坑。你可能会本能地想这样写:
tsx
// ❌ 错误:Tailwind 不认识变量
<div className={`w-[${percentage}%]`}>
Tailwind 是编译时工具 ,它在打包时扫描你的代码,看到什么类名就生成什么 CSS。它看到模板字符串里的 ${percentage} 时一脸懵------这是个变量,不是具体值,跳过。
所以编译时能确定的值用 className,运行时动态变化的值用 style:
| 场景 | 方案 |
|---|---|
| 固定宽度 | className="w-1/2" |
| 动态百分比 | style={{ width: ${percentage}% }} |
| 动态颜色 | style={{ color: userColor }} |
1.4 宽度为 0% 时为什么还有蓝色?
这就是 CSS 盒模型的经典坑。当 percentage 为 0 时:
scss
┌─────────────────────────────────────────────┐
│ 外层 (灰色背景) │
│ ┌─px-1─┬──width:0%──┬─px-1─┐ │
│ │ 4px │ 0px │ 4px │ ← 蓝色=8px │
│ └──────┴────────────┴──────┘ │
└─────────────────────────────────────────────┘
width: 0% 只让内容区域 归零,但 px-1(Tailwind 里是 padding-left: 0.25rem; padding-right: 0.25rem,各 4px)是加在内容区外面的。所以总宽度 = 0 + 8px = 8px 的蓝色区域依然可见。
解决方案:把文字从蓝色条里抽出来,用绝对定位盖在上面,进度条只管宽度,不管文字:
tsx
<div className="w-full bg-gray-100 rounded-lg overflow-hidden relative">
{/* 进度条:只管颜色和宽度 */}
<div
style={{ width: `${percentage}%` }}
className="bg-blue-400 h-5 rounded-lg"
/>
{/* 文字:绝对定位,不受进度宽度影响 */}
<span className="absolute inset-0 px-1 text-xs leading-5">
{text} {percentage.toFixed(2)}%
</span>
</div>
二、ES2021 空值合并赋值运算符 ??=
2.1 问题场景
初始化的时候文件还没开始下载,根本没有"进度"这个概念 。所以 percentage 这个字段可能压根不会传:
tsx
// 第一个文件:只传了文件名和总大小,没传 percentage
{ text: 'model.onnx', total: 34353543453 }
// 第二个文件:传了 percentage
{ text: 'model2.onnx', percentage: 10, total: 14353543453 }
2.2 ??= 的作用
tsx
percentage ??= 0;
这行代码等价于:
tsx
if (percentage === null || percentage === undefined) {
percentage = 0;
}
关键点在于它只在值为 null 或 undefined 时才赋值 。注意它和 || 的区别:
| 运算符 | 触发默认值的条件 | 0 会触发吗? |
'' 会触发吗? |
|---|---|---|---|
| ` | ` | 所有 falsy 值 | |
?? / ??= |
仅 null 和 undefined |
❌ 不会 | ❌ 不会 |
这在进度条场景非常关键------如果进度确实是 0%,??= 不会把它覆盖成 0 (本来就是 0),但如果根本没传这个字段(undefined),就会兜底为 0。用 || 的话,进度 0% 也会被当成"没传"而显示 0,虽然结果一样,但语义不对。
2.3 设计哲学
封装者多考虑,使用者用得爽。
组件内部用 ??= 处理了"不传 percentage"的情况,使用这个组件的父组件就不需要每次手动补 percentage: 0。这就是好的组件封装------把健壮性做在内部,对外暴露最简单的接口。
三、两种数据:State 与 Props
这是 React 最核心的概念区分,搞混了会写出很难排查的 bug。
3.1 State(组件自有状态)
tsx
const [input, setInput] = useState('');
const [status, setStatus] = useState('ready');
- 组件自己拥有、自己管理的数据
- 通过
useState声明 - 修改用
setXxx,修改后会触发重新渲染 - 只有组件自己能改
3.2 Props(父组件传来的属性)
tsx
const Progress = ({ text, percentage, total }) => {
// percentage 是从父组件传过来的,不能在这里修改
percentage ??= 0; // 这只是在本地变量上做兜底,不是改 props
}
- 从父组件传递到子组件的属性
- 在子组件里只读,不能修改
- 要改的话必须由父组件提供修改函数(回调)
3.3 数据流向
arduino
App (父组件)
│
│ state: progressItems = [{text, percentage, total}, ...]
│
├── Progress (子组件) ← props: text, percentage, total
├── Progress (子组件) ← props: text, percentage, total
└── Progress (子组件) ← props: text, percentage, total
子组件主要负责展示------父组件给我什么 props,我就显示成什么样。进度条组件不需要自己发请求、不需要自己管理下载状态,它就是个纯粹的展示组件。
四、字节格式化工具函数
4.1 为什么需要?
total: 34353543453 这串数字谁看得懂?得转成 31.99 GB 才行。
4.2 实现
tsx
function formatBytes(size) {
// 计算应该用哪个单位(0=B, 1=KB, 2=MB...)
// size 为 0 时直接用 B,避免 Math.log(0) = -Infinity
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (
// 字节数 / 1024^i,保留两位小数然后转回数字(去掉多余的 0)
+(size / Math.pow(1024, i)).toFixed(2) * 1 +
// 拼接单位
['B', 'KB', 'MB', 'GB', 'TB'][i]
);
}
formatBytes(34353543453); // "31.99GB"
formatBytes(1416416416); // "1.32GB"
formatBytes(0); // "0B"
4.3 容易出错的点
Math.log(0)返回-Infinity,所以size == 0得单独处理+(...).toFixed(2) * 1这个* 1是为了把"31.99"转回31.99数字,去掉尾部的无意义零isNaN(total)守卫:万一 total 没传或者传了脏数据,不会崩
五、React 列表渲染中的 key
5.1 key 的作用
tsx
{progressItems.map(({ text, percentage, total }, index) => (
<Progress
key={index}
text={text}
percentage={percentage}
total={total}
/>
))}
React 用 key 来追踪列表里每一项的身份。当列表数据变化时,React 通过对比前后两次的 key 来决定哪些元素需要更新、删除或新增。
5.2 为什么 key={index} 不报错?
key 是 React 的保留属性,React 内部识别并消费它,不会传给子组件:
tsx
<Progress key={0} text="model.onnx" />
// ↑ React 自己吃掉
// ↑ 传给 Progress 组件
你在 Progress 组件里解构 { text, percentage, total },根本拿不到 key------React 压根没往下传。
5.3 为什么不能用索引当 key?
| 场景 | key={index} |
key={唯一id} |
|---|---|---|
| 列表顺序不变 | 勉强能用 | ✅ |
| 列表项增/删/排序 | React 复用错乱,状态串位 | ✅ |
举例 :列表 [A, B, C],key 分别是 [0, 1, 2]。删除第一项后变成 [B, C],key 变成 [0, 1]。React 一看:key 0 还在,key 1 也在,key 2 没了------于是认为 A 和 B 没变,删掉了 C。但实际上 A 被删了,B 和 C 保留。React 把 A 的状态复用到 B 上了,造成显示错乱。
正确做法:给每条数据一个唯一 id,用 id 当 key。
六、TypeScript 类型断言:e.target.value 为什么报错?
6.1 问题
tsx
onInput={(e) => {
const target = e.target; // ✅ 不报错
setInput(e.target.value); // ❌ 报错:EventTarget 上没有 value
}}
e.target 的类型是 EventTarget------这是所有 DOM 元素的通用基类。它上面根本没有 value 属性。TypeScript 不让你访问一个不存在的属性。
6.2 两种解决方案
tsx
// 方案 1:类型断言 as
const target = e.target as HTMLTextAreaElement;
setInput(target.value); // ✅ TS 知道 textarea 有 value
// 方案 2:用 e.currentTarget(推荐,不会为 null)
setInput(e.currentTarget.value); // ✅ currentTarget 类型就是 HTMLTextAreaElement
| 属性 | 指向 | 类型 |
|---|---|---|
e.target |
实际触发事件的元素 | EventTarget(通用) |
e.currentTarget |
绑定事件处理器的元素 | HTMLTextAreaElement(具体) |
6.3 记忆口诀
e--- 通用的事件对象e.target--- 通用的事件目标对象,不一定有 valueas HTMLTextAreaElement--- 断言,敢于断定,"我确定它就是 textarea"- TS 信你,放行
七、可控组件与 Enter 发送
7.1 React 没有双向绑定
Vue 有 v-model,React 没有。React 的数据是单向流动的,你得手动把值传进去,手动监听变化更新状态:
tsx
// React 的 "手动双向绑定"
<textarea
value={input} // state → UI
onInput={(e) => { // UI → state
setInput(e.currentTarget.value);
}}
/>
7.2 Enter 发送消息
tsx
onKeyDown={(e) => {
if (input.length > 0 && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onEnter();
}
}}
三个条件的含义:
| 条件 | 为什么 |
|---|---|
input.length > 0 |
空内容不发,避免无意义请求 |
e.key === 'Enter' |
只响应回车键 |
!e.shiftKey |
Shift+Enter 用来换行,不是发送 |
7.3 一个容易困惑的地方
打字时控制台打印了"组件函数执行",按回车却没有------这不代表回车没用。打字时 setInput 改了 state → 触发重渲染 → 打印"组件函数执行"。回车时 onEnter 只是 console.log(input),没改 state → 不重渲染 → 看不到"组件函数执行"。但 console.log(input) 是有输出的,只是被淹没在日志里容易看漏。
八、isNaN 防御性编程
tsx
{isNaN(total) ? '' : `of ${formatBytes(total)}`}
这是一个小但重要的守卫。如果某个数据项忘了传 total,formatBytes(undefined) 进去就是 Math.log(undefined) = NaN,整条进度条渲染都会出问题。
做了一步 isNaN 检查,脏数据进来大不了不显示大小,但页面不会崩。
注:
Number.isNaN()比isNaN()更严格,isNaN会先尝试把参数转成数字,Number.isNaN只有NaN本身才返回 true。不过在这个场景用isNaN反而合适------我们就是要拦截各种非数字值。
总结
这篇文章覆盖了进度条组件从设计到实现的完整过程,涉及的核心知识点:
| 知识点 | 一句话 |
|---|---|
??= 空值合并赋值 |
只在 null/undefined 时赋默认值,不误伤 0 |
| State vs Props | State 组件自己管,Props 父组件传、子组件只读 |
| Tailwind 动态样式 | 编译时用 className,运行时用 style |
| CSS 盒模型 | width: 0% + padding = 还有可见区域 |
key 属性 |
React 保留字,不传给子组件;用 id 别用 index |
as 类型断言 |
告诉 TS "我确定这个元素是什么",解决 e.target.value 报错 |
e.currentTarget |
指向绑定事件的元素,类型更精确 |
| 防御性编程 | isNaN 守卫,脏数据不崩 |
整篇文章的代码基于真实项目,欢迎在评论区交流讨论~
项目源码:DeepSeek-R1 WebGPU Demo (贴上你的仓库地址)