前言
进了富途后,被要求用react来维护和开发新项目。这对于一直使用Vue的我来说需要好一段学习时间,在用react开发项目中,也用到redux,一开始我以为这玩意跟vuex差不多用法,结果却不是。在学习了一段时间后,通过这篇文章来总结一下自己对redux的理解。
一. Redux被创建的动机
为了解释redux,我们需要引入前端设计模型中的三个变量:View、Model、State。
先解释 View 和 Model:
View: 视图界面,代表用户看到的html页面逻辑。Model: 数据模型,代表与用户交互需要用到的数据。
通常他们在设计模型中的联系是这样子的:
View 和 Model 之间相互耦合:
View-->Model: 用户与<input/>等标签进行交互,从而改变model中的数据。Model-->View: 网络请求得到响应后,Model层数据发生更改,导致View内容随之改变。
如今的前端项目都是用组件化的思路去编写,每个组件在编写时都通常套用上面的设计模型进行开发。
而前端项目中,存在一些公共数据在多个组件需要用到,如UI主题、用户信息(用户名、用户角色)。这些公共数据我们称之为State(状态)。其在设计模型中与Model和View的联系如下:
State 与 View Model 两者之间相互耦合:
View<-->State: 用户通过交互设置State中的网站UI主题,更改后反过来影响多个ViewModel<-->State: 通过请求获取用户信息,然后更新State中的用户信息,多个Model的数据需要按照State中的用户信息进行更新。
从上可得出一个观点:State的变化会影响多个Model和View,因此管理State需要一定的要求。 在此可以套用redux官网里的原话加强此观点:
管理不断变化的 state 非常困难。如果一个 model 的变化会引起另一个 model 变化,那么当 view 变化时,就可能引起对应 model 以及另一个 model 的变化,依次地,可能会引起另一个 view 的变化。直至你搞不清楚到底发生了什么。state 在什么时候,由于什么原因,如何变化已然不受控制。 当系统变得错综复杂的时候,想重现问题或者添加新功能就会变得举步维艰。
在前端存在很多关于State管理的插件,比较广泛使用的是flux和redux。下面说一下redux的使用。
二. Redux在原生js中的使用
这里需要强调一下:Redux 和 React 之间没有关系。Redux 支持 React、Angular、Ember、jQuery 甚至纯 JavaScript。
这里先用原生js配合redux实现状态管理,主要是因为redux虽然一般配合react使用,但这样我们会错过认识几个原生API的机会。
1. Redux工作流的解释
首先要知道redux的工作流程是这样子的:
1. 使用action描述要执行的操作
action是用来给store描述操作类型的变量,其格式一般如下:
{
type: 'ADD',
// 可以有其他属性,但type必须存在
}
Action 本质上是 JavaScript 普通对象。我们约定,action 内必须使用一个字符串类型的 type 字段来表示将要执行的动作。
通常通过dispatch(action)向store传递action,然后store会把action连同state(也就是要更改的公共状态)传入到reducer中作处理。
2. 使用reducer处理action
reducer 就是一个纯函数,接收旧的 state 和 action,读取action.type明确处理类型,处理完毕后返回新的 state作为新的状态记录到store上。
const reducer = (state=0,action)=>{
switch (action.type) {
case 'ADD':
return state+1
default:
return state
}
}
3. 使用subscribe订阅listener
通过subscribe(listener)添加一个变化监听器。每当 dispatch(action) 的时候就会执行,state中的一部分可能已经变化。你可以在回调函数里调用 getState() 来拿到当前 state。
2. 示例:加减数字
实例中的页面有三个标签元素,从左到右分别是+按钮、显示数字的<span>标签、-按钮。按+按钮和-按钮分别让<span>标签中的数字递增和递减。如下所示:
目录结构如下:
store/action/number.js
定义需要dispatch的action
// 表示加1的action
export const ADD_NUMBER={
type:'ADD',
}
// 表示减1的action
export const SUB_NUMBER={
type:'SUB'
}
store/reducer/number.js
定义store中的reducer
// 处理action的reducer
const reducer = (state=0,action)=>{
switch (action.type) {
case 'ADD':
return state+1
case 'SUB':
return state-1
default:
return state
}
}
export default reducer
store/index.js
创建store
import { createStore } from 'redux'
import reducer from './reducer/number'
// 通过createStore函数创建store,传入参数中第一个是reducer,第二个是初始的state值(可省略)
const store = createStore(reducer,0)
export default store
index.html
页面代码
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>
<button id="add">+</button>
<span id="number"></span>
<button id="sub">-</button>
</div>
<script src="./index.js"></script>
</body>
</html>
index.js
import store from './store'
import {ADD_NUMBER,SUB_NUMBER} from './store/action/number'
// 更新显示数字的span标签内容
function changeNumber(){
document.querySelector('#number').textContent=store.getState()
}
// +按钮点击后执行的回调函数,内部通过dispatch分发加1的action
function add(){
store.dispatch(ADD_NUMBER)
}
// -按钮点击后执行的回调函数,内部通过dispatch分发减1的action
function sub(){
store.dispatch(SUB_NUMBER)
}
changeNumber()
// 通过subscribe注册监听器,当state变化时,会执行传入的回调函数
store.subscribe(()=>{
changeNumber()
})
document.querySelector('#add').addEventListener('click',add)
document.querySelector('#sub').addEventListener('click',sub)
通过阅读以上例子结合Redux工作流的解释可以更清晰地了解整个redux的运行机制。
3. redux编写的三个原则
-
单一数据源: 整个应用只能有一个
state,所有公共状态需存储到该state中,而这个state只能存储到一个store上。 -
State是只读的: 不能通过类似store.getState().args = 1的直接更改State的语句来更改State的值。唯一改变 state 的方法就是触发action。此外,由于
State是只读不可更改的,因此,reducer在switch语句块中,匹配到action.type经过处理后必须返回新的State,即:// 假设state为对象 // 错误用法 return Object.assign(state, {a:1,b:2}) // 正确用法 return {...state, a:1, b:2} // or return Object.assign({}, state, {a:1,b:2})在
action.type匹配不到switch语句中的case,即default情况下,返回传入的state。 -
使用作为纯函数的
Reducer来执行修改: 我们在编写Reducer函数时需要注意其必须为纯函数:简单来说,一个函数的返回结果只依赖于它的参数,并且在执行过程里面没有副作用,我们就把这个函数叫做纯函数。 永远不要在 reducer 里做这些操作:
- 修改传入参数;
- 执行有副作用的操作,如 API 请求和路由跳转;
- 调用非纯函数,如 Date.now() 或 Math.random()。
4. 例子:加减数字加单位
下面来一个基于开头的加减数字来进行添加需求的例子,我们不仅要实现加减,还是实现单位切换,如下:
目录结构不变,不过多出几个文件
在store/action和store/reducer中都建立unit.js文件分别存放涉及到单位更改的action和reducer。新建的store/reducer/index.js用于合并number.js和unit.js中的reducer。
src/action/unit.js
// 用户可以通过调用该方法获取一个actio,传入的参数unit是指单位('cm','mm','m')
export const CHANGE_UNIT=(unit)=>({
type:'CHANGE_UNIT',
unit
})
像CHANGE_UNIT这种用于生成action的函数,我们称之为action creator。不要混淆 action 和 action creator 这两个概念。action 是一个信息的负载,而 action creator 是一个创建 action(包括同步action和异步action) 的工厂。
src/reducer/unit.js
const reducer = (state='m',action)=>{
switch (action.type) {
case 'CHANGE_UNIT':
return action.unit
default:
return state
}
}
export default reducer
src/reducer/index.js
import { combineReducers } from 'redux'
import number from './number'
import unit from './unit'
// 通过combineReducers合并reducer,之后无论有多少个类型的reducer,都可以通过该函数合并成一个reducer然后传入到createStore中
export default combineReducers({
number,
unit
})
combineReducers 辅助函数的作用是,把一个由多个不同 reducer 函数作为 value 的 object,合并成一个最终的 reducer 函数,然后就可以对这个 reducer 调用 createStore 方法。
合并后的 reducer 可以调用各个子 reducer,并把它们返回的结果合并成一个 state 对象。 由 combineReducers() 返回的 state 对象,会将传入的每个 reducer 返回的 state 按其传递给 combineReducers() 时对应的 key 进行命名。
最后,在store/index.js、index.js以及index.html都有对应的变化如下所示:
store/index.js
import { createStore } from 'redux'
import reducer from './reducer'
//引入经combineReducers生成的reducer,且在createStore的第二个参数中把state的初始值弄成对象,以子级reducer的名字作为键名,存放初始值。
const store = createStore(reducer,{number:3,unit:'mm'})
export default store
index.js
import store from './store'
import {ADD_NUMBER,SUB_NUMBER} from './store/action/number'
import {CHANGE_UNIT} from './store/action/unit'
function changeNumber(){
const {number,unit}=store.getState()
document.querySelector('#number').textContent=`${number}${unit}`
}
function add(){
store.dispatch(ADD_NUMBER)
}
function sub(){
store.dispatch(SUB_NUMBER)
}
// 从'./store/action/unit'中引入生成涉及单位更改的action的函数,也就是CHANGE_UNIT。
function changeUnit(unit){
store.dispatch(CHANGE_UNIT(unit))
}
store.subscribe(()=>{
changeNumber()
})
changeNumber()
document.querySelector('#add').addEventListener('click',add)
document.querySelector('#sub').addEventListener('click',sub)
// 给按钮绑定事件
document.querySelector('#m').addEventListener('click',()=>{changeUnit('m')})
document.querySelector('#cm').addEventListener('click',()=>{changeUnit('cm')})
document.querySelector('#mm').addEventListener('click',()=>{changeUnit('mm')})
index.html
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>
<button id="add">+</button>
<span id="number"></span>
<button id="sub">-</button>
</div>
<!--添加相应的元素按钮-->
<div>
<button id="m">m</button>
<button id="cm">cm</button>
<button id="mm">mm</button>
</div>
<script src="./index.js"></script>
</body>
</html>
三. Redux在React框架中的使用
我们把上面的例子用react实现。react和redux毫无关联,为了在以react为前端框架的项目中更灵活地使用过redux,我们使用react官方写的react-redux插件。实现的应用同样也是下面的效果:
目录如下:
store文件夹的内容没变化,因为我们只是把项目从原生javascript转为react,对store部分的内容不需要变动。主要新增了components文件夹,用于存放react组件,我们把页面分成两部分,如图所示:
Number.jsx负责递增递减和显示内容的逻辑,Unit.jsx负责切换单位的逻辑,两个组件都最终都会被App.jsx引用,构成页面的内容,App.jsx的代码如下所示:
components/App.jsx
import Unit from './Unit'
import Number from './Number'
import React from 'react';
const App = ()=>(
<div>
<Number/>
<Unit/>
</div>
)
export default App
接下来看一下这两个React组件的代码:
components/Number.jsx
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import {ADD_NUMBER,SUB_NUMBER} from '../store/action/number'
import React from 'react';
// Number组件逻辑
const Number = ({add,number,unit,sub})=>{
return (
<div>
<button onClick={add}>+</button>
<span>{number}{unit}</span>
<button onClick={sub}>-</button>
</div>
)
}
Number.propTypes={
// 定义number和unit,将从store的state中注入
number: PropTypes.number.isRequired,
unit: PropTypes.string.isRequired,
// 定义add和sub分别用于加和减
add: PropTypes.func.isRequired,
sub: PropTypes.func.isRequired,
}
const mapStateToProps = ({number,unit}) => ({
number,unit
})
const mapDispatchToProps = (dispatch, ownProps) => ({
// 加和减方法其实都是dispatch对应的action
add: () => dispatch(ADD_NUMBER),
sub: () => dispatch(SUB_NUMBER)
})
// 使用connect把store中的State状态和自定义的用于派发action的方法合并到组件中,组件可通过props调用
export default connect(mapStateToProps,mapDispatchToProps)(Number)
components/Unit.jsx
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import {CHANGE_UNIT} from '../store/action/unit'
import React from 'react';
// Unit组件逻辑
const Unit = ({changeUnit})=>{
return (
<div>
<button onClick={()=>changeUnit('m')}>m</button>
<button onClick={()=>changeUnit('cm')}>cm</button>
<button onClick={()=>changeUnit('mm')}>mm</button>
</div>
)
}
Unit.propTypes={
// 定义changeUnit用于转换单位
changeUnit: PropTypes.func.isRequired,
}
const mapDispatchToProps = (dispatch) => ({
changeUnit:(unit)=>dispatch(CHANGE_UNIT(unit))
})
export default connect(null,mapDispatchToProps)(Unit)
我们从components/Number.jsx和components/Unit.jsx两个文件中可以看出我们的写作顺序是:
- 编写对应功能的组件,理清逻辑知道哪些变量需要从外部导入,最好用
prop-types编写对props数据的校验 - 如果需要用到
store中state的数据时,要定义mapStateToProps。如果需要让组件有更改state里面的数据的能力时,要定义mapDispatchToProps。 - 最后用
connect方法把mapStateToProps和mapDispatchToProps注入到步骤一的功能中
这里我们来说说这个connect方法:
connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])
一个用于把react组件和redux联系起来的方法。connect是一个柯里化函数,共通过两次传参来获取经处理后的react组件,第一次传参是传入mapStateToProps、mapDispatchToProps这类与store有关联的变量,第二次传参是传入纯react组件。
connect不会改变原来的组件类。反而返回一个新的已与 Redux store 连接的组件类。
我们先分析第一次传入的参数:
-
mapStateToProps:
结构:
(state, [ownProps])=>stateProps说明:
如果定义该参数,组件将会监听
store的变化。任何时候,只要store发生改变,mapStateToProps函数就会被调用。该回调函数必须返回一个纯对象,这个对象(即stateProps)会与组件的props合并。从而有以下流程:
graph TD
store中的state发生变化 --> mapStateToProps被调用 --> 返回纯对象与组件的props合并 --> 组件props发生变化触发组件render函数执行 --> 组件UI更新
通过以上流程,实现了store中相关状态变化从而触发组件更新。
如果省略了mapStateToProps这个参数(如 components/Unit.jsx ),你的组件将不会监听 store。如果指定了该回调函数中的第二个参数 ownProps,则该参数的值为传递到组件的 props,而且只要组件的props有更新,mapStateToProps 也会被调用(例如:父组件传入到子组件中的参数有变化,导致子组件的props有变化,则会导致 mapStateToProps 被重新调用)。
-
mapDispatchToProps
该参数有两种类型结构,对象和函数:
1. Object: 如果传递的是一个对象,那么每个定义在该对象的函数都将被当作
action creator,对象所定义的方法名将作为属性名;每个方法将返回一个新的函数,函数中dispatch方法会将action creator的返回值作为参数执行。这些属性会被合并到组件的props中。2. Function:
(dispatch, [ownProps])=>dispatchProps如果传递的是一个函数,该函数将接收一个用于派发
action的dispatch函数,然后由你来决定如何返回一个对象,这个对象通过dispatch函数与action creator以某种方式绑定在一起。如果你省略这个mapDispatchToProps参数,默认情况下,dispatch会注入到你的组件props中。如果指定了该回调函数中第二个参数ownProps,该参数的值为传递到组件的props,而且只要组件接收到新props,mapDispatchToProps也会被调用。
好了,关于components,也就是页面功能方面已经分析完了。我们继续分析接下来的代码,看index.html和index.jsx:
index.html
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!--直接把原本页面的内容换成一个div#app-->
<div id="app"></div>
<!-- <div>
<button id="add">+</button>
<span id="number"></span>
<button id="sub">-</button>
</div>
<div>
<button id="m">m</button>
<button id="cm">cm</button>
<button id="mm">mm</button>
</div> -->
<!--引入的文件格式从js改为jsx-->
<script src="./index.jsx"></script>
</body>
</html>
index.jsx
import React from 'react';
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import App from './components/App'
import store from './store'
render(
<Provider store={store}>
<App/>
</Provider>,
document.getElementById('app')
)
引用官方原话说一下<Provider store>这个API:
<Provider store>使组件层级中的connect()方法都能够获得store。正常情况下,你的根组件应该嵌套在<Provider>中才能使用connect()方法。
总结: 总的来说,react-redux提供的我们常用的就两个API:connect和<Provider store>。如果想了解更多,特别是connect有很多种传参方式,可去redux-react API详细阅读。
后记
本文从Redux被创建的动机出发,到Redux在原生js中的使用中通过例子让大家了解redux的大部分API(applyMiddleware我会另外写文章说明),再到Redux在React框架中的使用让大家了解react-redux提供了什么API让react和redux联系起来。
之后我下一篇文章会着重写一下redux的中间件,从而引出异步action,以及处理异步action的redux-thunk和redux-saga插件的使用和原理。