Redux专题:实用

weiqi 2019-06-28

本文是『horseshoe·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包含的字段不应该超过typepayloaderrormeta这四种。

  • 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,它接受三个参数:reducerpreloadedStateenhancer

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接受的两个参数:mapStateToPropsmapDispatchToProps,所谓的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包裹。

Redux专题一览

考古
实用
中间件
时间旅行

相关推荐

jiangcs0 / 0评论 2020-04-19

空谷足音 / 0评论 2019-04-28