本文来自#React系列教程:https://mp.weixin.qq.com/mp/appmsgalbum?__biz=Mzg5MDAzNzkwNA==&action=getalbum&album_id=1566025152667107329)
在React的开发过程中,Redux对于我们是非常重要的。
但是对于很多人来说,初次接触redux会感觉redux的学习坡度非常陡峭,难度比较大。
在接下来,我会通过一个系列的方式循循渐进、层层深入的方式来讲解Redux:从redux的独立使用过程、到redux的结构划分、到redux结合react的过程、到redux中异步操作、到复杂数据的结构处理、到结合immutable的使用。
整个过程,都是在现有知识的基础之上层层递增、一步步掌握redux的使用!
一. 认识Redux
1.1. JavaScript纯函数
函数式编程中有一个概念叫纯函数,JavaScript符合函数式编程的范式,所以也有纯函数的概念;
在React中,纯函数的概念非常重要,在接下来我们学习的Redux中也非常重要,所以我们有必须来回顾(如果你之前没有学过,那么你就是学习)一下纯函数。
纯函数的维基百科定义:
在程序设计中,若一个函数符合以下条件,那么这个函数被称为纯函数:
- 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
- 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
当然上面的定义会过于的晦涩,所以我简单总结一下:
- 确定的输入,一定会产生确定的输出;
- 函数在执行过程中,不能产生副作用;
那么我们来看几个函数是否是纯函数:
案例一:
function sum(num1, num2) {return num1 + num2;
}
- 很明显,上面的函数是一个纯函数;
- 它的输出是依赖我们的输入内容,并且中间没有产生任何副作用;
案例二:
let foo = 5;function add(num) {return foo + num;
}console.log(add(5));
foo = 10;
console.log(add(5));
add
函数不是一个纯函数;- 函数依赖一个外部的变量,变量发生改变时,会影响:确定的输入,产生确定的输出;
- 能否改进成纯函数呢?
const foo = 5;
即可
案例三:
function printInfo(info) {console.log(info.name, info.age);info.name = "哈哈哈";
}
printInfo
不是一个纯函数;- 虽然无论输入什么,最终输出都是
undefined
,但是它产生了副作用,修改了传入的对象;
当然纯函数还有很多的变种,但是我们只需要理解它的核心就可以了。
为什么纯函数在函数式编程中非常重要呢?
- 因为你可以安心的写和安心的用;
- 你在写的时候保证了函数的纯度,只是实现自己的业务逻辑即可,不需要关心传入的内容或者依赖其他的外部变量;
- 你在用的时候,你确定你的输入内容不会被任意篡改,并且自己确定的输入,一定会有确定的输出;
React 非常灵活,但它也有一个严格的规则:
- 所有的 React 组件(无论是函数组件还是
class
声明的组件),都必须像纯函数一样,保护它们的props
不被修改。
在之后学习redux中,reducer
也被要求是一个纯函数。
1.2. 认识Redux
1.2.1. 为什么需要redux?
JavaScript开发的应用程序,已经变得越来越复杂了:
- JavaScript需要管理的状态越来越多,越来越复杂;
- 这些状态包括服务器返回的数据、缓存数据、用户操作产生的数据等等,也包括一些UI的状态,比如某些元素是否被选中,是否显示加载动效,当前分页;
管理不断变化的state
是非常困难的:
- 状态之间相互会存在依赖,一个状态的变化会引起另一个状态的变化,View页面也有可能会引起状态的变化;
- 当应用程序复杂时,
state
在什么时候,因为什么原因而发生了变化,发生了怎么样的变化,会变得非常难以控制和追踪;
React是在视图层帮助我们解决了DOM的渲染过程,但是State
依然是留给我们自己来管理:
- 无论是组件定义自己的
state
,还是组件之间的通信通过props
进行传递;也包括通过Context
进行数据之间的共享; - React主要负责帮助我们管理视图,
state
如何维护最终还是我们自己来决定;
Redux就是一个帮助我们管理State
的容器:Redux是JavaScript的状态容器,提供了可预测的状态管理;
Redux除了和React一起使用之外,它也可以和其他界面库一起来使用(比如Vue),并且它非常小(包括依赖在内,只有2kb)
1.2.2. redux的核心理念
Redux的核心理念非常简单。
比如我们有一个朋友列表需要管理:
const initialState = {friends: [{ name: "why", age: 18 },{ name: "kobe", age: 40 },{ name: "lilei", age: 30 },]
};
如果我们没有定义统一的规范来操作这段数据,那么整个数据的变化就是无法跟踪的;
- 比如页面的某处通过
friends.push
的方式增加了一条数据; - 比如另一个页面通过
friends[0].age = 25
修改了一条数据; - 整个应用程序错综复杂,当出现bug时,很难跟踪到底哪里发生的变化;
Redux要求我们通过action
来更新数据:
- 所有数据的变化,必须通过派发(dispatch)
action
来更新; action
是一个普通的JavaScript对象,用来描述这次更新的type
和content
;
比如下面就是几个更新friends
的action
:
const action1 = { type: "ADD_FRIEND", info: { name: "lucy", age: 20 } }
const action2 = { type: "INC_AGE", index: 0 }
const action3 = { type: "CHANGE_NAME", playload: { index: 0, newName: "coderwhy" } }
- 强制使用
action
的好处是可以清晰的知道数据到底发生了什么样的变化,所有的数据变化都是可跟追、可预测的; - 当然,目前我们的
action
是固定的对象,真实应用中,我们会通过函数来定义,返回一个action
;
但是如何将state
和action
联系在一起呢?答案就是reducer
reducer
是一个纯函数;reducer
做的事情就是将传入的state
和action
结合起来生成一个新的state
;
function reducer(state = initialState, action) {switch (action.type) {case "ADD_FRIEND":return { ...state, friends: [...state.friends, action.info] }case "INC_AGE":return {...state, friends: state.friends.map((item, index) => {if (index === action.index) {return { ...item, age: item.age + 1 }}return item;})}case "CHANGE_NAME":return {...state, friends: state.friends.map((item, index) => {if (index === action.index) {return { ...item, name: action.newName }}return item;})}default:return state;}
}
这个reducer
有点像 Android 的 Handler
的 handleMessage()
方法中根据不同的消息命令进行分发处理的逻辑。
1.2.3. redux的三大原则
1. 单一数据源
整个应用程序的state
被存储在一颗object tree
中,并且这个object tree
只存储在一个 store
中:
- Redux并没有强制让我们不能创建多个
Store
,但是那样做并不利于数据的维护; - 单一的数据源可以让整个应用程序的state变得方便维护、追踪、修改;
2. State是只读的
唯一修改State
的方法一定是触发action
,不要试图在其他地方通过任何的方式来修改State
:
- 这样就确保了View或网络请求都不能直接修改
state
,它们只能通过action
来描述自己想要如何修改state
; - 这样可以保证所有的修改都被集中化处理,并且按照严格的顺序来执行,所以不需要担心race condition(竟态)的问题;
3. 使用纯函数来执行修改
通过 reducer
将 旧state
和 actions
联系在一起,并且返回一个新的State
:
- 随着应用程序的复杂度增加,我们可以将
reducer
拆分成多个小的reducers
,分别操作不同state tree的一部分; - 但是所有的
reducer
都应该是纯函数,不能产生任何的副作用;
二. redux的基本使用
2.1. redux使用过程
安装redux
:
npm install redux --save
# 或
yarn add redux
这里,我通过创建一个简单的js
文件,我们先来简单学习一下redux
:
搭建项目结构
- 创建一个新的项目文件夹:
learn-redux
# 执行初始化操作
yarn init# 安装redux
yarn add redux
-
创建
src
目录,并且创建index.js
文件 -
修改
package.json
可以执行index.js
"scripts": {"start": "node src/index.js"
}
开始在index.js
中编写代码
- 创建一个对象,作为我们要保存的状态:
const redux = require('redux');const initialState = {counter: 0
}
- 创建
Store
来存储这个state
- 创建
store
时必须创建reducer
; - 我们可以通过
store.getState
来获取当前的state
// 创建reducer
const reducer = (state = initialState, action) => {return state;
}// 根据reducer创建store
const store = redux.createStore(reducer);
console.log(store.getState());
- 通过
action
来修改state
- 通过
dispatch
来派发action
; - 通常
action
中都会有type
属性,也可以携带其他的数据;
store.dispatch({type: "INCREMENT"
})store.dispath({type: "DECREMENT"
})store.dispatch({type: "ADD_NUMBER",number: 5
})
- 修改
reducer
中的处理代码
- 这里一定要记住,
reducer
是一个纯函数,不需要直接修改state
; - 后面我会讲到直接修改
state
带来的问题;
const reducer = (state = initialState, action) => {switch (action.type) {case "INCREMENT":return {...state, counter: state.counter + 1};case "DECREMENT":return {...state, counter: state.counter - 1};case "ADD_NUMBER":return {...state, counter: state.counter + action.number}default: return state;}
}
- 可以在派发
action
之前,监听store
的变化:
store.subscribe(() => {console.log(store.getState());
})
完成的案例代码如下:
const redux = require('redux');const initialState = {counter: 0
}// 创建reducer
const reducer = (state = initialState, action) => {switch (action.type) {case "INCREMENT":return {...state, counter: state.counter + 1};case "DECREMENT":return {...state, counter: state.counter - 1};case "ADD_NUMBER":return {...state, counter: state.counter + action.number}default: return state;}
}// 根据reducer创建store
const store = redux.createStore(reducer);store.subscribe(() => {console.log(store.getState());
})// 修改store中的state
store.dispatch({type: "INCREMENT"
})
// console.log(store.getState());store.dispatch({type: "DECREMENT"
})
// console.log(store.getState());store.dispatch({type: "ADD_NUMBER",number: 5
})
// console.log(store.getState());
2.2. redux结构划分
如果我们将所有的逻辑代码写到一起,那么当redux
变得复杂时代码就难以维护。
接下来,我会对代码进行拆分,将store、reducer、action、constants
拆分成一个个文件。
注意:node
中对ES6模块化的支持
目前我使用的node版本是v12.16.1
,从node v13.2.0
开始,node
才对ES6模块化提供了支持:
node v13.2.0
之前,需要进行如下操作:
- 在
package.json
中添加属性:"type": "module"
; - 在执行命令中添加如下选项:
node --experimental-modules src/index.js
;
node v13.2.0
之后,只需要进行如下操作:
- 在
package.json
中添加属性:"type": "module"
;
注意:导入文件时,需要跟上.js
后缀名;
对redux
结构进行划分
创建store/index.js
文件:
import redux from 'redux';
import reducer from './reducer.js';const store = redux.createStore(reducer);export default store;
创建store/reducer.js
文件:
import {ADD_NUMBER,SUB_NUMBER
} from './constants.js';const initialState = {counter: 0
}function reducer(state = initialState, action) {switch(action.type) {case ADD_NUMBER:return {...state, counter: state.counter + action.num};case SUB_NUMBER:return {...state, counter: state.counter - action.num};default:return state;}
}export default reducer;
创建store/actionCreators.js
文件:
import {ADD_NUMBER,SUB_NUMBER
} from './constants.js'const addAction = (count) => ({type: ADD_NUMBER,num: count
});const subAction = (count) => ({type: SUB_NUMBER,num: count
})export {addAction,subAction
}
创建store/constants.js文
件:
const ADD_NUMBER = "ADD_NUMBER";
const SUB_NUMBER = "SUB_NUMER";export {ADD_NUMBER,SUB_NUMBER
}
2.3. Redux流程图
我们已经知道了redux
的基本使用过程,那么我们就更加清晰来认识一下redux
在实际开发中的流程:
- 全局通常只有一个
Store
,存储我们的State
; Component
中在某些情况会派发Action
(这些Action
是我们提前定义好的);Reducer
会接收到这些Action
,并且在Reducer
中会返回一个新的State
,作为Store
的State
;State
发生更新之后会触发通知,告知订阅者数据发生了改变;- 订阅者拿到最新的数据(在
props
中),更新到jsx
中,界面发生改变;
三. react结合redux
3.1. redux融入react代码
目前redux
在react中使用是最大的,所以我们需要将之前编写的redux代码,融入到react当中去。
这里我创建了两个组件:
Home
组件:其中会展示当前的counter
值,并且有一个+1和+5的按钮;Profile
组件:其中会展示当前的counter
值,并且有一个-1和-5的按钮;
home.js
代码实现:
import React, { PureComponent } from 'react';import store from '../store';
import {addAction
} from '../store/actionCreators';export default class Home extends PureComponent {constructor(props) {super(props);this.state = {counter: store.getState().counter}}componentDidMount() {store.subscribe(() => {this.setState({counter: store.getState().counter})})}render() {return (<div><h1>Home</h1><h2>当前计数: {this.state.counter}</h2><button onClick={e => this.increment()}>+1</button><button onClick={e => this.addCounter()}>+5</button></div>)}increment() {store.dispatch(addAction(1));}addCounter() {store.dispatch(addAction(5));}
}
Profile.js
代码实现:
import React, { PureComponent } from 'react';import store from '../store';
import {subAction
} from '../store/actionCreators';export default class Profile extends PureComponent {constructor(props) {super(props);this.state = {counter: store.getState().counter}}componentDidMount() {store.subscribe(() => {this.setState({counter: store.getState().counter})})}render() {return (<div><hr/><h1>Profile</h1><div><h2>当前计数: {this.state.counter}</h2><button onClick={e => this.decrement()}>-1</button><button onClick={e => this.subCounter()}>-5</button></div></div>)}decrement() {store.dispatch(subAction(1));}subCounter() {store.dispatch(subAction(5));}
}
上面的代码其实非常简单,核心代码主要是两个:
- 在
componentDidMount
中定义数据的变化,当数据发生变化时重新设置counter
; - 在发生点击事件时,调用
store
的dispatch
来派发对应的action
;
不要忘记取消订阅:
3.2. 自定义 connect 函数
上面的代码是否可以实现react
组件和redux
结合起来呢?
- 当然是可以的,但是我们会发现每个使用的地方其实会有一些重复的代码:
- 比如监听
store
数据改变的代码,都需要在componentDidMount
中完成; - 比如派发事件,我们都需要去先拿到
store
, 在调用其dispatch
等;
能否将这些公共的内容提取出来呢?
我们来定义一个connect
函数:
这个connect
函数本身接受两个参数:
- 参数一:里面存放
component
希望使用到的State
属性; - 参数二:里面存放
component
希望使用到的dispatch
动作;
这个connect
函数有一个返回值,是一个高阶组件:
- 在
constructor
中的state
中保存一下我们需要获取的状态; - 在
componentDidMount
中订阅store
中数据的变化,并且执行setState
操作; - 在
componentWillUnmount
中需要取消订阅; - 在
render
函数中返回传入的WrappedComponent
,并且将所有的状态映射到其props
中; - 这个高阶组件接受一个组件作为参数,返回一个
class
组件; - 在这个
class
组件中,我们进行如下操作:
import React, { PureComponent } from "react";import store from '../store';export default function connect(mapStateToProps, mapDispatchToProps) {return function handleMapCpn(WrappedComponent) {return class extends PureComponent {constructor(props) {super(props);this.state = {storeState: mapStateToProps(store.getState())}}componentDidMount() {this.unsubscribe = store.subscribe(() => {this.setState({storeState: mapStateToProps(store.getState())})})}componentWillUnmount() {this.unsubscribe();}render() {return <WrappedComponent {...this.props} {...mapStateToProps(store.getState())}{...mapDispatchToProps(store.dispatch)}/>}}}
}
在home
和props
文件中,我们按照自己需要的state、dispatch
来进行映射:
比如home.js
中进行如下修改:
const mapStateToProps = state => {return {counter: state.counter}
}const mapDispatchToProps = dispatch => {return {addNumber: function(number) {dispatch(addAction(number));}}
}export default connect(mapStateToProps, mapDispatchToProps)(Home);
mapStateToProps
:用于将state
映射到一个对象中,对象中包含我们需要的属性;mapDispatchToProps
:用于将dispatch
映射到对象中,对象中包含在组件中可能操作的函数;当调用该函数时,本质上其实是调用dispatch(对应的Action)
;
在Profile
中也是类似的操作。
有了connect
函数,我们之后只需要关心从state
和dispatch
中映射自己需要的状态和行为即可;
3.3. store的context处理
但是上面的connect
函数有一个很大的缺陷:依赖导入的store
如果我们将其封装成一个独立的库,需要依赖用于创建的store
,我们应该如何去获取呢?
难道让用户来修改我们的源码吗?不太现实;
正确的做法是我们提供一个Provider
,Provider
来自于我们创建的Context
,让用户将store
传入到value
中即可;
创建一个context.js
文件:
import { createContext } from 'react';export const StoreContext = createContext();
修改connect
函数中class
组件部分的代码:
- 注意下面我们将
class
组件的名称明确的定义出来,并且给它的contextType
进行了赋值; - 在组件内部用到
store
的地方,统一使用this.context
代替(注意:constructor
中直接使用第二个参数即可)
import React, { PureComponent } from "react";import { StoreContext } from './context';export default function connect(mapStateToProps, mapDispatchToProps) {return function handleMapCpn(WrappedComponent) {class ConnectCpn extends PureComponent {constructor(props, context) {super(props);this.state = {storeState: mapStateToProps(context.getState())}}componentDidMount() {this.unsubscribe = this.context.subscribe(() => {this.setState({storeState: mapStateToProps(this.context.getState())})})}componentWillUnmount() {this.unsubscribe();}render() {return <WrappedComponent {...this.props}{...mapStateToProps(this.context.getState())}{...mapDispatchToProps(this.context.dispatch)} />}}ConnectCpn.contextType = StoreContext;return ConnectCpn;}
}
在入口的index.js
中,使用Provider
并且提供store
即可:
import { StoreContext } from './utils/context';
import store from './store';ReactDOM.render(<StoreContext.Provider value={store}><App /></StoreContext.Provider>,document.getElementById('root')
);
测试代码,依然可以正常运行。
四. react-redux使用
4.1. react-redux的使用
开始之前需要强调一下,redux和react没有直接的关系,你完全可以在React, Angular, Ember, jQuery, or vanilla JavaScript中使用Redux。
尽管这样说,redux依然是和React或者Deku的库结合的更好,因为他们是通过state
函数来描述界面的状态,Redux可以发射状态的更新,让他们作出相应。
虽然我们之前已经实现了connect
、Provider
这些帮助我们完成连接redux
、react
的辅助工具,但是实际上redux
官方帮助我们提供了 react-redux
的库,可以直接在项目中使用,并且实现的逻辑会更加的严谨和高效。
安装react-redux
:
yarn add react-redux
使用connect
函数:
- 将之前使用的
connect
函数,换成react-redux
的connect
函数;
import React, { PureComponent } from 'react';
import { connect } from "react-redux";// import connect from '../utils/connect2';export default connect(mapStateToProps, mapDispatchToProps)(Home);
使用Provider
:
- 将之前自己创建的
Context
的Provider
,换成react-redux
的Provider
组件: - 注意:这里传入的是
store
属性,而不是value
属性(待会儿可以在源码中查看);
import { Provider } from 'react-redux';import store from './store';ReactDOM.render(<Provider store={store}><App /></Provider>,document.getElementById('root')
);
2.2. react-redux的源码
这里我简单带着大家看一下react-redux
的源码:
- 但是第一因为这个教程不是讲源码为主的教程(穿插讲解部分源码),所以源码只会阅读核心的部分;
- 另外我经常会说,整个社区在
hooks
出现后大量的库转向了hooks
,所以在源码中会出现大量的hooks
代码; - 因为目前还没有讲解
hooks
相关的API,所以某些hooks
的作用在这里也不方便解释(可以学习完hooks
之后再详细阅读);
首先,我们简单看一下Provider
的源码:
- 使用了一个
useMemo
来返回一个contextValue
的对象; - 这里使用
useMemo
的原因是为了进行性能的优化; - 在依赖的
store
不改变的情况下,不会进行重新计算,返回一个新的对象; - 在下面的
Context
的Provider
中就会将其赋值给value
属性;
ReactReduxContext
来自另外一个文件:
connect函数的依赖比较复杂:
- 调用
createConnect
来返回一个connect
函数:
createConnect
函数的调用:
connect
函数最终调用的是connectHOC
:
connectHOC
其实是connectAdvanced
的函数;connectAdvanced
函数最终返回的是wrapWithConnect
函数;
wrapWithConnect
函数: