缩减样板代码

Redux 很大部分 受到 Flux 的启发,而最常见的关于 Flux 的抱怨是必须写一大堆的样板代码。在这章中,我们将考虑 Redux 如何根据个人风格,团队偏好,长期可维护性等自由决定代码的繁复程度。

Actions

Actions 是用来描述在 app 中发生了什么的普通对象,并且是描述突变数据意图的唯一途径。很重要的一点是 不得不 dispatch 的 action 对象并非是一个样板代码,而是 Redux 的一个 基本设计选择.

不少框架声称自己和 Flux 很像,只不过缺少了 action 对象的概念。在可预测性方面,这是从 Flux 或 Redux 的倒退。如果没有可序列化的普通对象 action,便无法记录或重演用户会话,也无法实现 带有时间旅行的热重载。如果你更喜欢直接修改数据,那你并不需要使用 Redux 。

Action 一般长这样:

{ type: 'ADD_TODO', text: 'Use Redux' }
{ type: 'REMOVE_TODO', id: 42 }
{ type: 'LOAD_ARTICLE', response: { ... } }

一个约定俗成的做法是,action 拥有一个不变的 type 帮助 reducer (或 Flux 中的 Stores ) 识别它们。我们建议你使用 string 而不是 符号(Symbols) 作为 action type ,因为 string 是可序列化的,并且使用符号会使记录和重演变得困难。

在 Flux 中,传统的想法是将每个 action type 定义为 string 常量:

const ADD_TODO = 'ADD_TODO'
const REMOVE_TODO = 'REMOVE_TODO'
const LOAD_ARTICLE = 'LOAD_ARTICLE'

这么做的优势是什么?人们通常声称常量不是必要的。对于小项目也许正确。 对于大的项目,将 action types 定义为常量有如下好处:

  • 帮助维护命名一致性,因为所有的 action type 汇总在同一位置。
  • 有时,在开发一个新功能之前你想看到所有现存的 actions 。而你的团队里可能已经有人添加了你所需要的 action,而你并不知道。
  • Action types 列表在 Pull Request 中能查到所有添加,删除,修改的记录。这能帮助团队中的所有人及时追踪新功能的范围与实现。
  • 如果你在 import 一个 Action 常量的时候拼写错了,你会得到 undefined 。在 dispatch 这个 action 的时候,Redux 会立即抛出这个错误,你也会马上发现错误。

你的项目约定取决与你自己。开始时,可能在刚开始用内联字符串(inline string),之后转为常量,也许再之后将他们归为一个独立文件。Redux 在这里没有任何建议,选择你自己最喜欢的。

Action Creators

另一个约定俗成的做法是通过创建函数生成 action 对象,而不是在你 dispatch 的时候内联生成它们。

例如,不是使用对象字面量调用 dispatch

// event handler 里的某处
dispatch({
  type: 'ADD_TODO',
  text: 'Use Redux'
})

你其实可以在单独的文件中写一个 action creator ,然后从 component 里 import:

actionCreators.js

export function addTodo(text) {
  return {
    type: 'ADD_TODO',
    text
  }
}

AddTodo.js

import { addTodo } from './actionCreators'

// event handler 里的某处
dispatch(addTodo('Use Redux'))

Action creators 总被当作样板代码受到批评。好吧,其实你并不非得把他们写出来!如果你觉得更适合你的项目,你可以选用对象字面量 然而,你应该知道写 action creators 是存在某种优势的。

假设有个设计师看完我们的原型之后回来说,我们最多只允许三个 todo 。我们可以使用 redux-thunk 中间件,并添加一个提前退出,把我们的 action creator 重写成回调形式:

function addTodoWithoutCheck(text) {
  return {
    type: 'ADD_TODO',
    text
  }
}

export function addTodo(text) {
  // Redux Thunk 中间件允许这种形式
  // 在下面的 “异步 Action Creators” 段落中有写
  return function(dispatch, getState) {
    if (getState().todos.length === 3) {
      // 提前退出
      return
    }
    dispatch(addTodoWithoutCheck(text))
  }
}

我们刚修改了 addTodo action creator 的行为,使得它对调用它的代码完全不可见。我们不用担心去每个添加 todo 的地方看一看,以确认他们有了这个检查 Action creator 让你可以解耦额外的分发 action 逻辑与实际发送这些 action 的 components 。当你有大量开发工作且需求经常变更的时候,这种方法十分简便易用。

Action Creators 生成器

某些框架如 Flummox 自动从 action creator 函数定义生成 action type 常量。这个想法是说你不需要同时定义 ADD_TODO 常量和 addTodo() action creator 。这样的方法在底层也生成了 action type 常量,但他们是隐式生成的、间接级,会造成混乱。因此我们建议直接清晰地创建 action type 常量。

写简单的 action creator 很容易让人厌烦,且往往最终生成多余的样板代码:

export function addTodo(text) {
  return {
    type: 'ADD_TODO',
    text
  }
}

export function editTodo(id, text) {
  return {
    type: 'EDIT_TODO',
    id,
    text
  }
}

export function removeTodo(id) {
  return {
    type: 'REMOVE_TODO',
    id
  }
}

