本文译自 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" }
]
视觉稿如下:
使用 React 创建 UI,通常可以分 5 个步骤。
第一步:将 UI 分解为组件层级
首先,在视觉稿上为每个组件及其子组件上画框框,并增加名称。如果你有设计师同事,他们可能在设计工具中已完成标注。提前问问!
根据你的技能不同,拆分组件有三类方法:
- 编程 - 你可以把 UI 拆分为函数或对象。单一职责是其中的重要原则,即每个组件应该只做一件事。如果它的职责变多,就应该拆分为更小的子组件。
- CSS - 想一想合理的 class 选择器类名是什么
- 设计 - 想想如何规划图层
如果 JSON 结构良好,通常它能很自然地对应到组件结构上。这是因为 UI 和数据模型通常都有一致的信息架构 --- 或者说,拥有同样的形状。将 UI 拆分为组件,每个组件对应数据模型的一部分。
下图中可以拆分为 5 个组件:
FilterableProductTable(灰色)包含整个应用SearchBar(蓝色)获取用户输入ProductTable(紫色)根据用户输入展示和过滤产品列表ProductCategoryRow(绿色)展示每个类别的标题ProductRow(黄色)展示产品的行,每行展示一个产品
可以看到,ProductTable 的头部区域(包含“Name”和“Price”)没有拆分为子组件。这是因为目前它们功能简单,没有必要拆分出去。如果后期需求变化,头部功能变得复杂(比如增加排序功能),就需要将头部拆分出去,形成单独组件。
当你分析完视觉稿的所有组件,就可以把它们组织到一个结构中。注意组件的父子关系:
FilterableProductTableSearchBarProductTableProductCategoryRowProductRow
第二步:使用 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 数值,而是直接读取数组长度。
我们考虑一下本示例应用中的所有数据:
- 原始的产品列表
- 用户输入的搜索文本
- 多选框的数值
- 产品过滤后的列表
哪些是 state?非 state 变量的标准如下:
- 它是否一直保持不变?如果一直不变,那么它不是 state。
- 它是否从父组件通过 props 传递?如果是,那么它不是 state。
- 你能否通过现有的 state 或 props 计算得到它?如果能,它绝对不能是 state!
剩下的可能是 state。
让我们挨个过一遍这几个数据:
- 原始产品列表通过 props 传递,因此它不是 state。
- 用户输入的搜索文本貌似是 state,因为它会变化,而且无法通过其他变量计算得到。
- 多选框的选中状态貌似也是 state。原因同上。
- 产品过滤后的列表不是 state,因为它可以通过原始数据列表和搜索文本以及选中状态计算得到。
这意味着,只有搜索文本和选中框状态可以当作 state!🎉
第四步:确定 state 的存放位置
当确定了应用的最小状态数据,你还需要确定哪些组件负责修改这些 state,或拥有这些 state?记住:React 使用单向数据流,数据只能从父组件向下传递子组件。哪个组件拥有 state 一开始也许并不清楚。如果你是新手,这会更难。但是,只要跟着下面的步骤做,就能得到答案!
对于应用的每个 state:
- 找出跟该 state 相关的组件
- 找出它们共同的最近父级组件
- 决定 state 的归宿:
- 通常,可以将 state 直接放入共同的父组件
- 你也可以将 state 放入共同父组件的上级组件
- 如果你找不到存储 state 的合理组件,创建一个新组件,只用于包含这些 state,把新组件置于共同父组件之上
在上述步骤,你发现这个应用有两个 state 数据:用户输入的搜索文本和多选框的值。在本例中,他俩总是一起出现,可以把它俩当作一条数据考虑,这样更简单一些。
现在,我们挨个过一下这个状态的归宿:
- 找出跟这个 state 相关的组件:
ProductTable需要根据这个状态过滤展示产品列表SearchBar需要展示这个 state
- 找出它们共同的父组件:这些组件共同的最近父组件是
FilterableProductTable。 - 决定 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);
// ...
}
然后,将 filterText 和 inStockOnly 分别传入 ProductTable 和 SearchBar 组件:
<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}> 时,你已经把 input 的 value 属性设定为等于 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)} />
在增加交互章节,可以学习更多的事件处理和状态更新。