👨🎓:使用了 React,你可能不需要 Redux,但你需要了解它!
👩🎓:既然不需要,为什么需要了解?
👨🎓:不了解你怎么知道不需要呢?
👩🎓:这话没毛病,学它!
Redux 是一个可预测的状态管理容器,也是 react 中最流行的一个状态管理工具,无论是工作或面试只要你使用了 react 都需要掌握它。核心理念是在全局维护了一个状态,称为 store,为应用系统提供了全局状态管理的能力,使得跨组件通信变得更简单。
Redux 抽象程度很高,关注的是 “哲学设计”,开发者最关心的是 “如何实现”,做为初学者尽管看了官网 API 介绍但面对实际项目时还是发现无从入手,特别是面对一些新名词 store、state、action、dispatch、reducer、middlwware 时,有些小伙伴表示我就认识 state...
本篇在介绍一些 Redux 的概念后会重构上一节 useReducer + useContext 实现的 Todos,介绍如何在 React 中应用 Redux,从实践中学习。
Redux 数据流转过程
Redux 通过一系列规范约定来约束应用程序如何根据 action 来更新 store 中的状态,下图展示了 Redux 数据流转过程,也是 Redux 的主要组成部分:
- View:Redux 不能单独工作,需要结合 React/Vue/Angular 等 View 层框架工作,通常 Redux 主要应用于 React 框架中,渲染时页面从 Redux store 中获取数据渲染展现给用户。
- Action:当页面想改变 store 里的数据,通过 dispatch 方法派发一个 action 给 store(例如,请求接口响应之后派发 action 改变数据状态),这里的 action 是 store 唯一的信息来源,做为一个信息的载体存在。
- Store:store 是链接 action 和 reducer 的桥梁,它在收到 action 后会把之前的 state 和 action 一起发给 reducer。
- Reducer:reducer 主要责任是计算下一个状态,因此它在接收到之前的 state 和 action 之后会返回新的数据给到 store(这里要保证 reducer 是一个纯函数),最终 store 更新自己数据告诉页面,回到 View 层页面自动刷新。
图片来源:redux application data flow
Immutable
在 reducer 纯函数中不允许直接修改 state 对象,每次都应返回一个新的 state。原生 JavaScript 中我们要时刻记得使用 ES6 的扩展符 ... 或 Object.assign() 函数创建一个新 state,但是仍然是一个浅 copy,遇到复杂的数据结构我们还需要做深拷贝返回一个新的状态,总之你要保证每次都返回一个新对象,一方面深拷贝会造成性能损耗、另一方面难免会忘记从而直接修改原来的 state。
Immutable 数据一旦创建,对该数据的增、删、改操作都会返回一个新的 immutable 对象,保证了旧数据可用同时不可变。
Immutable 实现的原理是 Persistent Data Structure(持久化数据结构),也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immutable 使用了 Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。参考 Immutable 详解及 React 中实践
请看下面动画:
在本文中整个 redux store 状态树都采用的是 Immutable 数据对象,同时使用时也应避免与普通的 JavaScript 对象混合使用,从下面例子中可以学习到一些常用的 API 使用,更详细的介绍参考官网文档 immutable-js.com/docs。
项目结构
React + Redux 项目的组织结构,在第一次写项目时也犯了困惑,你如果在社区中搜索会发现很多种声音,例如,按类型划分(类似于 MVC 这样按不同的角色划分)、页面功能划分、Ducks(将 actionTypes、actionCreators、reducer 放在一个文件里),这里每一种的区别也可以单独写篇文章讨论了,本节采用的方式是按页面功能划分,也是笔者刚开始写 React + Redux 时的一种目录组织方式。没有最佳的方式,选择适合于你的方式。
按照一个页面功能对应一个文件夹划分,pages/todos 文件夹负责待办事项功能,如果页面复杂可在页面组件内创建一个 pages/todos/components 文件夹,redux 相关的 action、reducer 等放在 page/todos/store 文件夹中。
src/
├── App.css
├── App.js
├── index.css
├── index.js
├── components
├── pages
│ └── todos
│ ├── components
│ │ ├── Todo.jsx
│ │ └── TodoAdd.jsx
│ ├── index.jsx
│ └── store
│ ├── actionCreators.js
│ ├── constants.js
│ ├── index.js
│ └── reducer.js
├── reducers
│ └── todos-reducer.js
├── routes
│ └── index.js
└── store
├── index.js
└── reducer.js
Todos View 层展示组件
View 层我们定义为 “展示组件”,负责 UI 渲染,至于渲染时用到的数据如何获取交由后面的容器组件负责。以下 Todo、TodoAdd、Todos 三个组件中使用到的数据都是从 props 属性获取,后面容器组件链接 React 与 Redux 时会再讲。
Todo 组件
组件位置src/pages/todos/components/Todo.jsx
import { useState } from "react";
/**
* Todo component
* @param {Number} props.todo.id
* @param {String} props.todo.content
* @param {Function} props.editTodo
* @param {Function} props.removeTodo
* @returns
*/
const Todo = ({ todo, editTodo, removeTodo }) => {
console.log('Todo render');
const [isEdit, setIsEdit] = useState(false);
const [content, setContent] = useState(todo.get('content'));
return <div className="todo-list-item">
{
!isEdit ? <>
<div className="todo-list-item-content">{todo.get('content')}</div>
<button className="btn" onClick={() => setIsEdit(true)}> 编辑 </button>
<button className="btn" onClick={() => removeTodo(todo.get('id'))}> 删除 </button>
</> : <>
<div className="todo-list-item-content">
<input className="input" value={content} type="text" onChange={ e => setContent(e.target.value) } />
</div>
<button className="btn" onClick={() => {
setIsEdit(false);
editTodo(todo.get('id'), content);
}}> 更新 </button>
<button className="btn" onClick={() => setIsEdit(false)}> 取消 </button>
</>
}
</div>
}
export default Todo;
Todos 组件
组件位置src/pages/todos/index.jsx。
import { useState } from "react";
import { actionCreators } from '../store';
/**
* Add todo component
* @param {Function} props.addTodo
* @returns
*/
const TodoAdd = ({ addTodo }) => {
console.log('TodoAdd render');
const [content, setContent] = useState('');
return <div className="todo-add">
<input className="input" type="text" onChange={e => setContent(e.target.value)} />
<button className="btn btn-lg" onClick={() => addTodo(content)}>
添加
</button>
</div>
};
export default TodoAdd;
唯一数据源Store
一个 React + Redux 的应用程序中只有一个 store,是应用程序的唯一数据源,类似于在我们应用中抽象出一个状态树,与组件一一关联。这也是一种集中式管理应用状态的方式,也是和 React hooks 提供的 useState/useReducer 一个重大区别之处。
创建 store
通过 redux 的 createStore() 方法创建 store,支持预设一些初始化状态。
代码位置src/store/index.js。
import { createStore, compose } from 'redux';
import reducer from './reducer';
const store = createStore(reducer, /* preloadedState, */);
export default store;
reducer 拆分与组装
当应用复杂时我们通常会拆分出多个子 reducer 函数,每个 reducer 处理自己负责的 state 数据。例如,按页面功能划分项目结构,每个页面/公共组件都可以维护自己的 reducer。
有了拆分,对应还有组合,redux 为我们提供了 combineReducers 函数用于合并多个 reducer。因为我们的 state 是一个 Immutable 对象,而 redux 提供的 combineReducers 只支持原生 JavaScript 对象,不能操作 Immutable 对象,我们还需要借助另外一个中间件 **redux-immutable** 从 state 取出 Immutable 对象。
可以为 reducer 函数指定不同的 key 值,这个 key 值在组件从 store 获取 state 时会用到,下文 “容器组件链接 React 与 Redux” 中会使用到。
代码位置src/store/reducer.js。
import { combineReducers } from 'redux-immutable';
import { reducer as todosReducer } from '../pages/todos/store';
import { reducer as otherComponentReducer } from '../pages/other-component/store';
const reducer = combineReducers({
todosPage: todosReducer,
otherComonpent: otherComponentReducer, // 其它组件的 reducer 函数,在这里依次写
});
export default reducer;
为 todos 组件创建 store 文件
代码位置:src/pages/todos/store/index.js。
import * as constants from './constants';
import * as actionCreators from './actionCreators';
import reducer from './reducer';
export {
reducer,
constants,
actionCreators,
};
constants
代码位置:src/pages/todos/store/constants.js。
export const TODO_LIST = 'todos/TODO_LIST';
export const TODO_LIST_ADD = 'todos/TODO_LIST_ADD';
export const TODO_LIST_EDIT = 'todos/TODO_LIST_EDIT';
export const TODO_LIST_REMOVE = 'todos/TODO_LIST_REMOVE';
创建 action creator 与引入中间件
action 是 store 唯一的信息来源,action 的数据结构要能清晰描述实际业务场景,通常 type 属性是必须的,描述类型。我的习惯是放一个 payload 对象,描述类型对应的数据内容。
一般会通过 action creator 创建一个 action。例如,以下为一个获取待办事项列表的 action creator,这种写法是同步的。
function getTodos() {
return {
type: 'TODO_LIST',
payload: {}
}
}
在实际的业务中,异步操作是必不可少的,而 store.dispatch 方法只能处理普通的 JavaScript 对象,如果返回一个异步 function 代码就会报错。通常需要结合 redux-thunk 中间件使用,实现思路是** action creator 返回的异步函数先经过 redux-thunk 处理,当真正的请求响应后,在发送一个 dispatch(action) 此时的 action 就是一个普通的 JavaScript 对象了**。
Redux 的中间件概念与 Node.js 的 Web 框架 Express 类似,通用的逻辑可以抽象出来做为一个中间件,一个请求先经过中间件处理后 -> 到达业务处理逻辑 -> 业务逻辑响应之后 -> 响应再到中间件。redux 里的 action 好比 Web 框架收到的请求。
代码位置:src/store/index.js。修改 store 文件,引入中间件使得 action 支持异步操作。
import { createStore, compose, applyMiddleware } from 'redux'; // 导入 compose、applyMiddleware
import chunk from 'redux-thunk'; // 导入 redux-thunk 包
import reducer from './reducer';
const store = createStore(reducer, /* preloadedState, */ compose(
applyMiddleware(chunk),
));
export default store;
创建本次 todos 需要的 action creator,实际业务中增、删、改、查我们会调用服务端的接口查询或修改数据,为了模拟异步,我们简单点使用 Promise 模拟异步操作。
代码位置:src/pages/todos/store/actionCreators.js。
import { TODO_LIST, TODO_LIST_ADD, TODO_LIST_REMOVE, TODO_LIST_EDIT } from './constants';
const randomID = () => Math.floor(Math.random() * 10000);
// 获取待办事项列表
export const getTodos = () => async dispatch => {
// 模拟 API 异步获取数据
const todos = await Promise.resolve([
{
id: randomID(),
content: '学习 React',
},
{
id: randomID(),
content: '学习 Node.js',
}
]);
const action = {
type: TODO_LIST,
payload: {
todos
}
};
dispatch(action);
}
// 添加待办事项
export const addTodo = (content) => async dispatch => {
const result = await Promise.resolve({
id: randomID(),
content,
});
const action = {
type: TODO_LIST_ADD,
payload: result
};
dispatch(action);
}
// 编辑待办事项
export const editTodo = (id, content) => async dispatch => {
const result = await Promise.resolve({ id, content });
const action = {
type: TODO_LIST_EDIT,
payload: result,
};
dispatch(action);
}
// 移除待办事项
export const removeTodo = id => async dispatch => {
const result = await Promise.resolve({ id });
const action = {
type: TODO_LIST_REMOVE,
payload: result,
};
dispatch(action);
}
reducer 纯函数
reducer 根据 action 的响应决定怎么去修改 store 中的 state。编写 reducer 函数没那么复杂,倒要切记该函数始终为一个纯函数,应避免直接修改 state。reducer 纯函数要保证以下两点:
- 同样的参数,函数的返回结果也总是相同的。例如,根据上一个 state 和 action 也会返回一个新的 state,类似这样的结构 (previousState, action) => newState。
- 函数执行没有任何副作用,不受外部执行环境的影响。例如,不会有任何的接口调用或修改外部对象。
需要注意一点是在第一次调用时 state 为 undefined,这时需使用 initialState 初始化 state。
代码位置:src/pages/todos/store/reducer.js。
import { fromJS } from 'immutable';
import { TODO_LIST, TODO_LIST_ADD, TODO_LIST_REMOVE, TODO_LIST_EDIT } from './constants';
export const initialState = fromJS({
todos: [],
});
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case TODO_LIST: {
return state.merge({
todos: state.get('todos').concat(fromJS(action.payload.todos)),
});
}
case TODO_LIST_ADD: {
return state.set('todos', state.get('todos').push(fromJS({
id: action.payload.id,
content: action.payload.content,
})));
}
case TODO_LIST_EDIT: {
return state.merge({
todos: state.get('todos').map(item => {
if (item.get('id') === action.payload.id) {
const newItem = { ...item.toJS(), content: action.payload.content };
return fromJS(newItem);
}
return item;
})
})
}
case TODO_LIST_REMOVE: {
return state.merge({
todos: state.get('todos').filter(item => item.get('id') !== action.payload.id),
})
}
default: return state;
}
};
export default reducer;
容器组件链接 React 与 Redux
Redux 做为一个状态管理容器,本身并没有与任何 View 层框架绑定,当在 React 框架中使用 Redux 时需安装 react-redux npm i react-redux -S 库。
容器组件
react-redux 提供的 connect 函数,可以把 React 组件和 Redux 的 store 链接起来生成一个新的容器组件(这里有个经典的设计模式 “高阶组件”),数据如何获取就是容器组件需要负责的事情,在获取到数据后通过 props 属性传递到展示组件,当展示组件需要变更状态时调用容器组件提供的方法同步这些状态变化。
总结下来,容器组件需要做两件事:
- 从 Redux 的 store 中获取数据给到展示组件,对应下例 mapStateToProps() 方法。
- 提供方法供展示组件同步需要变更的状态,对应下例 mapDispatchToProps() 方法。
// 创建容器组件代码示例
import { connect } from 'react-redux';
import ExampleComponent from './ExampleComponent'
const mapStateToProps = (state) => ({ // 从全局状态取出数据映射到展示组件的 props
todos: state.getIn(['todosComponent', 'todos']),
});
const mapDispatchToProps = (dispatch) => ({ // 把展示组件变更状态需要用到的方法映射到展示组件的 props 上。
getTodos() {
dispatch(actionCreators.getTodos());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ExampleComponent);
上例,当 redux store 中的 state 变化时,对应的 mapStateToProps 函数会被执行,如果 mapStateToProps 函数新返回的对象与之前对象浅比较相等(此时,如果是类组件可以理解为 shouldComponentUpdate 方法返回 false),展示组件就不会重新渲染,否则重新渲染展示组件。
展示组件与容器组件之间的关系可以自由组合,可以单独创建一个 container 文件,来包含多个展示组件,同样也可以在展示组件里包含容器组件。在我们的示例中,也比较简单是在展示组件里返回一个容器组件,下面开始修改我们展示组件。
修改 Todo 组件
组件位置src/pages/todos/components/Todo.jsx。
在我们的 Todo 组件中,参数 todo 是由上层的 Todos 组件传递的这里并不需要从 Redux 的 store 中获取 state,只需要修改状态的函数就可以了,connect() 函数第一个参数 state 可以省略,这样 state 的更新也就不会引起该组件的重新渲染了。
import { connect } from 'react-redux';
const Todo = ({ todo, editTodo, removeTodo }) => {...} // 中间代码省略
const mapDispatchToProps = (dispatch) => ({
editTodo(id, content) {
dispatch(actionCreators.editTodo(id, content));
},
removeTodo(id) {
dispatch(actionCreators.removeTodo(id));
}
});
export default connect(null, mapDispatchToProps)(Todo);
修改 TodoAdd 组件
组件位置src/pages/todos/components/TodoAdd.jsx。
import { connect } from 'react-redux';
const TodoAdd = ({ addTodo }) => {...}; // 中间代码省略
const mapDispatchToProps = (dispatch) => ({
addTodo(content) {
dispatch(actionCreators.addTodo(content));
},
});
export default connect(null, mapDispatchToProps)(TodoAdd);
修改 Todos 组件
组件位置src/pages/todos/components/Todos.jsx。
import { connect } from 'react-redux';
const Todos = ({ todos, getTodos }) => { ... } // 中间代码省略
const mapStateToProps = (state) => ({
todos: state.getIn(['todosPage', 'todos']),
});
const mapDispatchToProps = (dispatch) => ({
getTodos() {
dispatch(actionCreators.getTodos());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Todos);
创建 MyRoutes 组件
有了 Page 相应的也有路由,创建 MyRoutes 组件,代码位置 src/routes/index.js。
import {
BrowserRouter, Routes, Route
} from 'react-router-dom';
import Todos from '../pages/todos';
const MyRoutes = () => {
return (
<BrowserRouter>
<Routes>
<Route path="/todos" element={<Todos />} />
</Routes>
</BrowserRouter>
);
};
export default MyRoutes;
Provider 组件传递 store
通过 react-redux 的 connect 函数创建的容器组件可以获取 redux store,那么有没有想过容器组件又是如何获取的 redux store?
在 React 状态管理 - Context 一篇中介绍过,使用 React.createContext() 方法创建一个上下文(MyContext),之后通过 MyContext 提供的 Provider 组件可以传递 value 属性供子组件使用。react-redux 也提供了一个 Provider 组件,正是通过 context 传递 store 供子组件使用,所以我们使用 redux 时,一般会把 Provider 组件做为根组件,这样被 Provider 根组件包裹的所有子组件都可以获取到 store 中的存储的状态。
创建 App.js 组件,组件位置:src/app.js。
import { Provider } from 'react-redux';
import store from './store';
import Routers from './routes';
const App = () => (
<Provider store={store}>
<Routers />
</Provider>
);
export default App;
Redux 调试工具
介绍一个在开发过程中调试 Redux 应用的一款浏览器插件 Redux DevTools extension,可以实时显示当前应用的 action 触发、state 变更记录。
该插件目前支持 Chrome 浏览器、Firefox 浏览器、Electron,安装方法参考 redux-devtools-extension installation。
代码位置src/store/index.js。 修改 store 文件,在非生产环境优先使用调试插件提供的 compose 函数。
const composeEnhancers = (
process.env.NODE_ENV !== 'production' &&
typeof window !== 'undefined' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
) || compose;
const store = createStore(reducer, /* preloadedState, */ composeEnhancers(
applyMiddleware(chunk),
));
如下所示,每次 action 触发及修改的状态都可以记录到。
演示
上一节 React 状态管理 - useState/useReducer + useContext 实现全局状态管理 提出了 Context 一旦某个属性发生变化,依赖于该上下文的组件同样也会重新渲染。Redux 内部对应用性能是做了优化的,当组件的数据没有发生变化时是不会重新渲染的。
总结
在写 Redux 过程中,你会体会到它关注的 “哲学问题”,有一套自己的流程,你需要理解什么是唯一数据源、不可变、函数式编程思想。
为了保持保持应用的状态为只读原则,无论何时我们都不能直接修改应用状态,必须先发送一个 action 描述修改行为,由 store 交给 reducer 纯函数修改应用的状态。
有时一个简单的修改行为,就需要编写 actionCreator、reducer 等 “样板代码”,站在开发者 “如何简单使用” 角度,会感觉过于繁琐。但是,这一看似繁琐的修改流程也正是 Redux 状态管理流程中的核心概念。在大型复杂项目的应用状态管理中,一个流程清晰、职责范围明确的数据层框架会使应用代码变的思路清晰、易于测试、团队协作。每个状态管理框架都有其优缺点,利于弊,看你怎么看待了。
在 Redux 基础之上又衍生了 redux-toolbox 来优化项目的状态管理,解决了一些 Redux 的配置复杂、样板代码太多、需要添加多个依赖包等问题,具体怎么样呢?