你可以写一个用于生成 action creator 的函数:

function makeActionCreator(type, ...argNames) {
  return function(...args) {
    const action = { type }
    argNames.forEach((arg, index) => {
      action[argNames[index]] = args[index]
    })
    return action
  }
}

const ADD_TODO = 'ADD_TODO'
const EDIT_TODO = 'EDIT_TODO'
const REMOVE_TODO = 'REMOVE_TODO'

export const addTodo = makeActionCreator(ADD_TODO, 'text')
export const editTodo = makeActionCreator(EDIT_TODO, 'id', 'text')
export const removeTodo = makeActionCreator(REMOVE_TODO, 'id')

一些工具库也可以帮助生成 action creator ,例如 redux-actredux-actions 。这些库可以有效减少你的样板代码,并紧守例如 Flux Standard Action (FSA) 一类的标准。

异步 Action Creators

中间件 让你在每个 action 对象 dispatch 出去之前,注入一个自定义的逻辑来解释你的 action 对象。异步 action 是中间件的最常见用例。

如果没有中间件,dispatch 只能接收一个普通对象。因此我们必须在 components 里面进行 AJAX 调用:

actionCreators.js

export function loadPostsSuccess(userId, response) {
  return {
    type: 'LOAD_POSTS_SUCCESS',
    userId,
    response
  }
}

export function loadPostsFailure(userId, error) {
  return {
    type: 'LOAD_POSTS_FAILURE',
    userId,
    error
  }
}

export function loadPostsRequest(userId) {
  return {
    type: 'LOAD_POSTS_REQUEST',
    userId
  }
}

UserInfo.js

import { Component } from 'react'
import { connect } from 'react-redux'
import {
  loadPostsRequest,
  loadPostsSuccess,
  loadPostsFailure
} from './actionCreators'

class Posts extends Component {
  loadData(userId) {
    // 调用 React Redux `connect()` 注入的 props :
    const { dispatch, posts } = this.props

    if (posts[userId]) {
      // 这里是被缓存的数据!啥也不做。
      return
    }

    // Reducer 可以通过设置 `isFetching` 响应这个 action
    // 因此让我们显示一个 Spinner 控件。
    dispatch(loadPostsRequest(userId))

    // Reducer 可以通过填写 `users` 响应这些 actions
    fetch(`http://myapi.com/users/${userId}/posts`).then(
      response => dispatch(loadPostsSuccess(userId, response)),
      error => dispatch(loadPostsFailure(userId, error))
    )
  }

  componentDidMount() {
    this.loadData(this.props.userId)
  }

  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.loadData(this.props.userId)
    }
  }

  render() {
    if (this.props.isFetching) {
      return <p>Loading...</p>
    }

    const posts = this.props.posts.map(post => (
      <Post post={post} key={post.id} />
    ))

    return <div>{posts}</div>
  }
}

export default connect(state => ({
  posts: state.posts,
  isFetching: state.isFetching
}))(Posts)

然而,不久就需要再来一遍,因为不同的 components 从同样的 API 端点请求数据。而且我们想要在多个 components 中重用一些逻辑(比如,当缓存数据有效的时候提前退出)。

中间件让我们能写表达更清晰的、潜在的异步 action creators。 它允许我们 dispatch 普通对象之外的东西,并且解释它们的值。比如,中间件能 “捕捉” 到已经 dispatch 的 Promises 并把他们变为一对请求和成功/失败的 action.

中间件最简单的例子是 redux-thunk. “Thunk” 中间件让你可以把 action creators 写成 “thunks”,也就是返回函数的函数。 这使得控制被反转了: 你会像一个参数一样取得 dispatch ,所以你也能写一个多次分发的 action creator 。

注意

Thunk 只是一个中间件的例子。中间件不仅仅是关于 “分发函数” 的:而是关于你可以使用特定的中间件来分发任何该中间件可以处理的东西。例子中的 Thunk 中间件添加了一个特定的行为用来分发函数,但这实际取决于你用的中间件。

redux-thunk 重写上面的代码:

actionCreators.js

export function loadPosts(userId) {
  // 用 thunk 中间件解释:
  return function(dispatch, getState) {
    const { posts } = getState()
    if (posts[userId]) {
      // 这里是数据缓存!啥也不做。
      return
    }

    dispatch({
      type: 'LOAD_POSTS_REQUEST',
      userId
    })

    // 异步分发原味 action
    fetch(`http://myapi.com/users/${userId}/posts`).then(
      response =>
        dispatch({
          type: 'LOAD_POSTS_SUCCESS',
          userId,
          response
        }),
      error =>
        dispatch({
          type: 'LOAD_POSTS_FAILURE',
          userId,
          error
        })
    )
  }
}

UserInfo.js

import { Component } from 'react'
import { connect } from 'react-redux'
import { loadPosts } from './actionCreators'

class Posts extends Component {
  componentDidMount() {
    this.props.dispatch(loadPosts(this.props.userId))
  }

  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.props.dispatch(loadPosts(this.props.userId))
    }
  }

  render() {
    if (this.props.isFetching) {
      return <p>Loading...</p>
    }

    const posts = this.props.posts.map(post => (
      <Post post={post} key={post.id} />
    ))

    return <div>{posts}</div>
  }
}

