端侧 AI DEEPSEEK-R1-WEBGPU 项目实战(三):把进度条做成真的,搞懂 state 与 props 两种数据
系列定位:从零开始,在浏览器里用 WebGPU 跑通一个 DeepSeek-R1 蒸馏模型。
这是第三篇。前两篇我们搭好了骨架、学会了点按钮改状态;这一篇,我们要把进度条做成真正会「长大」的进度条,并借此讲透 React 里最绕、也最重要的一个概念——state 和 props 两种数据。
📌 一、这一篇要解决什么问题
上一篇结束时,Progress 组件还只是个「雏形」——它用三个 <p> 标签,把文件名、百分比、大小平铺出来。这显然不是一个「进度条」,顶多算个「清单」。
真正的进度条应该长这样:一条灰色的「轨道」,里面一条蓝色的「填充条」,下载进度越多,蓝色填充条就越长。
于是这一篇要回答三个问题:
- 🤔 怎么用 React 做出「会跟着进度长大」的进度条?
- 🤔 模型文件动不动几十 GB,
34353543453这种 11 位的数字谁能看懂?怎么把它翻译成「人话」(比如31.99 GB)? - 🤔 组件里的数据其实有两种——
state和props,它俩到底什么区别?为什么子组件不能直接改 props?
这三个问题,正好对应这一篇的三大主题:进度条实现与内联样式、formatBytes 换算、两种数据。
🎨 二、把进度条做成真的
2.1 回顾:上一版的 Progress 只是个「清单」
上一篇我们抽出来的 Progress 组件长这样(简写):
const Progress = ({ text, percentage, total }) => {
return (
<div>
<p>{text}</p>
<p>{percentage}%</p>
<p>{total}</p>
</div>
)
}
三个 <p> 上中下排开,只能「读出」进度是多少,不能「看出」进度条在动。这不算进度条。
2.2 进度条的本质:一个容器 + 一个会变长的子元素
进度条的视觉效果,其实就两层:
┌──────────────────────────────┐
│██████████░░░░░░░░░░░░░░░░░░░░│ ← 外层容器:宽度固定 100%,灰色轨道
│◄─ 内层填充:宽度 = percentage% ─►│ ← 内层子元素:蓝色,宽度随进度变
└──────────────────────────────┘
- 外层:一条固定宽度的「轨道」,宽度始终是 100%,灰色背景;
- 内层:一条「填充条」,它的宽度不是一个固定值,而是由
percentage决定的——percentage越大,填充条越宽。
所以实现起来就是两个嵌套的 div:
<div className="w-full bg-gray-100 text-left rounded-lg overflow-hidden mb-0.5">
{/* 外层:w-full = 100% 宽度,bg-gray-100 = 灰色轨道 */}
<div
style={{ width: `${percentage}%` }}
className="bg-blue-400 whitespace-nowrap px-1 text-sm">
{/* 内层:width 由 percentage 决定,bg-blue-400 = 蓝色填充 */}
{text}
{percentage.toFixed(2)}%{/* 保留2位小数 */}
{isNaN(total) ? "" : `of ${formatBytes(total)}`}
</div>
</div>
几个新出现的 Tailwind 类名补充一下:
| 类名 | 含义 | 在这里的作用 |
|---|---|---|
w-full |
width: 100% |
轨道占满父级宽度 |
overflow-hidden |
overflow: hidden |
填充条变宽时裁掉超出部分,圆角才不被「顶破」 |
rounded-lg |
圆角 | 轨道两端的圆角 |
whitespace-nowrap |
文字不换行 | 文件名再长也一行显示,不折行 |
2.3 React 的内联样式:style={{ width: ... }}
注意内层 div 的 style 属性,它和普通 HTML 的写法不一样:
style={{ width: `${percentage}%` }}
这里是两对大括号,特别容易看懵,拆开就清楚了:
- 外层
{}:JSX 的「运行区」,表示「这里要执行一段 JS 表达式」,和{text}、{percentage.toFixed(2)}里的{}是同一个意思; - 内层
{}:一个 JS 对象字面量——{ width: "50%" }就是一个对象。
所以 style={{ width: "50%" }} 翻译过来就是:给 style 属性传一个 JS 对象,对象里 width 是 "50%"。
💡 React 里的内联样式不是字符串,而是一个「键值对对象」。这也是为什么 CSS 的
background-color到了这里要写成驼峰backgroundColor——因为对象里的 key 得是合法的 JS 标识符,background-color里的-会被当成减号。
这里 width: percentage{percentage}%`` 用的是**模板字符串**——反引号里用 `percentage{}把变量percentage的值嵌进去,拼出"50%"` 这样的字符串。
🎭 到这里,「响应式」又闭环了:
percentage这个 props 一变,width跟着变,蓝色填充条跟着「长」。改数据 → 界面自动变,还是第一篇那个「川剧变脸」的心法。
🔢 三、formatBytes:把字节数翻译成「人话」
3.1 为什么需要它?
模型文件很大,progressItems 里的 total 是 34353543453 这种数字——11 位,没人能一眼看懂这到底有多大。我们需要把它翻译成 31.99 GB 这种「人话」。
这就引出 Progress.tsx 里第二个函数 formatBytes:
function formatBytes(size) {
// 计算应该用哪个单位(0=B, 1=kB, 2=MB...),size为0时直接用B
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (
// 1024 的 i 次方
// 把字节数除以对应单位量级,保留两位小数后转回数字
+(size / Math.pow(1024, i)).toFixed(2) * 1 +
// 拼接上对应的单位字符串
["B", "kB", "MB", "GB", "TB"][i]
);
}
一行一行拆。
3.2 关键一步:用「对数」判断数量级
计算机里,1 kB = 1024 B、1 MB = 1024 kB……每升一级就 × 1024。所以要判断一个字节数该用哪个单位,本质是问:它是 1024 的几次方?
size ≈ 1024⁰→ 用B;size ≈ 1024¹→ 用kB;size ≈ 1024²→ 用MB;size ≈ 1024³→ 用GB……
「是 1024 的几次方」这个动作,数学上就叫以 1024 为底的对数:log₁₀₂₄(size)。
但 JS 里没有现成的「以 1024 为底」的 log,只有一个以自然常数 e 为底的 Math.log。好在有个换底公式:
log₁₀₂₄(x) = Math.log(x) / Math.log(1024)
于是:
Math.log(size) / Math.log(1024)
算出来的就是「size 是 1024 的几次方」,再用 Math.floor 向下取整,就得到「该用哪个单位的序号」i。
💡 比如
size = 3000:Math.log(3000) / Math.log(1024) ≈ 1.15,Math.floor后i = 1,对应kB。3000 B ≈ 2.93 kB,合理。
还有个小细节:size == 0 时 Math.log(0) 是 -Infinity,会崩。所以开头用三元表达式兜底:size == 0 ? 0 : ...,0 字节就老老实实用 B 单位。
3.3 换算数值 + 拼单位
算出单位序号 i 之后,剩下两步:
(size / Math.pow(1024, i)).toFixed(2) * 1 + ["B", "kB", "MB", "GB", "TB"][i]
Math.pow(1024, i):1024的i次方,把字节数除以它,得到该单位下的数值;.toFixed(2):保留两位小数,但返回的是字符串(比如"31.99");- 前面的
+和* 1:把字符串转回数字,顺带把多余的 0 去掉("31.50"→31.5); ["B", "kB", "MB", "GB", "TB"][i]:单位数组,用序号i当下标,取出对应的单位字符串,和数字拼接。
🎭 一句话:
formatBytes= 「算单位序号 → 换算出数值 → 保留两位小数 → 拼上单位」。11 位的34353543453进去,出来就是31.99 GB。
❓ 四、??=:封装者的小体贴
4.1 场景:percentage 可能压根没传
再回看 Progress 组件第一行:
const Progress = ({ text, percentage, total }) => {
// es6+ 编程风格
percentage ??= 0;
console.log(percentage);
...
}
这行 percentage ??= 0 是在干嘛?先想一个问题:Progress 是给别人(父组件)用的。父组件用的时候,万一忘了传 percentage 呢?
<Progress text="model.onnx" total={34353543453} /> {/* 没传 percentage! */}
那组件里的 percentage 就是 undefined。后面代码要算 percentage.toFixed(2)、要拼 width: percentage%,undefined 直接报错。
所以封装组件的人(我们自己)要多替使用者考虑一步:没传就给它个默认值 0。这就是这行的意义。
4.2 ??= 是什么?
??= 全称空值合并赋值运算符(Logical Nullish Assignment),是 ES2021(也就是 ES12)加入的新语法。
先认识它的「爸爸」??——空值合并运算符(ES2020/ES11):
const result = value ?? 默认值;
// 只有当 value 是 null 或 undefined 时,才用默认值
而 ??= 就是它的「赋值版」:
percentage ??= 0;
// 完全等价于:
// percentage = percentage ?? 0;
读作:「如果 percentage 是 null 或 undefined,就把它赋成 0;否则保持原样。」
4.3 它和 || 的区别(重要!)
很多人会问:这跟常见的 percentage = percentage || 0 有啥不一样?差别就在一个坑上:
| 写法 | 把 0 当默认吗? |
把空字符串 "" 当默认吗? |
|---|---|---|
| `percentage | 0` | |
percentage ?? 0 |
✅ 不会(0 是合法值) |
✅ 不会 |
⚠️ 关键区别:
||把所有「假值」都当「空」——0、""、false、NaN统统会被替换成默认值;而??只认null和undefined两个「真空」。
对进度条来说,percentage = 0 是完全合法的状态(刚下载,进度就是 0)!如果用 ||,0 会被误判成「没传」,逻辑就错了。所以这里必须用 ??=。
🎭 这正是 readme 里那句「封装者多考虑,使用者用的爽」的体现:写组件的人把「没传参」这种边界情况兜住,用组件的人才能随手一写都不出错。组件封装得好不好、健壮性强不强,全在这种小细节里。
🏠 五、两种数据:state 与 props(本篇的灵魂)
讲完进度条,我们回头俯瞰一下整个 App + Progress 这套代码:里面的数据,其实分成了两拨。这是 React 里最核心、也最容易混的概念——state 和 props。
5.1 数据的两条来路
- 在
App.tsx里,status、error、progressItems这些,是用useState声明的; - 在
Progress.tsx里,text、percentage、total这些,是从父组件传进来的 props。
两条来路,对应两种身份。
5.2 state:组件自己的「私有数据」
const [status, setStatus] = useState(null); // App 自己的状态
useState 声明出来的数据,是组件自己的:
- 🏠 归组件所有:
status属于App这个组件,别人管不着; - ✍️ 自己随便改:想改就用配套的
setStatus,这是组件自己的「内政」; - 🔄 改了能触发重渲染:
setStatus("loading")一调,界面就跟着变。
🎭 打个比方:state 就像你自己口袋里的钱——归你管,想花就花,别人无权干涉。
5.3 props:父组件传下来的「只读数据」
const Progress = ({ text, percentage, total }) => { ... } // 从父组件接到的
props 是从父组件流到子组件的:
- 📦 别人给的:
<Progress text={...} percentage={...} total={...} />,是父组件App传进来的; - 🔒 不能直接改:子组件
Progress拿到了 props,只能读、用来展示,不能直接props.percentage = 50这样改它; - 📤 想改怎么办?要「报告父组件」:子组件不能自己动手,得通知父组件,由父组件去改,再把新值传下来。
🎭 接着上面的比方:props 就像爸妈给你的零花钱——钱是爸妈的,你可以看、可以花,但你不能自己「印钱」;不够花了,得跟爸妈申请,由爸妈决定给不给、给多少。
5.4 单向数据流:数据只能从上往下走
把 5.2 和 5.3 合起来,就得出 React 最著名的一条规矩——单向数据流(one-way data flow):
App(父组件)
│ useState 持有 status / progressItems
│ 通过 props 往下传 ↓
▼
Progress(子组件)
只能展示,不能直接改 props
- 数据只能从父组件往子组件流,像瀑布一样只往下,不往上;
- 子组件永远不能反过来直接改父组件的数据——这是 React 的「铁律」。
💡 为什么这么设计?一句话:好追踪、好调试。如果谁都能改别人的数据,数据一乱,你根本不知道是哪个组件改的。单向流动让「数据从哪来、去了哪」始终清清楚楚。
5.5 子组件的职责:你给什么,我展示什么
正因为 props 是「只读」的,子组件的定位就非常纯粹:
🖥️ 子组件主要负责展示——父组件给我什么 props,我就展示成什么样子。
Progress 就是典型:它不关心「进度是怎么来的、接下来会不会变」,它只干一件事——把 text、percentage、total 渲染成一条进度条。数据怎么变、什么时候变,那是父组件 App 的事。
🎭 这就像前台接待员:他不会自己去谈业务、改合同,他只会把你递给他的信息,工工整整地展示出来。
5.6 一张表分清两种数据
| 维度 | state | props |
|---|---|---|
| 🎯 归属 | 组件自己(useState 声明) |
父组件传下来的 |
| ✍️ 能不能改 | 能,用 setXxx |
不能直接改,要报告父组件 |
| 📍 数据流向 | 组件内部 | 从上往下(单向) |
| 🏠 角色定位 | 组件的「私有财产」 | 组件的「零花钱」 |
| 🎭 一句话 | 自己管 | 别人给 |
🔍 六、小结
这一篇我们把进度条从「清单」升级成了「真进度条」,并借它讲透了 React 的两种数据:
| 知识点 | 一句话理解 |
|---|---|
| 🎨 进度条实现 | 外层容器 w-full + 内层填充条 width: percentage% |
| 📐 内联样式 | style={{}}:外层 {} 是 JSX 运行区,内层 {} 是 JS 对象 |
🔢 formatBytes |
用对数算单位序号,把字节数翻译成 31.99 GB 这种人话 |
❓ ??= |
空值合并赋值,null/undefined 才用默认值,不误伤 0 |
| 🏠 state | 组件自己的数据,useState 声明,setXxx 改 |
| 📦 props | 父传子的只读数据,子组件只能展示,改要报告父组件 |
到这里,进度条已经「会动」了,两种数据也分清了。但还有两个问题悬着:界面上那个输入框(textarea),你敲一个字,React 是怎么把这个字接住的? 以及——「加载模型」到现在还只是一句口号,模型到底是怎么被真正下载、真正跑起来的?
更多推荐
所有评论(0)