React 组件设计:从 Props 传递到组件封装
摘要:本文以 React 进度条组件为例,拆解 Props 传值机制、空值合并运算符的巧妙应用,以及组件封装如何兼顾复用性与健壮性,帮助读者写出更可靠的 React 组件。
📑 目录
- 从"搭积木"理解 React 组件化
- 组件的两种数据来源:Props 与 State
- 实战:设计一个进度条组件
- Props 的类型定义与默认值处理
- 父组件如何传递 Props
- 组件封装的思考:健壮性优先
- 互动讨论
从"搭积木"理解 React 组件化
在 React 的世界里,组件就是函数,函数返回 JSX。一个组件由 HTML 结构、CSS 样式和 JavaScript 逻辑组合而成,形成一个独立的功能单元。
这种设计让页面开发变成了"搭积木"------把一个个独立的小组件拼装起来,就构成了完整的页面。相比直接操作 DOM 标签,组件树的层级结构更清晰,一眼就能看出页面的构成和功能划分。
组件化带来的好处不仅仅是可读性:
| 优势 | 说明 |
|---|---|
| 团队协作 | 不同成员可以并行开发不同组件 |
| 代码复用 | 一个组件可以在多个页面中使用 |
| 易于维护 | 修改只影响组件内部,不波及全局 |
| 便于测试 | 每个组件可以独立测试 |
组件的两种数据来源:Props 与 State
一个 React 组件的数据来源有两种,理解它们的区别是写好组件的第一步。
State(状态)------组件的"私有财产"
State 是组件自己管理的数据,通过 useState 声明。它完全由组件自己掌控,想怎么改就怎么改。
tsx
scss
const [value, setValue] = useState(''); // 这是组件自己的状态
Props(属性)------父组件给的"礼物"
Props 是从父组件传递到子组件的数据,子组件只能读,不能改。就像别人送的礼物,你可以展示它、使用它,但不能擅自改变它。
tsx
javascript
// Props 是函数参数,由父组件传入
function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
// 只能使用 props,不能修改 props
}
如果子组件需要修改 props 传递的数据,正确的做法是:调用父组件传递过来的回调函数,由父组件去修改。这种"数据单向流动"的设计,让 React 应用的状态变化变得可预测、可追踪。
实战:设计一个进度条组件
下面通过一个完整的进度条组件来展示组件设计的关键技术点。
tsx
typescript
function formatBytes(size: number) {
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]
);
}
const Progress = ({ text, percentage, total }: { text: string, percentage: number, total: number }) => {
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}
{percentage.toFixed(2)}%
{isNaN(total) ? "" : `of ${formatBytes(total)}`}
</div>
</div>
);
};
export default Progress;
接下来逐一拆解这个组件的设计细节。
Props 的类型定义与默认值处理
显式声明 Props 类型
组件接收三个 props:
tsx
typescript
({ text, percentage, total }: { text: string, percentage: number, total: number })
text:显示在进度条上的文字(如文件名)percentage:进度百分比数值total:文件总大小(用于显示"of 12.5MB"的辅助信息)
空值合并赋值 ??=
在组件内部,有一行关键的防御性代码:
tsx
ini
percentage ??= 0;
这是 ES2021 引入的空值合并赋值运算符 。它的行为是:只有当 percentage 的值为 null 或 undefined 时,才将 0 赋值给它。
tsx
ini
// 等价于
percentage = (percentage ?? 0);
// 也等价于传统的三元写法
percentage = percentage !== null && percentage !== undefined ? percentage : 0;
为什么要这样写?在实际场景中,进度条的初始状态可能还没有下载进度数据,percentage 可能为 undefined。如果直接使用 percentage 来计算进度条宽度,width: undefined% 会导致样式异常。通过 ??= 保证了即使父组件没有传 percentage,组件也不会出错。
行内样式动态控制宽度
进度条的核心是宽度动态变化,这通过行内样式实现:
tsx
ini
<div style={{ width: `${percentage}%` }} className="...">
注意这里的双层花括号:外层 {} 是 JSX 的 JavaScript 表达式语法,内层 {} 是 JavaScript 对象 。${percentage}% 是模板字符串,最终产生类似 "75%" 的字符串值。
格式化函数
formatBytes 函数将字节数转换为可读的单位:
tsx
arduino
function formatBytes(size: number) {
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]
);
}
它的计算逻辑是:
- 计算
size是 1024 的几次方 - 根据次方数选择对应的单位(B、kB、MB、GB、TB)
- 将字节数除以对应量级,保留两位小数
条件渲染
组件底部有一段条件渲染逻辑:
tsx
javascript
{isNaN(total) ? "" : `of ${formatBytes(total)}`}
如果 total 不是有效数字,就不显示文件大小信息;否则显示"of 12.5MB"。这进一步增强了组件的健壮性------即使父组件没有传入 total,进度条也不会崩溃。
父组件如何传递 Props
在父组件中,通过 map 遍历数据数组,为每个进度条传递不同的 props:
tsx
javascript
<div className="w-full max-w-[1500px] text-left mx-auto p-4 bottom-0 mt-auto">
<p className="text-center mb-1">{loadingMessage}</p>
{progressItems.map(({ text, percentage, total }, i) => (
<Progress key={i} text={text} percentage={percentage} total={total} />
))}
</div>
这里有三个关键点:
1. 属性名与 Props 参数名一一对应
tsx
scss
<Progress text={text} percentage={percentage} total={total} />
// ↑ ↑ ↑
// 参数名text 参数名percentage 参数名total
2. key 属性用于列表渲染优化
React 在渲染列表时需要 key 来唯一标识每个元素,帮助框架高效地对比和更新 DOM。这里使用了数组索引 i 作为 key。
3. 组件可以像 HTML 标签一样使用
定义好的组件通过 import 引入后,就可以像使用 <div> 一样使用 <Progress>,这就是 React 组件化的直观体现------封装好的功能单元,以标签的形式被引用。
组件封装的思考:健壮性优先
这个进度条组件虽然只有 20 多行代码,却体现了组件封装的一条重要原则:封装者多考虑一些,使用者就会多不少爽感。
防御性设计
组件内部通过 ??= 和 isNaN() 判断处理了 props 可能为空的边缘情况。调用者即使忘记传某些参数,组件也不会报错或显示异常。
语义化的 UI 反馈
进度条不仅显示百分比数字,还显示文件名和文件大小。用户能直观地知道"哪个文件下载了多少",而不是只看到一个孤零零的进度条。
无需手动管理样式
调用者只需要传递数据,样式完全由组件内部控制。这保证了项目中所有进度条的外观一致,也减少了调用者的代码量。
对比两种调用方式:
tsx
xml
// 不健壮的组件------调用者需要处理所有边缘情况
<Progress text={item.text} percentage={item.percentage ?? 0} total={item.total ?? 0} />
// 健壮的组件------组件内部处理边缘情况,调用者只需传入原始数据
<Progress text={item.text} percentage={item.percentage} total={item.total} />
好的组件设计,应该让调用者少操心、少写防御代码。
互动讨论
💬 Props 和 State 的区别是什么?
Props 是从父组件传入的,子组件只能读不能改;State 是组件自己管理的数据,通过 useState 声明,可以自由修改。两者共同构成了组件的"数据输入"------State 是内部输入,Props 是外部输入。
💬 为什么 React 不推荐直接修改 Props?
React 遵循单向数据流原则,数据从父组件流向子组件。如果子组件可以随意修改 Props,数据流动就会变得混乱,难以追踪状态变化的来源,调试和维护都会变得困难。
💬 ??= 和 ||= 有什么区别?
??= 只在值为 null 或 undefined 时赋值;||= 在值为假值(false、0、""、null、undefined、NaN)时都会赋值。对于数值型数据,percentage 可能为 0,但 0 是合法值不应被覆盖,所以 ??= 更合适。
💬 行内样式和 CSS 类名应该如何选择?
动态变化的样式(如进度条宽度)适合用行内样式;静态样式(如颜色、间距、圆角)适合用 CSS 类名。两者结合使用最为高效。
💬 组件拆分的粒度怎么把握?
核心原则是单一职责:一个组件只做一件事。如果一个组件做的事情太多(比如既展示列表又管理输入框),就应该拆分成更小的子组件。进度条组件就是"只做进度展示"的典型例子。