Redux深度揭秘

#1

原文链接:https://github.com/jnotnull/dva-generator/issues/5 ,后续还有更多文章,欢迎star

背景

根据React官方定义,React是解决UI层的库,所以在实际项目中要想完成功能,必须借助其他手段来完成其它层的定义和控制。Redux的出现很好的解决了数据流的问题,完成了其它层的定义和控制。

和传统MVC相比的优势

我们先看下传统的MVC结构。

                         + Input
                         |
                         |
                 +-------v------+
       +---------+  Controller  +---------+
       |         +--------------+         |
       |                                  |
       |                                  |
       |                                  |
+------v-------+                  +-------v------+
|     Model    |                  |      View    |
+--------------+                  +--------------+

从图中我们可以看出以下问题:

  1. 当Controller和Model进行交互时候,他们会改变Model的取值,但是随着项目复杂度的增加,可能会有很多Controll操作相同的Model,带来的问题就是最后不知道有哪些操作了Model,这也带来了数据的不确定性。
  2. 因为不可预测,所以很难做到undo。
                                       +--------+
                      +----------------+ Action <-------------+
                      |                +--------+             |
                      |                                       |
                      |                                       |
+--------+      +-----v------+        +---------+        +----+---+
| Action +------> Dispatcher +--------> Reducer +-------->  View  |
+--------+      +------------+        +---------+        +--------+

而Redux的出现很好的解决了这些问题。根据官方所述,它主要有以下特点。

  1. 因为使用了pure函数,所以任何时候数据的输出都是可预测的,包括UI,这也极大的方便进行单元测试;
  2. 通过记录action,我们能知道谁在什么时候修改了数据,这就让时间旅行成为现实。我们只要记录下修改上下文就可以了。

Redux思想

因为Redux有这多好处,那我们现在就来重点看下它到底是何物。首先从它的名字说起吧。

根据维基百科Redux 的解释: brought back, restored可以看出它强调的就是状态的undo,如何做到这一点呢,靠的就是pure函数。pure函数是我们熟悉的了:对于相同的输入值,能够产生相同的输出,并且不依赖外部上下文。

关于Redux名字的讨论,有兴趣的可以看下这个帖子 Redux? Why is it called that? ,全当娱乐了。

下面我们来重点看下Redux组成。它主要分为三个部分 Action、Reducer、及 Store。先看下Reducer,根据名字可以看出来它是类似Reduce的角色。Reduce来源于函数式编程,参考MSDN 的定义,它会对数组中的所有元素调用指定的回调函数。该回调函数的返回值为累积结果,并且此返回值在下一次调用该回调函数时作为参数提供。方法签名如下:

array1.reduce(callbackfn[, initialValue])

这里的callbackfn就是reducer。由此我们可以模仿上面方法签名得出如下表达式:

Final State = [Action1, Action2, ..., ActionN].reduce(reducer, Initial State);

这就形成了Redux的基本核心思路:通过对Action数组的reduce处理,得到最终的状态。不仅如此,为了动作可控,Redux还定义了三个原则:

  1. 单一数据源
  2. State 是只读的
  3. 使用纯函数来执行修改

三个原则中都是针对数据的规范,由此我们可以得出结论,数据就是Redux的心脏,所有动作都是围绕它来做的。

Action和store

说完reducer之后我们再看下Action和store。按照官方所述,Action 是把数据从应用传到 store 的有效载荷。它是 store 数据的唯一来源。一般来说你会通过 store.dispatch() 将 action 传到 store。

Action长啥样子呢?

{
  type: ADD_TODO,
  content: ''
}

可以看到就是一个普通的带有type的对象,type用来区分动作,其他都为参数。那store又是啥呢,很明显,它是对reducer的进一步混装,要想调用reducer里面的方法,必须走store的dispatch方法。

自己实现Redux – state和action

理解了Redux的核心思想后,我们自己动手来实现一个Redux!

因为数据如此重要,我们首先从它开始入手。比如我们想开发一个TODO list,按照Redux单一数据源原则,我们定义如下:

window.state = {
    todos: [],
    nextId: 1
}

