深入理解Redux——手撸一个简单版Redux

1,233 阅读14分钟

本篇文章首先会介绍和分析Redux,然后从头开始,一步一步实现一个简单的Redux。

你应该知道的

什么是Redux

参考Redux中文官网:JS应用的状态容器提供可预测的状态管理。它可以帮助你开发出行为稳定可预测的、运行于不同的环境(客户端、服务器、原生应用)、易于测试的应用程序。不仅于此,它还提供超爽的开发体验。

Redux经常和React搭配使用,但也可以单独使用。另外还有Redux团队开发的其他工具库:React-ReduxRedux Toolkit

Redux中的重要概念

首先看这两张图,讲解一下里面的各个元素。

image.png

Redux

image.png

React + Redux
  • Action Creator:View 要发送多少种消息,就会有多少种 Action。如果都手写,会很麻烦。可以定义一个函数来生成 Action,这个函数就叫 Action Creator。它是一种辅助函数,根据传入的参数中生成一个Action,为这个Action分配一个类型(type),并把这个Action传送给Dispatcher。
  • Action:Action 是一个对象。其中的type属性是必须的,表示 Action 的名称。
  • Dispatcher:接受一个 Action 对象作为参数,将它发送出去,然后将Action的结果发送到Store。
  • Store: 用于存储状态,Store 就是保存数据的地方,你可以把它看成一个容器。整个应用只能有一个 Store。
  • State:如果想得到某个时点的数据,就要对 Store 生成快照。这种时点的数据集合,就叫做 State。
  • View:用户视图,用于产生Action和接收渲染Store中状态的改变
  • Reducer:Store 收到 Action 以后,必须给出一个新的 State,这样 View 才会发生变化。这种 State 的计算过程就叫做 Reducer。Reducer 是一个函数,它接受 Action 和当前 State 作为参数,返回一个新的 State。

需要注意的几个点:

  1. 在Redux中,状态存储在store中。存储的状态通过 action 改变。 action 一个是对象,它至少有一个字段(type)确定操作的类型。
  2. Action 对应用程序状态的影响是通过使用一个 reducer 来定义的。 实际上, reducer 是一个纯函数,它以当前状态(state)和 action 为参数。 它返回一个基于传入的action的新状态(state)。state的状态不应该被修改,所以返回的state值应该是一个新创建的state
  3. Reducer 不直接从应用程序中调用。 Reducer 只作为创建store,即 createStore 的一个参数给出
  4. store 现在使用 reducer 来处理actions,这些action通过 dispatch方法 被分派或“发送”到 store 中。
  5. store发生改变之后,由React来将改变渲染到页面上(View)。

因此,action 对应用程序状态的影响是通过使用一个 reducer 来定义的。 实际上,reducer 是一个函数,它以当前(previous) state 和 action 为参数,返回一个新的 state

image.png

这里的createStorecombineReducers就是之后要实现的部分功能。多个reducer可以被结合到一起(combineReducers),在结合的时候为每个reducer创建一个标识符,在action中可以使用该标识符获取对应reducer的state(即数据)。

image.png

为什么要有Redux

Redux是为了解决React组件间通信和组件间状态共享而提出的一种解决方案。

解决了什么问题

随着项目的越来越大,组件和组件的状态越来越多,嵌套层数越来越深,所以组件之间通信越来越复杂,项目将越来越难以维护。使用Redux之后,组件的状态都保存到store之中,各个组件可以直接从store之中获取到自己需要的状态。如果需要改变store中的状态,Redux也提供了dispatch方法,组件可以dispatch一个action,根据action的type属性,reducer会对状态做出变化,得到新的状态。因为全局只能有一个store,这样将全局状态保存到一处的做法,使得项目更加容易维护,组件之间的通信也更加容易实现和清晰。

  1. 组件间通信 由于connect后,各connect组件是共享store的,所以各组件可以通过store来进行数据通信,当然这里必须遵守redux的一些规范,比如遵守 view -> aciton -> reducer的改变state的路径。

  2. 通过对象驱动组件进入生命周期 对于一个React组件来说,只能对自己的state改变驱动自己的生命周期,或者通过外部传入的props进行驱动。通过Redux,可以通过store中改变的state,来驱动组件进行update。

  3. 方便进行数据管理和切片 redux通过对store的管理和控制,可以很方便的实现页面状态的管理和切片。通过切片的操作,可以轻松的实现redo之类的操作。

使用场景

  • 同一个 state 需要在多个 Component 中共享;
  • 需要操作一些全局性的常驻 Component,比如 Notifications,Tooltips 等;
  • 太多 props 需要在组件树中传递,其中大部分只是为了透传给子组件;
  • 业务太复杂导致 Component 文件太大,可以考虑将业务逻辑拆出来放到 Reducer 中;

