react-boilerplate 中的 Immer 实践:用 produce 书写可读、安全的不可变 Reducer
2026/9/19 6:35:58 网站建设 项目流程

react-boilerplate 中的 Immer 实践:用 produce 书写可读、安全的不可变 Reducer

【免费下载链接】react-boilerplate🔥 A highly scalable, offline-first foundation with the best developer experience and a focus on performance and best practices.项目地址: https://gitcode.com/gh_mirrors/rea/react-boilerplate

本文以 react-boilerplate 项目官方文档 docs/js/immer.md 为主体,结合仓库内 HomePage、LanguageProvider、App 等容器模块的 reducer、selector 与测试源码,系统讲解 Immer 的核心思想、produce函数在 Redux reducer 中的标准写法、与 reselect 的组合使用方式,以及配套的 ESLint 规则与单元测试验证方法。读完本文,你将掌握在 react-boilerplate 中基于 Immer 编写"看起来在改、实际上不可变"的 reducer 的完整实战方案。

为什么需要 Immer:不可变数据与 React 重渲染

react-boilerplate 是一个"高度可扩展、离线优先、聚焦性能与最佳实践"的 React 应用脚手架(见 package.json),其状态管理建立在 Redux 之上,而 Redux 的核心约定是:状态(state)不可直接修改,每次更新都必须产生一个全新的对象

这一约定与 React 的性能模型直接相关:不可变的数据结构可以在常量时间内完成深度比较(deep compare)。当我们知道props是否发生变化时,就能高效地判断组件是否需要重新渲染——如果引用没有变化,组件就可以安全地跳过渲染。这正是 docs/js/immer.md 开头所强调的:"Immutable data structures can be deeply compared in no time"(不可变数据结构可以瞬时完成深度比较),从而让我们快速判定组件是否应该 rerender。

然而,手写不可变更新非常痛苦。以 React 官方推荐的展开语法为例,要更新一层嵌套对象已经略显啰嗦,更深层的嵌套更是可读性灾难:

return { ...state, userData: { ...state.userData, repositories: action.repos, }, };

Immer 解决的就是这个问题:允许你以"直接修改"的直觉方式来书写更新逻辑,同时自动产出不可变的新状态,从根源上避免手写展开语法带来的错误与样板代码。

核心概念:currentState、draftState 与 nextState

根据 docs/js/immer.md 的说明,Immer 的基本思路是:

  1. 你将所有修改应用在一个**临时的 draftState(草稿状态)**上,它是 currentState(当前状态)的代理(proxy);
  2. 当所有修改完成后,Immer 会根据对 draft 的修改自动产生nextState(下一个状态)
  3. 原始 state 在这个过程中始终保持不变。

也就是说,对开发者而言,你"感觉"自己在直接改 state;对应用而言,state 依然是不可变的。底层原理是 Immer 利用 ES6 的 Proxy 机制对 draft 进行拦截:所有读操作都从原始状态取值,所有写操作都被记录下来,最终基于"修改补丁"生成新的不可变对象——没有修改过的部分会保持原引用,从而让深度比较在常量时间内完成。

Immer 对外暴露的核心 API 是produce函数:

