翻译文章
原文:https://hackernoon.com/my-journey-toward-a-maintainable-project-structure-for-react-redux-b05dfd999b5#.qyjuly2h3
原文作者:Matteo Mazzarolo
文章很长,内容也比较多,需要慢慢消化!
如翻译有问题,麻烦请指出,谢谢。(菜鸟级博文翻译员不胜感激!)
-------- 正文开始 ----------
当我开始学习Redux的时候,我被有关于Redux的大量讨论和“最佳实践”(可以在网上找到)震惊到,但是没有太多的时间理解为什么:Redux关于构建一个围绕它的项目的方式不是很有见地,而且,当你试着弄清楚什么样的结构适合你的风格和你的项目时,你会遇到一些麻烦。
在这篇文章中,我想要分享一些关于我获取到的舒适的Redux项目结构信息。
这不是一个介绍/教程,需要具备一些Redux的基础知识才能完整的理解这篇文章
而且,除了redux-sagas(它可以被redux-thunk或者你喜欢的处理异步actions的库替代),我将不会使用任何外部的Redux库/实用程序
当我开始使用Redux时,我从上到下的学习官方文档,我通过下面这种方式来组织我的项目:
src
├── components
│
├── containers
│ ├── auth.js
│ └── product.js
│
├── actions (action creators)
│ ├── auth.js
│ └── product.js
│
├── types (action types)
│ ├── auth.js
│ └── product.js
│
└── reducers
├── auth.js
└── product.js
这个结构由官方Redux库示例提出,而且在我看来,现在它依然是一个牢固的选择。
这种组织结构最主要的缺点是,即使是添加一个小小的特性,最终可能会编辑几个不同的文件。
例如添加一个字段(通过action来更新)到产品商店,意味着你必须进行如下操作:
而且这不是最终结果!当你的应用程序增长时,你可能需要添加其它的目录:
├── sagas
│ ├── auth.js
│ └── product.js
└── selectors
├── auth.js
└── product.js
因此,在我的项目中引入redux-saga之后,我意识到这变的更加难以维护,我开始寻找替代方案。
上述项目结构的一个替代方法是按特征方法对文件进行分组:
src
├── components
│
├── auth
│ ├── container.js
│ ├── actions.js
│ ├── reducers.js
│ ├── types.js
│ ├── sagas.js
│ └── selectors.js
│
└── product
├── container.js
├── actions.js
├── reducers.js
├── types.js
├── sagas.js
└── selectors.js
这个方法在React社区中已经被不同有趣的文章推广,并且它被用于最常见的一个React boilerpalte项目中。
乍一看,这个结构对我来说似乎是合理的,因为我遵循React的组件概念,将一个组件(the container),它的state(the store)和它的行为(the actions)封装在一个文件夹中。
当在一个更大的项目中使用它时,我发现这个方法也不能带来所有的你想要的结果: 如果你使用Redux,你可能正在做的是在你的应用程序之间共享一个分支的存储...你可以很容易的看到这与此结构提出的封装概念冲突。例如,从product container(容器)分派一个action可能会对cart container产生副作用(如果cart reducer以某种方式对action做出反应)。
你还必须小心,不要陷入另一个概念陷阱: 不要觉得被迫把一个Redux store绑定到一个container,因为你可能最终会使用Redux,即使你应该选择使用简单的setState方法。
在按照feature分组的小冒险之后,我回到了我的初始项目结构,我注意到使用Sagas来处理异步流有一个有趣的副作用,将90%的action creators转换为一句话:
const login = (email, password) => ({ type: LOGIN_REQUEST, email, password })
在这样的情况下,每一个action creator,action type和reducer都有一个专门的文件似乎有点过度,因此我决定尝试“Ducks”方法。
Ducks推荐将reducers,action types和actions绑定到同一个文件中,导致减少样板:
// src/ducks/auth.js
const AUTO_LOGIN = 'AUTH/AUTH_AUTO_LOGIN'
const SIGNUP_REQUEST = 'AUTH/SIGNUP_REQUEST'
const SIGNUP_SUCCESS = 'AUTH/SIGNUP_SUCCESS'
const SIGNUP_FAILURE = 'AUTH/SIGNUP_FAILURE'
const LOGIN_REQUEST = 'AUTH/LOGIN_REQUEST'
const LOGIN_SUCCESS = 'AUTH/LOGIN_SUCCESS'
const LOGIN_FAILURE = 'AUTH/LOGIN_FAILURE'
const LOGOUT = 'AUTH/LOGOUT'
const initialState = {
user: null,
isLoading: false,
error: null
}
export default (state = initialState, action) => {
switch (action.type) {
case SIGNUP_REQUEST:
case LOGIN_REQUEST:
return { ...state, isLoading: true, error: null }
case SIGNUP_SUCCESS:
case LOGIN_SUCCESS:
return { ...state, isLoading: false, user: action.user }
case SIGNUP_FAILURE:
case LOGIN_FAILURE:
return { ...state, isLoading: false, error: action.error }
case LOGOUT:
return { ...state, user: null }
default:
return state
}
}
export const signup = (email, password) => ({ type: SIGNUP_REQUEST, email, password })
export const login = (email, password) => ({ type: LOGIN_REQUEST, email, password })
export const logout = () => ({ type: LOGOUT })
// src/ducks/product.js
const GET_PRODUCTS_REQUEST = 'PRODUCT/GET_PRODUCTS_REQUEST'
const GET_PRODUCTS_SUCCESS = 'PRODUCT/GET_PRODUCTS_SUCCESS'
const GET_PRODUCTS_FAILURE = 'PRODUCT/GET_PRODUCTS_FAILURE'
const initialState = {
products: null,
isLoading: false,
error: null
}
export default (state = initialState, action) => {
switch (action.type) {
case GET_PRODUCTS_REQUEST:
return { ...state, isLoading: true, error: null }
case GET_PRODUCTS_SUCCESS:
return { ...state, isLoading: false, user: action.products }
case GET_PRODUCTS_FAILURE:
return { ...state, isLoading: false, error: action.error }
default:
return state
}
}
export const getProducts = () => ({ type: GET_PRODUCTS_REQUEST })
第一次采用这种语法时,我就爱上它了。 它移除了很多不必要的样板文件,而且你很容易的通过改变一个文件来添加一个action或者一个字段到reducer。
不幸的是,使用ducks开始很快但也有限制,因为单独暴露每个action creator和action type有一些令人讨厌的副作用。
在一个更大的containers中,你会得到一个巨大的导入的actions列表,这个列表只使用一次(作为mapDispatchToProps
的参数):
import { signup, login, resetPassword, logout, … } from ‘ducks/authReducer’
你将不能直接的将duck文件的所有actions传递到mapDispatchToProps
中。使用import * as actions
没有作用,因为你最终也导入了reducer。
mapDispatchToProps
。你甚至不会做类似这样的事情:const {login} = this.props
,因为你已经通过将login
变量分配给从duck文件导入的action creator定义了login
变量。对于以上问题,我的解决方法很简单: 不是单独的暴露action types和action creators给外部引用,而是将它们组织到一个types和actions对象中之后在导出:
// src/ducks/auth.js
export const types = {
AUTO_LOGIN: 'AUTH/AUTH_AUTO_LOGIN',
SIGNUP_REQUEST: 'AUTH/SIGNUP_REQUEST',
SIGNUP_SUCCESS: 'AUTH/SIGNUP_SUCCESS',
SIGNUP_FAILURE: 'AUTH/SIGNUP_FAILURE',
LOGIN_REQUEST: 'AUTH/LOGIN_REQUEST',
LOGIN_SUCCESS: 'AUTH/LOGIN_SUCCESS',
LOGIN_FAILURE: 'AUTH/LOGIN_FAILURE',
LOGOUT: 'AUTH/LOGOUT'
}
export const initialState = {
user: null,
isLoading: false,
error: null
}
export default (state = initialState, action) => {
switch (action.type) {
case types.SIGNUP_REQUEST:
case types.LOGIN_REQUEST:
return { ...state, isLoading: true, error: null }
case types.SIGNUP_SUCCESS:
case types.LOGIN_SUCCESS:
return { ...state, isLoading: false, user: action.user }
case types.SIGNUP_FAILURE:
case types.LOGIN_FAILURE:
return { ...state, isLoading: false, error: action.error }
case types.LOGOUT:
return { ...state, user: null }
default:
return state
}
}
export const actions = {
signup: (email, password) => ({ type: SIGNUP_REQUEST, email, password })
login: (email, password) => ({ type: actionTypes.LOGIN_REQUEST, email, password }),
logout: () => ({ type: actionTypes.LOGOUT })
}
如果你以这种方式组织你的ducks文件,你可以很容易的在你的组件中引入actions:
import { actions } from ‘ducks/auth’
...
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators(actions, dispatch)
})
现在你可能会问:如果我需要在组件内容分派不同ducks文件的actions,怎么办呢?
你可以这样处理:
import { actions as ticketActions } from 'ducks/ticket'
import { actions as messageActions } from 'ducks/message'
import { actions as navigationActions } from 'ducks/navigation'
...
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
...ticketActions,
...messageActions,
...navigationActions
}, dispatch)
})
我知道,这看起来有点丑陋,欢迎任何其它的替代方案!
最初我选择了actionTypes/actionCreators这个名字而不是types/actions,但是后来我重构代码的时候使用了后者:第一个选项对我来说太详细了。
P.S.:从现在起,为了简单起见,我会继续引用包含actions/reducers/types作为“ducks”的文件,即使在我目前的项目中,我将它们放在“reducers”文件夹中。
在我看来,选择器是最容易被忽视的Redux特性。我必须承认我开始使用它们的时候有点晚,但是读了Dan Abramov的推文后打开了我的眼界,而且我开始将selectors作为暴露store给containers的接口。
getProductOrderedByNameselector
selector。getProductById
selector。getExpiredProducts
selector。通过遵循这个策略,你定义的大多数选择器将被绑定到一个特定的reducer,所以定义它们正确的地方是包含reducer本身的文件。
// src/ducks/product.js
import { filter, find, sortBy } from 'your-favorite-library'
export const types = {
...
}
export const initialState = {
products: [],
isLoading: false,
error: null
}
export default (state = initialState, action) => {
...
}
export const actions = {
...
}
export const getProduct = (state) => state.product.products
export const getProductById = (state, id) => find(state.product.products, id)
export const getProductSortedByName = (state) => sortBy(state.product.products, 'name')
export const getExpiredProducts = (state) => filter(state.product.products, { isExpired: true })
有时你需要定义更复杂的selectors来处理来自不同store的输入。这种情况下,我将它们放入reducers/index.js文件中。
Sagas真的很强大并且可测试,但是用于管理异步操作,如何将它们添加到您的项目结构有点困难。
我的建议是开始分组sagas,sagas会被一个单独的redux action在同一个操作域中触发。
这意味着如果你有一个reducer用于处理身份认证位于ducks/auth.js文件中,我可以创建sagas/auth.js文件,包含由authTypes.SIGNUP_REQUEST
,authTypes.LOGIN_REQUEST
等等触发的sagas。
有时想想你需要触发来自不同actions的同一个saga。在这种情况下,你可以创建一个通用的文件包含这种情况的sagas。例如下面一个例子,当拦截错误时显示一个警告:
// sagas/index.js
takeEvery(authTypes.LOGIN_FAILURE, uiSagas,showErrorAlert),
takeEvery(menuTypes.GET_MENU_ERROR_FAILURE, uiSagas.showErrorAlert)
// sagas/ui.js
import { call } from 'redux-saga/effects'
import { Alert } from 'react-native'
export function* showErrorAlert (action) {
const { error } = action
yield call(Alert.alert, 'Error', error)
}
如果这个通用的文件(在这个按理中成为sagas/ui.js)增长太多,你可以随时重构它使其变的更加具体。
另一个值得注意的事情是,在sagas/index.js中,我有一个文件是链接每一个saga到它的实现:
import { types as authTypes } from 'ducks/auth'
import { types as productTypes } from 'ducks/product'
import { * as authSagas } from 'sagas/auth'
import { * as productSagas } from 'sagas/product'
export default function* rootSaga () {
yield [
takeEvery(authTypes.AUTO_LOGIN, authSagas.autoLogin),
takeEvery(authTypes.SIGNUP_REQUEST, authSagas.signup),
takeEvery(authTypes.LOGIN_REQUEST, authSagas.login),
takeEvery(authTypes.PASSWORD_RESET_REQUEST, authSagas.resetPassword),
takeEvery(authTypes.LOGOUT, authSagas.logout),
takeEvery(productTypes.GET_PRODUCTS_REQUEST, productSagas.getTickets)
]
}
以这种方式,我能够通过关联的saga和触发它的action types轻松跟踪每个saga。
这是我现在的项目目录结构(正如我上面预期的,我将“ducks”文件夹重命名为“reducers”):
src
├── components
│
├── containers
│ ├── auth.js
│ ├── productList.js
│ └── productDetail.js
│
├── reducers (aka ducks)
│ ├── index.js (combineReducers + complex selectors)
│ ├── auth.js (reducers, action types, actions creators, selectors)
│ └── product.js (reducers, action types, actions creators, selectors)
│
├── sagas
│ ├── index.js (root saga/table of content of all the sagas)
│ ├── auth.js
│ └── product.js
│
└── services
├── authenticationService.js
└── productsApi.js
我知道还有改进的余地,这可能是使我发表这篇文章的主要原因:欢迎批评和意见!
在我的大部分项目中,我有一个(或多个)通用duck处理这种actions。
例如,如果项目足够小,我只需创建ducks/app.js,一个duck文件包含 1)所有不是reducer特定的action creators和action types和 2)ui/app的state
export const types = {
LOAD_DATA: 'APP/LOAD_DATA', // Triggers a saga that 1) makes some HTTP requests 2) updates other reducers
SHOW_SNACKBAR: 'APP/SHOW_SNACKBAR',
HIDE_SNACKBAR: 'APP/HIDE_SNACKBAR',
SHOW_DRAWER: 'APP/SHOW_DRAWER',
HIDE_DRAWER: 'APP/HIDE_DRAWER'
}
export const initialState = {
snackbarMessage: null,
isDrawerVisible: false
}
export default (state = initialState, action) => {
switch (action.type) {
case types.SHOW_SNACKBAR:
return { ...state, snackbarMessage: action.snackbarMessage }
case types.HIDE_SNACKBAR:
return { ...state, isSnackbarVisible: false }
case types.SHOW_DRAWER:
return { ...state, isDrawerVisible: true }
case types.HIDE_DRAWER:
return { ...state, isDrawerVisible: false }
default:
return state
}
}
export const actions = {
loadData: () => ({ type: types.LOAD_DATA_REQUEST }),
showSnackbar: (snackbarMessage) => ({ type: types.SHOW_SNACKBAR, snackbarMessage }),
hideSnackbar: () => ({ type: types.HIDE_SNACKBAR }),
showDrawer: () => ({ type: types.SHOW_DRAWER }),
hideDrawer: () => ({ type: types.HIDE_DRAWER })
}
请记住这些ducks可能不需要reducer:它们可以仅仅包含action creators和action types。
在Reddit和Twitter上的一些评论暗示ducks可以提升,该想法是说actions和reducers直接的关系是many:1,而它们实际上是many:many。我同意他们的观点。
实际上一个遵循Redux哲学的结构甚至比我给你展示的结构更多:
src
├── actions
│ ├── index.js (action types + action creators)
│ ├── auth.js (action types + action creators)
│ ├── other.js (action types + action creators)
│ └── product.js (action types + action creators)
│
├── reducers
│ ├── index.js (combineReducers + complex selectors)
│ ├── auth.js (reducer + specific reducer selectors)
│ └── product.js (reducer + specific reducer selectors)
│
└── sagas
├── index.js (root saga/table of content of all the sagas)
├── auth.js
└── product.js
我仍然喜欢使用自定义的ducks,当我有一个没有绑定到reducer(或相反)的actions,我采取上面在编辑 #1中已经解释过的解决方案。最后,你可能需要自己测试不同的结构,找到更适合你的风格和项目的方案。
扫码关注w3ctech微信公众号
哎,项目都像这些 demo 一样简单就好了~
共收到1条回复