按照上面对Action的理解,我们定义如下Action:

{type: `ADD_TODO`}
{type: `UPDATE_TODO`, id:1, content: 'xx' }

因为按照第二个和第三个原则,我们不能直接修改state,所以我们要定义下纯函数:

add(state){
    state.todos[state.nextId] = {
        id: state.nextId,
        content: `TODO${state.nextId}`
      };
    state.nextId++;
    return state;
}
update(state, action){
    state.todos[action.id].content = action.content;
    return state;
}

但是你会发现这么写是有问题的,因为你直接修改了state的值,而对象的引用并没有变,这就无法做到undo了,所以我们必须引入新的概念,那就是Immutability。

add(state){
    const id = state.nextId;
    const newTODO = {
        id,
        content: ''
    };
    return {
        ...state,
        nextId: id + 1,
        todos: {
          ...state.todos,
          [id]: newTODO
        }
    };
}
update(state, action){
    const {id, content} = action;
    const editedTODO = {
        ...state.todos[id],
        content
    };
    return {
        ...state,
        todos: {
          ...state.todos,
          [id]: editedTODO
        }
    };
}

reducer

那现在我们就把它们封装到reducer中了

const CREATE_TODO = 'CREATE_TODO';
const UPDATE_TODO = 'UPDATE_TODO';
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case CREATE_TODO: {
      const id = state.nextId;
      const newTODO = {
        id,
        content: ''
      };
      return {
        ...state,
        nextId: id + 1,
        todos: {
          ...state.todos,
          [id]: newTODO
        }
      };
    }
    case UPDATE_TODO: {
      const {id, content} = action;
      const editedTODO = {
        ...state.todos[id],
        content
      };
      return {
        ...state,
        todos: {
          ...state.todos,
          [id]: editedTODO
        }
      };
    }
    default:
      return state;
  }
};

现在我们用自己的reducer测试下:

const state0 = reducer(undefined, {
  type: CREATE_TODO
});

我们可以看到state0为如下结果:

{nextId:2, todos:{id: 1, content: ''}}

我们再测试下UPDATE方法:

const state1  = reducer(state0, {
  type: UPDATE_TODO,
  id: 1,
  content: 'Hello, world!'
});

我们可以看到state1为如下结果:

{nextId:2, todos:{id: 1, content: 'Hello, world!'}}

看看,测试起来都非常方便。那这两个Action如何一起调用了,很简单:

const actions = [
  {type: CREATE_TODO},
  {type: UPDATE_TODO, id: 1, content: 'Hello, world!'}
];

const state = actions.reduce(reducer, undefined);

我们得到了同样的结果:

{nextId:2, todos:{id: 1, content: 'Hello, world!'}}

store

完成action和reducer的构造之后,我们再来构造store。因为我们已经知道,调用reducer要走dispatch,所以先给出如下结构:

const createStore = (reducer, preloadedState) => {
  let currentState = undefined;
  return {
    dispatch: (action) => {
      currentState = reducer(preloadedState, action);
    },
    getState: () => currentState
  };
};

增加下测试方法:

const store = createStore(reducer, window.state);
store.dispatch({
  type: CREATE_TODO
});
console.log(store.getState());

非常赞,已经有了Redux的影子了,但是光有这些还不够。当数据发生变化时候,必须要进行通知,不然就没法进行界面渲染了。我们修改createStore如下:

const createStore = (reducer, preloadedState) => {
  let currentState = undefined;
  let nextListeners = [];
  return {
    dispatch: (action) => {
      currentState = reducer(preloadedState, action);
      nextListeners.forEach(handler => handler());
    },
    getState: () => currentState,
    subscribe: handler => {
	    nextListeners.push(listener)

	    return function unsubscribe() {
	      var index = nextListeners.indexOf(listener)
	      nextListeners.splice(index, 1)
	    }
     }

  };
};

添加listener到nextListeners后,返回unsubscribe,以供取消订阅。我们写下renderDOM:

store.subscribe(() => {
  ReactDOM.render(
    <div>{JSON.stringify(store.getState())}</div>,
    document.getElementById('root')
  );
});

