我们研发开源了一款基于 Git 进行技术实战教程写作的工具,我们图雀社区的所有教程都是用这款工具写作而成,欢迎 Star 哦
如果你想快速了解如何使用,欢迎阅读我们的 教程文档哦
在这一部分中,我们会提出 “容器组件” 和 “展示组件” 的概念,“容器组件” 用于接管 “状态”,“展示组件” 用于渲染界面,其中 “展示组件” 也是 React 诞生的初心,专注于高效的编写用户界面。
重构代码:将 TodoList 的状态和渲染分离
欢迎阅读 Redux 包教包会系列:
- Redux 包教包会(一):解救 React 状态危机
- Redux 包教包会(二):趁热打铁,完全重构
- Redux 包教包会(三):各司其职,重拾初心(也就是这篇)
此教程属于React 前端工程师学习路线的一部分,点击可查看全部内容。
展示组件和容器组件
Redux 的出现,通过将 State 从 React 组件剥离,并将其保存在 Store 里面,来确保状态来源的可预测性,你可能觉得这样就已经很好了,但是 Redux 的动作还没完,它又进一步提出了展示组件(Presentational Components)和容器组件(Container Components)的概念,将纯展示性的 React 组件和状态进一步抽离。
当我们把 Redux 状态循环图中的 View 层进一步拆分时,它看起来是这样的:
即我们在最终渲染界面的组件和 Store 中存储的 State 之间又加了一层,我们称这一层为它专门负责接收来自 Store 的 State,并把组件中想要发起的状态改变组装成 Action,然后通过 dispatch
函数发出。
将状态彻底剥离之后剩下的那层称之为展示组件,它专门接收来自容器组件的数据,然后将其渲染成 UI 界面,并在需要改变状态时,告知容器组件,让其代为 dispatch
Action。
首先,我们将 App.js 中的 VisibilityFilters
移动到了 src/actions/index.js 中。因为 VisibilityFilters
定义了过滤展示 TodoList 的三种操作,和 Action 的含义更相近一点,所以我们将相似的东西放在了一起。修改 src/actions/index.js 如下:
let nextTodoId = 0;
export const addTodo = text => ({
type: "ADD_TODO",
id: nextTodoId++,
text
});
export const toggleTodo = id => ({
type: "TOGGLE_TODO",
id
});
export const setVisibilityFilter = filter => ({
type: "SET_VISIBILITY_FILTER",
filter
});
export const VisibilityFilters = {
SHOW_ALL: "SHOW_ALL",
SHOW_COMPLETED: "SHOW_COMPLETED",
SHOW_ACTIVE: "SHOW_ACTIVE"
};
编写容器组件
容器组件其实也是一个 React 组件,它只是将原来从 Store 到 View 的状态和从组件中 dispatch
Action 这两个逻辑从原组件中抽离出来。
根据 Redux 的最佳实践,容器组件一般保存在 containers
文件夹中,我们在 src
文件夹下建立一个 containers
文件夹,然后在里面新建 VisibleTodoList.js
文件,用来表示原 TodoList.js
的容器组件,并在文件中加入如下代码:
import { connect } from "react-redux";
import { toggleTodo } from "../actions";
import TodoList from "../components/TodoList";
import { VisibilityFilters } from "../actions";
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case VisibilityFilters.SHOW_ALL:
return todos;
case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(t => t.completed);
case VisibilityFilters.SHOW_ACTIVE:
return todos.filter(t => !t.completed);
default:
throw new Error("Unknown filter: " + filter);
}
};
const mapStateToProps = state => ({
todos: getVisibleTodos(state.todos, state.filter)
});
const mapDispatchToProps = dispatch => ({
toggleTodo: id => dispatch(toggleTodo(id))
});
export default connect(mapStateToProps, mapDispatchToProps)(TodoList);
可以看到,上面的代码主要做了这几件事情:
- 我们定义了一个
mapStateToProps
,这是我们之前详细讲解过,它主要是可以获取到来自 Redux Store 的 State 以及组件自身的原 Props,然后组合这两者成新的 Props,然后传给组件,这个函数是 Store 到组件的唯一接口。这里我们将之前定义在App.js
中的getVisibleTodos
函数移过来,并根据state.filter
过滤条件返回相应需要展示的todos
。 - 接着我们定义了一个没见过的
mapDispatchToProps
函数,这个函数接收两个参数:dispatch
和ownProps
,前者我们很熟悉了就是用来发出更新动作的函数,后者就是原组件的 Props,它是一个可选参数,这里我们没有声明它。我们主要在这个函数声明式的定义所有需要dispatch
的 Action 函数,并将其作为 Props 传给组件。这里我们定义了一个toggleTodo
函数,使得在组件中通过调用toggleTodo(id)
就可以dispatch(toggleTodo(id))
。 - 最后我们通过熟悉的
connect
函数接收mapStateToProps
和mapDispatchToProps
并调用,然后再接收 TodoList 组件并调用,返回最终的导出的容器组件。
编写展示组件
当我们编写了 TodoList
的容器组件之后,接着我们要考虑就是抽离了 State 和 dispatch
的关于 TodoList 的展示组件了。
打开 src/components/TodoList.js
对文件做出相应的改动如下:
import React from "react";
import PropTypes from "prop-types";
import Todo from "./Todo";
const TodoList = ({ todos, toggleTodo }) => (
<ul>
{todos.map(todo => (
<Todo key={todo.id} {...todo} onClick={() => toggleTodo(todo.id)} />
))}
</ul>
);
TodoList.propTypes = {
todos: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
completed: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired
}).isRequired
).isRequired,
toggleTodo: PropTypes.func.isRequired
};
export default TodoList;
在上面的代码中,我们删除了 connect
和 toggleTodo
Action,并将 TodoList 接收的 dispatch
属性删除,转而改成通过 mapDispatchToProps
传进来的 toggleTodo
函数,并在 Todo 被点击时调用 toggleTodo
函数。
当然我们的 toggleTodo
属性又回来了,所以我们在 propTypes
中恢复之前删除的 toggleTodo
。:)
最后,我们不再需要 connect()(TodoList)
,因为 VisibleTodoList.js
中定义的 TodoList
的对应容器组件会取到 Redux Store 中的 State,然后传给 TodoList。
可以看到,TodoList 不用再考虑状态相关的操作,只需要专心地做好界面的展示和动作的响应。我们进一步将状态与渲染分离,让合适的人做 TA 最擅长的事。
一些琐碎的收尾工作
因为我们将原来的 TodoList 剥离成了容器组件和 展示组件,所以我们要将 App.js
里面对应的 TodoList
换成我们的 VisibleTodoList
,由容器组件来提供原 TodoList 对外的接口。
我们打开 src/components/App.js
对相应的内容作出如下修改:
import React from "react";
import AddTodo from "./AddTodo";
import VisibleTodoList from "../containers/VisibleTodoList";
import Footer from "./Footer";
import { connect } from "react-redux";
class App extends React.Component {
render() {
const { filter } = this.props;
return (
<div>
<AddTodo />
<VisibleTodoList />
<Footer filter={filter} />
</div>
);
}
}
const mapStateToProps = (state, props) => ({
filter: state.filter
});
export default connect(mapStateToProps)(App);
可以看到我们做了这么几件事:
- 将之前的 TodoList 更换成 VisibleTodoList。
- 删除 VisibilityFilters,因为它已经被放到了
src/actions/index.js
中 - 删除 getVisibleTodos,因为它已经被放到了 VisibleTodoList 中。
- 删除
mapStateToProps
中获取todos
的操作,因为我们已经在 VisibleTodoList 中获取了。 - 删除对应在 App 组件中的
todos
。
接着我们处理一下因 VisibilityFilters 变动而引起的其他几个文件的导包问题。
打开 src/components/Footer.js
修改导包路径:
import React from "react";
import Link from "./Link";
import { VisibilityFilters } from "../actions";
import { connect } from "react-redux";
import { setVisibilityFilter } from "../actions";
const Footer = ({ filter, dispatch }) => (
<div>
<span>Show: </span>
<Link
active={VisibilityFilters.SHOW_ALL === filter}
onClick={() => dispatch(setVisibilityFilter(VisibilityFilters.SHOW_ALL))}
>
All
</Link>
<Link
active={VisibilityFilters.SHOW_ACTIVE === filter}
onClick={() =>
dispatch(setVisibilityFilter(VisibilityFilters.SHOW_ACTIVE))
}
>
Active
</Link>
<Link
active={VisibilityFilters.SHOW_COMPLETED === filter}
onClick={() =>
dispatch(setVisibilityFilter(VisibilityFilters.SHOW_COMPLETED))
}
>
Completed
</Link>
</div>
);
export default connect()(Footer);
打开 src/reducers/filter.js
修改导包路径:
import { VisibilityFilters } from "../actions";
const filter = (state = VisibilityFilters.SHOW_ALL, action) => {
switch (action.type) {
case "SET_VISIBILITY_FILTER":
return action.filter;
default:
return state;
}
};
export default filter;
因为我们在 src/actions/index.js
中的 nextTodoId
是从 0 开始自增的,所以之前我们定义的 initialTodoState
会出现一些问题,比如新添加的 todo 的 id 会与初始的重叠,导致出现问题,所以我们删除 src/reducers/todos.js
中对应的 initialTodoState
,然后给 todos
reducer 的 state 赋予一个 []
的默认值。
const todos = (state = [], action) => {
switch (action.type) {
case "ADD_TODO": {
return [
...state,
{
id: action.id,
text: action.text,
completed: false
}
];
}
case "TOGGLE_TODO": {
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
}
default:
return state;
}
};
export default todos;
小结
保存修改的内容,你会发现我们的待办事项小应用依然可以完整的运行,但是我们已经成功的将原来的 TodoList
分离成了容器组件的 VisibleTodoList
以及展示组件的 TodoList
了。
重构代码:将 Footer 的状态和渲染分离
我们趁热打铁,用上一节学到的知识来马上将 Footer 组件的状态和渲染抽离。
编写容器组件
我们在 src/containers
文件夹下创建一个 FilterLink.js
文件,添加对应的内容如下:
import { connect } from "react-redux";
import { setVisibilityFilter } from "../actions";
import Link from "../components/Link";
const mapStateToProps = (state, ownProps) => ({
active: ownProps.filter === state.filter
});
const mapDispatchToProps = (dispatch, ownProps) => ({
onClick: () => dispatch(setVisibilityFilter(ownProps.filter))
});
export default connect(mapStateToProps, mapDispatchToProps)(Link);
可以看到我们做了以下几件工作:
- 定义
mapStateToProps
,它负责比较 Redux Store 中保存的 State 的state.filter
属性和组件接收父级传下来的ownProps.filter
属性是否相同,如果相同,则把active
设置为true
。 - 定义
mapDispatchToProps
,它通过返回一个onClick
函数,当组件点击时,调用生成一个dispatch
Action,将此时组件接收父级传下来的ownProps.filter
参数传进setVisibilityFilter
,生成action.type
为"SET_VISIBILITY_FILTER"
的 Action,并dispatch
这个 Action。 - 最后我们通过
connect
组合这两者,将对应的属性合并进Link
组件并导出。我们现在应该可以在Link
组件中取到我们在上面两个函数中定义的active
和onClick
属性了。
编写展示组件
接着我们来编写原 Footer 的展示组件部分,打开 src/components/Footer.js
文件,对相应的内容作出如下的修改:
import React from "react";
import FilterLink from "../containers/FilterLink";
import { VisibilityFilters } from "../actions";
const Footer = () => (
<div>
<span>Show: </span>
<FilterLink filter={VisibilityFilters.SHOW_ALL}>All</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>Active</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>Completed</FilterLink>
</div>
);
export default Footer;
可以看到上面的代码修改做了这么几件工作:
- 我们将之前的导出
Link
换成了FilterLink
。请注意当组件的状态和渲染分离之后,我们将使用容器组件为导出给其他组件使用的组件。 - 我们使用
FilterLink
组件,并传递对应的三个FilterLink
过滤器类型。 - 接着我们删除不再不需要的
connect
和setVisibilityFilter
导出。 - 最后删除不再需要的
filter
和dispatch
属性,因为它们已经在FilterLink
中定义并传给了Link
组件了。
删除不必要的内容
当我们将 Footer 中的状态和渲染拆分之后,src/components/App.js
对应的 Footer 相关的内容就不再需要了,我们对文件中对应的内容作出如下修改:
import React from "react";
import AddTodo from "./AddTodo";
import VisibleTodoList from "../containers/VisibleTodoList";
import Footer from "./Footer";
class App extends React.Component {
render() {
return (
<div>
<AddTodo />
<VisibleTodoList />
<Footer />
</div>
);
}
}
export default App;
可以看到我们做了如下工作:
- 删除
App
组件中对应的filter
属性和mapStateToProps
函数,因为我们已经在FilterLink
中获取了对应的属性,所以我们不再需要直接从 App 组件传给 Footer 组件了。 - 删除对应的
connect
函数。 - 删除对应
connect(mapStateToProps)()
,因为 App 不再需要直接从 Redux Store 中获取内容了。
小结
保存修改的内容,你会发现我们的待办事项小应用依然可以完整的运行,但是我们已经成功的将原来的 Footer
分离成了容器组件的 FilterLink
以及展示组件的 Footer
了。
重构代码: 将 AddTodo 的状态和渲染分离
让我们来完成最后一点收尾工作,将 AddTodo
组件的状态和渲染分离。
编写容器组件
我们在 src/containers
文件夹中创建 AddTodoContainer.js
文件,在其中添加如下内容:
import { connect } from "react-redux";
import { addTodo } from "../actions";
import AddTodo from "../components/AddTodo";
const mapStateToProps = (state, ownProps) => {
return ownProps;
};
const mapDispatchToProps = dispatch => ({
addTodo: text => dispatch(addTodo(text))
});
export default connect(mapStateToProps, mapDispatchToProps)(AddTodo);
可以看到我们做了几件熟悉的工作:
- 定义
mapStateToProps
,因为AddTodo
不需要从 Redux Store 中取内容,所以mapStateToProps
只是单纯地填充connect
的第一个参数,然后简单地返回组件的原props
,不起其它作用。 - 定义
mapDispatchToProps
,我们定义了一个addTodo
函数,它接收text
,然后dispatch
一个action.type
为"ADD_TODO"
的 Action。 - 最后我们通过
connect
组合这两者,将对应的属性合并进AddTodo
组件并导出。我们现在应该可以在AddTodo
组件中取到我们在上面两个函数中定义的addTodo
属性了。
编写展示组件
接着我们来编写 AddTodo 的展示组件部分,打开 src/components/AddTodo.js
文件,对相应的内容作出如下的修改:
import React from "react";
const AddTodo = ({ addTodo }) => {
let input;
return (
<div>
<form
onSubmit={e => {
e.preventDefault();
if (!input.value.trim()) {
return;
}
addTodo(input.value);
input.value = "";
}}
>
<input ref={node => (input = node)} />
<button type="submit">Add Todo</button>
</form>
</div>
);
};
export default AddTodo;
可以看到,上面的代码做了这么几件工作:
- 我们删除了导出的
connect
函数,并且去掉了其对AddTodo
的包裹。 - 我们将
AddTodo
接收的属性从dispatch
替换成从AddTodoContainer
传过来的addTodo
函数,当表单提交时,它将被调用,dispatch
一个action.type
为"ADD_TODO"
,text
为input.value
的 Action。
修改对应的内容
因为我们将原 TodoList
分离成了容器组件 AddTodoContainer
和展示组件 TodoList
,所以我们需要对 src/components/App.js
做出如下的修改:
import React from "react";
import AddTodoContainer from "../containers/AddTodoContainer";
import VisibleTodoList from "../containers/VisibleTodoList";
import Footer from "./Footer";
class App extends React.Component {
render() {
return (
<div>
<AddTodoContainer />
<VisibleTodoList />
<Footer />
</div>
);
}
}
export default App;
可以看到我们使用 AddTodoContainer
替换了原来的 AddTodo
导出,并在 render
方法中渲染 AddTodoContainer
组件。
小结
保存修改的内容,你会发现我们的待办事项小应用依然可以完整的运行,但是我们已经成功的将原来的 AddTodo
分离成了容器组件的 AddTodoContainer
以及展示组件的 AddTodo
了。
总结
到目前为止,我们就已经学习完了 Redux 的所有基础概念,并且运用这些基础概念将一个纯 React 版的待办事项一步一步重构到了 Redux。
让我们最后一次祭出 Redux 状态循环图,回顾我们在这篇教程中学到的知识:
我们在这篇教程中首先提出了 Redux 的三大概念:Store,Action,Reducers:
- Store 用来保存整个应用的状态,这个状态是一个被称之为 State 的 JavaScript 对象。所有应用的状态都是从 Store 中获取,所以状态的改变都是改变 Store 中的状态,所以 Store 也有着 “数据的唯一真相来源” 的称号。
- Action 是 Redux 中用来改变 Store 状态的唯一手段,所有状态的改变都是以类似
{ type: 'ACTION_TYPE', data1, data2 }
这样的形式声明式的定义一个 Action,然后通过dispatch
这个 Action 来发生的。 - Reducers 是用来响应 Action 发出的改变动作,通过
switch
语句匹配action.type
,通过对 State 的属性进行增删改查,然后返回一个新 State 的操作。同时它也是一个纯函数,即不会直接修改State
本身。
具体反映到我们重构的待办事项项目里,我们使用 Store 保存的状态来替换之前 React 中的 this.state
,使用 Action 来代替之前 React 发起修改 this.state
的动作,通过 dispatch
Action 来发起修改 Store 中状态的操作,使用 Reducers 代替之前 React 中更新状态的 this.setState
操作,纯化的更新 Store 里面保存的 State。
接着我们趁热打铁,使用之前学到的三大概念,将整个待办事情的剩下部分重构到了 Redux。
但是重构完我们发现,我们现在的 rootReducer
函数已经有点臃肿了,它包含了 todos
和 filter
两类不同的状态属性,并且如果我们想要继续扩展这个待办事项应用,那么还会继续添加不同的状态属性,到时候各种状态属性的操作夹杂在一起很容易造成混乱和降低代码的可读性,不利于维护,因此我们提出了 combineReducers
方法,用于切分 rootReducer
到多个分散在不同文件的保存着单一状态属性的 Reducer,,然后通过 combineReducers
来组合这些拆分的 Reducers。
详细讲解 combineReducers
的概念之后,我们接着将之前的不完全重构的 Redux 代码进行了又一次重构,将 rootReducer
拆分成了 todos
和 filter
两个 Reducer。
最后我们更进一步,让 React 专注做好它擅长的编写用户界面的事情,让应用的状态和渲染分离,我们提出了展示组件和容器组件的概念,前者是完完全全的 React,接收来自后者的数据,然后负责将数据高效正确的渲染;前者负责响应用户的操作,然后交给后者发出具体的指令,可以看到,当我们使用 Redux 之后,我们在 React 上盖了一层逻辑,这层逻辑完全负责状态方面的工作,这就是 Redux 的精妙之处啊!
希望看到这里的同学能对 Redux 有个很好的了解,并能灵活的结合 React 和 Redux 的使用,感谢你的阅读!
One More Thing!
细心的读者可能发现了,我们画的 Redux 状态循环图都是单向的,它有一个明确的箭头指向,这其实也是 Redux 的哲学,即 ”单向数据流“,也是 React 社区推崇的设计模式,再加上 Reducer 的纯函数约定,这使得我们整个应用的每一次状态更改都是可以被记录下来,并且可以重现出来,或者说状态是可预测的,它可以追根溯源的找到某一次状态的改变时由某一个 Action 发起的,所以 Redux 也被冠名为 ”可预测的状态管理容器“。
此教程属于React 前端工程师学习路线的一部分,点击可查看全部内容。
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。
给每个状态事件起名字累不累?switch case 字符串不会觉得很不优雅吗?
@Hanggi Redux 最佳实践建议起名字 😂
@Hanggi 这是为了应对复杂逻辑时,各种状态变更一目了然,轻松定位问题,还能“时光机”回溯。
@szy0syz 是的,复杂应用确实需要这样去定义
来试试concent的todo-list呀,更有趣更简单 https://codesandbox.io/s/todoapp-react-concent-fvgvc
不知道你有么有尝试过上百个用字符串定义的状态间跳来跳去,还一目了然,轻松定位? Redux 太啰嗦,太墨迹了。
Redux 的状态管理还不如 Flutter 的状态管理来的方便好用,用结构体定义状态肯定比字符串来得清晰,感兴趣可以了解下。
@Hanggi 啰嗦其实不是最重要的,在case 字符串状态中跳转定位(的麻烦)才是问题。