Redux 实战入门

前言

上一节我们了解了 Redux 基本的概念和特性后,本章我们要实际动手用 Redux、React Redux 结合 ImmutableJS 开发一个简单的 Todo 应用。话不多说,那就让让我们开始吧!

以下这张图表示了整个 React Redux App 的资料流程图(使用者与 View 互动 => dispatch 出 Action => Reducers 依据 action tyoe 分配到对应处理方式,回传新的 state => 透过 React Redux 传送给 React,React 重新绘制 View):

React Redux

动手创作 React Redux ImmutableJS TodoApp

在开始创作之前我们先完成一些开发的前置作业,先透过以下指令在根目录产生 npm 设定档 package.json

  1. $ npm init

安装相关套件(包含开发环境使用的套件):

  1. $ npm install --save react react-dom redux react-redux immutable redux-actions redux-immutable
  1. $ npm install --save-dev babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-react eslint eslint-config-airbnb eslint-loader eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react html-webpack-plugin webpack webpack-dev-server

安装好后我们可以设计一下我们的资料夹结构,首先我们在根目录建立 src,放置 scriptsource 。在 components 资料夹中我们会放置所有 components(个别组件资料夹中会用 index.js 输出组件,让引入组件更简洁)、containers(负责和 store 互动取得 state),另外还有 actionsconstantsreducersstore,其余设定档则放置于根目录下。

大致上的资料夹结构会长这样:

React Redux

接下来我们参考上一章设定一下开发文档(.babelrc.eslintrcwebpack.config.js)。这样我们就完成了开发环境的设定可以开始动手实作 React Redux 应用程式了!

首先我们先用 Component 之眼感受一下我们应用程式,将它切成一个个 Component。在这边我们设计一个主要的 Main 包含两个子 Component:TodoHeaderTodoList

React Redux

首先设计 HTML Markup:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>Redux Todo</title>
  6. </head>
  7. <body>
  8. <div id="app"></div>
  9. </body>
  10. </html>

在撰写 src/index.js 之前,我们先说明整合 react-redux 的用法。从以下这张图可以看到 react-redux 是 React 和 Redux 间的桥梁,使用 Providerconnect 去连结 store 和 React View。

React Redux

事实上,整合了 react-redux 后,我们的 React App 就可以解决传统跨 Component 之前传递 state 的问题和困难。只要透过 Provider 就可以让每个 React App 中的 Component 取用 store 中的 state,非常方便(接下来我们也会更详细说明 Container/Component、connect 的用法)。

React Redux

以下是 src/index.js 完整程式码:

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { Provider } from 'react-redux';
  4. import Main from './components/Main';
  5. import store from './store';
  6. ReactDOM.render(
  7. <Provider store={store}>
  8. <Main />
  9. </Provider>,
  10. document.getElementById('app')
  11. );

其中 src/components/Main/Main.js 是 Stateless Component,负责所有 View 的进入点。

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import TodoHeaderContainer from '../../containers/TodoHeaderContainer';
  4. import TodoListContainer from '../../containers/TodoListContainer';
  5. const Main = () => (
  6. <div>
  7. <TodoHeaderContainer />
  8. <TodoListContainer />
  9. </div>
  10. );
  11. export default Main;

接下来我们定义一下 Actions 的部份,由于是范例 App 所以相对简单,这边只定义一个 todoActions。在这边我们使用了 redux-actions,它可以方便我们使用 Flux Standard Action 格式的 action。以下是 src/actions/todoActions.js 完整程式码:

  1. import { createAction } from 'redux-actions';
  2. import {
  3. CREATE_TODO,
  4. DELETE_TODO,
  5. CHANGE_TEXT,
  6. } from '../constants/actionTypes';
  7. export const createTodo = createAction('CREATE_TODO');
  8. export const deleteTodo = createAction('DELETE_TODO');
  9. export const changeText = createAction('CHANGE_TEXT');

我们在 src/actions/index.js 将所有 actions 输出

  1. export * from './todoActions';

另外我们把 constants 放到 components 资料夹中方便管理,以下是 src/constants/actionTypes.js 程式码:

  1. export const CREATE_TODO = 'CREATE_TODO';
  2. export const DELETE_TODO = 'DELETE_TODO';
  3. export const CHANGE_TEXT = 'CHANGE_TEXT';
  4. /*
  5. 或是可以考虑使用 keyMirror,方便产生与 key 相同的常数
  6. import keyMirror from 'fbjs/lib/keyMirror';
  7. export default keyMirror({
  8. ADD_ITEM: null,
  9. DELETE_ITEM: null,
  10. DELETE_ALL: null,
  11. FILTER_ITEM: null
  12. });
  13. */

