React 思维方式
到目前为止,每一章都讲了一个方面:组件、Props、State、事件、列表。这一章把它们汇聚在一起,形成一套方法。当你拿到一个设计稿,如何决定要分出哪些组件、哪些值应该是 state、state 应该放在哪里?有一种可重复的工作流,一旦你做过几次,几乎在每个屏幕上都会用到同样的五个步骤。
我们来逐步完成一个小 UI:一个可搜索的产品列表。它显示一组产品、一个搜索框用来按名称筛选、一个复选框用来隐藏缺货商品。这是它用到的数据:
const PRODUCTS = [
{ id: 1, name: '苹果', price: '$1', stocked: true },
{ id: 2, name: '火龙果', price: '$1', stocked: false },
{ id: 3, name: '百香果', price: '$2', stocked: true },
]第 1 步:把设计分解成组件树
看着设计稿,在各个部分周围画出矩形。好的矩形划分就是一件单独的东西做一件单独的工作,这个直觉就像你决定一个函数应该做什么一样。我们的小屏幕分出几个清晰的部分:
FilterableProductList包装整个界面。SearchBar包含文本输入框和缺货复选框。ProductTable显示产品列表。ProductRow是表格中的一行。
这些组件相互嵌套,形成一个**组件树**:
FilterableProductList
├── SearchBar
└── ProductTable
└── ProductRow (每个产品一行)没有唯一正确的分割方式,组件那一章讲的经验法则在这里也适用:让每个组件保持小巧,赋予它一个明确的职责。一行知道怎么绘制一个产品,表格知道怎么排列行,搜索栏知道控制元素。如果某个部分开始做两件不相关的事情,那通常是分割它的信号。
第 2 步:用纯 props 构建静态版本
现在构建一个能渲染数据但不做任何交互的版本。没有点击,没有筛选,没有输入框改变任何东西。数据单向流动,从树顶流向树底,通过 props 传递。这一步中根本不会出现 useState。
function FilterableProductList({ products }) {
return (
<div>
<SearchBar />
<ProductTable products={products} />
</div>
)
}
function ProductTable({ products }) {
return (
<table>
<tbody>
{products.map(product => (
<ProductRow key={product.id} product={product} />
))}
</tbody>
</table>
)
}
function ProductRow({ product }) {
return (
<tr>
<td>{product.name}</td>
<td>{product.price}</td>
</tr>
)
}
function SearchBar() {
return (
<form>
<input type="text" placeholder="搜索..." />
<label>
<input type="checkbox" /> 仅显示有货商品
</label>
</form>
)
}这会每次都渲染完整列表。搜索框和复选框出现在屏幕上,但还没有任何作用。这正是这一步的意义:你通过纯 props 让整个布局运作,在涉及到任何动态部分之前,你确认了第 1 步的树结构真的能工作。products 数组作为 prop 在顶部传入,然后向下传递;这里的任何东西都不会记住或改变任何东西。
第 3 步:找出最少的 state
现在让它变成交互式的,第一个问题是哪些值根本需要成为 state。State 很难保证正确,所以你希望尽可能少。其余的你可以计算出来。
对每个值的检验很短。问两件事:
- 它会随时间变化吗?
- 你能从已有的其他值计算出它吗?
如果两个都是肯定,那它是 state。否则就不是。把 UI 中的值都过一遍这个检验:
- 产品列表。 它被传入作为 prop,在用户操作页面时不会改变,所以它不是 state。它是一个 prop。
- 用户输入的搜索文本。 它随时间变化,没有办法从其他东西计算它。它是 state。
- 缺货复选框是否打开。 同样:它会变化,没有其他东西能推导它。它是 state。
- 屏幕上实际显示的筛选后列表。 它会变化,但你可以从产品、搜索文本和复选框计算它。所以它不是 state。
最后这一条值得深深记住:推导,不要复制。可见列表就是产品通过当前筛选器的结果,所以你在渲染时计算它,而不是在 state 中存储一份副本:
const visible = products.filter(product => {
const matchesText = product.name
.toLowerCase()
.includes(filterText.toLowerCase())
const matchesStock = !inStockOnly || product.stocked
return matchesText && matchesStock
})如果你在自己的 useState 中存储 visible,那么每当产品、文本或复选框改变时,你都得记得更新它。漏掉一次屏幕就会显示陈旧的列表。推导它意味着只有一个真实源头,它永远不会失步,因为它在每次渲染时都会重新计算。所以这里最少的 state 就是两个值:filterText 和 inStockOnly。
第 4 步:决定每个 state 应该放在哪个组件
你有两个 state。现在要搞清楚哪个组件拥有它们。规则是把 state 放在需要它的所有组件的最近公共父组件中,这和 状态提升那一章深入讨论的想法是一样的。
分析谁需要每个值:
SearchBar需要两个值,因为它渲染显示这些值的输入框和复选框。ProductTable也需要两个值,因为它用它们来筛选行。
SearchBar 和 ProductTable 是兄弟组件。值只能通过 props 向下流动,所以兄弟间无法直接传递 state。最近的同时位于它们上方的组件是 FilterableProductList。State 应该放在那里:
import { useState } from 'react'
function FilterableProductList({ products }) {
const [filterText, setFilterText] = useState('')
const [inStockOnly, setInStockOnly] = useState(false)
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly}
/>
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly}
/>
</div>
)
}State 存在父组件中,值作为 props 向下流向两个子组件。ProductTable 读取它们来构建它的 visible 列表;SearchBar 读取它们来填充输入框和复选框。
第 5 步:添加更新 state 的交互
值到达了子组件,但用户仍然改不了它们。数据只向下流动,所以要把改变送回上去,你需要把 setters 作为 props 传下去,让子组件调用它们。FilterableProductList 已经在上面传了 onFilterTextChange 和 onInStockOnlyChange。SearchBar 把它们连接到输入框:
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange,
}) {
return (
<form>
<input
type="text"
placeholder="搜索..."
value={filterText}
onChange={e => onFilterTextChange(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={e => onInStockOnlyChange(e.target.checked)}
/>{' '}
仅显示有货商品
</label>
</form>
)
}现在循环就闭合了。在框中输入会调用 onFilterTextChange,它就是父组件中的 setFilterText。那会更新 state,父组件重新渲染,新的 filterText 向下流向两个子组件,ProductTable 重新计算它的 visible 列表,屏幕就显示用户输入的内容。复选框也以同样的方式工作。State 向下作为值,改变向上作为函数调用,推导出的列表自己跟随其后。
这就是整个方法:绘制组件树,用纯 props 构建静态版本,找出最少的 state,提升到正确的所有者,然后接上交互。几乎你遇到的任何屏幕都遵循这五个步骤。
接下来:可访问的 React,这样你设计的界面就会让每个人都能使用。

