从零搭建 DeepSeek-R1 WebGPU 聊天应用(一):进度条组件与 React 核心概念

本文记录了一个真实 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;
}

关键点在于它只在值为 nullundefined 时才赋值 。注意它和 || 的区别:

运算符 触发默认值的条件 0 会触发吗? '' 会触发吗?
` ` 所有 falsy 值
?? / ??= nullundefined ❌ 不会 ❌ 不会

这在进度条场景非常关键------如果进度确实是 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 --- 通用的事件目标对象,不一定有 value
  • as 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)}`}

这是一个小但重要的守卫。如果某个数据项忘了传 totalformatBytes(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 (贴上你的仓库地址)

相关推荐
ShineWinsu1 小时前
对于Linux:模版方法类的解析以及socket、TcpSocket的封装
linux·c++·面试·socket·模板方法模式·封装·tcpsocket
不简说1 小时前
JS 代码技巧 vol.8 — 20 个函数式编程实战,把 if/else 拍扁的骚操作
前端·javascript·面试
我叫黑大帅4 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
吃饱了得干活4 小时前
亿级订单表分库分表设计,从0到1全流程
java·数据库·面试
程序员爱钓鱼7 小时前
Go 开发环境安装(Windows、macOS、Linux)
后端·面试·go
触底反弹7 小时前
🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚!
人工智能·设计模式·面试
uzong14 小时前
一个好的面试官,应该让候选人感到被尊重
面试
触底反弹17 小时前
深入理解大模型采样:Temperature、Top-K、Top-P 的原理与实战
人工智能·算法·面试
沉默王二19 小时前
腾讯一面,我霸气反问:“你说你们在做Agent项目,说说 SubAgent、Plan 模式、Skill 调用这些你们都是怎么做的?”面试官一直在擦汗。。
面试·agent·ai编程