减少样板代码
Redux 部分 受 Flux 启发,而关于 Flux 最常见的抱怨是它迫使你编写大量样板代码。在本食谱中,我们将考虑 Redux 如何让我们根据个人风格、团队偏好、长期可维护性等选择代码的详细程度。
操作
动作是描述应用程序中发生事件的普通对象,是描述数据变更意图的唯一方式。重要的是,**动作是必须分发的对象,这不是样板代码,而是 Redux 的 基本设计选择 之一。**
有一些框架声称与 Flux 类似,但没有动作对象的概念。在可预测性方面,这比 Flux 或 Redux 退步了一步。如果没有可序列化的普通对象动作,就无法记录和回放用户会话,也无法实现 带有时间旅行的热重载。如果你想直接修改数据,你不需要 Redux。
动作看起来像这样
{ type: 'ADD_TODO', text: 'Use Redux' }
{ type: 'REMOVE_TODO', id: 42 }
{ type: 'LOAD_ARTICLE', response: { ... } }
一个常见的约定是,动作具有一个常量类型,帮助 reducer(或 Flux 中的 Stores)识别它们。我们建议你使用字符串而不是 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-act 和 redux-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” 中间件允许您将动作创建者编写为“thunk”,即返回函数的函数。这反转了控制:您将获得 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 }
}
}
Reducers
Redux 通过将更新逻辑描述为一个函数,大大减少了 Flux 存储的样板代码。函数比对象更简单,比类更简单。
考虑这个 Flux 存储
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
让我们编写一个函数,让我们将 reducer 表达为一个从动作类型到处理程序的映射对象。例如,如果我们希望我们的 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 对象以填充服务器状态。也许您希望将返回的状态与当前状态合并。可能会有不同的方法来处理“catch all”处理程序。所有这些都取决于您在特定项目中为团队选择的约定。
Redux reducer API 是 (state, action) => newState
,但您如何创建这些 reducer 则由您决定。