要点
React 的”语法”薄得惊人:组件、JSX、props、Hooks,四个概念讲完,剩下全是 JavaScript。这一篇按数据流走一遍:props 怎么单向流动、children 怎么把组装权交回调用方、条件与列表渲染的坑、受控组件的原理、以及 React 为什么用组合彻底拒绝继承。渲染机制的深水区(什么时候重渲染、怎么避免白算)不在本篇——那是深潜文的地盘,文内随用随链。
props:单向数据流是纪律,不是限制
props 是组件的输入契约:父组件传什么,子组件拿什么,子组件对 props 只有读的份。数据只能从上往下流,子组件想改数据,只有一个办法——调用父组件通过 props 传进来的回调:
// 父组件:持有数据,也持有改数据的操作
function TodoList() {
const [todos, setTodos] = useState([])
function toggle(id) {
setTodos(todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)))
}
return todos.map(t => (
<TodoItem key={t.id} todo={t} onToggle={toggle} />
))
}
// 子组件:数据怎么画我说了算,数据本身我碰不了
function TodoItem({ todo, onToggle }) {
return (
<li onClick={() => onToggle(todo.id)}>
{todo.done ? '✓ ' : ''}{todo.text}
</li>
)
}
为什么要这么绕?因为单向流动让”谁改了数据”永远只有一个答案:数据的持有者。反面对照是双向绑定随手改——bug 出现时你要在全项目里找”哪个组件偷偷改了它”。React 把改数据的路径收窄成显式的回调链,代价是样板代码,收益是可追踪。
props 里传的不只是数据:函数(回调)、组件本身、任何 JavaScript 值都行。默认值用解构参数给:function Badge({ tone = 'gray' })。
children:组装权交回调用方
每个组件都自动有一个 props.children——标签之间包着的内容:
// Card 只定义骨架,不关心里面放什么
function Card({ title, children }) {
return (
<section className="card">
<header>{title}</header>
<div className="body">{children}</div>
</section>
)
}
// 调用方决定内容——这就是组合(composition)
<Card title="今日">
<p>任何组件、任何 JSX 都能放进来</p>
</Card>
children 是 React 解决”内容分发”的原生答案,对应 Vue 的插槽。具名插槽的等价物是”传组件当 props”:<Layout sidebar={<Menu />} main={<Article />} />——不需要框架发明新语法,因为 JSX 里组件本来就是值。
条件渲染:三种形态与一个经典坑
// 形态一:&& —— 有就渲染,没有就算了
{hasError && <p className="error">出错了</p>}
// 形态二:三元 —— 二选一
{todos.length > 0 ? <List items={todos} /> : <p>暂无数据</p>}
// 形态三:提前 return —— 分支复杂时把渲染逻辑拆段
function Panel({ user }) {
if (!user) return <p>请先登录</p>
return <div>{user.name} 的面板</div>
}
经典坑在形态一:JSX 里布尔与 null 不渲染,但数字 0 会。{count && <Badge />} 在 count 为 0 时会把一个孤零零的 0 渲染到页面上。写法上防死:{count > 0 && <Badge />}。
列表渲染:key 是给 React 的对账凭据
{todos.map(t => (
<TodoItem key={t.id} todo={t} />
))}
map 把数组变成元素数组,key 是其中每项的身份标识。React 更新列表时靠 key 判断”哪个节点还是原来那个”——勾选状态、输入框内容这些挂在节点上的东西,跟着 key 走而不是跟着位置走。
用数组下标当 key 的后果:头部插入或删除一项后,所有下标错位,React 把第 n 个节点当成还是原来的第 n 个,节点的本地状态与数据对不上号。数据没有 id 就造一个稳定的(crypto.randomUUID()),随机数每次渲染都变,等于没有 key。这一机制与 React 的 diff 过程直接相关,想看渲染时到底发生了什么,接《React 渲染与重渲染》。
受控组件:表单值住进 state
HTML 表单控件自带内部状态(输入框自己记自己的值),React 的做法是把这个状态收编——value 由 state 提供,变更由 onChange 写回,形成闭环:
const [text, setText] = useState('')
<input value={text} onChange={e => setText(e.target.value)} />
// textarea 与 select 是同一套:都是 value + onChange
<textarea value={text} onChange={e => setText(e.target.value)} />
<select value={lang} onChange={e => setLang(e.target.value)}>
<option value="zh">中文</option>
<option value="en">English</option>
</select>
受控的价值:值永远在 state 里,校验、禁用、联动、提交前处理都在数据层完成,DOM 只是投影。代价是每次按键触发一次渲染——输入规模下这个开销无感,真遇到性能问题时的出路在渲染篇。非受控写法(defaultValue + ref 直取 DOM 值)留给文件输入这类无法受控的场景,不作默认。
状态提升:共享数据的唯一正路
两个组件要读写同一份数据时,把这份数据提到它们最近的公共父组件里,通过 props 分发下去。比如”筛选输入框”与”待办列表”都要用关键词:
function FilterableList() {
const [keyword, setKeyword] = useState('') // 状态住在公共父组件
const filtered = todos.filter(t => t.text.includes(keyword))
return (
<>
<SearchInput value={keyword} onChange={setKeyword} />
<TodoList items={filtered} />
</>
)
}
找状态的居住地有句口诀:谁用,住谁那;都用,住最近公共父组件那。再往上,当”最近公共父组件”变成整个应用时,才轮到 Context 或状态库出场——先把提升用透,才知道那些工具在替你解决什么。
组合优于继承:React 的明确立场
React 没有组件继承。官方文档的态度很直接:他们没遇到过任何必须用继承才能解决的 UI 复用问题。复用的两把工具都是组合:
| 复用诉求 | 组合手法 | 继承思维会怎么写(反面) |
|---|---|---|
| 外壳相同内容不同 | children(上文 Card) | 写 CardBase 再 extends 出 CardWithTitle |
| 行为相同数据不同 | props 传数据 + 回调 | 基类持有数据,子类覆盖渲染方法 |
| 逻辑复用 | 自定义 Hook(下一节) | 基类里的公共方法,子类调用 |
继承的问题在 React 语境里很具体:组件是”被 React 反复调用的函数”,继承链会把”哪些层改了什么状态”藏进调用栈,与单向数据流的可见性直接冲突。
自定义 Hook 基础:复用逻辑,不复用状态
自定义 Hook 就是名字以 use 开头、内部调用了其他 Hook 的函数。它复用的是”逻辑的形状”,每次调用各自持有独立状态:
// useLocalStorage:把"state + 持久化"打包成一个可复用单位
import { useState, useEffect } from 'react'
export function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key)
return raw !== null ? JSON.parse(raw) : initial
} catch {
return initial // 坏数据宁可回初始值——持久化层不能带崩应用
}
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value]) // value 变了才写盘;key 变了也要重写
return [value, setValue]
}
// 用起来与 useState 无异,但多了持久化
const [theme, setTheme] = useLocalStorage('theme', 'light')
两条铁律(违反会导致渲染错乱,ESLint 的 rules-of-hooks 规则会拦):只在函数顶层调用 Hook,不在条件、循环、嵌套函数里调;只在 React 函数组件或自定义 Hook 里调用。use 前缀同时是给 lint 和给读者的约定——看到 use 就知道它参与渲染周期。
两个组件分别调用 useLocalStorage(‘a’) 与 useLocalStorage(‘b’),拿到的是两份互不影响的状态——”复用逻辑不复用状态”。要共享状态,回到状态提升,或 Context。Hook 的执行语义(闭包快照、依赖数组背后的机制)在《React Hooks 心智模型》整篇展开。
反模式
反模式一:在子组件里改 props。props.todo.done = true 改的是父组件对象的嵌套字段,看似生效,实则父组件毫无感知,下一次渲染就被覆盖,还伴随告警。改数据的路径只有一条:回调给数据的持有者。
反模式二:props 层层透传当默认操作。中间组件既不用某数据又原样转发(props drilling),三层之后每加一个字段要改三个文件。先用组合把”需要数据的组件”直接变成 children 或 props 传进去;确实全局的语义(主题、当前用户)再上 Context——但记住 Context 变化会让所有消费者重渲染,粒度设计见渲染篇。
检查清单
- 能对”单向数据流”说出子组件改数据的唯一合法路径
- children 与”传组件当 props”两种组合手法各写过一个例子
- 知道
{count && …}在 count 为 0 时渲染什么,以及怎么防 - 能解释 index 当 key 在什么操作下串位、为什么随机数 key 也不行
- 受控组件的闭环(value ← state,onChange → setState)画得出来
- useLocalStorage 例子能独立重写,两条 Hook 铁律背得出
下一步
这一篇讲完”为什么”,全部 API 的签名、示例与坑集中在《React 基础知识参考字典》随查随用;接下来《用 React + Vite 做一个热榜阅读客户端》把这些语法放进一个真实项目,补上数据层与持久化的工程决策。
版本核实(撰写时检索,2025-07):React 当前稳定版为 19.1.x;本篇涉及的 props/children/key/受控组件/自定义 Hook 均为 React 长期稳定特性,无版本敏感 API;React 19 起函数组件的 ref 作为普通 prop 传递(本篇未涉及)。具体以官方文档为准。