Skip to content
This page has been auto-translated and may contain errors.View in English

React 思维方式

到目前为止,每一章都教了一个方面:组件propsstate事件列表。这一章是它们汇聚成一套方法论的地方。面对一个设计,你如何决定组件应该是什么、哪些值应该是状态、以及状态应该放在哪里?有一个可重复的工作流程,一旦你做过几次,在构建几乎任何界面时都会用到这相同的五个步骤。

我们来完整地走一遍一个小型 UI:一个可搜索的产品列表。它展示一组产品、一个搜索框用来按名称筛选,以及一个复选框来隐藏缺货商品。这是它渲染的数据:

jsx
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

jsx
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 在顶部传入并往下传递;这里什么都不会记住或改变任何东西。

第三步:找到最少的状态

现在让它交互起来,首先的问题是哪些值真的需要是状态。状态很昂贵,要保持正确,所以你想要尽可能少的状态。其余的你可以计算出来。

对每个值的测试很短。问两个事情:

  1. 它会随时间改变吗?
  2. 是否无法从你已经拥有的其他值计算出来?

如果两个都是,那它就是状态。如果其中之一是否,那它就不是。让我们的 UI 中的值通过这个测试走一遍:

  • 产品列表。 它作为 prop 被传入,在用户与页面交互时不改变,所以它不是状态。它是一个 prop。
  • 用户输入的搜索文本。 它随时间改变,没有什么可以计算它。它是状态。
  • 缺货复选框是否开启。 同样的情况:它改变,没有东西从中推导它。它是状态。
  • 屏幕上实际显示的筛选列表。 它改变,但你可以从产品、搜索文本和复选框计算它。所以它不是状态。

最后一个是值得牢记的规则:推导,不要重复。可见列表是通过当前筛选器传递的产品,所以你在渲染时计算它,而不是在状态中存储第二份副本:

jsx
const visible = products.filter(product => {
  const matchesText = product.name
    .toLowerCase()
    .includes(filterText.toLowerCase())
  const matchesStock = !inStockOnly || product.stocked
  return matchesText && matchesStock
})

如果你在单独的 useState 中存储了 visible,每次产品、文本或复选框改变时都必须记得更新它。漏掉一次屏幕就会显示一个过期的列表。推导意味着只有一个真实来源,它永远不会不同步,因为它在每次渲染时都重新计算。所以这里的最少状态正好是两个值:filterTextinStockOnly

第四步:决定每个状态应该存放在哪个组件

你有两个状态。现在弄清楚哪个组件拥有每一个。规则是把状态放在需要它的所有组件的最近的共同父组件中,这和 提升状态章节深入讲述的想法相同。

弄清楚谁需要每个值:

  • SearchBar 需要两个值,因为它渲染显示它们的输入框和复选框。
  • ProductTable 也需要两个值,因为它用它们筛选行。

SearchBarProductTable 是兄弟。值只能通过 props 往下流动,所以兄弟之间无法互相传递状态。坐在两者之上的最近的组件是 FilterableProductList。状态应该放在这里:

jsx
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 已经在上面传递了 onFilterTextChangeonInStockOnlyChangeSearchBar 把它们连接到输入框:

jsx
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 构建静态版本,找到最少的状态,提升到合适的拥有者,然后连接交互。几乎任何界面都可以用这五个步骤解决。

第三步是值得仔细对待的,因为"最少"比初次看起来要更严格。工作问题是:从什么最小的值集合可以重新计算屏幕上的每一个其他值?对于我们的列表那就是 filterTextinStockOnly,任何可见的东西都是这两个加上传入的 products 的纯函数。任何你能在渲染时推导的东西,你都应该推导。筛选列表是最清楚的情况,但相同的逻辑排除了存储匹配的 count、一个 hasResults 布尔值,或数组的一个排序副本。这些都是你已经持有的状态的函数,所以在单独的 useState 中保存它会给你第二个值要保持同步,以及一个新的出错方式。推导的值不会过期,因为它们不持久;它们从唯一的真实来源在每次渲染时重新计算。

另一侧要注意的权衡是在每次渲染时推导昂贵的东西。只有当分析表明真实成本时才用 useMemo。默认做法是:在渲染时推导,只有当值随时间改变且没有东西生成它时才把它提升为状态。

第一步有自己的判断呼唤,它可以两个方向发展。分割太少,组件会长成一个难以重用或理解的纠缠。分割太多,你会得到一堆微小的组件通过只为了转发它们的层中的 props,这是它自己的一种难以跟踪的方式。有用的测试是责任和重用:当片段有一个明确的工作、当它重复(一个 ProductRow 每个项目渲染一次)或提取它能使父组件可读时,一个边界就赚取了它的位置。一个只在一个地方渲染且毫无改变地转发它的 props 的边界通常可以内联。让真实的重复和真实的复杂性拉开组件,而不是在猜测你稍后需要接缝时分割。

Juno从设计到 React 的五个步骤 这是你可以依靠的秘诀:在片段周围画框来获得你的组件,首先用 props 构建它所以它展示数据别的什么也不做,然后找到真的改变并且无法从其他东西推断出来的几个值,那些就是你的状态。

把每一个放在最近需要它的组件上方,往下传递一个 setter 所以点击或按键可以更新它。

你在屏幕上显示的列表不是状态;你从产品和筛选器每次计算它。

Juno从设计到 React 的五个步骤 方法每次都是相同的:组件树、仅用 props 的静态版本、最少的状态、提升到最近的共同父组件、连接交互。

状态问题是尖锐的,如果一个值随时间改变而且你不能推导它,那它就是状态,否则在渲染时计算它。

推导,不要重复:存储 filterTextinStockOnly,从它们计算可见列表这样它永远不会失同步。

Juno从设计到 React 的五个步骤 最少状态意味着最小的值集合其他一切都是它的纯函数;这里,filterTextinStockOnly,筛选列表、计数和标记全都在渲染时推导而不是存储。额外的状态是另一种失同步的方式,useMemo 只有在分析表明真实成本时才赚取它的位置。

组件边界是另一个判断呼唤:为了真实的工作、重复或可读性分割,内联你在规格上添加的传递通道包装器。

接下来:无障碍 React,你刚刚设计的界面对每个人都可用。