import produce from 'immer'; const nextState = produce(currentState, draft => { // 在这里直接修改 draft,例如: draft.myData = action.payload; });

produce接收两个参数:

参数类型说明
currentState任意当前(旧的)状态对象,作为修改的基准
recipe函数接收draft作为唯一参数,在其中书写所有"修改"逻辑,可以包含switchif、循环、赋值等任意语句

recipe内部可以有返回值(用于完全替换 draft),也可以不返回(此时基于对 draft 的修改生成新状态)。在 reducer 场景中,我们通常使用后一种形式,让produce返回的nextState直接作为 reducer 的返回值。

在 Redux reducer 中使用 produce:标准写法

docs/js/immer.md 给出了在 reducer 中使用produce的规范示例,这也是 react-boilerplate 全仓库统一遵循的写法:

import produce from 'immer'; import { SOME_ACTION, SOME_OTHER_ACTION } from './actions'; // [...] /* eslint-disable default-case, no-param-reassign */ const myReducer = (state = initialState, action) => produce(state, draft => { switch (action.type) { case SOME_ACTION: draft.myData = action.payload; break; case SOME_OTHER_ACTION: draft.myData.message = action.payload; break; } });

注意其中的关键细节:

  • produce(state, draft => { ... })的结果直接作为 reducer 的返回值,无需手动return { ...state }
  • draft上直接赋值,例如draft.myData = action.payload,Immer 会将其翻译为不可变更新;
  • 嵌套更新同样直接赋值draft.myData.message = action.payload会生成一个新对象,其中myData被替换,而myData之外的其他属性保持原引用;
  • switch中可以不写default分支:当 action 类型不匹配任何 case 时,produce发现 draft 未被修改,会直接返回原始 state(引用不变),这正是不可变比较能"瞬时完成"的前提。

eslint-disable 注释的用意

示例中/* eslint-disable default-case, no-param-reassign */并非可有可无:

  • default-case:ESLint 的default-case规则要求switch必须有default分支,而 Immer 的 reducer 模式天然不需要它——未匹配的 action 应保持 state 不变;
  • no-param-reassign:该规则禁止修改函数参数,而修改draft正是 Immer 的用法核心,必须显式豁免。

react-boilerplate 的 ESLint 配置基于eslint-config-airbnb(见 package.json 的 devDependencies),这两条规则默认开启,因此在所有使用 Immer 的 reducer 文件顶部都能看到这一行注释。

仓库实战:三类 reducer 的源码级解析

1. 扁平状态的简单更新:HomePage

app/containers/HomePage/reducer.js 是文档示例最直接的落地产物。它维护一个非常扁平的状态{ username: '' },处理CHANGE_USERNAMEaction 时,直接在 draft 上赋值:

import produce from 'immer'; import { CHANGE_USERNAME } from './constants'; export const initialState = { username: '', }; /* eslint-disable default-case, no-param-reassign */ const homeReducer = (state = initialState, action) => produce(state, draft => { switch (action.type) { case CHANGE_USERNAME: // Delete prefixed '@' from the github username draft.username = action.username.replace(/@/gi, ''); break; } }); export default homeReducer;

这里还有一个值得学习的细节:对 draft 的赋值并不局限于"原样覆盖",你完全可以先对 payload 做加工(如示例中把 GitHub 用户名开头的@前缀去掉),再写入 draft——所有修改逻辑都能自然地写在 recipe 内部。action 本身由 app/containers/HomePage/actions.js 中的changeUsername(username)创建,其 type 常量定义在 app/containers/HomePage/constants.js:boilerplate/Home/CHANGE_USERNAME

2. 嵌套状态的原生深更新:App

Immer 最能体现价值的是嵌套状态的更新。app/containers/App/reducer.js 维护了包含三层嵌套的状态{ loading, error, currentUser, userData: { repositories } },并处理三个 action:

case LOAD_REPOS: draft.loading = true; draft.error = false; draft.userData.repositories = false; break; case LOAD_REPOS_SUCCESS: draft.userData.repositories = action.repos; draft.loading = false; draft.currentUser = action.username; break; case LOAD_REPOS_ERROR: draft.error = action.error; draft.loading = false; break;

请注意draft.userData.repositories = action.repos这一行:如果用手写展开语法,需要先展开state再展开state.userData;而 Immer 允许直接对draft.userData的属性赋值,同时保证只有userData及被修改的叶子节点产生新引用,loadingerrorcurrentUser等兄弟属性在未被触碰时保持原引用。这正是文档所说"apply all your changes to a temporary draftState"的直观体现——深层的更新代码与浅层更新一样简洁。

3. 基于常量与外部默认值:LanguageProvider

app/containers/LanguageProvider/reducer.js 展示了 Immer reducer 的另一种组合方式——初始状态可以来自其他模块的导入值:

import produce from 'immer'; import { CHANGE_LOCALE } from './constants'; import { DEFAULT_LOCALE } from '../../i18n'; export const initialState = { locale: DEFAULT_LOCALE, }; const languageProviderReducer = (state = initialState, action) => produce(state, draft => { switch (action.type) { case CHANGE_LOCALE: draft.locale = action.locale; break; } });

这里initialState被导出,供同目录下的 selectors.js 用作兜底值(state.language || initialState),这是 react-boilerplate 的惯例:reducer 导出initialState,selector 引用它作为 state 尚未初始化时的回退

测试验证:用 produce 断言不可变行为

react-boilerplate 为每个 reducer 都配套了单元测试(npm test基于 Jest,见 package.json),app/containers/HomePage/tests/reducer.test.js 完整覆盖了 Immer reducer 的两个关键行为:

import produce from 'immer'; import homeReducer from '../reducer'; import { changeUsername } from '../actions'; describe('homeReducer', () => { let state; beforeEach(() => { state = { username: '', }; }); it('should return the initial state', () => { const expectedResult = state; expect(homeReducer(undefined, {})).toEqual(expectedResult); }); it('should handle the changeUsername action correctly', () => { const fixture = 'mxstbr'; const expectedResult = produce(state, draft => { draft.username = fixture; }); expect(homeReducer(state, changeUsername(fixture))).toEqual(expectedResult); }); });

两个用例分别验证了 Immer reducer 最重要的两个性质:

  1. 未匹配 action 时返回初始状态homeReducer(undefined, {})使用undefined触发默认参数initialState,传入空 action(不匹配任何 case)时,produce返回原始 state,测试断言其与initialState深度相等;
  2. 匹配 action 时产生预期新状态:测试自己用produce(state, draft => { draft.username = fixture; })构造期望结果,再与 reducer 的实际输出比较——用与实现相同的方式来描述期望,保证了测试与实现语义一致。

此外,App 模块还通过 app/containers/App/tests/reducer.test.js 覆盖了嵌套更新(LOAD_REPOS_SUCCESS等场景),可以对照阅读。

与 reselect 的组合:不可变 + 缓存选择器

docs/js/immer.md 明确指出,项目使用 reselect 高效缓存计算后的应用状态。这两者的配合逻辑是:

  • Immer 保证"未变化的状态引用不变",这是 reselect 能高效工作的前提;
  • reselect 基于引用比较做记忆化(memoize):只有当输入 selector 的返回值引用发生变化时,才会重新计算派生数据,否则直接返回上次缓存的结果,从而避免不必要的重复计算与组件重渲染。

文档给出了最基础的 selector 写法:

const myDataSelector = (state) => state.myData; const messageSelector = (state) => state.myData.message; export default myDataSelector;

而 react-boilerplate 的实际实现更进一步,统一采用 reselect 的createSelector工厂模式。以 app/containers/HomePage/selectors.js 为例:

import { createSelector } from 'reselect'; import { initialState } from './reducer'; const selectHome = state => state.home || initialState; const makeSelectUsername = () => createSelector( selectHome, homeState => homeState.username, ); export { selectHome, makeSelectUsername };

注意这里与文档示例的差异:由于 react-boilerplate 使用动态注入 reducer机制(见 app/utils/injectReducer.js 与容器内useInjectReducer({ key, reducer })的调用,例如 app/containers/HomePage/index.js),state 的顶层 key 由注入时指定的key决定(如'home'),因此 selector 先通过state.home || initialState做兜底,再进入createSelector的组合链路。

App 模块的 app/containers/App/selectors.js 展示了更复杂的选择器组合——makeSelectRepos()等函数从globalState.userData.repositories中逐层取值,而这些值正是由前述 Immer reducer 以不可变方式写入的。容器侧则用createStructuredSelector(见 app/containers/HomePage/index.js 的mapStateToProps)一次组合多个makeSelect*工厂,把reposusernameloadingerror映射为组件 props。

完整工作流:从 action 到重渲染

综合以上源码,react-boilerplate 中一次状态更新的完整链路是:

  1. 用户在输入框输入内容,触发mapDispatchToProps中的onChangeUsername(app/containers/HomePage/index.js);
  2. dispatch(changeUsername(value))派发CHANGE_USERNAMEaction;
  3. HomePage 的 Immer reducer 在 draft 上直接赋值,produce返回新的不可变 state,未触碰的属性保持原引用;
  4. connect重新执行mapStateToPropsmakeSelectUsername()等 reselect 选择器基于引用变化判断是否重算;
  5. 只有值真正变化的分支触发组件重渲染,其余组件因引用未变而被跳过。

正是因为第 3 步由 Immer 保证了"变更最小化、引用精准替换",第 4、5 步的深度比较与记忆化缓存才能高效运转。这也是 react-boilerplate 把 docs/js/immer.md 与 docs/js/reselect.md 并列编写的原因——二者共同构成了其 Redux 状态层的性能基石。

【免费下载链接】react-boilerplate🔥 A highly scalable, offline-first foundation with the best developer experience and a focus on performance and best practices.项目地址: https://gitcode.com/gh_mirrors/rea/react-boilerplate

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询