React 思维方式
到目前为止,每一章都教了一个方面:组件、props、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 },
]第一步:把设计分解为组件树
看着设计稿在各个片段周围画框。一个好的框应该是一个专注做一件事的部分,这和你决定一个函数应该做什么的直觉是一样的。我们的小界面可以清晰地分成几个部分:
FilterableProductList包裹整个界面。SearchBar包含文本输入框和缺货复选框。ProductTable显示产品列表。ProductRow是表格中的一行。
这些组件互相嵌套,形成了一个 组件树:
FilterableProductList
├── SearchBar
└── ProductTable
└── ProductRow (每个产品一个)没有唯一正确的分割方式,组件章节的经验法则在这里同样适用:保持每个组件小巧,给它一个明确的职责。一行知道如何绘制一个产品;表格知道如何布局行;搜索栏知道控件怎么用。如果某个部分开始做两个不相关的事情,通常就是分割它的信号。
第二步:用 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 单独让整个布局工作,确认第一步的树确实能搭在一起,然后才涉及任何活动的部分。products 数组作为 prop 在顶部传入并往下传递;这里什么都不会记住或改变任何东西。
第三步:找到最少的状态
现在让它交互起来,首先的问题是哪些值真的需要是状态。状态很昂贵,要保持正确,所以你想要尽可能少的状态。其余的你可以计算出来。
对每个值的测试很短。问两个事情:
- 它会随时间改变吗?
- 是否无法从你已经拥有的其他值计算出来?
如果两个都是,那它就是状态。如果其中之一是否,那它就不是。让我们的 UI 中的值通过这个测试走一遍:
- 产品列表。 它作为 prop 被传入,在用户与页面交互时不改变,所以它不是状态。它是一个 prop。
- 用户输入的搜索文本。 它随时间改变,没有什么可以计算它。它是状态。
- 缺货复选框是否开启。 同样的情况:它改变,没有东西从中推导它。它是状态。
- 屏幕上实际显示的筛选列表。 它改变,但你可以从产品、搜索文本和复选框计算它。所以它不是状态。
最后一个是值得牢记的规则:推导,不要重复。可见列表是通过当前筛选器传递的产品,所以你在渲染时计算它,而不是在状态中存储第二份副本:
const visible = products.filter(product => {
const matchesText = product.name
.toLowerCase()
.includes(filterText.toLowerCase())
const matchesStock = !inStockOnly || product.stocked
return matchesText && matchesStock
})如果你在单独的 useState 中存储了 visible,每次产品、文本或复选框改变时都必须记得更新它。漏掉一次屏幕就会显示一个过期的列表。推导意味着只有一个真实来源,它永远不会不同步,因为它在每次渲染时都重新计算。所以这里的最少状态正好是两个值:filterText 和 inStockOnly。
第四步:决定每个状态应该存放在哪个组件
你有两个状态。现在弄清楚哪个组件拥有每一个。规则是把状态放在需要它的所有组件的最近的共同父组件中,这和 提升状态章节深入讲述的想法相同。
弄清楚谁需要每个值:
SearchBar需要两个值,因为它渲染显示它们的输入框和复选框。ProductTable也需要两个值,因为它用它们筛选行。
SearchBar 和 ProductTable 是兄弟。值只能通过 props 往下流动,所以兄弟之间无法互相传递状态。坐在两者之上的最近的组件是 FilterableProductList。状态应该放在这里:
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>
)
}状态存放在父组件中,值作为 props 往下流向两个子组件。ProductTable 读取它们来构建 visible 列表;SearchBar 读取它们来填充输入框和复选框。
第五步:添加更新状态的交互
值到达了子组件,但用户仍然无法改变它们。数据只往下流动,所以为了把改变发送回去,你把 setter 作为 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。这更新了状态,父组件重新渲染,新的 filterText 流回两个子组件,ProductTable 重新计算它的 visible 列表,屏幕匹配用户输入的内容。复选框也是同样的工作方式。状态作为值往下去,改变作为函数调用往上来,推导的列表自动跟着走。
这就是整个方法:画出组件树,用 props 构建静态版本,找到最少的状态,提升到合适的拥有者,然后连接交互。几乎任何界面都可以用这五个步骤解决。
把每一个放在最近需要它的组件上方,往下传递一个 setter 所以点击或按键可以更新它。
你在屏幕上显示的列表不是状态;你从产品和筛选器每次计算它。
接下来:无障碍 React,你刚刚设计的界面对每个人都可用。

