《端侧大模型(三)“精装修”日记:进度条整容、聊天框首秀》

进度条"整容" + 聊天框亮相:DeepSeek-R1 WebGPU 交互打磨实录

前言

嘿,各位一路跟到这里的"端侧AI探险家"们!

系列第一篇 我们搭好了静态骨架 ,让 React 组件像川剧变脸一样随状态切换界面;第二篇 我们装上了"发动机",用合成事件激活按钮抽离进度条组件理解了组件树思想 。但当时的进度条实在太过简陋------几个 <p> 标签堆叠文字,就像素颜出镜,完全撑不起"浏览器跑大模型"的科技感。

今天,我们要对项目进行一次"精装修":让进度条真正可视化,像下载软件一样直观填充;编写优雅的 formatBytes 工具函数,把冷冰冰的字节数变成"63.7 MB"这样的友好格式;新增聊天输入框,为后续模型推理铺好路;更重要的是,深入辨析 React 中 State 与 Props 这对核心概念,让你的组件设计功底再上一层楼。

准备好迎接这个越来越完整的 AI 前端项目了吗?系好安全带,发车!


一、让进度条"活"起来:Progress 组件可视化改造

上节课的 Progress 组件只返回了三个 <p> 标签,界面效果就是干巴巴的文字堆叠。这节课我们在子组件里进行了大幅度迭代,把它变成了真正的"进度条"。

先看完整代码,注意它从上到下的执行顺序------这正是我们接下来要逐一拆解的知识点:

tsx 复制代码
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 的值是 nullundefined,就把它设为 0;如果已经传入了具体的数值(包括 0),就保持原样

笔记里说得特别好:"初始化的时候,没有下载进度这个概念的,组件里使用??= 空值合并,没传值为空,赋值为0。封装者多考虑,使用者用的爽。"

作为 Progress 组件的作者,你无法控制外部传来的数据:可能因为某个异步请求还没返回,percentageundefined。与其让界面显示 "undefined%" 甚至报错,不如自动归一化为 0。相较于老式的 percentage = percentage || 0??= 只针对 nullundefined 生效,不会错误地把合法的 0 也替换掉(虽然此处替换了也无妨,但语义更精确)。

这种"封装者多考虑,使用者用的爽"的理念,正是构建健壮组件库的核心思想。做好了这一步防御,percentage 就是一个可靠的数字了,后续的渲染和计算都可以放心使用。

1.2 formatBytes:字节换算的艺术

数据安全之后,我们需要把它展示得更好看。进度条上如果直接显示 66666666 这样的数字,用户得数半天零。我们必须把原始字节数转换成可读的单位(B、kB、MB、GB 等)。于是就有了这个工具函数:

tsx 复制代码
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 才是进度指示的核心。它的宽度通过行内样式动态绑定:

tsx 复制代码
style={{ width: `${percentage}%` }}

注意这里的双花括号:外层 {} 是 JSX 表达式语法,内层 {} 是一个 JavaScript 对象字面量。React 会为这个元素生成内联样式 width: 45%(假设 percentage 为 45),并且每当 percentage 改变,React 自动更新 DOM 样式,进度条就"长大"了。

这种声明式的 UI 更新,正是 React 魅力的体现------你只需要声明"宽度等于 percentage%",至于怎么变、何时变,框架全包了。

再来看看内层 divclassName"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 处理表单的"仪式感":

tsx 复制代码
<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,分两步:

  1. 显示层value={input} 把 state 里的 input 值绑定到文本框。任何时候,文本框里显示的内容都严格等于 input 这个状态。
  2. 修改层 :当用户敲键盘时,并不会直接改变文本框的 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 的回调里,有一行看着有点奇怪的代码:

tsx 复制代码
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 换行的可能性。

tsx 复制代码
onKeyDown={e => {
    if (input.length > 0 && e.key === 'Enter' && !e.ctrlKey) {
        e.preventDefault();
        onSubmit();
    }
}}
  • e.key === 'Enter'key 属性是键盘事件的标准化按键值,比老旧的 keyCode 更直观。
  • !e.ctrlKeyctrlKey 是一个布尔值,表示事件触发时 Ctrl 键是否被按住。如果用户按了 Ctrl+Enter,我们不做任何处理,让浏览器默认换行;如果只按了 Enter,就进入发送逻辑。
  • e.preventDefault():阻止 Enter 键的默认行为------在 textarea 中默认是换行。我们不希望发送消息时文本框中还多一个换行符。
  • input.length > 0:空白消息不发,礼貌且节省资源。

这些细节组合在一起,才能打磨出一个"懂用户"的交互体验。

最后来看 onSubmit,它是当前流程的状态枢纽:

tsx 复制代码
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 想法,评论区就是咱们的技术客厅,畅所欲言!咱们第四集见!👋

相关推荐
方方洛2 小时前
AI 教程系列-TUI 应用开发教程14-技能系统与可扩展性
前端·javascript·typescript
方方洛2 小时前
AI 教程系列-TUI 应用开发教程05-大模型对话界面设计
前端·javascript·typescript
方方洛2 小时前
AI 教程系列-TUI 应用开发教程08-流式响应与动画
前端·javascript·typescript
方方洛2 小时前
AI 教程系列-TUI 应用开发教程04-第一个 TUI 应用
前端·javascript·typescript
lymboy3 小时前
Spring AI的工具调用循环,本质就是ReAct:一次源码级拆解
人工智能·spring·react.js
qq_5896660514 小时前
TypeScript 完整入门教程
前端·javascript·typescript
Darling噜啦啦16 小时前
React 组件化实战:从 TodoList 吃透父子通信与状态管理
react.js
bloglin999991 天前
langchain 和 langgraph 和 react
javascript·react.js·langchain
触底反弹1 天前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js