Redux Style Guide中文翻译

193 阅读30分钟

本文是翻译,原文为redux.js.org/style-guide…

介绍

这是为Redux开发者编写的官方指导意见,包括在编写Redux应用的过程中使用推荐模式、最佳实践和建议途径。 Redux核心库和绝大多数的Redux文档均为非强制的,有非常多的方式来使用Redux,而且很多时候没有所谓唯一正确的做法。 然而,随着时间积累和实践,针对某些主题,有一些方法的确比其他的更优秀些。同时,很多开发者要求官方提供指导来减少繁琐决策。 基于以上背景,我们将这些汇集成为列表,帮助开发者避免错误、“翻车”和反模式。我们知道不同的团队和不同的项目有很多要求,满足所有要求的指导意见是不存在的。尽管如此,我们仍然建议开发者遵守这些建议,同时花费必要精力去评估在当前的场景下,这些建议是否符合需要。 最后,要感谢Vue Style Guide的作者给予我们灵感。

规则分类

我们建议将规则分为3个类别

优先级A规则:必要

这些规则将帮助开发者避免错误,所以要不计成本地学习并遵守。当然存在例外,而且非常罕见,只应该出现在同时具备深厚的JavaScript和Redux的知识的开发者那里。

优先级B规则:强烈推荐

这些规则被认为可以对大多数项目和开发者提升可读性。违反这些规则,开发者的代码仍能运行,但是这些违反应十分少见而且理由充分。只要合理,请尽量遵守这些规则。

优先级C规则:推荐

开发者可以在一些也很优秀的方案中选择任意一种来保证一致性。在这些规则中,我们介绍了每种规则并推荐默认选项。这意味着开发者可以在代码库中自由地做出不同的选择,只要保持一致性并有充分的理由。但是,请慎重!

优先级A规则:必要

不要修改State

