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

React 思维方式

到目前为止,每一章都讲了一个方面:组件PropsState事件列表。这一章把它们汇聚在一起,形成一套方法。当你拿到一个设计稿,如何决定要分出哪些组件、哪些值应该是 state、state 应该放在哪里?有一种可重复的工作流,一旦你做过几次,几乎在每个屏幕上都会用到同样的五个步骤。

我们来逐步完成一个小 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 },
]

第 1 步:把设计分解成组件树

看着设计稿,在各个部分周围画出矩形。好的矩形划分就是一件单独的东西做一件单独的工作,这个直觉就像你决定一个函数应该做什么一样。我们的小屏幕分出几个清晰的部分:

  • FilterableProductList 包装整个界面。
  • SearchBar 包含文本输入框和缺货复选框。
  • ProductTable 显示产品列表。
  • ProductRow 是表格中的一行。

这些组件相互嵌套,形成一个**组件树**:

FilterableProductList
├── SearchBar
└── ProductTable
    └── ProductRow  (每个产品一行)

没有唯一正确的分割方式,组件那一章讲的经验法则在这里也适用:让每个组件保持小巧,赋予它一个明确的职责。一行知道怎么绘制一个产品,表格知道怎么排列行,搜索栏知道控制元素。如果某个部分开始做两件不相关的事情,那通常是分割它的信号。

第 2 步:用纯 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 让整个布局运作,在涉及到任何动态部分之前,你确认了第 1 步的树结构真的能工作。products 数组作为 prop 在顶部传入,然后向下传递;这里的任何东西都不会记住或改变任何东西。

第 3 步:找出最少的 state

现在让它变成交互式的,第一个问题是哪些值根本需要成为 state。State 很难保证正确,所以你希望尽可能少。其余的你可以计算出来。

对每个值的检验很短。问两件事:

  1. 它会随时间变化吗?
  2. 你能从已有的其他值计算出它吗?

如果两个都是肯定,那它是 state。否则就不是。把 UI 中的值都过一遍这个检验:

  • 产品列表。 它被传入作为 prop,在用户操作页面时不会改变,所以它不是 state。它是一个 prop。
  • 用户输入的搜索文本。 它随时间变化,没有办法从其他东西计算它。它是 state。
  • 缺货复选框是否打开。 同样:它会变化,没有其他东西能推导它。它是 state。
  • 屏幕上实际显示的筛选后列表。 它会变化,但你可以从产品、搜索文本和复选框计算它。所以它不是 state。

最后这一条值得深深记住:推导,不要复制。可见列表就是产品通过当前筛选器的结果,所以你在渲染时计算它,而不是在 state 中存储一份副本:

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

如果你在自己的 useState 中存储 visible,那么每当产品、文本或复选框改变时,你都得记得更新它。漏掉一次屏幕就会显示陈旧的列表。推导它意味着只有一个真实源头,它永远不会失步,因为它在每次渲染时都会重新计算。所以这里最少的 state 就是两个值:filterTextinStockOnly

第 4 步:决定每个 state 应该放在哪个组件

你有两个 state。现在要搞清楚哪个组件拥有它们。规则是把 state 放在需要它的所有组件的最近公共父组件中,这和 状态提升那一章深入讨论的想法是一样的。

分析谁需要每个值:

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

SearchBarProductTable 是兄弟组件。值只能通过 props 向下流动,所以兄弟间无法直接传递 state。最近的同时位于它们上方的组件是 FilterableProductList。State 应该放在那里:

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>
  )
}

State 存在父组件中,值作为 props 向下流向两个子组件。ProductTable 读取它们来构建它的 visible 列表;SearchBar 读取它们来填充输入框和复选框。

第 5 步:添加更新 state 的交互

值到达了子组件,但用户仍然改不了它们。数据只向下流动,所以要把改变送回上去,你需要把 setters 作为 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。那会更新 state,父组件重新渲染,新的 filterText 向下流向两个子组件,ProductTable 重新计算它的 visible 列表,屏幕就显示用户输入的内容。复选框也以同样的方式工作。State 向下作为值,改变向上作为函数调用,推导出的列表自己跟随其后。

这就是整个方法:绘制组件树,用纯 props 构建静态版本,找出最少的 state,提升到正确的所有者,然后接上交互。几乎你遇到的任何屏幕都遵循这五个步骤。

第 3 步是最值得用心的地方,因为"最少"的含义比一开始看起来要严格。工作中的问题是:最小的值集是什么,使得屏幕上所有其他值都可以从它重新计算?对我们的列表来说就是 filterTextinStockOnly,一切可见的都是这两个加传入的 products 的纯函数。任何你在渲染时能推导的东西,你都应该这样做。筛选后的列表是最清楚的案例,但同样的逻辑也排除了存储匹配的 count、一个 hasResults 布尔值,或数组的排序副本。那些都是你已经持有的 state 的函数,所以把它放在自己的 useState 中会买来第二个要保持同步的值和一个新的出错方式。推导的值不会过时,因为它们不持久化;它们在每次渲染时从唯一的真实源头重新计算。

另一方面要注意的权衡是在每次渲染时推导某个昂贵的计算。只在性能分析显示真实成本时才用 useMemo。默认做法是:在渲染时推导,只有当值随时间变化且没有东西能生成它时,才把它提升为 state。

第 1 步有自己的判断,它也是双向的。分割太少了,组件会长成很难重用或理解的一团。分割太多了,你会得到一堆微小组件,层层传递 props,只为了把它们传下去,这本身也很难理解。有用的检验是职责和重用:当这个部分有一个清楚的工作、它会重复(每个项目一个 ProductRow),或者提出它让父组件更可读时,边界就赚到了它的位置。一个边界只在一个地方渲染并且只转发它的 props,通常你可以内联它。让真实的重复和真实的复杂度来分割组件,而不是在猜测你以后会需要接缝的时候就提前分割。

Juno从设计到 React 的五步 这是你可以依靠的配方:在各个部分周围画框来得到组件,用 props 先构建它让它展示数据仅此而已,然后找出真正会变化且无法从别的东西推导的那几个值,那就是你的 state。把每个放在需要它的所有东西上方最近的组件中,把 setter 向下传,这样点击或键盘敲击就能更新它。屏幕上显示的列表不是 state;你每次都从产品和筛选器计算它。
Juno从设计到 React 的五步 方法每次都是一样的:组件树、用纯 props 的静态版本、最少 state、提升到最近公共父组件、接上交互。State 的问题是关键的,如果一个值随时间变化且你无法推导它,它就是 state,否则在渲染时计算它。推导,不要复制:存储 filterTextinStockOnly,从它们计算可见列表,这样它永远不会失步。
Juno从设计到 React 的五步 最少 state 意味着最小的值集,其他一切都是它的纯函数;这里是 filterTextinStockOnly,所有筛选后的列表、计数和标志都在渲染时推导而不是存储。额外的 state 是另一种失步的方式,useMemo 只有在性能分析显示真实成本时才赚到它的位置。组件边界是另一个判断:为了真实的工作、重复或可读性而分割,内联你按规格添加的直通包装器。

接下来:可访问的 React,这样你设计的界面就会让每个人都能使用。