export default connect(state => ({
  posts: state.posts,
  isFetching: state.isFetching
}))(Posts)

这样打得字少多了!如果你喜欢,你还是可以保留 “原味” action creators 比如从一个容器 loadPosts action creator 里用到的 loadPostsSuccess

最后,你可以编写你自己的中间件 你可以把上面的模式泛化,然后代之以这样的异步 action creators :

export function loadPosts(userId) {
  return {
    // 要在之前和之后发送的 action types
    types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
    // 检查缓存 (可选):
    shouldCallAPI: state => !state.users[userId],
    // 进行取:
    callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
    // 在 actions 的开始和结束注入的参数
    payload: { userId }
  }
}

解释这个 actions 的中间件可以像这样:

function callAPIMiddleware({ dispatch, getState }) {
  return next => action => {
    const { types, callAPI, shouldCallAPI = () => true, payload = {} } = action

    if (!types) {
      // Normal action: pass it on
      return next(action)
    }

    if (
      !Array.isArray(types) ||
      types.length !== 3 ||
      !types.every(type => typeof type === 'string')
    ) {
      throw new Error('Expected an array of three string types.')
    }

    if (typeof callAPI !== 'function') {
      throw new Error('Expected callAPI to be a function.')
    }

    if (!shouldCallAPI(getState())) {
      return
    }

    const [requestType, successType, failureType] = types

    dispatch(
      Object.assign({}, payload, {
        type: requestType
      })
    )

    return callAPI().then(
      response =>
        dispatch(
          Object.assign({}, payload, {
            response,
            type: successType
          })
        ),
      error =>
        dispatch(
          Object.assign({}, payload, {
            error,
            type: failureType
          })
        )
    )
  }
}

在传给 applyMiddleware(...middlewares) 一次以后,你能用相同方式写你的 API 调用 action creators :

export function loadPosts(userId) {
  return {
    types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
    shouldCallAPI: state => !state.users[userId],
    callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
    payload: { userId }
  }
}

export function loadComments(postId) {
  return {
    types: [
      'LOAD_COMMENTS_REQUEST',
      'LOAD_COMMENTS_SUCCESS',
      'LOAD_COMMENTS_FAILURE'
    ],
    shouldCallAPI: state => !state.posts[postId],
    callAPI: () => fetch(`http://myapi.com/posts/${postId}/comments`),
    payload: { postId }
  }
}

export function addComment(postId, message) {
  return {
    types: [
      'ADD_COMMENT_REQUEST',
      'ADD_COMMENT_SUCCESS',
      'ADD_COMMENT_FAILURE'
    ],
    callAPI: () =>
      fetch(`http://myapi.com/posts/${postId}/comments`, {
        method: 'post',
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ message })
      }),
    payload: { postId, message }
  }
}

Reducers

Redux reducer 用函数描述逻辑更新减少了样板代码里大量的 Flux stores 。函数比对象简单,比类更简单得多。

这个 Flux store:

const _todos = []

const TodoStore = Object.assign({}, EventEmitter.prototype, {
  getAll() {
    return _todos
  }
})

AppDispatcher.register(function(action) {
  switch (action.type) {
    case ActionTypes.ADD_TODO:
      const text = action.text.trim()
      _todos.push(text)
      TodoStore.emitChange()
  }
})

export default TodoStore

用了 Redux 之后,同样的逻辑更新可以被写成 reducing function:

export function todos(state = [], action) {
  switch (action.type) {
    case ActionTypes.ADD_TODO:
      const text = action.text.trim()
      return [...state, text]
    default:
      return state
  }
}

switch 语句 不是 真正的样板代码。真正的 Flux 样板代码是概念性的:发送更新的需求,用 Dispatcher 注册 Store 的需求,Store 是对象的需求 (当你想要一个哪都能跑的 App 的时候复杂度会提升)。

不幸的是很多人仍然靠文档里用没用 switch 来选择 Flux 框架。如果你不爱用 switch 你可以用一个单独的函数来解决,下面会演示。

Reducers 生成器

写一个函数将 reducers 表达为 action types 到 handlers 的映射对象。例如,如果想在 todos reducer 里这样定义:

export const todos = createReducer([], {
  [ActionTypes.ADD_TODO]: (state, action) => {
    const text = action.text.trim()
    return [...state, text]
  }
})

我们可以编写下面的辅助函数来完成:

function createReducer(initialState, handlers) {
  return function reducer(state = initialState, action) {
    if (handlers.hasOwnProperty(action.type)) {
      return handlers[action.type](state, action)
    } else {
      return state
    }
  }
}

不难对吧?鉴于写法多种多样,Redux 没有默认提供这样的辅助函数。可能你想要自动地将普通 JS 对象变成 Immutable 对象,以填满服务器状态的对象数据。可能你想合并返回状态和当前状态。有多种多样的方法来 “获取所有” handler,具体怎么做则取决于项目中你和你的团队的约定。

Redux reducer 的 API 是 (state, action) => newState,但是怎么创建这些 reducers 由你来定。