系列定位:从零开始,在浏览器里用 WebGPU 跑通一个 DeepSeek-R1 蒸馏模型
这是第三篇。前两篇我们搭好了骨架、学会了点按钮改状态;这一篇,我们要把进度条做成真正会「长大」的进度条,并借此讲透 React 里最绕、也最重要的一个概念——state 和 props 两种数据


📌 一、这一篇要解决什么问题

上一篇结束时,Progress 组件还只是个「雏形」——它用三个 <p> 标签,把文件名、百分比、大小平铺出来。这显然不是一个「进度条」,顶多算个「清单」。

真正的进度条应该长这样:一条灰色的「轨道」,里面一条蓝色的「填充条」,下载进度越多,蓝色填充条就越长。

于是这一篇要回答三个问题:

  1. 🤔 怎么用 React 做出「会跟着进度长大」的进度条?
  2. 🤔 模型文件动不动几十 GB,34353543453 这种 11 位的数字谁能看懂?怎么把它翻译成「人话」(比如 31.99 GB)?
  3. 🤔 组件里的数据其实有两种——stateprops,它俩到底什么区别?为什么子组件不能直接改 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: ... }}

注意内层 divstyle 属性,它和普通 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 里的 total34353543453 这种数字——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 B1 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 = 3000Math.log(3000) / Math.log(1024) ≈ 1.15Math.floori = 1,对应 kB3000 B ≈ 2.93 kB,合理。

还有个小细节:size == 0Math.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)1024i 次方,把字节数除以它,得到该单位下的数值;
  • .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;

读作:「如果 percentagenullundefined,就把它赋成 0;否则保持原样。」

4.3 它和 || 的区别(重要!)

很多人会问:这跟常见的 percentage = percentage || 0 有啥不一样?差别就在一个坑上:

写法 0 当默认吗? 把空字符串 "" 当默认吗?
`percentage 0`
percentage ?? 0 ✅ 不会(0 是合法值) ✅ 不会

⚠️ 关键区别|| 把所有「假值」都当「空」——0""falseNaN 统统会被替换成默认值;而 ?? 只认 nullundefined 两个「真空」。

对进度条来说,percentage = 0完全合法的状态(刚下载,进度就是 0)!如果用 ||0 会被误判成「没传」,逻辑就错了。所以这里必须用 ??=

🎭 这正是 readme 里那句「封装者多考虑,使用者用的爽」的体现:写组件的人把「没传参」这种边界情况兜住,用组件的人才能随手一写都不出错。组件封装得好不好、健壮性强不强,全在这种小细节里。


🏠 五、两种数据:state 与 props(本篇的灵魂)

讲完进度条,我们回头俯瞰一下整个 App + Progress 这套代码:里面的数据,其实分成了两拨。这是 React 里最核心、也最容易混的概念——state 和 props

5.1 数据的两条来路

  • App.tsx 里,statuserrorprogressItems 这些,是用 useState 声明的;
  • Progress.tsx 里,textpercentagetotal 这些,是从父组件传进来的 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 就是典型:它不关心「进度是怎么来的、接下来会不会变」,它只干一件事——textpercentagetotal 渲染成一条进度条。数据怎么变、什么时候变,那是父组件 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 是怎么把这个字接住的? 以及——「加载模型」到现在还只是一句口号,模型到底是怎么被真正下载、真正跑起来的?

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