w3ctech

面向React/Redux的可维护项目结构之旅【翻】

面向React/Redux的可维护项目结构之旅【翻】

翻译文章
原文: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库/实用程序

首先介绍: 按照“类型(type)”来组织文件

当我开始使用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来更新)到产品商店,意味着你必须进行如下操作:

  • types/product.js文件中添加一个action类型
  • actions/products.js文件中添加action creator
  • reducers/product.js文件中添加一个字段

而且这不是最终结果!当你的应用程序增长时,你可能需要添加其它的目录:

├── sagas
│  ├── auth.js
│  └── product.js
└── selectors
   ├── auth.js
   └── product.js

因此,在我的项目中引入redux-saga之后,我意识到这变的更加难以维护,我开始寻找替代方案。

一个不同的方法: 按照特征(feature)来组织文件

上述项目结构的一个替代方法是按特征方法对文件进行分组:

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方法。

Ducks的救援

在按照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。

  • 你会浪费一个超级宝贵的变量名,只是为了将actions传递给mapDispatchToProps。你甚至不会做类似这样的事情:const {login} = this.props,因为你已经通过将login变量分配给从duck文件导入的action creator定义了login变量。
  • 在更大的sagas中,你最终会使用很多不同的不知道上下文的actions(每一次,你必须滚动到顶部引入它们)。

自定义ducks

对于以上问题,我的解决方法很简单: 不是单独的暴露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”文件夹中。

Selectors

在我看来,选择器是最容易被忽视的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,sagas会被一个单独的redux action在同一个操作域中触发。 这意味着如果你有一个reducer用于处理身份认证位于ducks/auth.js文件中,我可以创建sagas/auth.js文件,包含由authTypes.SIGNUP_REQUESTauthTypes.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

我知道还有改进的余地,这可能是使我发表这篇文章的主要原因:欢迎批评和意见!

编辑#1:在哪里放置不和特定reducer关联的actions/types

在我的大部分项目中,我有一个(或多个)通用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。

编辑#2 一个更明确的方法

在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中已经解释过的解决方案。最后,你可能需要自己测试不同的结构,找到更适合你的风格和项目的方案。

谢谢@mxstbrjoshwcomeau

有用的链接

w3ctech微信

扫码关注w3ctech微信公众号

共收到1条回复

  • 哎,项目都像这些 demo 一样简单就好了~

    回复此楼