在绝大多数情况下,修改State都将导致Bug,包括组件没有如预期般重新渲染,同时也会破坏Redux DevTools的时间旅行调试。无论是 reducer 中还是任意其他应用代码中,都要始终避免state 的真实变换。 可以在开发阶段使用[redux-immutable-state-invariant](https://github.com/leoasis/redux-immutable-state-invariant)捕获修改State,使用Immer避免在State更新时意外导致修改State。

注意:修改已存在值的副本是被允许的,这是编写不可变更新逻辑的常见操作。同时,如果开发者使用Immer来实现更新,编写“修改”逻辑是被允许的,因为真实数据并没有被更新——Immer框架安全地追踪变化而且在内部生成新的不可变的值。

Reducer不要包含副作用

Reducer函数应只依赖其自身的stateaction参数,而且只能根据参数计算获取新的state,不能是任何种类的异步逻辑(Ajax调用、超时和Promise)、生成类数据(Date.now(),Math.random())、在reducer外层修改变量或者运行其他影响在reducer作用域之外。

注意:reducer调用在其外部定义的函数是被允许的,例如从其他库导入或者工具函数,只要这些函数遵守相同的规范

详细说明: 这条规则的意义在于保证reducer被调用时是可以被预测的。例如,当开发者做时间旅行调试时,reducer函数会被调用多次,其参数依赖更早的过程产生当前state。如果reducer具有副作用,那么在调试应用程序中会发生预期外的行为。 这条规则存在“灰色”的地带。严格地说,例如console.log(state)这样的代码也是具有副作用的,但对应用来说没有实质行为。

避免将不可序列化值保存到State或者Action

避免将不可序列化的值例如Promise、Symbols、Map/Set、函数或者类实例放入到Redux的State或者dispatch的action。这将确保通过Redux DevTool调试工具正常工作,也同时确保UI如预期一样更新。

例外:开发者可以将不可序列化的值保存到action,当且仅当action到达前被中间件被中断和阻止,常见的中间件包括redux-thunkredux-promise

每个应用有且只有一个Store

标准的Redux应用应该只有一个Store实例,这个实例被整个应用使用,通常使用store.js这样的单独文件进行定义。 理想情况下,业务逻辑代码不应该直接引用store,而是应该通过例如<Provider>传递给React组件树,或者通过中间件(例如thunk)间接引用。极少数情况下,开发者可能会导入其他业务逻辑,但这应该是没有办法的办法。

优先级B规则:强烈推荐

使用Redux Tookit编写业务逻辑

Redux Tookit是我们推荐的编写Redux应用时的工具集合,其囊括构建我们推荐的最佳实践的函数,包括设置store去捕获修改异常、启用Redux DevTools插件、使用Immer简化更新逻辑的不可变等等。 开发者并不会被限制必须使用RTK,可以自由选择其他方式,值得一提的是使用RTK将简化应用逻辑同时确保应用程序遵循好的默认行为。

使用Immer编写不可变对象更新

徒手编写不可变更新通常都较为困难,并且常常发生错误。Immer帮助开发者使用更“可变”的方式编写不可变更新,而且会在开发环境冻结state对象修改以便捕获在应用中的修改行为。我们建议使用Immer编写不可变更新逻辑,其也是RTK的推荐的一部分。

将文件结构构造为具有单文件逻辑的功能性文件夹

Redux自身并不关注应用的文件夹和文件的组织结构,然而将指定功能的相关逻辑组织在同一个地方会使得维护更加容易。 基于这一点,我们建议大多数应用程序应该使用特性目录的途径组织文件(与特性相关的文件都组织在同一个目录)。使用特性目录,这个特性的逻辑应该如被分割的一个文件,最好是使用RTK的createSlice(这也被叫做“鸭子”模式)。之前Redux的应用通常按照类型组织方式,这导致不同的action和reducer的目录,将相关的文件组织在一起会使得查找和更新更加简单。

详细解释:示例目录机构 示例目录结构的示例,大概长这样:

  • /src
    • index.tsx: React 组件树渲染的入口文件
    • /app
      • store.ts: store 配置
      • rootReducer.ts: 根 reducer (可选)
      • App.tsx: React 根组件
    • /common: hooks、通用组件、工具方法等
    • /features: 包含所有的“功能性文件夹”
      • /todos: 整个功能的文件夹
        • todosSlice.ts: Redux reducer 逻辑和相关的 action
        • Todos.tsx: 一个 React 组件

/app 包含应用级别的一些配置和布局等,这些配置和布局取决于项目中的其他文件夹

/common 包含真正通用和可重用的工具方法和组件 /features 是一个存放包含一个特定功能所有的相关方法的文件夹。在本例中,todosSlice.ts 就是一个"duck" 风格的文件,其中包含了 RTK 的 createSlice() 函数的调用,并导出了 slice reducer 和 action creators。

尽可能将业务逻辑放在Reducer

只要可能,试着将尽可能多的计算新的state逻辑放在reducer,而不是放在准备并dispatch action的代码中(例如点击回调)。这可以确保更多的应用逻辑更容易被测试,使得时间旅行调试更加有效,同时帮助避免导致 mutation 和 bug 的一般性错误。 存在一些场景,新state的部分或者全部需要被首先计算(例如生成唯一ID),但这些应该被控制在最小的范围。

详细解释 Redux框架核心并不关心新state是在reducer或者在action的逻辑中创建。例如,对于todo应用,切换todo状态功能需要更新todos集合,让 action 只携带 todo ID 并在 reducer 中计算新数组是是合法的:

// Click handler:
const onTodoClicked = (id) => {
    dispatch({type: "todos/toggleTodo", payload: {id}})
}

// Reducer:
case "todos/toggleTodo": {
    return state.map(todo => {
        if(todo.id !== action.payload.id) return todo;

        return {...todo, completed: !todo.completed };
    })
}

当然也可以先计算得到新的集合,再把整个新集合放到action中

// Click handler:
const onTodoClicked = id => {
  const newTodos = todos.map(todo => {
    if (todo.id !== id) return todo

    return { ...todo, completed: !todo.completed }
  })

  dispatch({ type: 'todos/toggleTodo', payload: { todos: newTodos } })
}

// Reducer:
case "todos/toggleTodo":
    return action.payload.todos;

然而,将逻辑写到reducer中是被推荐的,原因如下:

  • Reducer通常更容易被测试,因为这些函数是纯函数,只需要调用const result = reducer(testState, action)同时对返回的结果进行断言判断。所以,更多的逻辑放到reducer中,更多的逻辑易被测试
  • Redux的state更新必须遵守不可变对象的更新准则。大多数开发者意识到,他们不得不在reducer中遵守该规则,但也可能不知道如果要在reducer的外部计算出一个新的state也必须这么干。这容易导致错误,例如意外的修改,或者从Store中读取数据并直接返回给action,在reducer进行这些计算能避免这些错误。
  • 如果使用RTK或者Immer,开发者会更容易在reducer中编写更新逻辑,而且Immer还会冻结state并捕获意外的修改。
  • 时间旅行调试工具通过让开发者撤销dispatch过的action,然后做点其他或者重复action。另外,reducer的热更新通常导致新的reducer被再次执行。如果有正确的action和有bug的reducer,开发者可以更新reducer来修复,热更新后将立刻得到正确的state。如果action本身就错误了,开发者不得不重复触发action的步骤。所以,如果更多逻辑在reducer中,调试将更加容易
  • 最后,将逻辑放到reducer中意味着开发者知道去哪里找更新逻辑,而不是在应用代码中随机地去寻找

Reducer应该控制State的类型

Redux的根state是被唯一的根函数持有和计算的。从可维护性角度,reducer会被按照key/value形式分成一个个slice,每个slice reducer函数都负责提供初始值以及计算和更新slice state的值。 另外,slice reducer要控制被计算出state的一部分的值,尽可能少使用spreads/returns,例如return action.payload或者```return {...state,...action.payload}````,因为这些依赖dispatch action去正确地格式化输入,而且reducer放弃state的数据定义的掌控权。如果action的实现不正确,容易导致出bug。

注意:spreads/returns的reducer在一些场景下是合理的,例如编辑表单,如果为每个字端都编写单独的action,将是浪费时间且收效甚微。 详细说明: 一个控制当前登录的用户reducer如下所示

const initialState = {
    firstName: null,
    lastName: null,
    age: null,
};

export default usersReducer = (state = initialState, action) {
    switch(action.type) {
        case "users/userLoggedIn": {
            return action.payload;
        }
        default: return state;
    }
}

在这个例子中,reducer完全假设action.payload是正确的数据格式 然而,假设如下代码,错把todo的数据发送给user了

dispatch({
  type: 'users/userLoggedIn',
  payload: {
    id: 42,
    text: 'Buy milk'
  }
})

reducer会直接返回todo,当整个应用的其他部分试图读取user时可能会崩溃。 如果在reducer中增加一个简单的校验,检查下action.payload是不是有正确的字端,或者根据正确的字端读取数据。这会增加一些代码,如何权衡为安全性增加更多代码是一个问题。 使用静态类型检查可以提高安全性,在一定程度上可以接受。如果reducer知道action是PayloadAction,那么返回action.payload就更加安全。

根据存储的数据命名State Slice

如之前提到的reducer应该控制state的类型,基于state的slice划分reducer逻辑是一个标准的做法,与此对应,combineReducers也是将slice reducer聚合为较大的reducer的标准函数。 传递给combineReducers的对象中的键名最终定义state对象的键名。确保在内部保存数据后命名这些键,避免在键名中使用reducer这个词。对象应该是{users: {}, posts: {}},而不是{usersReducer: {}, postsReducer: {}}

详细说明: ES6的对象字面量简化同时定义对象的键名和值,如下所示

const data = 42
const obj = { data }
// same as: {data: data}

combineReducers接受都是reducer函数的对象,并用来生成与键名相同的state对象。这意味着在函数的对象的键名就是state对象的键名。 这导致一个很常见的错误,当reducer导入一个变量中含有reducer的reducer,然后将这个reducer用字面量传递给combineReducers。

import usersReducer from 'features/users/usersSlice'

const rootReducer = combineReducers({
  usersReducer
})

在这个情况下,使用对象字面量创建类似{usersReducer: usersReducer}的对象,所以reducer现在出现在state对象的键名中,是重复且无意义的。 相反,定义键名仅仅和内部对象相关,建议使用显示的key:value形式,如下所示

import usersReducer from 'features/users/usersSlice'
import postsReducer from 'features/posts/postsSlice'

const rootReducer = combineReducers({
  users: usersReducer,
  posts: postsReducer
})

这样会多一点代码,但是会更加可读和state的定义。

使用数据类型而不是组件来组织状态对象

在应用中根state划分应该被定义而且基于数据类型或者函数作用域,而不是基于在UI中的具体组件。这是因为没有在store中的数据与UI组件无法做到严格一一对应,而且很多组件需要访问同样的数据。想象state树是一系列全局数据库,应用的任何部分都可以访问,读取组件需要的部分数据。 例如,博客应用或许需要追踪谁在登录、作者和博文的信息也许还需要页面激活状态信息。一个好的state结构应该是这样的,{auth, posts, users, ui},一个不好的state结构可能是{loginScreen, usersList, postsList}

将Reducer视为状态机

很多reducer是无条件的,只会根据dispatch的action去产生新的state对象,而不基于当前状态下的逻辑。这可能会产生bug,因为某些action可能在某些时候是无效的。例如,一个请求成功的action计算新的值,当且仅当state已经被加载了,或者更新某个元素的action被dispatch当且仅当这个元素被编辑。 为了解决这个问题,开发者应该把reducer当作状态机,通过当前state和dispatch的action共同决定是否计算一个新的state,而不是让这个action没有状态。

详细说明: 当我们构建在任何时候只有一个状态时,有限状态机是个有用的工具。例如fetchUserReducer有以下状态

  • idle(请求还没开始)
  • loading(正在获取数据)
  • success(获取数据成功)
  • failure(获取数据失败)

为了这些状态更清晰且不产生意外,开发者可以指定一个属性来保存这些状态

const initialUserState = {
  status: 'idle', // explicit finite state
  user: null,
  error: null
}

如果用TypeScript,使用discriminated unions来表示每个状态更为简单。举个例子,如果state.status === 'success',那么state.user将被定义,而且不会期待state.error为真,还能使用类型约束。 典型来说,写reducer的逻辑的时候应该首先将action考虑进去。当使用状态机来编程时,首先考虑state是重要的,为每个状态创建有限状态有助于封装每个state的行为。

import {
  FETCH_USER,
  // ...
} from './actions'

const IDLE_STATUS = 'idle';
const LOADING_STATUS = 'loading';
const SUCCESS_STATUS = 'success';
const FAILURE_STATUS = 'failure';

const fetchIdleUserReducer = (state, action) => {
  // state.status is "idle"
  switch (action.type) {
    case FETCH_USER:
      return {
        ...state,
        status: LOADING_STATUS
      }
    }
    default:
      return state;
  }
}

// ... other reducers

const fetchUserReducer = (state, action) => {
  switch (state.status) {
    case IDLE_STATUS:
      return fetchIdleUserReducer(state, action);
    case LOADING_STATUS:
      return fetchLoadingUserReducer(state, action);
    case SUCCESS_STATUS:
      return fetchSuccessUserReducer(state, action);
    case FAILURE_STATUS:
      return fetchFailureUserReducer(state, action);
    default:
      // this should never be reached
      return state;
  }
}

现在,由于为每个状态而不是每个action定义,所以阻止不可能的变化。例如,FETCH_USER在status === LOADING_STATUS时不会产生副作用,开发者可以强制,而不会意外产生一些边界case。

归一复杂嵌套和关联式State

很多应用需要在store缓存复杂的数据,这些数据通常通过API获取的嵌套结构,或者在数据的不同实体见存在关联关系(例如一篇博客包括用户、博文和评论)。 在 store 中使用“归一化的”格式来存储以上数据是更优的,这使得通过id查找和更新store中的单一元素更加容易,而且还能获得更好的性能模式。

保持最小化State,派生其他值

无论如何,尽可能保持在Redux存储的store尽可能小,并且按照需求派生其他值,包括计算过滤列表或者求和值。例如,一个todo应用在state中保存原始的todo对象集合,并且当状态更新时,会派生一个过滤后的列表。类似的场景,一个是否所有todo均被完成的标记或者正在执行中的todo数量,都可以在store外计算。 这带来如下好处:

  • state对象容易被阅读
  • 计算额外的值并使其与其他数据保持一致的逻辑更少
  • 原始的state对象保持引用,没有被替换

派生数据通过在“selector”函数实现,这些函数封装派生数据的计算逻辑。为了提升性能,这些函数可以使用reselectproxy-memoize缓存前一次的结果。

将action建模为事件而不是setter

Redux从不关心action.type的字段内容是什么,只需要被定义。写现在时态和过去的失态,用事件描述或者用setter都是合法的。如何组织和定义action都是由开发者决定。 然而,我们建议将action更多使用事件而不是setter。将这些action视为事件会使用更具有意义的名字,更少的action被dispatch,以及更有意义的日志。视action为setter导致有很多特别的action type,更多的dispatch而且日志更没有意义。

详细说明 想象有一个餐厅的应用,某个顾客下单了一个披萨和一杯可乐。那么开发者可以dispatch一个action如下所示

{ type: "food/orderAdded",  payload: {pizza: 1, coke: 1} }

或者这样

{
    type: "orders/setPizzasOrdered",
    payload: {
        amount: getState().orders.pizza + 1,
    }
}

{
    type: "orders/setCokesOrdered",
    payload: {
        amount: getState().orders.coke + 1,
    }
}

第一个例子像个事件,“喂,有人点了披萨和汽水,想办法处理下” 第二个例子像个setter,“我知道有下单披萨和汽水的字段,并在现有的数量下增加” 事件方法只需要dispatch一个action,而且更加灵活。不关心之前下单了多少披萨,也许没有厨师了,订单会被忽略。 通过setter的方法,客户需要知道state的数据结构,要知道正确的值是什么,还需要多次dispatch去完成一次事务。

使用有语义的Action名称

action.type字段有以下两个目标:

  • Reducer逻辑检查action的type来判断如何计算新的state
  • 在Redux DevlTools的历史日志中显示

每次讲action建模为事件时,type对于Redux来说没什么意义,对于开发者来说不是没有意义。action应该更加具有意义,包含信息,有描述性属性。理想情况下,开发者应该从一系列dispatched的action的type读取,并且不需要看action的实现就能理解发生的事情,避免使用非常泛化的action名称,例如SET_DATA或者UPDATE_STORE,这样无法表述更有含义的信息。

允许多个Reducer响应相同的Action

Redux的reducer逻辑可以被分为多个较小的reducer,每一个分别更新自己的状态树,所有小的reducer组合起来组成应用的根reducer函数。每当一个Action被触发,其可能被所有reducer执行,或者其中一部分,甚至没有reducer执行。 作为一部分,鼓励开发者创建不同reducer函数分别处理同一个action。有实践表明,大多数action通常只由单个reducer函数处理,但是,将建模操作为事件并允许多个reducer响应通常可以提升应用代码的可维护性,同时减少分发多个action去完成一个更新所需要的时间。

避免依次分发过多Actions

避免连续分发多个Action去完成一个概念上很大的事务。这虽然合法,但是通常导致多次昂贵的UI更新,而且一些中间状态可能是被程序中的其他逻辑置为无效。推荐分发简单事件类型的action,这样所有相关的状态只更新一次,或者考虑使用action的批处理插件来分发多个action来保持只有一次UI更新。

一次分发action的数量是没有限制的,然而每次分发的action都会导致所有store订阅回调(典型每个Redux关联的UI组件一个或多个),而且通常导致UI更新。 UI更新是根据React的事件队列,通常被合并到一次render,对于外部的事件就不是这样的。这多数来自async函数、超时回调和非React代码。在这些情况下,每一次dispatch完成前,都会触发一个完整的同步性React的render,这将降低性能。 另外,在概念上属于较大的事务类的更新序列的多个dispatch会产生临时无效的状态。例如,当UPDATE_A,UPDATE_B,UPDATE_C依次分发,一些代码希望a、b和c一起更新,在前两个dispatch后将是未完成的,因为只有其中一个或者两个更新。 如果真的需要多次dispatch,考虑以某种方式对更新进行批处理。取决于开发者的场景,可能只是批处理React的渲染(可以使用React-Redux的batch)、截流对store的通知回调或者将很多action归到一次较大的通知订阅。查看FAQ获取更多例子和相关插件的链接

评估每个state应该放在哪里

Redux三原则指出应用程序的state应该被保存在单一的树,这句话被过度解读。这并不是意味着整个应用将每个数据值都必须存储到Redux的Store中。相反,你能想到的全局和app级别的数据应该被放在一起。局部的数据应该被保存在最近UI组件中。 正因为如此,作为开发者应该决定哪些数据放到Redux的Store中,什么数据应该放到组件中。使用这些经验原则来评估每个state应该放在哪里。

使用React-Redux Hooks的API

推荐使用React-Redux Hooks的API作为默认的方法来使组件和Redux Store之间的交互。虽然传统的connect的API仍然可用而且未来都将得到支持,hooks的API使用起来更加简单,更少间接性联系,编写更少的代码,与TypeScript一起使用更加简单。 hooks的API在性能和数据流上确实引入一些与connect不同的权衡,但仍然建议默认使用。

详细说明 经典的connect的API是个高阶组件。它生成一个包装组件用来订阅store的变化,并且渲染自己的组件,通过props传递store的数据和action creator。 这是一个特意设计的间接使用,让开发者编写纯展示类的组件,将store中的数据或者方法作为props接收,而且无需依赖Redux。 hooks的引入改变了大多数React开发人员的编写风格。虽然容器/展示概念仍然有效,hooks促使开发者编写通过适当的内部请求数据的组件,这改变了编写和测试组件和逻辑。 connect的间接地使一些用户追踪数据变得有点困难,此外,connect的复杂度在使用TypeScript的时候类型定义非常困难,因为存在多重overload和option的参数,合并父组件的mapState/mapDispatch以及整合action creator和trunk这些操作。 userSelector和useDispatch消除了这种间接性,组件与Redux交互更加清晰。因为useSelector只接收一个selector,使用TypeScript定义类型更加容易,对useDispatch也是如此。 获取更多细节,请看Mark Erikson的博客以及如何权衡hooks和高阶组件的讨论

同时,也可以看React-Redux hooks的API文档查看如何正确优化组件以及处理边界情况。

关联更多组件从Store获取数据

推荐以更细粒度,在UI组件中从Redux Store中订阅。这通常会获得更好的UI性能,而且当制定的部分state被更新时更少的组件被渲染。 例如,应该使用获取用户的ID,使用渲染,使得只和store中对应的用户关联,而不是将与整个用户集合关联。 以上对于React-Redux的connect和userSelector都适用。

将mapDispatch的对象简写形式和connect一起使用

connect的mapDispatch参数可以使用函数定义,这个函数可以接收dispatch作为参数,也可以是包含action creator的对象。我们建议总是使用mapDispatch的对象简写形式,因为这样可以简化代码。很少需要将mapDispatch参数用函数来编写。

在函数式组件中多次使用useSelector

当在使用useSelector接受数据时,尽量多次调用useSelector并且获取较少的数据,而不是通过较大的useSelector调用获取大对象。与mapState不同,useSelector并不要求返回对象,读取更少的数据,这意味着给定的状态不太可能导致组件渲染。 尽管如此,也要试着找到一个合适的粒度作为平衡。当一个简单的组件需要一部分状态时,只需要用一次useSelector返回状态对象的切分,而不要对每个字段都单独返回。

使用静态类型

使用静态类型系统,例如TypeScript或者Flow而不是原始的JavaScript。类型系统可以捕捉很多常见的错误,改善代码的规范性,最终提升长期的可维护性。尽管Redux和React-Redux最初设计考虑的是简单的JavaScript,但两者都可以和TypeScript或者Flow共存。Redux Tookit是使用TypeScript编写,试图通过最小的附加类型生命提供较好的类型安全。

使用Redux DevTools来Debug

配置Redux store支持Redux DevTool扩展的调试,这将带来:

  • action的分发历史
  • 每个action的内容
  • 一个action分发后的最终状态
  • action执行后的状态差别
  • action被分发处的函数调用堆栈

另外,Redux DevTool扩展还能实现时间旅行,在action历史中前进和后退去看到整个App的状态以及在不同时刻的UI变化。 Redux被特地设计支持这种debug,Redux DevTool扩展是使用Redux的最强有力的理由之一。

使用简单JavaScript对象来定义State

推荐使用普通JavaScript对象和数组表示state,而不是使用类似Immutable.js这样的库。使用Immutable.js能带来一些好处,大多数常见的state操作目标,例如弱引用比较,通常是不可变更新的属性,这时就不需要特定的库。这会带来更小的包体积,减少类型转换带来的复杂性。 如上文提到,我们推荐开发者在使用Immer简化不可变的更新逻辑,特别是作为Redux Tookit的一部分。

Immutable.js在一开始的Redux应用中被频繁使用,使用Immutable.js有以下原因

  • 轻量级引用比较的性能优化
  • 专门的数据结构进行更新而带来的性能提升
  • 组织意外的修改
  • 实用setIn这样的API使得嵌套更新更容易

这在当时是很有道理的,但是在实践中,这些好处并没有与预期般,还同时伴有很多负向影响

  • 简单引用比较是任何不可变更新的特点,不仅仅是Immutable.js的特性
  • 偶然的修改可以被其他机制阻止,例如使用Immer(减少容易发生错误的手动拷贝,而且在开发环境下默认深度冻结state)或者redux-immutable-state-invariant(检查状态是否有改变)
  • Immer简化更新逻辑,减少setIn的使用
  • Immutable.js体积非常大
  • API优点复杂
  • API影响应用程序代码。所有的逻辑都必须要知道是在修改普通 js 对象还是 Immutable 对象
  • 从 Immutable 对象转换为普通 JS 对象的成本相对较高,通常产生新的深层对象引用
  • 缺乏持续维护

使用Immutable.js最强的理由是应对大对象(几千个key)的快速更新,大多数应用不会遇到这样的对象。

总的来说,Immutable.js增加太多开销,但实际好处太少,Immer是另一个更好的选择。

优先级C规则:推荐

使用作用域/事件名来编写Action的类型

Redux的文档和例子通常使用“SCREAMING_SNAKE_CASE”的风格命名action的类型,例如“ADD_TODO”和“INCREMENT”。这和大多数编程语言中声明常量的方式一致,缺点是大写字符可能较为难以阅读。 其他组织接受另一种约定,通常会以action相关的特性或者作用域进行设定action的类型。NgRx社区使用一种"[Domain] Action Type"模式,例如"[Login Page] Login",其他一些模式例如"domain:action"也被接受使用。 Redux Tookit的createSlice函数现在生成的action的类型类似于"domain/action",例如"todos/addTodo",从可读性来说建议使用"domain/action"。

采用标准化Flux编写Action

最初的Flux架构只规定action对象应该有type字段,没有对action的字段应该使用什么类型的字段或者命名做出进一步的指导。为了一致性,Andrew Clark在Redux的开发早期阶段创建一种名为Flux标准Action的规范。总的来说,应该这样定义action

  • 应该将数据放到payload字段
  • 可能会有一个meta字段放补充信息
  • 可能会有一个error字段来表示action失败的一些错误信息

在Redux生态下的很多库接受这个约定,而且Redux Tookit生成的action creator满足这个格式。 从一致性角度建议使用这个约定

这个约定,错误的action需要设置error:true,并对正确的action使用相同action类型。实践中,大多数开发者会对成功和失败,分开不同类型。两者都是可以接受的。

使用action creator

action creator函数起源于最初的Flux架构,结合Redux,action creator则不是严格必须的。组件和其他逻辑只需要调用dipatch来使用action。 然而,使用action creator会带来一致性,特别是需要某种准备或者补充逻辑来填充action的情况下,例如生成唯一ID。 建议使用action creator来dispatch action。但是,与手写不同,建议使用Redux Toolkit的createSlice,可以自动生成action creator和action types。

使用RTK查询获取数据

实践中,在Redux应用中最常见的副作用场景就是从服务器获取数据。 正因为如此,我们建议在Redux应用中使用RTK查询作为默认的数据获取和缓存方式。RTK查询被设计成正确地管理从服务器获取数据、缓存、重复请求、更新组件和其他。在大多数场景下,我们不推荐手写数据获取逻辑。

使用Thunks和Listeners编写异步逻辑

Redux从设计上是可以扩展的,其中间件API被设计为允许不同类型异步逻辑嵌入到Redux中。这样,如果不满足需求,开发者就不需要特地去学习像RxJS这样的库。 这导致创建了各种各样的中间件插件,反过来导致混乱以及应该使用哪种中间件。 我们建议使用Redux thunks中间件作为默认,例如需要通过dispatch和getState实现的复杂的同步逻辑和复杂的异步逻辑。这包括将逻辑从组建中移出。 我们建议使用RTK的listener中间件处理响应式逻辑,例如长时间的异步流程和背景线程类的逻辑。 在多数场景下,我们不建议使用更加复杂的Redux-Saga和Redux- Observable库,尤其是异步数据获取。除非没有其他更好的工具能满足场景,这时才考虑使用这些库。

将复杂业务逻辑从组件移除

我们通常建议尽可能将逻辑抽离到组件外部。部分是鼓励“container/presentational“模式,在这个模式下,组件只需要接收数据并渲染UI,而且还因为在类组件生命周期中去维护异步逻辑可能变得难以维护。 我们依然建议将复杂的同步或者异步的逻辑移动到组件外部,通常放到thunks中。如果这部分逻辑需要从store的state中读取数据,这时更为正确。 但是,使用React Hooks使得在组件内部维护例如数据获取的逻辑容易一些,并且在一些场景下替代thunks。

使用Selector函数读取State

selector函数在封装从Redux读取数据和从这些值派生的值的有力工具。另外,一些如Reselect的库支持创建可缓存的selector函数,仅在输入值发生变化时重新计算,这是性能优化的一个重要方面。 我们建议尽可能在从store中读取state时使用可缓存的selector函数,而且建议使用Reselect。 然而,也不要认为开发者必须写selector函数,基于字段被经常访问或者更新以及能在应用中提供的真实好处中,要找到一个合适的平衡。

使用SelectXX命名Selector函数

我们建议将selector函数的命名前缀为select,结合要选择的值的描述,例如selectTodos、selectVisibleTodos和selectTodoById。

避免在Redux中保存表单数据

大多数表单状态都不应该出现在Redux中。在大多数场景下,数据并不是全局的,不被缓存的而且同时不被多个组件使用。另外,将表单数据链接到Redux通常导致每次change事件都会dispatch,这会导致性能开销,而且没有真正的好处。(通常不需要在从name:Mark修改为name:Mar获得到时间旅行)。 即使数据最终非要保存到Redux,也可以尽量将数据保持在本地组件更新,而且只有当用户完成表单后在dispatch去更新Redux的store。 一些案例中在Redux维护表单是有意义的,例如所见即所得预览,但大多数情况下是不必要的。