🚀 手把手带你写一个端侧 AI 应用的"加载进度条"与"聊天对话框"

上个月我开源了一个项目: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...    ││  ← 聊天输入框
│  └──────────────────────────────┘│
└──────────────────────────────────┘

两个硬需求:

  1. 加载时:模型文件可能有好几个,每个都要显示文件名 + 百分比 + 文件大小
  2. 加载完:输入框才能用,支持 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 合成事件 onInputonKeyDown
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}%` }}`

七、如果面试官问你"这个进度条你怎么设计的?"

你可以这么答:

  1. 首先我把它拆成独立组件 Progress,接收三个 props------文件名、百分比、文件大小。
  2. 内部做了边界兜底:??= 给百分比设默认值 0,isNaN 判断文件大小是否存在。
  3. 文件大小用 formatBytes 工具函数自动换算单位(B → kB → MB → GB),让数字可读。
  4. 父组件 AppprogressItems 数组驱动,.map() 遍历渲染多个进度条,数据变化时 React 自动更新。
  5. 外层灰色 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.


觉得有帮助?点个赞让更多人看到。有不同意见或者更好的写法?评论区等你 👇


日拱一阶,持续学习,终身成长。

相关推荐
qetfw3 分钟前
MXU:Tauri 2 + React 的 MaaFramework 跨平台 GUI 源码
前端·python·react.js·前端框架·开源项目·效率工具
甲维斯4 分钟前
我要开始吹牛逼了!Kimi K3 “宇宙无敌”!
前端·人工智能
周末程序猿6 分钟前
图解 120 个大语言模型(LLM)核心概念(61-90)
人工智能
a11177611 分钟前
微光小屋-前端养成小游戏 开源项目
前端
科技圈快迅11 分钟前
游戏投影仪和普通投影仪区别是什么?2026游戏投影仪测评
人工智能
kyriewen24 分钟前
我让AI给前端项目做了一次完整的Code Review——它和人类的差距,比我想的大得多
前端·javascript·ai编程
张鑫旭24 分钟前
快速过下我2026年上半年的前端学了些啥
前端
陆枫Larry41 分钟前
CPU 和 GPU 的核心区别与适用场景
人工智能
Aa99883341 小时前
AI视觉检测设备厂家的技术选型框架——密封件和磁材的缺陷分类与光学成像原理
人工智能