进度条“整容” + 聊天框亮相:DeepSeek-R1 WebGPU 交互打磨实录
前言
嘿,各位一路跟到这里的“端侧AI探险家”们!
系列第一篇我们搭好了静态骨架,让 React 组件像川剧变脸一样随状态切换界面;第二篇我们装上了“发动机”,用合成事件激活按钮、抽离进度条组件,理解了组件树思想。但当时的进度条实在太过简陋——几个 <p> 标签堆叠文字,就像素颜出镜,完全撑不起“浏览器跑大模型”的科技感。
今天,我们要对项目进行一次“精装修”:让进度条真正可视化,像下载软件一样直观填充;编写优雅的 formatBytes 工具函数,把冷冰冰的字节数变成“63.7 MB”这样的友好格式;新增聊天输入框,为后续模型推理铺好路;更重要的是,深入辨析 React 中 State 与 Props 这对核心概念,让你的组件设计功底再上一层楼。
准备好迎接这个越来越完整的 AI 前端项目了吗?系好安全带,发车!
一、让进度条“活”起来:Progress 组件可视化改造
上节课的 Progress 组件只返回了三个 <p> 标签,界面效果就是干巴巴的文字堆叠。这节课我们在子组件里进行了大幅度迭代,把它变成了真正的“进度条”。
先看完整代码,注意它从上到下的执行顺序——这正是我们接下来要逐一拆解的知识点:
function Progress({ text, percentage, total }) {
// 第一步:防御性赋值
percentage ??= 0;
// 第二步:工具函数(在组件外部定义,渲染时调用)
return (
<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 px-1 text-sm"
>
{text}
{percentage.toFixed(2)}%
{isNaN(total) ? 'N/A' : ` of ${formatBytes(total)}`}
</div>
</div>
)
}
1.1 空值合并运算符 ??= —— 给组件加一道安全锁
代码执行的第一步是 percentage ??= 0;。这行代码是什么意思呢?
它是 ES2021(ES12)引入的“逻辑空赋值运算符”。通俗地讲:如果 percentage 的值是 null 或 undefined,就把它设为 0;如果已经传入了具体的数值(包括 0),就保持原样。
笔记里说得特别好:“初始化的时候,没有下载进度这个概念的,组件里使用??= 空值合并,没传值为空,赋值为0。封装者多考虑,使用者用的爽。”
作为 Progress 组件的作者,你无法控制外部传来的数据:可能因为某个异步请求还没返回,percentage 是 undefined。与其让界面显示 “undefined%” 甚至报错,不如自动归一化为 0。相较于老式的 percentage = percentage || 0,??= 只针对 null 和 undefined 生效,不会错误地把合法的 0 也替换掉(虽然此处替换了也无妨,但语义更精确)。
这种“封装者多考虑,使用者用的爽”的理念,正是构建健壮组件库的核心思想。做好了这一步防御,percentage 就是一个可靠的数字了,后续的渲染和计算都可以放心使用。
1.2 formatBytes:字节换算的艺术
数据安全之后,我们需要把它展示得更好看。进度条上如果直接显示 66666666 这样的数字,用户得数半天零。我们必须把原始字节数转换成可读的单位(B、kB、MB、GB 等)。于是就有了这个工具函数:
function formatBytes(size) {
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]
);
}
第一步:确定单位索引
Math.log(size) / Math.log(1024) 运用了对数的换底公式,计算 size 是 1024 的几次方。例如 70,000,000 字节 ≈ 2.01,取整后索引为 2,对应 MB。size == 0 时直接取索引 0,特殊处理避免对数计算错误。
第二步:数值转换
size / Math.pow(1024, i) 将原始字节换算到对应单位,.toFixed(2) 保留两位小数,再 * 1 转回数字去掉末尾多余的零(如 1.50 → 1.5)。
第三步:拼接单位
用索引从数组 ["B", "kB", "MB", "GB", "TB"] 中取出单位,返回如 "63.74 MB" 的可读字符串。
在组件中,isNaN(total) ? 'N/A' : of ${formatBytes(total)} 确保 total 异常时显示 N/A,增强健壮性。
1.3 从“文字列表”到“百分比宽度”的魔法
数据准备好之后,终于到了渲染环节。进度条的设计思想非常朴素:容器 100% 宽度,子元素宽度由 props.percentage 决定。这正是笔记中那句“容器 100%,子元素(进度条,宽度 props percentage 来长大的)”的落地。
外层 div 使用 w-full bg-gray-100 rounded-lg overflow-hidden,撑满父容器、灰色背景、圆角,关键是 overflow-hidden 确保内层进度条不会超出圆角边界。
内层 div 才是进度指示的核心。它的宽度通过行内样式动态绑定:
style={{ width: `${percentage}%` }}
注意这里的双花括号:外层 {} 是 JSX 表达式语法,内层 {} 是一个 JavaScript 对象字面量。React 会为这个元素生成内联样式 width: 45%(假设 percentage 为 45),并且每当 percentage 改变,React 自动更新 DOM 样式,进度条就“长大”了。
这种声明式的 UI 更新,正是 React 魅力的体现——你只需要声明“宽度等于 percentage%”,至于怎么变、何时变,框架全包了。
再来看看内层 div 的 className:"bg-blue-400 whitespace-nowrap px-1 text-sm"。这里 whitespace-nowrap 是一个很实用的小细节。进度条里的文字信息(文件名、百分比、总大小)可能会很长,如果进度条宽度较小,文字默认会自动换行,把进度条撑出几行的高度,非常影响美观。whitespace-nowrap 强制所有文字始终在一行内显示,即使超出进度条宽度也不会折行。配合外层的 overflow-hidden,超出的部分会被优雅地裁剪,保证了进度条的紧凑和整洁。这种原子类的组合就像给组件穿上了一件合身的衣服,多一分太宽,少一分太紧。
二、State 与 Props:组件的“自有财产”和“家族信托”
在组件封装和父组件 App 的更新中,我们反复接触到两种数据,笔记里特意总结了它们:
“state 状态数据,useState 申明 表示组件自有状态,组件自己打理。props 传递数据,是单向的,只能从父组件传递给子组件,不能在子组件里修改父组件的状态,报告父组件才可以修改。子组件主要负责展示,父组件给我什么 props 我显示成什么样子。组件封装和健壮性。”
State(状态) 就像组件的“自有财产”。用 useState 声明,组件自己拥有,可以随时通过 setState 修改。
Props(属性) 则像“家族信托”——父组件传递给子组件的只读资产。子组件不能直接修改 props,需要变更时必须通过父组件传下来的回调“上报申请”,由父组件亲自修改自己的 state,然后新的 props 再次向下流动。
这种单向数据流让数据变化有迹可循,不会出现“某个值被莫名其妙篡改”的诡异 bug。Progress 组件就是典型:给我 percentage: 45,我渲染 45% 宽度;给我 percentage: 78,我渲染 78% 宽度。它不关心数据从哪来,只管展示。
三、聊天输入框上线:受控组件与事件细节
模型加载完毕,总得有个地方输入问题吧?在 Load Model 按钮下方,我们新增了一个 textarea,并围绕它实现了完整的交互闭环。
先看完整代码,感受一下 React 处理表单的“仪式感”:
<div className="mt-2 border border-gray-300 rounded-lg w-[600px]
max-w-[80%] max-h-[200px] mx-auto relative mb-3 flex">
<textarea
className="w-[550px] dark-gray-700 px-3 py-4 rounded-lg
bg-transparent border-none outline-hidden
disabled:text-gray-400 disabled:placeholder-gray-200"
placeholder="Type your message here"
rows={1}
disabled={status !== 'ready'}
value={input}
onInput={e => {
const target = e.target as HTMLTextAreaElement;
setInput(target.value);
}}
onKeyDown={e => {
if (input.length > 0 && e.key === 'Enter' && !e.ctrlKey) {
e.preventDefault();
onSubmit();
}
}}
title={status === 'ready' ? 'model is ready' : 'model is not ready'}
/>
</div>
我们一步步拆解。
3.1 受控组件:React 为什么不用双向绑定?
笔记中有一句非常关键的注释:
“react 不支持双向绑定,因为性能不太好”
这背后其实是 React 数据流哲学的核心体现。Vue 里的 v-model 确实是双向绑定的利器:你修改输入框,数据自动变;你修改数据,输入框自动变。但在 React 看来,这种“魔法”在大型应用里反而会带来隐患——数据到底是被谁改的?什么时候改的?调试起来就像追踪一条在暗处流淌的河流。
React 选择了一种更“笨”但更“透明”的方式:受控组件。
所谓受控,就是让 React 的 state 成为表单值的唯一真相来源。具体到我们这个 textarea,分两步:
- 显示层:
value={input}把 state 里的input值绑定到文本框。任何时候,文本框里显示的内容都严格等于input这个状态。 - 修改层:当用户敲键盘时,并不会直接改变文本框的 DOM 值(因为 React 接管了),而是触发
onInput事件,我们在事件回调里调用setInput(target.value),把新的文本“上报”给 state。state 一更新,React 重新渲染,value又变成最新的值,文本框内容随之更新。
这样一来,整个数据流形成一个完美的单向循环:state → value → 用户输入 → onInput → setState → 新 state → 新 value。每一步都是显式的、可追踪的。任何时候你想知道文本框里是什么,直接看 input 这个状态就行,不用去翻 DOM。
而且这种显式控制带来了额外的灵活性:比如在 onInput 里你可以轻松地做格式化、限制字符数、实时校验等操作,所有逻辑都在 JS 里,一目了然。
3.2 类型断言:告诉 TypeScript“这家伙是 textarea”
在 onInput 的回调里,有一行看着有点奇怪的代码:
const target = e.target as HTMLTextAreaElement;
为什么要多此一举,不用 e.target.value 直接拿呢?因为 TypeScript 不答应。
React 的合成事件对象 e 是一个泛型结构,它的 target 属性类型是 EventTarget——一个非常宽泛的类型,只包含了 addEventListener 等通用方法,没有 value 属性。笔记里解释得很形象:
“事件对象上一定会有一个 target 属性,不一定有 value 属性。e 是通用的事件对象,不一定有 value。e.target.value 是表单元素才有 value 属性,普通的 div 盒子的 click 事件没有 value 属性。”
但我们自己心里清楚:这个 onInput 是写在 <textarea> 上的,运行时 e.target 一定是一个 HTMLTextAreaElement 实例,它当然有 value 属性。所以我们需要类型断言(as 关键字),明确地告诉 TypeScript:“请相信我,我保证它就是个 textarea,请允许我访问它的 value”。
这就像你去银行办事,柜台需要一个特定表格,但你手里的文件抬头是“通用文档”。你跟柜员说:“我确定这是那份表格,你按这个处理就行。” as HTMLTextAreaElement 就是这么一句话。它不改变运行时的任何行为,纯粹是为了让 TypeScript 编译器放行,同时保留类型检查的其他好处。
3.3 键盘事件与状态机:让 Enter 键“发送”消息
用户在输入框里打字,最终要发送给模型。我们选择了最符合聊天习惯的方式:按下 Enter 键发送,同时保留 Ctrl+Enter 换行的可能性。
onKeyDown={e => {
if (input.length > 0 && e.key === 'Enter' && !e.ctrlKey) {
e.preventDefault();
onSubmit();
}
}}
e.key === 'Enter':key属性是键盘事件的标准化按键值,比老旧的keyCode更直观。!e.ctrlKey:ctrlKey是一个布尔值,表示事件触发时 Ctrl 键是否被按住。如果用户按了 Ctrl+Enter,我们不做任何处理,让浏览器默认换行;如果只按了 Enter,就进入发送逻辑。e.preventDefault():阻止 Enter 键的默认行为——在 textarea 中默认是换行。我们不希望发送消息时文本框中还多一个换行符。input.length > 0:空白消息不发,礼貌且节省资源。
这些细节组合在一起,才能打磨出一个“懂用户”的交互体验。
最后来看 onSubmit,它是当前流程的状态枢纽:
const onSubmit = () => {
if (input.length > 0 && status === 'ready') {
setStatus('loading');
setLoadingMessage('推理中...');
setError(null);
setProgressItem([]);
}
}
一旦触发提交,它会把界面从“就绪”瞬间拉回“加载中”:状态变为 loading,顶部消息换成“推理中...”,清空之前的错误和进度列表。虽然现在还没有真正接上模型推理,但整个状态流转已经严阵以待——就像赛车已经暖胎,只等发令枪响。
四、下集预告:模型真的要下载了!
这节课我们为项目添上了漂亮的进度条、人性化的字节显示、合理的 State/Props 分工,以及完整的聊天输入逻辑。现在只差最后一步:接入真实的模型下载与推理。
下一集,我们将引入 Transformers.js,从 HuggingFace 拉取 DeepSeek-R1 蒸馏版的 ONNX 模型文件。你将看到进度条真的“跑”起来,对话能力被激活,一个完全离线的浏览器端大模型聊天机器人正式诞生!
点个关注,不要错过最震撼的第四集!
五、总结
今天这节“精装修”课,我们为 DeepSeek-R1 WebGPU 项目带来了质的飞跃:
- 🛡️ 防御性编程:
??=空值合并给组件加安全锁,封装者用心,使用者省心。 - 📏 字节单位转换:
formatBytes用对数优雅换算,数字不再冰冷。 - 🎨 进度条可视化:动态
style+whitespace-nowrap,打造专业级进度条。 - 📊 State vs Props:理解“自有财产”与“家族信托”,建立单向数据流思维。
- 💬 聊天输入框:受控组件、类型断言、键盘事件,为推理对话铺平道路。
项目从空壳逐渐变成有血肉、有交互、有设计感的真实应用。每一行新增的代码,每一次组件的迭代,都在夯实你的 React 内功。
如果这篇文章让你有所启发,别忘了点赞、收藏、转发~有什么疑问或天马行空的端侧 AI 想法,评论区就是咱们的技术客厅,畅所欲言!咱们第四集见!👋