设定 Actions 后我们来讨论一下 Reducers 的部份。在讨论 Reducers 之前我们先来设定一下我们的前端的资料结构,在这边我们把所有资料结构(initialState)放到 src/constants/models.js 中。这边特别注意的是由于 Redux 中有一个重要特性是 State is read-only,也就是说更新当 reducers 进到 action 只会回传新的 state 不会更改到原有的 state。因此我们会在整个 Redux App 中使用 ImmutableJS 让整个资料流维持在 Immutable 的状态,也可以提升程式开发上的效能和避免不可预期的副作用。

以下是 src/constants/models.js 完整程式码,其设定了 TodoState 的资料结构并使用 fromJS() 转成 Immutable

  1. import Immutable from 'immutable';
  2. export const TodoState = Immutable.fromJS({
  3. 'todos': [],
  4. 'todo': {
  5. id: '',
  6. text: '',
  7. updatedAt: '',
  8. completed: false,
  9. }
  10. });

接下来我们要讨论的是 Reducers 的部份,在 todoReducers 中我们会根据接收到的 action 进行 mapping 到对应的处理函式并传入夹带的 payload 资料(这边我们使用 redux-actions 来进行 mapping,使用上比传统的 switch 更为简洁)。Reducers 接收到 action 的处理方式为 (initialState, action) => newState,最终会回传一个新的 state,而非更改原来的 state,所以这边我们使用 ImmutableJS

  1. import { handleActions } from 'redux-actions';
  2. import { TodoState } from '../../constants/models';
  3. import {
  4. CREATE_TODO,
  5. DELETE_TODO,
  6. CHANGE_TEXT,
  7. } from '../../constants/actionTypes';
  8. const todoReducers = handleActions({
  9. CREATE_TODO: (state) => {
  10. let todos = state.get('todos').push(state.get('todo'));
  11. return state.set('todos', todos)
  12. },
  13. DELETE_TODO: (state, { payload }) => (
  14. state.set('todos', state.get('todos').splice(payload.index, 1))
  15. ),
  16. CHANGE_TEXT: (state, { payload }) => (
  17. state.merge({ 'todo': payload })
  18. )
  19. }, TodoState);
  20. export default todoReducers;
  1. import { handleActions } from 'redux-actions';
  2. import UiState from '../../constants/models';
  3. export default handleActions({
  4. SHOW: (state, { payload }) => (
  5. state.set('todos', payload.todo)
  6. ),
  7. }, UiState);

虽然 Redux 本身仅会有一个 store,但 redux 本身有提供了 combineReducers 可以让我们切割我们 state 方便维护和管理。实上,state 的规划也是一们学问,通常需要不断地实作和工作团队讨论才能找到比较好的方式。不过这边要注意的是我们改使用了 redux-immutablecombineReducers 这样可以确保我们的 state 维持在 Immutable 的状态。

由于 Redux 官方也没有特别明确或严谨的规范。在一般情况我会将 reducers 分为 data 和单纯和 UI 有关的 ui state。但由于这边是比较简单的例子,我们最终只使用到 src/reducers/data/todoReducers.js

  1. import { combineReducers } from 'redux-immutable';
  2. import ui from './ui/uiReducers';// import routes from './routes';
  3. import todo from './data/todoReducers';// import routes from './routes';
  4. const rootReducer = combineReducers({
  5. todo,
  6. });
  7. export default rootReducer;

还记得我们上面说明 React Redux 之前的桥梁时有提到的 store 吗?现在我们要更仔细地去设计 store,我们这边使用到了 redux 其中两个 API:applyMiddleware、createStore。分别可以产生 store 和挂载我们要使用的 middleware(这边我们只使用到 redux-logger 方便我们除错)。注意我们 initialState 也是维持在 Immutable 的状态。

  1. import { createStore, applyMiddleware } from 'redux';
  2. import createLogger from 'redux-logger';
  3. import Immutable from 'immutable';
  4. import rootReducer from '../reducers';
  5. const initialState = Immutable.Map();
  6. export default createStore(
  7. rootReducer,
  8. initialState,
  9. applyMiddleware(createLogger({ stateTransformer: state => state.toJS() }))
  10. );

