1.4 组件基础 本节摘要:组件是 React 应用的基本构建块。本节用同一个计数器分别用函数组件与类组件实现,让你在动手对比中理解两者的差异;再讲透 props 与 state 的分工——props 只读传数据,state 可变管界面,以及 setState 触发重渲染的因果链;最后看组件的组合与复用。 阅读收获 阅读完本节,你应当能够: 用函数组件和类组件各写一个计数器,说明两者差异 解释 props 为什么只读、state 为什么可变 理解 setState 触发重渲染的因果链 通过 props.children 组合组件,说出组合与继承的取舍 判断何时该拆分组件 一、问题与直觉:组件是 UI 的乐高积木 上一节我们把界面写成了 JSX。
本节摘要:组件是 React 应用的基本构建块。本节用同一个计数器分别用函数组件与类组件实现,让你在动手对比中理解两者的差异;再讲透 props 与 state 的分工——props 只读传数据,state 可变管界面,以及 setState 触发重渲染的因果链;最后看组件的组合与复用。
阅读完本节,你应当能够:
上一节我们把界面写成了 JSX。但一个真实的界面往往包含几十个区块:导航栏、侧边栏、卡片、弹窗……把它们全部写在一个巨大的函数里,很快会变成几千行的怪物,改一处牵全身。
组件的思路是:把界面拆成独立、可复用的小积木,每块积木只负责一件事。导航栏是组件,卡片是组件,卡片里的按钮也是组件。积木可以嵌套——大积木由小积木拼成,小积木还能继续拆。
SOURCE 原文的比喻很贴切:组件是 React 应用的基本砖块。它接收输入(props),返回描述界面的 React 元素。这句话值得刻在脑子里,因为整个 React 的学习都围绕它展开。
先给组件下个精确的定义:组件是接收 props 并返回 React 元素的函数。
这个概念性理解非常重要。把组件当函数看,一切豁然开朗:
函数组件就是最直接的体现:
function Welcome(props) { return <h1>Hello, {props.name}!</h1>; }
接收 props,返回 JSX,完了。它是个纯函数——同一次调用,同样的 props,必然返回同样的 JSX。这个「纯」字是 React 的心法:纯函数易推理、易测试、易复用,后面讲 React.memo(第 3.1 节)时,记忆化的前提就是纯函数。
类组件则是另一个历史阶段的主角:
import React from 'react'; class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}!</h1>; } }
类组件继承 React.Component,必须实现 render 方法,通过 this.props 访问属性。它能持有 state 和生命周期方法——这在 Hooks 出现之前是函数组件做不到的。
为了不让差异停留在概念,把同一个计数器分别用两种写法实现。
类组件版:
import React from 'react'; class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; this.increment = this.increment.bind(this); } increment() { this.setState({ count: this.state.count + 1 }); } render() { return ( <div> <p>Count: {this.state.count}</p> <button onClick={this.increment}>Increment</button> </div> ); } }
注意三个细节:constructor 里 super(props) 是必须的,否则 this 未正确初始化;state 在构造函数里初始化;方法里要用 this.setState 修改状态,直接 this.state.count++ 不会触发重渲染。另外 this.increment.bind(this) 是类组件处理 this 的传统姿势——JavaScript 的方法调用方式决定了 this 可能丢失,所以要手动绑定(第 2.3 节事件处理会细讲)。
函数组件版:
import React, { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); }; return ( <div> <p>Count: {count}</p> <button onClick={increment}>Increment</button> </div> ); }
函数组件版短了一大截:没有 constructor、没有 this、没有 bind。useState 返回一个数组,解构出状态值 count 和更新函数 setCount。箭头函数天然捕获 this,不需要绑定。
对比下来,函数组件的简洁是压倒性的。这也是 React 16.8 之后官方推荐函数组件的原因——Hooks 把状态和副作用的能力给了函数组件,同时保留它原本的简洁与纯粹。
| 维度 | 类组件 | 函数组件 |
|---|---|---|
| 语法 | 类 + render 方法 | 纯函数 |
| state | this.state + setState | useState |
| this 处理 | 需要 bind 或箭头函数 | 无 this |
| 生命周期 | 生命周期方法 | useEffect 等 Hooks |
| 代码量 | 多 | 少 |
| 现状 | 维护存量项目 | 新代码首选 |
这是 React 初学者最容易混淆的一对概念,用一句话厘清:
props 是外部传进来的数据(只读),state 是组件内部的数据(可变)。
props 由父组件传入,子组件不能改。这保证了数据流方向的单一——父传子,层层往下,方向明确。如果子组件想改 props,正确做法是调用父组件传下来的回调函数,让父组件去改(第 2.3 节事件处理会看到这种「状态提升」的用法)。
function Profile(props) { return ( <div> <h2>{props.name}</h2> <p>Age: {props.age}</p> </div> ); } function App() { return <Profile name="John Doe" age={30} />; }
name="John Doe" 是字符串字面量直接写,age={30} 是数字需要花括号。props 可以是任意类型:数字、字符串、数组、对象、函数,甚至 JSX 元素(通过 children)。
state 是组件自己的数据,只有组件自己可以改。关键在「怎么改」:
function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> 点击次数:{count} </button> ); }
改 state 必须用 setCount,不能直接 count++。原因有两层:一是 React 需要知道「状态变了」,才能安排重渲染;二是直接修改 state 对象会绕过 React 的追踪,界面不会更新,还会留下脏数据。
这条链是 React 的心脏。每次点击按钮,链就完整走一遍。理解它,你就理解了为什么「改了 state 界面才变」「不 setState 界面不变」。
💡 关键直觉:把 props 想成「别人递给你的材料」,把 state 想成「你自己柜子里的东西」。材料只能看不能用改,自己柜子里的东西想换就换——但换之前必须跟 React 打声招呼(调用 setState),否则没人知道该重画界面。
组件最强大的能力是组合——用小组件拼大组件,大组件再拼更大。
function Card(props) { return <div className="card">{props.children}</div>; } function CardTitle(props) { return <h2 className="card-title">{props.children}</h2>; } function CardBody(props) { return <div className="card-body">{props.children}</div>; } function UserProfileCard(props) { const { name, bio } = props; return ( <Card> <CardTitle>{name}</CardTitle> <CardBody>{bio}</CardBody> </Card> ); }
Card、CardTitle、CardBody 各管一件事,UserProfileCard 把它们拼起来。props.children 是组合的钥匙——容器组件不关心里面装什么,只负责提供结构。
React 强调组合而非继承。你想扩展一个组件,优先考虑「在它外面包一层」或「往它里面塞内容」,而不是继承它。组合的好处是解耦:基础组件可以独立修改而不影响使用它的组件。
| 方案 | 做法 | 适用 |
|---|---|---|
| 组合 | 嵌套 + children 传内容 | React 官方推荐,绝大多数场景 |
| 继承 | 类继承复用 | 几乎不用,React 组件继承价值低 |
拆太碎,文件满天飞;不拆,组件臃肿。两条实用标准:
SOURCE 原文演示了一个反面案例:把加载、错误、展示全塞进一个 UserProfile,然后把它拆成 LoadingIndicator、ErrorMessage、UserInfo 三个独立组件——这个「先写臃肿、再拆干净」的对比练习值得亲手做一遍。
数据往往要从「拥有它的组件」传给「展示它的组件」,中间可能隔好几层。props 一层层传下去,叫 prop drilling——这在层数浅时没问题,深了就会很痛苦。第 2.5 节 Context 就是为缓解它而生的。这里先记住:状态放在尽量靠近使用它的地方,不要无脑放顶层。
props 不仅能传数据,还能传函数。这是 React 里子组件「通知父组件」的标准姿势:
function Child({ onIncrement }) { return <button onClick={onIncrement}>点我</button>; } function Parent() { const [count, setCount] = useState(0); return ( <div> <p>父组件计数:{count}</p> <Child onIncrement={() => setCount(count + 1)} /> </div> ); }
子组件 Child 不知道父组件具体怎么计数,它只知道「点击后调用 onIncrement」。这种「数据向上汇报」的模式是单向数据流的重要补充,后面事件处理章节会反复用到。
真实项目里,有些 props 是可选的,需要给默认值;有些必须传,缺了要提示。函数组件里可以用默认参数:
function StatusBar({ color = 'blue', size = 'medium' }) { return <div style={{ color, fontSize: size === 'large' ? 20 : 14 }}>{/* ... */}</div>; }
类组件用静态属性 defaultProps。缺省的 props 会在调用方漏传时自动补上默认值——这比在组件内部做「如果是空就怎么样」的判断干净得多。
为了真正理解「props 只读」,动手做一个实验:在子组件里尝试给 props 重新赋值。
function Child({ name }) { name = '被改动了'; // 运行时会不会报错? return <p>{name}</p>; }
JavaScript 允许对函数参数重新赋值,所以这行代码不会抛错,但没有任何意义——它只改了局部变量,父组件的 props 原封不动,下次渲染又回到原值。React 的「props 只读」不是靠语法禁止,而是靠约定。真正危险的是修改 props 里的对象或数组的引用——虽然 React 不强制冻结,但你应该把它当作不可变数据对待。
SOURCE 原文的反面案例值得完整展开。先看「违反单一职责」的版本——一个组件同时管加载、错误、内容三件事:
function UserProfile(props) { const { user, isLoading, error } = props; if (isLoading) { return <div>Loading...</div>; } if (error) { return <div>Error: {error.message}</div>; } return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); }
问题在于:加载态、错误态、内容态三种 UI 被绑在一个组件里,任何一处的样式改动都要进这个组件。拆开之后,每个组件只负责一种状态:
function LoadingIndicator() { return <div>Loading...</div>; } function ErrorMessage({ error }) { return <div>Error: {error.message}</div>; } function UserInfo({ user }) { return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); }
拆开后的价值立刻显现:LoadingIndicator 全项目复用,ErrorMessage 可以统一错误样式,UserInfo 只管展示。改一个状态不影响另外两个。判断拆不拆,就看你有没有「这里改一处、别处受牵连」的感觉——有,就说明该拆了。拆的时机也别太早,等确实出现重复或臃肿再动手,避免为了抽象而抽象。
命名是组件的脸面。React 社区约定用 PascalCase(首字母大写):UserProfile、ProductList。避免 MyComponent、DataDisplay 这类毫无信息量的名字——名字是给三个月后的自己看的,含糊的名字等于埋雷。一个好命名的组件,别人不看注释也能猜出它干嘛。
回到开头那个定义——组件是接收 props 并返回 JSX 的纯函数。纯函数的三个性质,对应 React 的三个保障:
第一,同样的输入必有同样的输出,这让组件可预测。同一个组件、同样的 props,在任何页面渲染结果一致,不会出现「这里正常那里抽风」的灵异现象。第二,纯函数不产生副作用,这让组件可测试。写测试时你只需要准备输入、断言输出,不需要 mock 一堆外部状态(第 3.6 节测试会展开)。第三,纯函数便于缓存,这让 React 可以做记忆化优化。React.memo 能跳过重复渲染的前提,就是「同样的 props 渲染结果必然相同」。
想保持组件的纯,就要克制两件事:不要在渲染过程中修改全局变量,不要在渲染过程中发起网络请求或写 localStorage——这些副作用应该放到事件处理或 useEffect 里。记住这条,你就避开了 React 初学者的一大类诡异 bug。
组件基础打牢了,下一章进入核心概念。先看虚拟 DOM 与 Diff——这是 React 性能的秘密,也是理解 key、React.memo 等一切优化的地基。你已经在第 1 章里亲手写出了组件,接下来的机制讲解就有了体感支撑,学起来会比直接啃原理轻松很多。