到此,一个基本的Redux已经完成了。

那如何在React的组件中使用我们自己创建的store呢。只要把store作为属性传递进去就可以了:

const TODOApp = ({todos, handeladd, handeledit}) => (
  <div>
    <ul>
    {
      todos && Object.keys(todos).map((id, content) => (
        <li key={id}>{content}</li>
      ))
    }
    </ul>
    <button onClick={handeladd}>add</button>
  </div>
);

class TODOAppContainer extends React.Component {
  constructor(props) {
    super();
    this.state = props.store.getState();
    this.handeladd = this.handeladd.bind(this);
    this.handeledit = this.handeledit.bind(this);
  }
  componentWillMount() {
    this.unsubscribe = this.props.store.subscribe(() =>
      this.setState(this.props.store.getState())
    );
  }
  componentWillUnmount() {
    this.unsubscribe();
  }
  handeladd() {
    this.props.store.dispatch({
      type: CREATE_TODO
    });
  }
  handeledit(id, content) {
    this.props.store.dispatch({
      type: UPDATE_TODO,
      id,
      content
    });
  }
  render() {
    return (
      <TODOApp
        {...this.state}
        handeladd={this.handeladd}
        handeledit={this.handeledit}
      />
    );
  }
}

ReactDOM.render(
  <TODOAppContainer store={store}/>,
  document.getElementById('root')
);

Provider 和 Connect

但是这样的做法显示是耦合太重了,我们针对React专门提供 Provider 和 Connect 方法,这就是 react-redux。

参考react-redux的做法,我们首先来新建一个Provider来包括APP,它的主要作用就是让store传递到所有子节点上去,getChildContext真是可以做这样的功能。

class Provider extends React.Component {
  getChildContext() {
    return {
      store: this.props.store
    };
  }
  render() {
    return this.props.children;
  }
}

另外,对于store中的数据变化要反映到组件中,我们通过connect来完成。根据定义,connect的方法签名如下:

connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])

我们重点关注前面两个参数。

const connect = (mapStateToProps, mapDispatchToProps) => {

	return (Component) => {

	  	class Connected extends React.Component {

		    onStoreOrPropsChange(props) {
		      const {store} = this.context;
		      const state = store.getState();
		      const stateProps = mapStateToProps(state, props);
		      const dispatchProps = mapDispatchToProps(store.dispatch, props);
		      this.setState({
		        ...stateProps,
		        ...dispatchProps
		      });
		    }

		    componentWillMount() {
		      const {store} = this.context;
		      this.onStoreOrPropsChange(this.props);
		      this.unsubscribe = store.subscribe(() =>
		        this.onStoreOrPropsChange(this.props)
		      );
		    }

		    componentWillReceiveProps(nextProps) {
		      this.onStoreOrPropsChange(nextProps);
		    }

		    componentWillUnmount() {
		      this.unsubscribe();
		    }

		    render() {
		      return <Component {...this.props} {...this.state}/>;
		    }
	  	}

	  	Connected.contextTypes = {
			store: PropTypes.object
		};

	  	return Connected;
	}
};

调用方式:

const TODOAppContainer = connect(
	mapStateToProps,
	mapDispatchToProps
)(TODOApp);

再加上Provider

ReactDOM.render(
  <Provider store={store}>
    <TODOAppContainer/>
  </Provider>,
  document.getElementById('root')
);

至此我们已经构造了一个同步的Redux了。

完整代码路径:https://github.com/jnotnull/build-your-own-redux

参考文章:

  1. https://blog.gisspan.com/2017/02/Redux-Vs-MVC,-Why-and-How.html
  2. https://fakefish.github.io/react-webpack-cookbook/
  3. https://blog.pusher.com/the-what-and-why-of-redux/
  4. https://zapier.com/engineering/how-to-build-redux/
  5. http://community.pearljam.com/discussion/95759/redux-why-is-it-called-that
  6. http://www.avitzurel.com/blog/2016/08/03/connected-higher-order-components-with-react-and-redux/