透过 src/store/index.js 输出 configureStore:

  1. export { default } from './configureStore';

讲解完架构层面的议题,终于我们来到了 View 的部份。加油,距离我们终点也不远了!
在开始讨论 Component 的部份之前我们先来研究一下

react-redux 所提供的 API connect 将 props 传给 Component,其用法如下:

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

在我们的范例 App 中我们只会先用到前两个参数,第三个参数会在之后的例子里用到。第一个参数 mapStateToProps 是一个让开发者可以从 store 取出想要 state 并当做 props 往下传的功能,第二个参数则是将 dispatch 行为封装成函数顺着 props 可以方便往下传和呼叫。

以下是 src/components/TodoHeader/TodoHeader.js 的部份:

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { connect } from 'react-redux';
  4. import TodoHeader from '../../components/TodoHeader';
  5. // 将欲使用的 actions 引入
  6. import {
  7. changeText,
  8. createTodo,
  9. } from '../../actions';
  10. const mapStateToProps = (state) => ({
  11. // 从 store 取得 todo state
  12. todo: state.getIn(['todo', 'todo'])
  13. });
  14. const mapDispatchToProps = (dispatch) => ({
  15. // 当使用者在 input 输入资料值即会触发这个函数,发出 changeText action 并附上使用者输入内容 event.target.value
  16. onChangeText: (event) => (
  17. dispatch(changeText({ text: event.target.value }))
  18. ),
  19. // 当使用者按下送出时,发出 createTodo action 并清空 input
  20. onCreateTodo: () => {
  21. dispatch(createTodo());
  22. dispatch(changeText({ text: '' }));
  23. }
  24. });
  25. export default connect(
  26. mapStateToProps,
  27. mapDispatchToProps,
  28. )(TodoHeader);
  29. // 开始建设 Component 并使用 connect 进来的 props 并绑定事件(onChange、onClick)。注意我们的 state 因为是使用 `ImmutableJS` 所以要用 `get()` 取值
  30. const TodoHeader = ({
  31. onChangeText,
  32. onCreateTodo,
  33. todo,
  34. }) => (
  35. <div>
  36. <h1>TodoHeader</h1>
  37. <input type="text" value={todo.get('text')} onChange={onChangeText} />
  38. <button onClick={onCreateTodo}>送出</button>
  39. </div>
  40. );
  41. export default TodoHeader;

以下是 src/components/TodoList/TodoList.js 的部份:

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { connect } from 'react-redux';
  4. import TodoList from '../../components/TodoList';
  5. import {
  6. deleteTodo,
  7. } from '../../actions';
  8. const mapStateToProps = (state) => ({
  9. todos: state.getIn(['todo', 'todos'])
  10. });
  11. // 由 Component 传进欲删除元素的 index
  12. const mapDispatchToProps = (dispatch) => ({
  13. onDeleteTodo: (index) => () => (
  14. dispatch(deleteTodo({ index }))
  15. )
  16. });
  17. export default connect(
  18. mapStateToProps,
  19. mapDispatchToProps,
  20. )(TodoList);
  21. // Component 部分值的注意的是 todos state 是透过 map function 去迭代出元素,由于要让 React JSX 可以渲染并保持传入触发 event state 的 immutable,所以需使用 toJS() 转换 component of array。
  22. const TodoList = ({
  23. todos,
  24. onDeleteTodo,
  25. }) => (
  26. <div>
  27. <ul>
  28. {
  29. todos.map((todo, index) => (
  30. <li key={index}>
  31. {todo.get('text')}
  32. <button onClick={onDeleteTodo(index)}>X</button>
  33. </li>
  34. )).toJS()
  35. }
  36. </ul>
  37. </div>
  38. );
  39. export default TodoList;

若是一切顺利的话就可以在浏览器上看到自己努力的成果囉!(因为我们有使用 redux-logger 所以打开 console 会看到 action 和 state 的变化情形,但记得在 production 环境要拿掉)

React Redux

总结

以上就是 Redux 实战入门,对于第一次自己动手写 Redux 的朋友可能会需要多练习几次,多体会整个架构。在接下来的章节我们将优化我们的 React Redux TodoApp,让它可以有更清晰好维护的架构。

延伸阅读

  1. Redux 官方网站

(image via JonasOhlssonlicdn

| 回首页 | 上一章:Redux 基础概念 | 下一章:Container 与 Presentational Components 入门 |

| 勘误、提问或许愿 |