连接列表组件
将详情、列表和列表项拆分为细粒度连接的容器组件
接续上文,我们可以看到,我们的主要“面板”组件以及 TabBar 组件都已连接到 Redux。然而,这些面板内部的组件并未直接连接。相反,我们通过将数据和动作创建函数作为属性从各个面板传递给其子组件。仅连接少数几个组件是完全可以接受的,但随着应用规模的增长,这可能会成为痛点。
Redux 官方常见问题解答(Redux FAQ)中对此有详细讨论,问题为:“我是否应该只连接顶层组件,还是可以在组件树中连接多个组件?” 以下是该问题的回答:
当前推荐的最佳实践是将组件划分为“展示型”(presentational)和“容器型”(container)两类,并在合适的地方提取出连接后的容器组件:
在 Redux 示例中强调“仅在顶层使用一个容器组件”是一个错误。不要将其视为铁律。应尽量保持展示型组件的独立性。当在方便时,通过连接来创建容器组件。当你发现自己在父组件中重复编写代码以向相同类型的子组件提供数据时,就是时候提取一个容器组件了。通常,一旦你感觉父组件对子组件的“私有”数据或行为了解过多,就该考虑提取容器组件了。
实际测试表明,更多数量的连接组件通常比更少的连接组件带来更好的性能表现。 总体而言,应在可理解的数据流与组件职责划分之间找到平衡。
遵循这一理念,下一步我们将以更细粒度的方式连接更多的独立组件。
连接 PilotDetails 组件
我们首先从 <PilotDetails> 组件开始。目前,<Pilots> 组件在其 mapState 函数中获取所有飞行员对象列表,以及当前选中的 currentPilot ID 值。渲染时,它会查找匹配所选 ID 的飞行员条目,并将该对象传给 <PilotDetails>。现在我们可以直接连接 <PilotDetails>,并移除 <Pilots> 中的这部分逻辑。
这是一个简单的重构。我们将在 <PilotDetails> 中添加一个 mapState 函数,根据可用的 ID 查找对应的飞行员对象,并返回该条目。同时,我们还将调整输入组件使其处于禁用状态,以便用户知道他们无法与之交互。
features/pilots/PilotDetails.jsx
1import React from "react"; 2+import {connect} from "react-redux"; 3import {Form, Dropdown} from "semantic-ui-react"; 4 5+import orm from "app/orm"; 6+import {selectCurrentPilot} from "./pilotsSelectors"; 7 8+const mapState = (state) => { 9+ let pilot; 10+ 11+ const currentPilot = selectCurrentPilot(state); 12+ 13+ const session = orm.session(state.entities); 14+ const {Pilot} = session; 15+ 16+ if(Pilot.hasId(currentPilot)) { 17+ pilot = Pilot.withId(currentPilot).ref; 18+ } 19+ 20+ return {pilot} 21+} 22 23// 省略组件代码以节省空间 24 25-export default PilotDetails; 26+export default connect(mapState)(PilotDetails);
1 render() { 2 const {pilots = [], selectPilot, currentPilot} = this.props; 3 4- const currentPilotEntry = pilots.find(pilot => pilot.id === currentPilot) || {} 5 6// 省略无关渲染代码 7- <PilotDetails pilot={currentPilotEntry} /> 8+ <PilotDetails />
mapState 的连接替代了之前在 <Pilots> 组件中的逻辑。
连接 PilotsList 组件
接下来处理 <PilotsList> 组件。目前,<Pilots> 组件从存储中提取实际的飞行员对象,将它们作为一个数组传给 <PilotsList>,然后每个普通的飞行员对象再作为属性传递给“展示型”的列表项。我们本可以简单地将 <Pilots> 中现有的 mapState 逻辑移到 <PilotsList>,但这里我们将采用Redux 中最实用的技术之一:一个连接的列表,它将项目 ID 传递给连接的列表项。下面我们先看实现方式,再讨论这种特定方法的一些细节。
<PilotsList> 的 mapState 需要返回所有飞行员条目的 ID 数组。<PilotsList> 将渲染一系列 <PilotsListRow> 组件,并将相应的飞行员 ID 传入每个列表项。无论是列表本身还是列表项,都需要判断该项是否被选中。两种方式都有道理,但由于我们已经向每个列表项传递了 selected 标志,因此保留这一设计。
features/pilots/PilotsList/PilotsList.jsx
1+import {selectPilot} from "../pilotsActions"; 2+import {selectCurrentPilot} from "../pilotsSelectors"; 3+ 4+ 5+const mapState = (state) => { 6+ const session = orm.session(state.entities); 7+ const {Pilot} = session; 8+ 9+ // 提取每个飞行员条目的 ID 列表 10+ const pilots = Pilot.all().toModelArray().map(pilotModel => pilotModel.getId()); 11+ 12+ const currentPilot = selectCurrentPilot(state); 13+ 14+ // 返回飞行员 ID 列表和当前选中飞行员 ID 作为 props 15+ return {pilots, currentPilot}; 16+} 17+ 18+// 创建一个包含动作创建函数的对象,可传递给 connect 并绑定, 19+// 而无需单独编写 mapDispatch 函数 20+const actions = { 21+ selectPilot, 22+}; 23+ 24 25export class PilotsList extends Component { 26 render() { 27- const {pilots, onPilotClicked, currentPilot} = this.props; 28+ const {pilots = [], selectPilot, currentPilot} = this.props; 29 30- const pilotRows = pilots.map(pilot => ( 31+ const pilotRows = pilots.map(pilotID => ( 32 <PilotsListRow 33- pilot={pilot} 34- key={pilot.name} 35- onPilotClicked={onPilotClicked} 36- selected={pilot.id === currentPilot} 37+ pilotID={pilotID} 38+ key={pilotID} 39+ onPilotClicked={selectPilot} 40+ selected={pilotID === currentPilot} 41 /> 42 ));
Model.getId() 方法非常有用,尤其当你不确定某个模型类型的 ID 字段名称时。例如,ID 可能是 name、guid 或其他字段。我们可以在模型声明中定义 ID 字段名,getId() 方法就会据此查找正确的字段。
我们还有其他几种方式可以获取飞行员 ID 列表。由于我们知道普通飞行员对象中的 ID 字段名,也可以使用 Pilots.all().toRefArray().map(pilot => pilot.id)。或者,如果我们想绕过 Redux-ORM 的 API,也可以直接访问 state.entities.Pilot.items 数组,因为 Redux-ORM 会在此处保存所有飞行员 ID 的列表。
pilots/PilotsList/PilotsListRow.jsx
1+const mapState = (state, ownProps) => { 2+ const session = orm.session(state.entities); 3+ const {Pilot} = session; 4+ 5+ let pilot; 6+ 7+ if(Pilot.hasId(ownProps.pilotID)) { 8+ const pilotModel = Pilot.withId(ownProps.pilotID); 9+ 10+ // 使用 "ref" 字段访问底层的原生 JS 对象,并创建浅拷贝 11+ pilot = { 12+ ...pilotModel.ref 13+ }; 14+ 15+ // 我们需要查找 pilotModel.mech.mechType。为了防止关联字段为空,我们在过程中加入安全检查。 16+ 17+ // 使用在 Pilot 模型类中定义的外键字段查找关联的 Mech 实例 18+ const {mech} = pilotModel; 19+ 20+ // 如果确实存在关联的 Mech,则将 mech type 的 ID 作为字段包含在传递给组件的数据中 21+ if(mech && mech.type) { 22+ pilot.mechType = mech.type.id; 23+ } 24+ } 25+ 26+ return {pilot}; 27+}
对于 <PilotsListRow>,我们只是复制了之前在 <Pilots> 中的查找逻辑,但现在只针对单个条目进行查找。此外,我们使用的是 <PilotsList> 父组件传下来的 pilotID 属性。连接后的包装组件会将所有传入的属性提供给 mapState,前提是 mapState 声明接收两个参数。按惯例,第二个参数被称为 ownProps。
注意,当 mapState 声明为接收两个参数时,它会被调用得更频繁。这是因为在传入的属性发生变化时,可能会影响 mapState 返回的值。
features/pilots/Pilots/Pilots.jsx
1- // 完全删除原有的 mapState 函数 2 3-export class Pilots extends Component { 4+export default class Pilots extends Component { 5 render() { 6- const {pilots = [], selectPilot, currentPilot} = this.props; 7 8 return ( 9 <Segment> 10 <Grid> 11 <Grid.Column width={10}> 12 <Header as="h3">飞行员列表</Header> 13- <PilotsList 14- pilots={pilots} 15- onPilotClicked={selectPilot} 16- currentPilot={currentPilot} 17- /> 18+ <PilotsList /> 19 </Grid.Column> 20 21// 省略其他渲染逻辑 22 23 24-export default connect(mapState, actions)(Pilots);
完成上述更改后,<Pilots> 组件不再连接,实际上已完全变为纯展示型组件。它渲染多个布局相关组件,并包含两个已连接的容器组件:<PilotsList> 和 <PilotDetails>。
连接 Mechs 组件
本节最后,我们将对“Mechs”面板中的各个组件应用相同的修改。