文档解释

可以参考官网文档When should I use Redux?

Redux 的特点

  • 单向数据流。View 发出 Action (store.dispatch(action)),Store 调用 Reducer 计算出新的 state ,若 state 产生变化,则调用监听函数重新渲染 View (store.subscribe(render))。
  • 单一数据源,只有一个 Store。
  • state 是只读的,不应该被改变,每次状态更新之后只能返回一个新的 state。
  • 没有 Dispatcher ,而是在 Store 中集成了 dispatch 方法,store.dispatch() 是 View 发出 Action 的唯一途径。
  • 支持使用中间件(Middleware)管理异步数据流。

实现一个简单的Redux

基于上面的理解,从最基础的开始,一步一步加入新的功能,最终实现一个简易版的Redux。

我们还是用简单但经典的计数器作为例子。

项目可以用cra(creat-react-app)或其他脚手架生成。结构如下图所示:

image.png

其中,App代码如下:

import Counter from "./Counter";
import "./App.css";

function App() {
  return (
    <div className="app">
      <h1>Redux implementation</h1>
      <Counter />
    </div>
  );
}

export default App;

其他功能我们一步一步来实现。

0. App结构

基于依赖关系,App的整体结构从左到右,从最内层到最外层如下所示:

image.png

a. counterStore.js

首先,我们要创建一个store来定义对应的actions,action creators和reducers。两个action creators分别为incrementActiondecrementAction,它们有对应action和各自的type:INCDEC。这里我们只需要一个Reducer就够了,处理两种的actions。

// 定义action creators和actions(types)
const incrementAction = () => ({ type: "INC" });
const decrementAction = () => ({ type: "DEC" });

// 定义reducer
const reducer = (state = 0, action) => {
    if (action.type === "INC") {
        return state + 1;
    }

    if (action.type === "DEC") {
        return state - 1;
    }

    return state;
};

export default reducer;
export { incrementAction, decrementAction };

b. Coutner.js

然后在Coutner.js组件内导入Actions,实现如下:

import React from "react";
import { connect } from "./redux-lib";
import { incrementAction, decrementAction } from "./counterStore";
import "./Counter.css";

const Counter = ({ value, increase, decrease }) => (
    <div>
        <p>Value: {value}</p>
        <button onClick={increase}>Increment</button>
        <button onClick={decrease}>Decrement</button>
    </div>
);

// 将state转化为props(value)
const mapStateToProps = (state) => {
    return {
        value: state.counter
    };
};

// 将dispatch(action)转化为props(increase和decrease)
const mapDispatchToProps = (dispatch) => ({
    increase: () => dispatch(incrementAction()),
    decrease: () => dispatch(decrementAction())
});

export default connect(mapStateToProps, mapDispatchToProps)(Counter);

这里需要用connect方法“连接”mapStateToPropsmapDispatchToProps这两个方法,简单用另外一张图来描述:

image.png

可以理解为mapStateToProps返回的是新的state,而mapDispatchToProps是基于旧的state和action来生成新state的过程,所以可以理解为“destroy”原来的state的一个过程。

现在这个组件可以通过它的props调用函数increase/decrease, 也就是基于action creator定义的action。这样一来我们就不需要单独调用 dispatch 函数,因为 connect 已经将 increase/decrease 的 action creator 修改为包含 dispatch 的形式了。

connect函数将在后面实现。

c. App.js

然后再将Coutner组件导入到App中。

import Counter from "./Counter";
import "./App.css";

function App() {
  return (
    <div className="app">
      <h1>Redux implementation</h1>
      <Counter />
    </div>
  );
}

export default App;

d. index.js

最后再将App导入到index.js中。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { Provider, createStore, combineReducers } from "./redux-lib";
import counterReducer from "./counterStore";

const rootReducer = combineReducers({
  counter: counterReducer
});
const store = createStore(rootReducer);

const rootElement = document.getElementById("root");
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
);

这里导入了在步骤a中创建的reducer,也就是counterReducer,作为combineReducers函数的参数。而combineReducers生成的reducer集合又作为createStore函数的参数,最终生成我们的store。最终,store作为Provider的props,包在了App的外面。

这里的{ Provider, createStore, combineReducers }将在后续部分一一实现。

1. createStore函数

参考Redux官网API文档:createStore

