「译」React 编程思路

192 阅读8分钟

本文译自 React 官方文档 Thinking in React

React 可以改变你设计开发应用的思想。它让你更容易联想设计系统和 UI 状态。本文会展示使用 React 的设计思路,以一个带搜索功能的产品数据列表为例。

从视觉稿开始

假设后端接口已就绪,视觉稿也完成了。

后端 API 返回的 JSON 数据如下:

[
    { category: "Fruits", price: "$1", stocked: true, name: "Apple" },
    { category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
    { category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
    { category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
    { category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
    { category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]

视觉稿如下:

s_thinking-in-react_ui.png

使用 React 创建 UI,通常可以分 5 个步骤。

第一步:将 UI 分解为组件层级

首先,在视觉稿上为每个组件及其子组件上画框框,并增加名称。如果你有设计师同事,他们可能在设计工具中已完成标注。提前问问!

根据你的技能不同,拆分组件有三类方法:

  • 编程 - 你可以把 UI 拆分为函数或对象。单一职责是其中的重要原则,即每个组件应该只做一件事。如果它的职责变多,就应该拆分为更小的子组件。
  • CSS - 想一想合理的 class 选择器类名是什么
  • 设计 - 想想如何规划图层

如果 JSON 结构良好,通常它能很自然地对应到组件结构上。这是因为 UI 和数据模型通常都有一致的信息架构 --- 或者说,拥有同样的形状。将 UI 拆分为组件,每个组件对应数据模型的一部分。

下图中可以拆分为 5 个组件:

s_thinking-in-react_ui_outline.png

  1. FilterableProductTable (灰色)包含整个应用
  2. SearchBar (蓝色)获取用户输入
  3. ProductTable (紫色)根据用户输入展示和过滤产品列表
  4. ProductCategoryRow (绿色)展示每个类别的标题
  5. ProductRow (黄色)展示产品的行,每行展示一个产品

可以看到,ProductTable 的头部区域(包含“Name”和“Price”)没有拆分为子组件。这是因为目前它们功能简单,没有必要拆分出去。如果后期需求变化,头部功能变得复杂(比如增加排序功能),就需要将头部拆分出去,形成单独组件。

当你分析完视觉稿的所有组件,就可以把它们组织到一个结构中。注意组件的父子关系:

  • FilterableProductTable
    • SearchBar
    • ProductTable
      • ProductCategoryRow
      • ProductRow

第二步:使用 React 构建静态版本

搞定组件层级后,就可以实现了。最直接的方法就是通过数据模型渲染 UI,先不添加任何交互!通常创建静态版本后,再添加交互会比较容易实现。创建静态版本多动手👋少动脑,而增加交互多动脑🧠少动手。

创建静态版本,你需要使用 props 传递数据。(如果你知道 state 的概念,在静态版本中请勿使用它!State 用于交互,它是随时间变化的数据。因为我们正在创建静态版本,现阶段暂时不需要 state。)

你可以自顶向下创建,也可以自底向上创建。在小型项目中,自顶向下通常更容易。大型项目中,自底向上更简单。

function ProductCategoryRow({ category }) {
    return (
        <tr>
            <th colSpan="2">
                {category}
            </th>
        </tr>
    );
}

function ProductRow({ product }) {
    const name = product.stocked ? product.name : 
        <span style={{ color: 'red' }}>
            {product.name}
        </span>;

    return (
        <tr>
            <td>{name}</td>
            <td>{product.price}</td>
        </tr>
    );
}

function ProductTable({ products }) {
    const rows = [];
    let lastCategory = null;
    
    products.forEach((product) => {
        if (product.category !== lastCategory) {
            rows.push(
                <ProductCategoryRow
                    category={product.category}
                    key={product.category} />
            );
        }
        
        rows.push(
            <ProductRow
                product={product}
                key={product.name} />
        );
        lastCategory = product.category;
    });
    
    return (
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Price</th>
                </tr>
            </thead>
            <tbody>{rows}</tbody>
        </table>
    );
}

function SearchBar() {
    return (
        <form>
            <input type="text" placeholder="Search..." />
            <label>
                <input type="checkbox" />
                {' '}
                Only show products in stock
            </label>
        </form>
    );
}

function FilterableProductTable({ products }) {
    return (
        <div>
            <SearchBar />
            <ProductTable products={products} />
        </div>
    );
}

const PRODUCTS = [
    {category: "Fruits", price: "$1", stocked: true, name: "Apple"},
    {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
    {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
    {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
    {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
    {category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];

export default function App() {
    return <FilterableProductTable products={PRODUCT} />;
}

至此,你会有一批可以渲染数据模型的可复用组件。因为这是静态版本,组件只返回 JSX。顶层组件(FilterableProductTable)将数据模型当作 prop 传入。这叫做单向数据流,因为数据总是从上面的组件,流向下面的组件。

第三步:找到能表示完整 UI 状态的最少变量

为了增加 UI 交互,需要让用户改变底层的数据模型。此时,该 state 上场了。

把 state 当作应用必需的最小变量集。创建 state 最重要的原则是 DRY(Don't Repeat Yourself)。找到应用状态的最小集,其他变量根据需要通过计算得到。比如,你要创建一个购物清单应用,可以把待购物品当作数组储存到 state 中。如果要展示清单物品数量,不要把数量储存为另一个 state 数值,而是直接读取数组长度。

我们考虑一下本示例应用中的所有数据:

  1. 原始的产品列表
  2. 用户输入的搜索文本
  3. 多选框的数值
  4. 产品过滤后的列表

哪些是 state?非 state 变量的标准如下:

  • 它是否一直保持不变?如果一直不变,那么它不是 state。
  • 它是否从父组件通过 props 传递?如果是,那么它不是 state。
  • 你能否通过现有的 state 或 props 计算得到它?如果能,它绝对不能是 state!

剩下的可能是 state。

让我们挨个过一遍这几个数据:

  1. 原始产品列表通过 props 传递,因此它不是 state。
  2. 用户输入的搜索文本貌似是 state,因为它会变化,而且无法通过其他变量计算得到。
  3. 多选框的选中状态貌似也是 state。原因同上。
  4. 产品过滤后的列表不是 state,因为它可以通过原始数据列表和搜索文本以及选中状态计算得到。

这意味着,只有搜索文本和选中框状态可以当作 state!🎉

第四步:确定 state 的存放位置

当确定了应用的最小状态数据,你还需要确定哪些组件负责修改这些 state,或拥有这些 state?记住:React 使用单向数据流,数据只能从父组件向下传递子组件。哪个组件拥有 state 一开始也许并不清楚。如果你是新手,这会更难。但是,只要跟着下面的步骤做,就能得到答案!

对于应用的每个 state:

  1. 找出跟该 state 相关的组件
  2. 找出它们共同的最近父级组件
  3. 决定 state 的归宿:
    1. 通常,可以将 state 直接放入共同的父组件
    2. 你也可以将 state 放入共同父组件的上级组件
    3. 如果你找不到存储 state 的合理组件,创建一个新组件,只用于包含这些 state,把新组件置于共同父组件之上

在上述步骤,你发现这个应用有两个 state 数据:用户输入的搜索文本和多选框的值。在本例中,他俩总是一起出现,可以把它俩当作一条数据考虑,这样更简单一些。

现在,我们挨个过一下这个状态的归宿:

  1. 找出跟这个 state 相关的组件:
    • ProductTable 需要根据这个状态过滤展示产品列表
    • SearchBar 需要展示这个 state
  2. 找出它们共同的父组件:这些组件共同的最近父组件是 FilterableProductTable
  3. 决定 state 的归宿:我们决定把这个 state 放到 FilterableProductTable 中。

因此,最终 state 会放到 FilterableProductTable 中。

使用 useState() Hook 为组件添加 state。Hooks 让你“接入”组件的渲染循环。在 FilterableProductTable 中增加两个 state 变量,并确定它们各自的初始值:

+ import { useState } from 'react';

function FilterableProductTable({ products }) {
+    const [filterText, setFilterText] = useState('');
+    const [inStockOnly, setInStockOnly] = useState(false);
    // ...
}

然后,将 filterTextinStockOnly 分别传入 ProductTableSearchBar 组件:

<div>
    <SearchBar
+        filterText={filterText}
+        inStockOnly={inStockOnly} />
    <ProductTable
        products={products}
+        filterText={filterText}
+        inStockOnly={inStockOnly} />
</div>

现在就能看到应用的交互行为了。编辑 filterText 初始值,从 useState('')useState('fruit'),会看到搜索框和表格的变化。

其他组件的代码也要做一些调整:

+ function ProductTable({ products, filterText, inStockOnly }) {
    const rows = [];
    let lastCategory = null;

    products.forEach((product) => {
+        if (
+            product.name.toLowerCase().indexOf(
+                filterText.toLowerCase()
+            ) === -1
+        ) {
+            return;
+        }
        
+        if (inStockOnly && !product.stocked) {
+            return;
+        }
        
        if (product.category !== lastCategory) {
            rows.push(
                <ProductCategoryRow
                    category={product.category}
                    key={product.category} />
            );
        }
        
        rows.push(
            <ProductRow
                product={product}
                key={product.name} />
        );
        lastCategory = product.category;
    });

    return (
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Price</th>
                </tr>
            </thead>
            <tbody>{rows}</tbody>
        </table>
    );
}

+ function SearchBar({ filterText, inStockOnly }) {
    return (
        <form>
            <input 
                type="text" 
+                value={filterText} 
                placeholder="Search..."/>
            <label>
                <input 
                    type="checkbox" 
+                    checked={inStockOnly} />
                {' '}
                Only show products in stock
            </label>
        </form>
    );
}

参考管理 state,深入学习 React 如何使用 state,你如何使用它阻止应用。

第五步:添加反向数据流

现在你的应用可以通过 prop 和 state 正确渲染页面。但是为了根据用户的输入改变状态,需要支持另一种方向的数据流:底层的表单组件需要更新 FilterableProductTable 的状态。

React 让这种数据流变得更明确,比双向数据绑定需要更多的代码量。当你修改或选中表单时,你会发现 React 会忽略你的输入。这是有意为之。当编写 <input value={filterText}> 时,你已经把 inputvalue 属性设定为等于 filterText 状态。因为 filterText 没有改变,input 也不会改变。

因为只有 FilterableProductTable 能改变 state,为了让底层组件更新 state,需要将设置函数传入 SearchBar 组件。

function FilterableProductTable({ products }) {
    const [filterText, setFilterText] = useState('');
    const [inStockOnly, setInStockOnly] = useState(false);
    
    return (
        <div>
            <SearchBar
                filterText={filterText}
                inStockOnly={inStockOnly}
+                onFilterTextChange={setFilterText}
+                onInStockOnlyChange={setInStockOnly} />
        </div>
    );
}

SearchBar 内部,需要增加 onChange 事件监听器,并设置父级 state:

<input 
    type="text"
    value={filterText}
    placeholder="Search..."
    onChange={(e) => onFilterTextChange(e.target.value)} />

增加交互章节,可以学习更多的事件处理和状态更新。