Redux专题:实用
来我的 GitHub repo 阅读完整的专题文章
来我的 个人博客 获得无与伦比的阅读体验
Redux是一套精巧而实用的工具,这也是它在开发者中如此流行的原因。
所以对待Redux,最重要的就是熟练使用它的主要API,一旦将它了然于胸,就会对Redux的设计思想有一个全局的认识,也就能清楚的判断自己的应用需不需要劳驾Redux出手。
需要注意:咱们默认将Redux和React搭配使用,不过Redux不是非得和它在一起的。
Action
要达成某个目的,开发者首先要描述自己的意图。Action就是用来描述开发者意图的。它不是一个函数,而是一个普通的对象,通过声明的类型来触发相应的动作。
我们来看一个例子:
{ type: 'ADD_TODO_ITEM', payload: { content: '每周看一本书', done: false, }, }
Redux官方定义了字段的一些规范:一个Action必须包含type
字段,同时一个Action包含的字段不应该超过type
、payload
、error
、meta
这四种。
- type声明Action的类型。一般用全大写的字符串表示,多个字母用下划线分隔。
- payload在英文中的意思是
有效载荷
。引申到程序中就是有效字段的意思,也就是说真正用于构建应用的信息都应该放到payload字段里。 - error字段并不承载错误信息,而是一个出错的token。只有当值为true时才表示出错,值为其他或者干脆没有该字段表示程序运行正常。那么错误信息放哪呢?当然是放payload里面,因为错误信息也属于构建应用的有效信息。
- meta在英文中的意思是
元
。在这里表示除了payload之外的信息。
因为意图是通过类型来定义的,所以type字段必不可少,称某个对象为一个Action的标志就是它有一个type字段。
除此之外,一个动作可能包含更为丰富的信息。开发者可以随意添加字段,毕竟它就是个普通对象。不过遵守一定的规范便于其他开发者阅读你的代码,可以提升协作效率。
Constants
前面说了type字段一般用全大写的字符串表示,多个字母用下划线分隔。不仅如此,大家还有一个约定俗成:用一个结构相同的变量保存该字符串,因为它会在多处用到。
const ADD_TODO_ITEM = 'ADD_TODO_ITEM';
集中保存这些变量的文件就叫Constants.js
。
在此,我提出一点异议。如果你觉得不麻烦,那遵循规范再好不过。但开发者向来觉得Redux过于繁琐,如果你也这么觉得,大可不必维护所谓的Constants。维护Constants的好处不过是一处更改处处生效,然而字符串和变量是结构相同的,如果字符串作了修改,语意上必然大打折扣,况且type字段一旦定义极少更改,所以视你的协作规模和个人喜好而定,为Redux的繁琐减负不是么?
Action Creators
我们知道Action是一个对象,但是如果多次用到这个对象,我们可以写一个生成Action的函数。
function addTodoItem(content) { return { type: ADD_TODO_ITEM, payload: { content, done: false }, }; }
同理,如果你觉得繁琐,这一步是可以免去的。
异步场景下Action Creators会大有用处,后面会讲到。
需要注意的是:所谓的Action更确切的说是一个执行动作的指令,而不是一个动作。或者我们换一种说法,这里的动作指的是动作描述,而不是动作派发。
Store
Redux的本质不复杂,就是用一个全局的外部的对象来存储状态,然后通过观察者模式来构建一套状态更新触发通知的机制。
这里的Store就是存储状态的容器。
但是呢?它需要开发者动手写一套逻辑来指导它怎么处理状态的更新,这就是后面要讲的Reducer,暂且按下不表。
问题是Store怎么接收这套逻辑呢?
import { createStore } from 'redux'; import reducer from './reducer'; const store = createStore(reducer);
看到没有,Redux专门有一个API用来创建Store,它接受三个参数:reducer
、preloadedState
和enhancer
。
reducer就是处理状态更新的逻辑。
preloadedState是初始状态,如果你需要让Store一开始不是空对象,那么可以从这里传进去。
enhancer翻译成中文是增强器
,是用来装载第三方插件以增强Redux的功能的。
怎么存
我们已经了解了Action的作用,但是Action只是对动作的描述,怎么说它得有个发射器吧。这个发射器就隐藏在Store里。
执行createStore
返回的对象包含了一个函数dispatch
,传入Action执行就会发射一个动作。
import React, { Component } from 'react'; import store from './store'; import action from './action'; class App extends Component { render() { return ( <button onClick={() => store.dispatch(action)}>dispatch</button> ); } } export default App;
怎么取
好了我们已经发射了一个动作,假设现在Store中已经有状态了,我们怎么把它取出来呢?
直接store.xxx
么?
我们先来打印Store这个对象看看:
{ dispatch: ƒ dispatch(action), getState: ƒ getState(), replaceReducer: ƒ replaceReducer(nextReducer), subscribe: ƒ subscribe(listener), Symbol(observable): ƒ observable(), }
打印出来一堆API,这可咋整?
别着急,茫茫人海中看到一个叫getState
的东西,它就是我们要找的高人吧。插一句,大家注意区分Store和State的区别,Store是存储State的容器。
Redux隐藏了Store的内部细节,所以开发者只能用getState来获取状态。
订阅
Redux是基于观察者模式的,所以它开放了一个订阅的API给开发者,每次发射一个动作,传入订阅器的回调都会执行。通过它开发者就能监听动作的派发以执行相应的逻辑。
import store from './store'; store.subscribe(() => console.log('有一个动作被发射了'));
replaceReducer
顾名思义,替换Reducer,这主要是方便开发者调试Redux用的。
Reducer
Reducer是Redux的核心概念,因为Redux的作者Dan Abramov这样解释Redux
这个名字的由来:Reducer+Flux。
其实Reducer是一个计算机术语,包括JavaScript中也有用于迭代的reduce
函数。所以我们先来聊聊应该怎样理解Reducer这个概念。
reduce翻译成中文是减少
,Reducer在计算机中的含义是归并,也是化多为少的意思。
我们来看JavaScript中reduce的写法:
const array = [1, 2, 3, 4, 5]; const sum = array.reduce((total, num) => total + num);
再来看Redux中Reducer的写法:
function todoReducer(state = [], action) { switch (action.type) { case 'ADD_TODO_ITEM': const { content, done } = action.payload; return [...state, { content, done }]; case 'REMOVE_TODO_ITEM': const todos = state.filter(todo => todo.content !== action.content); return todos; default: return state; } }
state参数是一个旧数据集合,action中包含的payload是一个新的数据项,Reducer要做的就是将新的数据项和旧数据集合归并到一起,返回给Store。这样看起来Reducer这个名字起的也没那么晦涩了是不是?
一个Reducer接受两个参数,第一个参数是旧的state,我们返回的数据就是用来替换它的,然后风水轮流转,这次返回的数据下次就变成旧的state了,如此往复;第二个参数是我们派发的action。
因为Reducer的结构类似,都是根据Action的类型返回相应的数据,所以一般采用switch case
语句,如果没有变动则返回旧的state,总之它必须有返回值。
纯函数
Reducer的作用是归并,也只能是归并,所以Redux规定它必须是一个纯函数。相同的输入必须返回相同的输出,而且不能对外产生副作用。
所以开发者在返回数据的时候不能直接修改原有的state,而是应该在拷贝的副本之上再做修改。
多个Reducer
一个Reducer只应该处理一个动作,可是我们的应用不可能只有一个动作,所以一个典型的Redux应用会有很多Reducer函数。那么怎么管理这些Reducer呢?
首先来看只有一个Reducer的情况:
import { createStore } from 'redux'; import reducer from './reducer'; const store = createStore(reducer); export default store;
如果只有一个Reducer,那我们只需要将它传入createStore
这个函数中,就这么简单。这时候Reducer返回的状态就是Store中的全部状态。
而如果有多个Reducer,我们就要动用Redux的另一个API了:combineReducers
。
const reducers = combineReducers({ userStore: userReducer, todoStore: todoReducer, });
当我们有多个Reducer,就意味着有多个状态需要交给Store管理,我们就需要子容器来存储它们,其实就是对象嵌套对象的意思。combineReducers就是用来干这个的,它把每一个Reducer分门别类的与不同的子容器对应起来,某个Reducer只处理对应的状态。
{ userStore: {}, todoStore: {}, };
当我们用getState获取整个Store的状态,返回的对象就是上面这样的。
你猜对了,传入combineReducers的对象的key就是子容器的名字。
默认值
当开发者调用createStore创建Store时,传入的所有Reducer都会执行一遍。注意,这时开发者还没有发射任何动作呢,那为什么会执行一遍?
const randomString = () => Math.random().toString(36).substring(7).split('').join('.'); const ActionTypes = { INIT: `@@redux/INIT${randomString()}`, REPLACE: `@@redux/REPLACE${randomString()}`, PROBE_UNKNOWN_ACTION: () => `@@redux/PROBE_UNKNOWN_ACTION${randomString()}` }; dispatch({ type: ActionTypes.INIT });
因为Redux源码中,在createStore函数里面放了这样一段逻辑,这初始化时的dispatch是Redux自己发射的。
为什么?
还记得Reducer接受两个参数吗?第一个是state,而我们可以给state设置默认值。
聪明的你一定想到了,初始化Store时Redux自己发射一个动作的目的是为了收集这些默认值。Reducer会将这些默认值返回给Store,这样默认值就保存到Store中了。
聪明的你大概还想到一个问题:createStore也有默认值,Reducer也有默认值,不会打架么?
Redux的规矩:createStore的默认值优先级更高,所以不会打架。
执行
在一个有若干Reducer的应用中,一个动作是怎么找到对应的Reducer的?
这是一个好问题,答案是挨个找。
假如应用有1000个Reducer,与某个动作对应的Reducer又恰好在最后一个,那要把1000个Reducer都执行一遍,Redux不会这么傻吧?
Redux还真就这么傻。
因为当一个动作被派发时,Redux并不知道应该由哪个Reducer来处理,所以只能让每个Reducer都处理一遍,看看到底是谁的菜。可不可以在设计上将动作与Reducer对应起来呢?当然是可以的,但是Redux为了保证API的简洁和优美,决定牺牲这一部分性能。
只是一些纯函数而已,莫慌。
react-redux
当我们使用Redux时,我们希望每发射一个动作,应用的状态自动发生改变,从而触发页面的重新渲染。
import React, { Component } from 'react'; import store from './store'; class App extends Component { state = { name: 'Redux' }; render() { const { name } = this.state; return ( <div>{name}</div> ); } componentDidMount() { this.unsubscribe = store.subscribe(() => { const { name } = store.getState(); this.setState({ name }); }); } componentWillUnmount() { this.unsubscribe(); } }
怎么办呢?开发者得手动维护一个订阅器,才能监听到状态变化,从而触发页面重新渲染。
但是React最佳实践告诉我们,一个负责渲染UI的组件不应该有太多的逻辑,那么有没有更好的办法使得开发者可以少写一点逻辑,同时让组件更加优雅呢?
别担心,Redux早就帮开发者做好了,不过它是一个独立的模块:react-redux
。顾名思义,这个模块的作用是连接React和Redux。
Provider
连接React和Redux的第一步是什么呢?当然是将Store集成到React组件中,这样我们就不用每次在组件代码中import store
了。多亏了React context的存在,Redux只需要将Store传入根组件,所有子组件就能通过某种方式获取传入的Store。
import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import store from './store'; import App from './App'; ReactDOM.render( <Provider store={store}> <App /> </Provider> , document.getElementById('root') );
connect
老式的context写法,在子组件中定义contextTypes
就可以接收到传入的参数。当然,你肯定也想到,Redux把这些细节都封装好了,这就是connect
。
connect接口的意义主要有三点:
- 封装用context从根组件获取数据的细节。
- 封装Redux订阅器的细节。
- 作为一个容器组件真正连接React和Redux。
import React from 'react'; import Todo from './Todo'; const App = ({ todos, addTodoItem }) => { return ( <div> <button onClick={() => addTodoItem()}>add</button> {todos.map(todo => <Todo key={todo.id} {...todo} />)} </div> ); } const mapStateToProps = (state, ownProps) => { return { todos: state.todoStore, }; }; const mapDispatchToProps = (dispatch, ownProps) => { return { addTodoItem: (todoItem) => dispatch({ type: 'ADD_TODO_ITEM', payload: todoItem }), }; }; export default connect(mapStateToProps, mapDispatchToProps)(App);
我们看上面例子,connect接受的两个参数:mapStateToProps
和mapDispatchToProps
,所谓的map就是映射,意思就是将所有state和dispatch依次映射到props上。如此真正的组件需要的数据和功能都在props上,它就可以安安心心的做一个傻瓜组件。
connect接受四个参数:
- mapStateToProps。也可以写成mapState,这个参数是用来接收订阅得到的数据更新的,也就是说如果这个参数传null或者undefined,则被connect包裹的组件无法收到更新的数据。mapStateToProps必须是一个函数,而且必须返回一个纯对象。它接收两个参数,第一个参数是存储在Store中完整的state,第二个参数是被connect包裹的组件自身的属性。假如App组件挂载时写成这样:
<App value={value} />
,那么ownProps就是一个包含value的对象。 - mapDispatchToProps。也可以写成mapDispatch,这个参数是用来封装所有发射器的。mapDispatchToProps必须是一个函数,而且必须返回一个纯对象。它接收两个参数,第一个参数是dispatch发射器函数,第二个参数和mapStateToProps的第二个参数相同。
- mergeProps。顾名思义,合并props。现在被connect包裹的组件拥有三种props:由state转化而来的props,由dispatch转化而来的props,自身的props。它返回的纯对象就是最终组件能接收到的props。默认返回的对象是用
Object.assign()
合并上述三种props。 - options。用来自定义connect的选项。
我们注意到,connect要先执行一次,返回的结果再次执行才传入开发者定义的组件。它返回一个新的组件,这个新的组件不会修改原组件(除非你操纵了ownProps的返回),而是为组件增加一些新的props。
我们也可以用装饰器写法来重写connect:
import React from 'react'; import Todo from './Todo'; const mapStateToProps = (state, ownProps) => { return { todos: state.todoStore, }; }; const mapDispatchToProps = (dispatch, ownProps) => { return { addTodoItem: (todoItem) => dispatch({ type: 'ADD_TODO_ITEM', payload: todoItem }), }; }; @connect(mapStateToProps, mapDispatchToProps) const App = ({ todos, addTodoItem }) => { return ( <div> <button onClick={() => addTodoItem()}>add</button> {todos.map(todo => <Todo key={todo.id} {...todo} />)} </div> ); } export default App;
总结
Redux通过调用createStore返回Store,它是一个独立于应用的全局对象,通过观察者模式能让应用监听到Store中状态的变化。最佳实践是一个应用只有一个Store。
Redux必须通过一个明确的动作来修改Store中的状态,描述动作的是一个纯对象,必须有type字段,传递动作的是Store的属性方法dispatch。
Store本身并没有任何处理状态更新的逻辑,所有逻辑都要通过Reducer传递进来,Reducer必须是一个纯函数,没有任何副作用。如果有多个Reducer,则需要利用combineReducers定义相应的子状态容器。
基于容器组件和展示组件分离的设计原则,也为了提高开发者的编程效率,Redux通过一个额外的模块将React和Redux连接起来,使得所有的状态管理接口都映射到组件的props上。其中,Provider将Store注入应用的根组件,解决的是连接的充分条件;connect将需要用到的state和dispatch都映射到组件的props上,解决的是连接的必要条件。只有被Provider包裹的组件,才能使用connect包裹。