跳至主内容
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

减少样板代码

Redux 部分受到 Flux 的启发,而 Flux 最常被诟病的就是需要编写大量样板代码。本文档将探讨 Redux 如何让我们根据个人风格、团队偏好、长期可维护性等因素,自由选择代码的详细程度。

动作 (Actions)

动作是描述应用程序中发生事件的普通对象,也是描述数据变更意图的唯一方式。必须通过分派对象来表示动作并非样板代码,而是 Redux 的核心设计原则,理解这一点至关重要。

有些框架宣称类似 Flux 却缺乏动作对象的概念。在可预测性方面,这相比 Flux 或 Redux 是一种倒退。若没有可序列化的普通对象动作,就无法实现用户会话记录与回放,也无法支持带时间旅行的热重载。如果你倾向直接修改数据,那就不需要 Redux。

动作的典型结构如下:

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

行业惯例是为动作定义常量类型,以帮助 reducer(或 Flux 中的 Store)识别它们。我们建议使用字符串而非 Symbols 作为动作类型,因为字符串可序列化,而使用 Symbols 会不必要地增加记录和回放的难度。

在 Flux 中,传统做法是将每个动作类型定义为字符串常量:

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

这有什么好处?虽然常有人认为常量非必需,对于小型项目可能确实如此。 但在大型项目中,将动作类型定义为常量有以下优势:

  • 所有动作类型集中管理,有助于保持命名一致性

  • 开发新功能前可查看现有动作,避免团队其他成员已添加所需动作却不知情

  • 通过 Pull Request 中新增、删除或变更的动作类型列表,团队成员可清晰跟踪新功能的范围和实施情况

  • 导入动作常量时若拼写错误将得到 undefined。Redux 分派此类动作会立即抛出错误,便于快速定位问题

具体规范由项目自行决定。可先使用内联字符串,后续转为常量,甚至最终整合到单一文件中。Redux 对此不作强制要求,请根据实际情况判断。

动作创建器

另一常见规范是:不在分派动作处直接创建动作对象,而是通过生成器函数创建。

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

// somewhere in an event handler
dispatch({
type: 'ADD_TODO',
text: 'Use Redux'
})

改为在独立文件中编写动作创建器,再导入组件:

actionCreators.js

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

AddTodo.js

import { addTodo } from './actionCreators'

// somewhere in an event handler
dispatch(addTodo('Use Redux'))

动作创建器常被视为样板代码。但请注意:如果对象字面量更适合项目,完全可以不写创建器! 不过了解动作创建器的优势仍有价值。

假设设计师评审原型后要求待办事项上限为三条。我们可以用 redux-thunk 中间件将动作创建器改写成回调形式,并添加提前退出逻辑:

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

export function addTodo(text) {
// This form is allowed by Redux Thunk middleware
// described below in “Async Action Creators” section.
return function (dispatch, getState) {
if (getState().todos.length === 3) {
// Exit early
return
}
dispatch(addTodoWithoutCheck(text))
}
}

我们刚刚修改了 addTodo 动作创建器的行为,调用代码完全感知不到这些变化。我们无需检查每个添加待办事项的位置来确保它们包含此校验。动作创建器让你能将调度动作的附加逻辑与实际触发动作的组件解耦。这在应用处于密集开发阶段且需求频繁变更时非常实用。

生成动作创建器

某些框架如 Flummox 会根据动作创建器定义自动生成动作类型常量。其理念是你无需同时定义 ADD_TODO 常量和 addTodo() 动作创建器。这类方案底层仍会生成动作类型常量,但由于是隐式创建,会产生间接层并可能导致混淆。我们建议显式创建动作类型常量。

编写简单的动作创建器可能枯燥且容易产生冗余样板代码:

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
}
}

你可以编写函数来生成动作创建器:

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')

也有工具库可辅助生成动作创建器,例如 redux-actredux-actions。它们能减少样板代码并帮助遵循 Flux 标准动作 (FSA) 等规范。

异步动作创建器

中间件 允许你在每个动作对象被调度前注入自定义解释逻辑。异步动作是中间件最常见的用例。

不使用任何中间件时,dispatch 仅接受纯对象,因此我们必须在组件内执行 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) {
// Injected into props by React Redux `connect()` call:
const { dispatch, posts } = this.props

if (posts[userId]) {
// There is cached data! Don't do anything.
return
}

// Reducer can react to this action by setting
// `isFetching` and thus letting us show a spinner.
dispatch(loadPostsRequest(userId))

// Reducer can react to these actions by filling the `users`.
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)

但当不同组件请求相同 API 端点时,代码会迅速变得重复。此外,我们希望跨多个组件复用部分逻辑(例如存在缓存数据时的提前退出机制)。

中间件让我们能编写表现力更强、支持异步的动作创建器。它允许调度非纯对象的值并解释这些值。例如,中间件可"捕获"被调度的 Promise 并将其转换为请求和成功/失败的动作对。

最基础的中间件案例是 redux-thunk"Thunk"中间件允许将动作创建器写作"thunks"(即返回函数的函数)。这实现了控制反转:你将获得 dispatch 作为参数,因此能编写多次调度动作的创建器。

注意

Thunk 中间件只是中间件的一种形式。中间件并非仅用于"允许调度函数",而是允许调度任何特定中间件能处理的内容。Thunk 中间件在调度函数时添加特定行为,实际功能取决于所用中间件。

使用 redux-thunk 重写上述代码:

actionCreators.js

export function loadPosts(userId) {
// Interpreted by the thunk middleware:
return function (dispatch, getState) {
const { posts } = getState()
if (posts[userId]) {
// There is cached data! Don't do anything.
return
}

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

// Dispatch vanilla actions asynchronously
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)

代码量大幅减少!如果需要,你仍可保留类似 loadPostsSuccess 的"纯"动作创建器,并在容器型 loadPosts 动作创建器中使用。

最后,你可以编写自定义中间件。假设你想通用化上述模式,将异步动作创建器描述为:

export function loadPosts(userId) {
return {
// Types of actions to emit before and after
types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
// Check the cache (optional):
shouldCallAPI: state => !state.posts[userId],
// Perform the fetching:
callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
// Arguments to inject in begin/end actions
payload: { userId }
}
}

解释此类动作的中间件可能如下:

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 调用动作创建器均可统一编写:

export function loadPosts(userId) {
return {
types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
shouldCallAPI: state => !state.posts[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.comments[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 }
}
}

Reducer

Redux 通过将更新逻辑描述为函数,显著减少了 Flux store 的样板代码。函数比对象更简洁,比类简单得多。

考虑这个 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,相同的更新逻辑可以被描述为一个 reducer 函数:

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 真正的样板是概念性的:需要发出更新、需要将 Store 注册到 Dispatcher、需要 Store 是一个对象(以及当你想要开发通用应用时出现的复杂性)。

令人遗憾的是,仍然有很多人根据文档中是否使用了 switch 语句来选择 Flux 框架。如果你不喜欢 switch,可以用一个函数来解决这个问题,如下所示。

生成 Reducers

让我们编写一个函数,允许我们将 reducers 定义为从 action 类型到处理函数的对象映射。例如,如果我们希望这样定义 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 对象来水合服务端状态;可能希望合并返回状态与当前状态;"全局捕获"处理程序可能有不同实现方式。这些都取决于具体项目中团队约定的规范。

Redux reducer 的 API 始终是 (state, action) => newState,但具体如何创建这些 reducers 完全取决于开发者。