参数

  1. reducer  (Function) : 接收两个参数,分别是当前的 state 树和要处理的 action,返回新的 state 树
  2. [preloadedState(any) : 初始时的 state。你可以决定是否把服务端传来的 state 水合(hydrate)后传给它,或者从之前保存的用户会话中恢复一个传给它。如果你使用 combineReducers 创建 reducer,它必须是一个普通对象,与传入的 keys 保持同样的结构。否则,你可以自由传入任何 reducer 可理解的内容。
  3. enhancer  (Function) : Store enhancer。你可以选择指定它以使用第三方功能,如middleware、时间旅行、持久化来增强 store。Redux 中唯一内置的 store enhander 是 applyMiddleware()

返回值

(Store): 保存了应用程序所有 state 的对象。改变 state 的唯一方法是 dispatch action。你也可以 subscribe state 的变化,然后更新 UI。

实现

我们已经知道,createStore函数的作用是接收reducer为参数,最后返回一个store对象。而这个store对象需要有三个基本方法(参考官方文档Store 方法):

  • getState:返回应用当前的 state 树。 它与 store 的最后一个 reducer 返回值相同。
  • dispatch(action):分发action,这是触发 state 变化的唯一途径。它返回一个可以解绑变化监听器的函数。
  • subscribe(listener): 添加一个变化监听器。每当 dispatch action 的时候就会执行,state 树中的一部分可能已经变化。

其中,listener (Function)是一个每当 dispatch action 的时候都会执行的回调。state 树中的一部分可能已经变化。

这个返回的store会作为props提供给Provider,而Provider是由React.createContext()生成的Context的Provider(生产者)组件(Context.Provider),它作为一个父组件,包裹了各种子组件(Context.Consumer),也就是我们的App。

不难发现,Context的使用基于生产者消费者模式。所以就有了上面store对象里的三种方法。Provider会在后续详细讲解实现。

那么createStore的实现如下:

const createStore = (reducer, initialState = {}) => {
    let state;
    let listeners = []; // listeners 是一组函数的集合(数组)

    //getState 返回应用当前的 state 树。 它与 store 的最后一个 reducer 返回值相同。
    const getState = () => state;

    //subscribe 是一个向store中添加监听器的函数,返回一个可以解绑变化监听器的函数。
    const subscribe = (listener) => {
        // 添加监听器
        listeners.push(listener);
        // 返回一个可以解绑变化监听器的函数
        return () => {
            let index = listeners.indexOf(listener);
            if (index >= 0) {
                // 解绑变化监听器
                listeners.splice(0, index);
            }
        }
    };

    // dispatch 函数使用reducer来获取新的state并通知每个监听器state发生了变化
    // 将使用当前 getState() 的结果(上面d的state)和传入的 action 以同步方式的调用 store 的 reducer 函数。这个 reducer 的返回值会被作为下一个 state。
    // 从现在开始,这个 state 就成为了 getState() 的返回值,同时每个变化监听器(change listener)会被触发。
    const dispatch = (action) => {
        state = reducer(state, action); // 新的state
        listeners.forEach((listener) => listener(state)); // 并通知每个监听器state发生了变化
    };

    // 分发初始state
    dispatch(initialState);

    return { getState, dispatch, subscribe };
};

export default createStore;

具体逻辑详见注释。

2. combineReducers函数

参考Redux官网API文档:combineReducers

参数

  1. reducers (Object): 一个对象,它的值(value)对应不同的 reducer 函数,这些 reducer 函数后面会被合并成一个。

返回值

(Function):一个调用 reducers 对象里所有 reducer 的 reducer,并且构造一个与 reducers 对象结构相同的 state 对象。

那么它的实现如下:

// combineReducers函数将一个含有不同reducers的对象转化为一个单独的reducer,这个合成的reducer将作为参数传递给createStore函数
const combineReducers = (reducers) => {
    // 函数返回值也是一个reducer,所以它也是一个返回函数的函数
    return (state = {}, action) => {
        return Object.keys(reducers).reduce((nextState, key) => {
            nextState[key] = reducers[key](state[key], action);
            return nextState;
        }, {}); // reduce方法,从一个空对象开始reduce
    };
};

export default combineReducers;

3. Connect函数

由前文的例子可知,connect()函数是一个将一个React组件和一个Redux的store连接起来的函数。

参考React-Redux官网API文档:connect()

参数

实际上它可以接收四个参数,但这里我们只采用前两个:

  1. mapStateToProps?: (state, ownProps?) => Object

    参考React-Redux官网API文档:mapStateToProps

    connect函数接受所谓的mapStateToProps函数作为它的第一个参数。这个函数可以用来定义基于 Redux 存储状态的连接组件的 props。

    如果mapStateToProps函数被定义为只接收一个参数(state),那么每次store的state发生变化时,它都会被调用,而state作为参数传递进去。

    如果mapStateToProps函数被定义为接收两个参数(state, ownProps?),那么每次store的state发生变化或包装组件接收到新props(对象浅比较)时,它都会被调用,而state作为参数传递进去。本篇没有实现这个。

    mapStateToProps返回一个对象,通常会被定义为stateProps,会合并为连接组件的props。如果connect函数使用了mergeProps函数,那么stateProps会作为它的第一个参数。

  2. mapDispatchToProps?: Function((dispatch, ownProps?) => Object) | Object

    connect函数的第二个参数可以为mapDispatchToProps(可以是一个函数,或者一个对象,或者没有),它是一组作为props传递给连接组件的 action creator 函数。

    mapDispatchToProps默认接收dispatch(store.dispatch)为第一个参数。它应该返回一个对象,这个对象的每个属性应该是一个函数,它会分发(dispatch)一个action到store(也就是action creator函数)。这个对象被定义为dispatchProps,它会被合并为连接组件的props。如果connect函数使用了mergeProps函数,那么stateProps会作为它的第二个参数。

返回值

connect()函数的返回值是一个包装函数,这个包装函数将你的组件作为参数,并返回一个包装组件,同时将额外的props注入到这个组件中。

基于以上分析,其实现如下:

// connect() 将一个React组件和一个Redux的store连接起来
import React, { Component } from "react";
import ReduxContext from './index';

const connect = (mapStateToProps, mapDispatchToProps) => (WrappedComponent) => {
    class Connect extends Component {
        constructor(props) {
            super(props);
            this.state = props.store.getState(); // 获取当前 state 树
        }

        // 当组件加载完成后需要更新state
        componentDidMount() {
            this.updateComponent(); // 更新state
            this.props.store.subscribe(() => this.updateComponent()); // 添加监听器
        }

        updateComponent() {
            const { store } = this.props;
            let stateProps = mapStateToProps(store.getState()); // 作为基于 Redux 存储状态的连接组件的 props
            let dispatchProps = mapDispatchToProps(store.dispatch); // 一组作为props传递给连接组件的 action creator 函数
            // 每次store数据更新后重新渲染一下页面
            this.setState({ allProps: { ...stateProps, ...dispatchProps } });
        }

        render() {
            return (
                <WrappedComponent {...this.state.allProps} />
            );
        }
    }

    // 返回一个包装函数,这个包装函数将你的组件作为参数,并返回一个包装组件,同时将额外的props注入到这个组件中
    return (props) => (
        <ReduxContext.Consumer>
            {(store) => <Connect {...props} store={store} />}
        </ReduxContext.Consumer>
    );
};

export default connect;

Hooks API

根据官方文档的建议,connect函数虽然仍然能在React-Redux 8.x版本中使用,但是他们仍建议使用Hooks API作为默认的开发方式。本文目前只实现connect函数,hooks可以作为后续研究点实现。

4. Provider函数

参考React-Redux官网API文档:Provider

Provider实际上是一个函数组件,它让任何内嵌的组件都能获取Redux store中的state。通常的做法是在最高层级使用Provider,然后将整个App的组件树放在里面。

其实现如下:

import React from 'react';
import ReduxContext from './index';

const Provider = ({ store, children }) => (
    <ReduxContext.Provider value={store}>
        {children}
    </ReduxContext.Provider>
);

export default Provider;

它借助了ReduxContext,也就是React.createContext()redux-lib中的index.js定义如下:

import createStore from "./createStore";
import combineReducers from "./combineReducers";
import connect from "./connect";
import Provider from "./provider";

import React from "react";

const ReduxContext = React.createContext("redux");

export default ReduxContext;
export { createStore, combineReducers, connect, Provider };

其中,ReduxContextProvider组件作为父节点,在App的顶层的index.js中使用,然后在connect.js中返回的包装组件使用了ReduxContextConsumer组件,作为子节点,包装了Counter组件然后封装到App中。这时App也可以被看作为一个消费者(子节点)。那么当 Provider 的 value 发生变化时,它内部的所有消费组件都会重新渲染。Provider 及其内部 consumer 组件都都不受 shouldComponentUpdate 函数的限制,因此当 consumer 组件在其祖先组件退出更新的情况下也能更新。

尾声

整个Redux的实现可以在这里找到: HaibiPeng/what-about-react

虽然也是拼拼凑凑,东改西改,但还是希望这篇总结能够帮你更好地理解Redux。

后续还可以有补充的点,比如:

  • 实现hooks API功能,代替connect函数
  • 加入createStore函数的enhancer参数支持
  • 加入connect函数的mergeProps/options参数支持
  • ...

另外Redux还有一个很重要的东西就是中间件(middlewares),如支持异步数据流的redux-thunkredux-saga。之后可能会写一篇关于对中间件的理解。

感谢阅读!下一篇见!