好的组件像乐高,随便拼;烂的组件像胶水,粘上就撕不下来
上一篇我们聊了 React 合成事件的来龙去脉,顺便拆解了一个 WebGPU 推理应用的完整逻辑。今天这篇,我把目光聚焦到组件设计 这件事上------借着项目里 Progress 进度条组件的"进化史",聊聊如何写出健壮、可复用、让使用者爽的组件。
一切得从一个细节说起:percentage ??= 0 这行代码。
一、那个不起眼的 ??=,暴露了组件设计的"良知"
先来看 Progress 组件的核心代码:
ini
const 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) ? "" : `of ${formatBytes(total)}`}
</div>
</div>
)
}
??= 是 ES2022 引入的逻辑空值赋值运算符。它的行为很简单:
ini
percentage ??= 0;
// 等价于:
if (percentage === null || percentage === undefined) {
percentage = 0;
}
那为什么需要这个?
想象一个场景:父组件刚开始加载数据,progressItems 还是空数组,或者某个文件的 percentage 字段压根没传。这时候子组件如果直接拿 undefined 去算宽度,页面可能直接崩,或者进度条变成 NaN%。
scss
// 父组件初始化时,数据可能是这样的
const [progressItems, setProgressItems] = useState([]);
// 或者这样(遗漏了 percentage)
{ text: 'model.onnx', total: 34353543453 } // 没有 percentage!
有了 ??= 0,不管外面传来什么妖魔鬼怪,percentage 至少是个数字 0。进度条不会炸,页面不会白屏。
一个好的组件,不是使用者用得爽,而是使用者随便用都不会出错。这叫"防御性编程"。
二、State vs Props:数据的两条"河流"
在我们这个应用里,数据有两条清晰的流向,理解它们,是 React 入门到进阶的关键分水岭。
1. State:组件自己的"私人管家"
scss
// App.tsx 中的 state
const [input, setInput] = useState('');
const [status, setStatus] = useState("ready");
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("开始加载");
const [progressItems, setProgressItems] = useState([]);
State 是组件自己打理的数据 ,别人动不了,只有组件自己通过 setXXX 函数来修改。
status 从 "ready" → "loading" → "ready",这个过程是 App 组件自己控制的。外部组件(比如 Progress)根本不知道 status 的存在,也不需要知道------这叫"信息隐藏" 。
2. Props:父组件的"传递手"
ini
// 父组件传递
<Progress
key={i}
text={text}
percentage={percentage}
total={total}
/>
// 子组件接收
const Progress = ({ text, percentage, total }) => { ... }
Props 是从父组件流向子组件的"只读数据" 。子组件只能读,不能改。
如果子组件想"修改" props 怎么办?答案是:你不能改,但你可以通知父组件来改 。通过回调函数(比如 onProgressUpdate),子组件把"我想改"的信号发出去,父组件收到后自己修改 state,然后新的 props 再流下来。
这就是 "单向数据流" 的核心思想:数据永远向下流,事件永远向上冒。
子组件负责展示,父组件负责逻辑。各司其职,互不干扰------这叫"单一职责原则"。
三、formatBytes:一个工具函数的"小确幸"
代码里有个不起眼但很贴心的工具函数:
arduino
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]
);
}
这个函数把字节数格式化成可读的字符串:34353543453 → "32.00 GB"。
为什么说它贴心?
因为用户在下载模型时,看到的不再是一串天文数字,而是人类可读的文件大小 。这个小细节直接提升了产品的"高级感"------好的用户体验,就藏在这些不起眼的细节里。
在 Progress 组件里,它被这样使用:
javascript
{isNaN(total) ? "" : `of ${formatBytes(total)}`}
如果 total 没传(undefined)或不是数字,isNaN(total) 为 true,直接不显示大小。又一层防御。
四、聊天输入框:状态如何"联动"?
来看新增的聊天输入框,它完美展示了 状态如何驱动多个 UI 元素协同工作:
ini
<textarea
placeholder="Type your message here..."
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.shiftKey) {
e.preventDefault();
onEnter(); // 发送消息
}
}}
title={status === 'ready' ? 'Model is ready' : 'Model is not loaded yet'}
/>
注意这些联动细节:
disabled联动 :只有status === 'ready'时输入框才可用。模型没加载完?想打字?门都没有。title联动:鼠标悬停时显示当前状态提示,用户体验拉满。- Enter 发送 :
Enter键发送消息,Shift+Enter换行------和微信、Discord 一样的交互习惯。 value受控 :输入框的值由inputstate 控制,输入事件更新 state,state 再回流到输入框。这就是 React 的"受控组件"模式。
受控组件 vs 非受控组件
| 模式 | 数据源 | 更新方式 | 适用场景 |
|---|---|---|---|
| 受控组件 | React state | onInput 更新 state |
需要实时校验、格式化、联动 |
| 非受控组件 | DOM 自身 | ref 获取值 |
简单表单、性能敏感场景 |
我们这里用的是受控组件 ,因为需要根据 status 控制 disabled,还要拦截 Enter 键做自定义逻辑。
五、onEnter:事件处理函数的"分水岭"
javascript
const onEnter = () => {
console.log(input);
// 真实场景:
// 1. 调用推理 API
// 2. 显示用户消息
// 3. 显示 LLM 回复
}
这个函数目前只打印输入内容,但它是一个重要的架构分界线:
- 之上(UI 层):只管展示和交互,不管业务逻辑
- 之下(逻辑层):处理推理、API 调用、状态管理
把 onEnter 单独抽出来,而不是直接写在 JSX 里,保证了:
- 可测试性 :可以单独测试
onEnter逻辑 - 可扩展性:后续添加推理逻辑,不影响 UI 代码
- 可读性:JSX 里不塞复杂逻辑,一眼看懂
onKeyDown:一个交互细节的"灵魂拷问"
最精彩的部分来了:onKeyDown 事件处理逻辑。
ini
onKeyDown={(e) => {
if (input.length > 0 && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onEnter();
}
}}
我们拆解一下这四个条件:
| 条件 | 含义 | 为什么这样设计 |
|---|---|---|
input.length > 0 |
输入框有内容 | 防止空消息发送 |
e.key === 'Enter' |
按下的是回车键 | 触发发送 |
!e.shiftKey |
没有同时按下 Shift | 区分"换行"和"发送" |
e.preventDefault() |
阻止默认行为 | 防止输入框内换行 |
核心交互逻辑:
- 单独按 Enter → 发送消息(调用
onEnter()) - Shift + Enter → 换行(不触发发送,让 textarea 默认换行行为生效)
这就是为什么需要 !e.shiftKey 和 e.preventDefault() ------我们要在 Enter 时阻止换行 ,而 Shift+Enter 时保留换行。这个交互习惯和微信、Discord 完全一致,用户零学习成本。
好的交互设计,就是让用户感觉"它本来就该这样"。
为什么 preventDefault() 放在条件里面?
因为只有在"发送"场景下我们才想阻止换行;如果是 Shift+Enter,我们希望 它换行,所以不能阻止默认行为。条件判断的精确性,决定了用户体验的细腻度。
六、类型断言 as:TypeScript 的"我比你懂"
代码里有一处 TypeScript 特有的写法:
ini
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
setInput(target.value);
}}
这里用了 as 进行类型断言。
为什么需要?因为 React 的 onInput 事件对象 e 是 SyntheticEvent 类型,它的 target 属性被定义为 EventTarget 类型,而 EventTarget 并不一定有 value 属性。
arduino
// 没有断言的话,TypeScript 会报错:
// Property 'value' does not exist on type 'EventTarget'.
const target = e.target; // 类型: EventTarget
target.value; // ❌ 类型错误
用 as HTMLTextAreaElement 告诉 TypeScript:"我确定这个 target 是一个 textarea 元素,你放心吧。 "
类型断言不是"欺骗编译器",而是"告诉编译器你掌握了他不知道的信息"。用在刀刃上,能大幅提升开发体验。
七、一个完整的组件设计清单
让我们总结一下,一个健壮的 React 组件应该考虑哪些方面:
| 检查项 | 本项目实践 | 为什么要这么做 |
|---|---|---|
| Props 默认值 | percentage ??= 0 |
防止 undefined 导致 UI 崩溃 |
| Props 类型校验 | 可用 PropTypes 或 TS 接口 |
开发阶段提前发现类型错误 |
| 空状态处理 | progressItems 初始为空数组 |
组件不会因为空数据而报错 |
| 条件渲染 | {error && <ErrorBox />} |
只在需要时渲染,节省性能 |
| 列表 key | key={i} |
帮助 React 识别列表项变化 |
| 禁用状态 | disabled={status !== 'ready'} |
防止用户在不可用状态操作 |
| 用户提示 | title 属性 |
让用户知道为什么被禁用 |
| 受控组件 | value + onInput |
数据和 UI 保持同步 |
八、总结:组件设计的"三字经"
- 防 :
??=防御 props 为空,isNaN防御计算错误,disabled防御误操作 - 分:State 管内部,Props 管外部,工具函数单独抽,各司其职
- 联:状态驱动 UI,事件触发状态更新,形成闭环单向数据流
好的组件像一个自动售货机:投币(props)→ 出饮料(UI),内部怎么运作,使用者不需要操心。
烂的组件像一个漏水的水管:外面看着正常,里面全是坑,碰一下就崩。
React 组件设计的本质,就是把复杂度封装在内部,把简洁暴露给外部 。Progress 组件虽然只有几十行代码,但它完美诠释了这个理念------使用者只需要传 text、percentage、total,剩下的(格式化、进度条样式、空值处理)全由组件自己搞定。
这种"让使用者爽"的封装思维,才是 React 组件开发的精髓所在。