Redux:不可变数据与纯函数的艺术

Redux:不可变数据与纯函数的艺术

状态管理的困境

随着现代 Web 应用功能的不断扩展,前端开发者面临着日益复杂的状态管理挑战。当应用从简单的表单交互发展到复杂的单页应用时,组件间共享状态的问题变得尤为突出。想象一个电商平台,购物车状态需要在商品列表、导航栏计数器和结算页面之间共享,这种跨组件的状态共享会导致:

  • 状态追踪困难:当多个组件可以修改同一状态时,很难确定状态变化的来源
  • 调试复杂度增加:状态变化路径不明确导致 bug 难以定位
  • 维护成本上升:随着应用规模扩大,组件间紧耦合的状态依赖使代码难以维护
  • 可预测性降低:当状态修改分散在各处时,应用行为变得难以预测

这些问题催生了对统一状态管理解决方案的需求。

Redux 的诞生背景

Redux 由 Dan Abramov 在 2015 年创建,其诞生并非偶然,而是对前端状态管理问题的深思熟虑回应。Redux 的灵感主要来自两个源头:

  1. Elm 架构:Elm 是一门函数式编程语言,其架构强调单向数据流和不可变状态。Redux 借鉴了 Elm 的模型-视图-更新(Model-View-Update)模式。

  2. Facebook 的 Flux:作为 React 配套的状态管理方案,Flux 提出了单向数据流的理念,但其实现相对复杂,包含多个 store 和 dispatcher。

Dan Abramov 在参加 React Europe 大会准备演讲"热加载与时间旅行调试"时,为展示状态回溯功能,设计了一个简化版的状态管理库,这就是 Redux 的雏形。Redux 保留了 Flux 的精髓,同时通过引入函数式编程概念大幅简化了其设计,它的出现恰逢其时,完美契合了 React 社区对状态管理的需求。

从 Flux 到 Redux

Flux 架构引入了单向数据流的概念,其数据流动路径为:

sql 复制代码
Action → Dispatcher → Store → View

这种模式要求所有数据变更必须通过派发(dispatch)action 来进行,使数据流动变得可预测。然而,Flux 实现上较为复杂:

  • 需要创建多个 Store
  • 需要显式注册 Dispatcher
  • Store 之间可能存在依赖关系
  • 没有明确处理副作用的机制

Redux 通过以下改进简化了这一架构:

  • 去除了 Dispatcher:Redux 将 dispatch 功能集成到 store 中
  • 单一 Store:整个应用状态集中在一个对象树中
  • 引入纯函数 Reducer:状态更新逻辑由纯函数处理,确保可预测性
  • 不可变数据更新:禁止直接修改状态,强制通过创建新状态对象来表示变化

这些简化使 Redux 更加优雅且易于理解,同时保留了 Flux 的核心优势。

Redux 核心概念

Redux 的设计建立在三个核心原则之上,这些原则共同构成了其强大而简洁的状态管理哲学。

三大原则详解

1. 单一数据源(Single Source of Truth)

Redux 要求将应用的整个状态存储在单一的 store 对象树中。这种方法带来的好处包括:

  • 简化应用状态模型:不再需要跟踪多个数据源
  • 便于状态快照:整个应用状态可通过一个对象快照保存
  • 简化调试:可以轻松观察状态变化历史
  • 服务器渲染支持:服务端可以预先生成状态并传递给客户端

例如,一个包含用户信息和待办事项的状态树可能如下:

javascript 复制代码
{
  user: {
    id: 1,
    name: 'Alex',
    isLoggedIn: true
  },
  todos: [
    { id: 1, text: '学习 Redux', completed: false },
    { id: 2, text: '理解不可变数据', completed: true }
  ],
  visibilityFilter: 'SHOW_ALL'
}
2. 状态只读(State is Read-Only)

在 Redux 中,唯一改变状态的方法是触发 action,这是一个描述发生了什么的普通对象。这一原则确保:

  • 视图和网络请求不能直接修改状态
  • 状态修改集中且按严格顺序执行
  • 所有修改可追踪
  • 便于实现撤销/重做功能

强制通过 action 修改状态看似繁琐,但这种约束为应用带来了可预测性,让复杂的状态变化变得可管理。

3. 使用纯函数修改(Changes are Made with Pure Functions)

Reducer 是指定状态如何响应 action 变化的纯函数,它接收当前状态和 action,返回新的状态:

scss 复制代码
(previousState, action) => newState

纯函数的特性保证了状态变化的可预测性和可测试性,避免了副作用带来的不确定性。

核心组件详解

Store

Store 是 Redux 应用中的核心,它承担着以下责任:

  • 持有应用状态
  • 提供 getState() 方法访问状态
  • 提供 dispatch(action) 方法更新状态
  • 通过 subscribe(listener) 注册监听器
  • 通过 unsubscribe() 取消监听

创建 store 非常简单:

javascript 复制代码
import { createStore } from 'redux'
import rootReducer from './reducers'

// 创建 store,传入根 reducer
const store = createStore(
  rootReducer,
  // 可选的初始状态
  initialState,
  // 可选的增强器(如中间件)
  enhancer
)

// 访问状态
console.log(store.getState())

// 订阅状态变化
const unsubscribe = store.subscribe(() => {
  console.log('状态已更新:', store.getState())
})

// 分发 action 触发状态更新
store.dispatch({ type: 'INCREMENT' })

// 取消订阅
unsubscribe()

在实际应用中,通常会为 store 添加中间件以处理异步操作、日志记录等:

javascript 复制代码
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk' // 处理异步操作
import logger from 'redux-logger' // 记录状态变化日志
import rootReducer from './reducers'

const store = createStore(
  rootReducer,
  applyMiddleware(thunk, logger)
)
Action

Action 是向 store 传递数据的唯一方式,它是一个普通 JavaScript 对象,必须包含一个 type 属性标识动作类型,其余结构自定义。设计良好的 action 应满足:

  • 具有描述性的 type 值,通常为字符串常量
  • 包含尽可能少的必要数据
  • 可序列化,方便存储和传输

为避免直接创建 action 对象带来的重复和错误,通常使用"action 创建函数":

javascript 复制代码
// Action 类型常量
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'

// Action 创建函数
export function addTodo(text) {
  return {
    type: ADD_TODO,
    payload: {
      id: Date.now(), // 生成唯一 ID
      text,
      completed: false
    }
  }
}

export function toggleTodo(id) {
  return {
    type: TOGGLE_TODO,
    payload: { id }
  }
}

// 使用 action 创建函数
dispatch(addTodo('学习 Redux'))

Action 的 type 通常使用字符串常量而非直接字符串,这有助于避免拼写错误并便于集中管理。

Reducer

Reducer 是 Redux 的核心,它负责指定应用状态如何响应 action 变化。reducer 是一个纯函数,接收当前状态和 action 作为参数,返回新状态。一个典型的 reducer 结构如下:

javascript 复制代码
const initialState = { todos: [] }

function todoReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_TODO':
      // 返回新状态,不修改原状态
      return {
        ...state, // 复制原状态所有属性
        todos: [...state.todos, action.payload] // 添加新待办项
      }
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed } // 更新匹配到的待办项
            : todo // 保持其他待办项不变
        )
      }
    
    default:
      // 对于不认识的 action,返回原状态
      return state
  }
}

关于 reducer 需要特别注意:

  1. 默认值处理:提供初始状态作为默认参数
  2. 处理未知 action:对不匹配的 action 类型返回当前状态
  3. 不可变更新:不直接修改原状态对象,而是创建新对象
  4. 纯函数特性:同样的输入必定得到同样的输出,无副作用

随着应用复杂度增加,可以使用 combineReducers() 将多个 reducer 合并:

javascript 复制代码
import { combineReducers } from 'redux'
import todosReducer from './todosReducer'
import filterReducer from './filterReducer'
import userReducer from './userReducer'

const rootReducer = combineReducers({
  todos: todosReducer,
  visibilityFilter: filterReducer,
  user: userReducer
})

// 生成的状态结构:
// {
//   todos: [...],
//   visibilityFilter: '...',
//   user: {...}
// }

这种组合方式使每个 reducer 只负责管理整个状态树中的特定部分,有助于保持代码模块化和可维护。

不可变数据的艺术

不可变性(Immutability)是 Redux 的基石之一,它要求我们在更新状态时不直接修改原对象,而是创建新对象。这一理念源自函数式编程,并在 Redux 中扮演着至关重要的角色。

为什么需要不可变性?

不可变性在 Redux 中的价值不仅是理论上的严谨,更在于实际应用中带来的诸多优势:

1. 可预测性

当状态对象不可变时,状态变化只能通过创建新对象来实现,这使得状态的变化变得可控和可预测。你可以确信只有通过明确的 action 才能改变状态,而不会有意外的修改发生。

2. 追踪变化

不可变数据结构使状态比较变得简单高效。比较两个对象是否发生变化,只需比较它们的引用是否相同,而不需要递归比较深层属性:

javascript 复制代码
// 高效的状态变化检测
const hasChanged = prevState !== nextState

这种特性使 React 组件能够高效决定是否需要重新渲染,例如在 React.memo()shouldComponentUpdate() 中进行浅比较。

3. 时间旅行调试

不可变性使得保存状态历史变得简单:每次状态更新都会产生一个全新的状态对象,我们只需保存这些历史状态的引用。这直接支持了时间旅行调试功能,开发者可以在不同的状态快照间切换,重现应用的过去状态。

Redux DevTools 正是基于这一特性,提供了强大的调试能力:

  • 记录所有 action 和状态变化
  • 回放用户操作
  • 导出/导入状态快照
  • "热重启"保留状态
4. 易于测试

纯函数和不可变数据使 Redux 应用易于测试。Reducer 测试尤其简单,只需传入特定状态和 action,然后断言返回的新状态:

javascript 复制代码
it('应该添加一个待办项', () => {
  const prevState = { todos: [] }
  const action = {
    type: 'ADD_TODO',
    payload: { id: 1, text: '测试 Redux', completed: false }
  }
  const nextState = todoReducer(prevState, action)
  
  expect(nextState.todos.length).toBe(1)
  expect(nextState.todos[0].text).toBe('测试 Redux')
  // 确保原状态不被修改
  expect(prevState.todos).toEqual([])
})

实现不可变性的方法

在 JavaScript 中实现不可变更新有多种方式,从手动实现到使用专用库,每种方法各有优劣:

1. 对象扩展运算符(...)

ES6 的扩展运算符是最常用的不可变更新方法:

javascript 复制代码
// 对象浅复制并更新属性
const updatedUser = { 
  ...user,          // 复制现有属性
  name: 'Redux',    // 更新特定属性
  score: user.score + 1
}

// 注意:这只是浅复制,嵌套对象仍需单独处理
const updatedState = {
  ...state,
  user: {
    ...state.user,
    settings: {
      ...state.user.settings,
      theme: 'dark'  // 更新深层嵌套属性
    }
  }
}

这种写法在嵌套对象较深时会变得冗长,但它不需要额外依赖,适合简单场景。

2. 数组方法

JavaScript 数组有许多返回新数组的方法,非常适合不可变更新:

javascript 复制代码
// 添加元素
const newArray = [...array, newItem]  // 末尾添加
const newArray = [newItem, ...array]  // 开头添加
const newArray = [
  ...array.slice(0, index),
  newItem,
  ...array.slice(index)
]  // 中间插入

// 删除元素
const newArray = array.filter(item => item.id !== idToRemove)

// 替换元素
const newArray = array.map(item => 
  item.id === targetId ? { ...item, updated: true } : item
)
3. Object.assign()

Object.assign() 是 ES6 特性,用于对象的浅合并:

javascript 复制代码
const updatedState = Object.assign({}, state, { name: 'Redux' })

// 嵌套对象更新
const updatedState = Object.assign({}, state, {
  user: Object.assign({}, state.user, {
    settings: Object.assign({}, state.user.settings, {
      theme: 'dark'
    })
  })
})

相比扩展运算符,这种方法更适合动态属性名:

javascript 复制代码
const updatedState = Object.assign({}, state, {
  [dynamicKey]: newValue
})
4. Immer 库

对于复杂嵌套状态,手动管理不可变更新可能繁琐且容易出错。Immer 库提供了一种优雅解决方案,允许编写看似可变的代码,同时保持不可变更新:

javascript 复制代码
import produce from 'immer'

const nextState = produce(state, draft => {
  // 可以"直接修改" draft
  draft.todos.push({ 
    id: Date.now(),
    text: '学习 Immer', 
    completed: false 
  })
  
  // 嵌套更新也变得简单
  draft.user.settings.notifications.email = false
  
  // 可以使用所有可变方法
  const todo = draft.todos.find(t => t.id === 3)
  if (todo) {
    todo.completed = true
  }
})

Immer 内部使用 Proxy 实现,它追踪对草稿(draft)的所有修改,然后据此生成不可变的新状态。这大大简化了复杂状态的更新,特别是在 Redux Toolkit 中,Immer 已成为内置工具。

不可变性陷阱与注意事项

虽然不可变性有诸多优点,但实现时需注意几个常见陷阱:

  1. 浅复制的局限性:扩展运算符和 Object.assign() 只执行浅复制,嵌套对象仍是引用,需要手动处理。

  2. 引用类型复杂性:对象、数组等引用类型需要特殊处理,尤其在深层嵌套结构中。

  3. 性能考量:频繁创建新对象可能带来性能开销,特别是大型数据结构。在这种情况下,可考虑使用优化的不可变数据库如 Immutable.js 或性能优化的 Immer。

  4. 错误写法

javascript 复制代码
// 错误:修改了原状态
function brokenReducer(state, action) {
  state.completed = true;  // 直接修改了参数
  return state;  // 返回被修改的同一对象
}

// 正确:创建新状态
function correctReducer(state, action) {
  return {
    ...state,
    completed: true
  };
}

纯函数的力量

纯函数是函数式编程的核心概念,也是 Redux 设计的重要基础。理解纯函数对于掌握 Redux 的工作原理至关重要。

什么是纯函数?

纯函数是指符合以下条件的函数:

  1. 确定性输出:相同输入总是返回相同输出,不受外部状态影响
  2. 无副作用:不修改外部状态,不执行 I/O 操作,不调用不纯的函数
  3. 不依赖外部状态:只依赖输入参数,不读取外部变量或全局状态

举例说明:

javascript 复制代码
// 纯函数
function add(a, b) {
  return a + b;
}

// 非纯函数 - 依赖外部状态
let multiplier = 2;
function multiply(a) {
  return a * multiplier;  // 依赖外部变量
}

// 非纯函数 - 有副作用
function logAndAdd(a, b) {
  console.log(`Adding ${a} and ${b}`);  // 副作用:I/O 操作
  return a + b;
}

// 非纯函数 - 修改输入
function addAndModify(arr, value) {
  arr.push(value);  // 修改了输入参数
  return arr;
}

纯函数的特性使得代码更易于理解、测试和维护,同时也是实现不变性的关键技术基础。

Reducer 作为纯函数

在 Redux 中,reducer 必须是纯函数,这是确保状态更新可预测性的核心要求。一个标准的 reducer 函数:

javascript 复制代码
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
}

这个 reducer 是纯函数,因为: 相同的 state 和 action 总是产生相同的结果

  • 不依赖任何外部状态
  • 不修改输入参数
  • 无副作用,如网络请求、随机值生成等

常见的纯函数错误模式

在编写 reducer 时,开发者常犯以下错误,破坏了纯函数特性:

1. 直接修改状态
javascript 复制代码
// 错误示例 - 修改原始状态
function impureReducer(state, action) {
  state.value += action.amount  // 直接修改了 state
  return state  // 返回同一对象引用
}

// 正确的实现
function pureReducer(state, action) {
  return { ...state, value: state.value + action.amount }  // 创建新对象
}
2. 包含副作用
javascript 复制代码
// 错误示例 - 包含副作用
function sideEffectReducer(state, action) {
  localStorage.setItem('data', JSON.stringify(state))  // 副作用:I/O 操作
  console.log('State updated:', state)  // 副作用:日志输出
  return { ...state, value: state.value + action.amount }
}

// 正确实现:副作用应在 reducer 外处理,如通过中间件
function pureReducer(state, action) {
  return { ...state, value: state.value + action.amount }
}
3. 使用非确定性函数
javascript 复制代码
// 错误示例 - 使用非确定性函数
function nonDeterministicReducer(state, action) {
  return {
    ...state,
    id: Math.random(),  // 非确定性:每次调用结果不同
    timestamp: Date.now()  // 非确定性:依赖当前时间
  }
}

// 正确实现:非确定性值应在 action 创建时生成
function createAddItemAction(text) {
  return {
    type: 'ADD_ITEM',
    payload: {
      id: Math.random(),  // 在 action 创建时生成
      timestamp: Date.now(),
      text
    }
  }
}

function pureReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload]
      }
    default:
      return state
  }
}

纯函数的优势

纯函数在 Redux 架构中带来诸多好处:

  1. 可预测性:无论何时调用,相同输入产生相同输出
  2. 易于测试:不需要模拟外部依赖,只需验证输入输出关系
  3. 自文档化:函数行为完全由参数决定,更易理解
  4. 可组合性:纯函数易于组合,创建更复杂的功能
  5. 并行安全:无副作用意味着可以安全地并行执行
  6. 易于缓存:结果可缓存,提高性能

纯函数与状态隔离

在 Redux 中,纯函数通过明确的输入输出边界将状态变化与副作用隔离:

  • 状态计算(纯函数):reducer 纯粹负责计算新状态
  • 副作用处理(非纯函数):通过中间件等机制在 reducer 之外处理

这种分离使得应用逻辑更清晰,状态变化更可控,也为处理复杂异步操作提供了基础。

单向数据流与副作用控制

Redux 的核心价值之一是实现了严格的单向数据流,这种模式使应用中的数据变化更加可预测和可追踪。

Redux 数据流详解

Redux 的数据在应用中遵循一个明确的单向循环流动路径:

sql 复制代码
View → Action → Reducer → Store → View

让我们详细解析这个循环:

  1. View 触发 Action:用户在界面上的交互(如点击按钮)触发 action 的分发

    javascript 复制代码
    // 在 React 组件中
    function TodoItem({ todo, dispatch }) {
      return (
        <li>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => dispatch(toggleTodo(todo.id))}
          />
          {todo.text}
        </li>
      );
    }
  2. Action 描述变化:被触发的 action 是一个普通对象,描述"发生了什么"

    javascript 复制代码
    // action 对象
    {
      type: 'TOGGLE_TODO',
      payload: { id: 42 }
    }
  3. Reducer 计算新状态:store 将当前状态和 action 传给 reducer,后者计算并返回新状态

    javascript 复制代码
    function todoReducer(state = [], action) {
      switch (action.type) {
        case 'TOGGLE_TODO':
          return state.map(todo =>
            todo.id === action.payload.id
              ? { ...todo, completed: !todo.completed }
              : todo
          );
        default:
          return state;
      }
    }
  4. Store 更新状态:store 用 reducer 返回的新状态替换旧状态

    javascript 复制代码
    // 内部实现类似于
    currentState = reducer(currentState, action);
  5. View 响应变化:订阅 store 的组件检测到状态变化,重新渲染

    javascript 复制代码
    // 在 React-Redux 中
    function TodoList() {
      // 通过 useSelector 订阅 store 状态
      const todos = useSelector(state => state.todos);
      
      return (
        <ul>
          {todos.map(todo => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ul>
      );
    }

这个循环确保了:

  • 所有状态变化有明确的来源(action)
  • 状态变化是可预测的(由 reducer 纯函数决定)
  • 变化是单向流动的,不会出现双向绑定带来的复杂性

处理副作用的策略

纯 Redux 只处理同步、无副作用的数据流。然而,实际应用中常需要处理网络请求、定时操作等副作用。Redux 生态提供了多种处理副作用的中间件方案:

1. Redux-Thunk

Redux-Thunk 是最简单的副作用处理方案,它允许 action 创建函数返回函数而非对象,这个返回的函数可以执行异步操作:

javascript 复制代码
// 普通 action 创建函数
const increment = () => ({ type: 'INCREMENT' })

// thunk action 创建函数
const fetchTodos = () => {
  // 返回一个函数,而非 action 对象
  return async (dispatch, getState) => {
    // 表明异步操作开始
    dispatch({ type: 'FETCH_TODOS_START' })
    
    try {
      // 执行异步操作
      const response = await fetch('/api/todos')
      const todos = await response.json()
      
      // 成功后分发包含数据的 action
      dispatch({ 
        type: 'FETCH_TODOS_SUCCESS', 
        payload: todos 
      })
    } catch (error) {
      // 失败时分发错误 action
      dispatch({ 
        type: 'FETCH_TODOS_FAILURE', 
        error: error.message 
      })
    }
  }
}

// 在组件中使用
dispatch(fetchTodos())

Thunk 的优势在于简单易学,适合处理基本的异步流程;劣势是复杂异步操作(如竞态条件、请求取消)处理起来较繁琐。

2. Redux-Saga

Redux-Saga 使用 ES6 生成器(Generator)函数来控制异步流程,提供了强大的异步流程编排能力:

javascript 复制代码
import { call, put, takeEvery } from 'redux-saga/effects'

// 监听 'FETCH_TODOS_REQUEST' action 的 saga
function* watchFetchTodos() {
  yield takeEvery('FETCH_TODOS_REQUEST', fetchTodosSaga)
}

// 处理获取 todos 的具体 saga
function* fetchTodosSaga(action) {
  try {
    // call 效应阻塞执行,直到 Promise 解决
    const response = yield call(fetch, '/api/todos')
    const todos = yield call([response, 'json'])
    
    // put 效应分发 action
    yield put({ type: 'FETCH_TODOS_SUCCESS', payload: todos })
  } catch (error) {
    yield put({ type: 'FETCH_TODOS_FAILURE', error: error.message })
  }
}

// 根 saga
function* rootSaga() {
  yield all([
    watchFetchTodos(),
    // 其他 saga
  ])
}

// 配置 saga 中间件
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
  rootReducer,
  applyMiddleware(sagaMiddleware)
)

// 运行 saga
sagaMiddleware.run(rootSaga)

Saga 的优势在于声明式处理复杂异步流程,支持请求取消、竞态处理等高级功能;劣势是学习曲线较陡,需要理解生成器和 Saga 特有概念。

3. Redux-Observable

Redux-Observable 基于 RxJS,使用响应式编程模型处理副作用:

javascript 复制代码
import { ofType } from 'redux-observable'
import { mergeMap, map, catchError } from 'rxjs/operators'
import { of, from } from 'rxjs'

// Epic 是处理 action 流的函数
const fetchTodosEpic = action$ => 
  action$.pipe(
    // 过滤出特定类型的 action
    ofType('FETCH_TODOS_REQUEST'),
    // 将 action 转换为 Observable
    mergeMap(action => 
      // 从 Promise 创建 Observable
      from(fetch('/api/todos').then(res => res.json())).pipe(
        // 成功时映射到一个新 action
        map(todos => ({ type: 'FETCH_TODOS_SUCCESS', payload: todos })),
        // 错误处理
        catchError(error => 
          of({ type: 'FETCH_TODOS_FAILURE', error: error.message })
        )
      )
    )
  )

// 配置 epic 中间件
const epicMiddleware = createEpicMiddleware()
const store = createStore(
  rootReducer,
  applyMiddleware(epicMiddleware)
)

// 运行 epic
epicMiddleware.run(fetchTodosEpic)

Redux-Observable 的优势在于 RxJS 提供的强大操作符,便于处理复杂的事件流、时间相关操作和组合异步操作;劣势是需要学习 RxJS,这是一个相对复杂的库。

4. Redux Toolkit 的 createAsyncThunk

Redux Toolkit 提供了 createAsyncThunk 工具,简化了异步 action 的处理:

javascript 复制代码
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'

// 创建异步 thunk
const fetchTodos = createAsyncThunk(
  'todos/fetchTodos', // action 类型前缀
  async (arg, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/todos')
      if (!response.ok) {
        throw new Error('Server error')
      }
      return await response.json()
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

// 创建 slice,包含 reducer 和 action
const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    loading: false,
    error: null
  },
  reducers: {
    // 同步 reducer
  },
  extraReducers: (builder) => {
    // 处理异步 action
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.loading = false
        state.items = action.payload
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload
      })
  }
})

Redux Toolkit 的方案结合了 Thunk 的简单性和现代化的 API 设计,是目前推荐的处理副作用的方式。

副作用管理

无论选择哪种中间件,以下建议有助于更好地管理副作用:

  1. 保持 reducer 纯净:所有副作用应在 reducer 外处理
  2. 规范化错误处理:为异步操作定义清晰的成功/失败状态和错误传播机制
  3. 考虑请求状态:跟踪异步操作的加载、成功、失败状态
  4. 处理竞态条件:考虑多个请求并发情况下的状态更新策略
  5. 请求缓存与防抖:避免重复的网络请求,提升用户体验

实战案例:Todo 应用

为深入理解 Redux 的工作原理,我们将构建一个功能完整的 Todo 应用。这个应用虽然简单,但涵盖了 Redux 的核心概念和最佳实践。

定义 Action 类型

首先,我们定义清晰的 action 类型常量,集中管理所有可能的状态变化类型:

javascript 复制代码
// actionTypes.js
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'
export const DELETE_TODO = 'DELETE_TODO'
export const SET_FILTER = 'SET_FILTER'

// 过滤器常量
export const FILTERS = {
  SHOW_ALL: 'SHOW_ALL',
  SHOW_ACTIVE: 'SHOW_ACTIVE',
  SHOW_COMPLETED: 'SHOW_COMPLETED'
}

这种集中定义方式有助于避免拼写错误,并使整个应用中的状态变化类型一目了然。

Action 创建函数

接下来,我们创建 action 创建函数,封装 action 对象的生成逻辑:

javascript 复制代码
// actions.js
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO, SET_FILTER } from './actionTypes'

// 添加待办事项
export const addTodo = text => ({
  type: ADD_TODO,
  payload: { 
    id: Date.now(),  // 使用时间戳作为简单的唯一 ID
    text,
    completed: false
  }
})

// 切换待办事项完成状态
export const toggleTodo = id => ({
  type: TOGGLE_TODO,
  payload: { id }
})

// 删除待办事项
export const deleteTodo = id => ({
  type: DELETE_TODO,
  payload: { id }
})

// 设置过滤器
export const setFilter = filter => ({
  type: SET_FILTER,
  payload: { filter }
})

这些函数使 action 的创建更加类型安全,也便于在不同组件间复用。

Reducer

现在,我们需要编写处理这些 action 的 reducer 函数。为了保持代码的模块化,我们将创建两个分离的 reducer,然后组合它们:

javascript 复制代码
// reducers.js
import { combineReducers } from 'redux'
import { 
  ADD_TODO, 
  TOGGLE_TODO, 
  DELETE_TODO, 
  SET_FILTER, 
  FILTERS 
} from './actionTypes'

// 处理 todos 列表的 reducer
function todos(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        ...state,
        action.payload
      ]
    
    case TOGGLE_TODO:
      return state.map(todo =>
        todo.id === action.payload.id
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    
    case DELETE_TODO:
      return state.filter(todo => todo.id !== action.payload.id)
    
    default:
      return state
  }
}

// 处理过滤器状态的 reducer
function visibilityFilter(state = FILTERS.SHOW_ALL, action) {
  switch (action.type) {
    case SET_FILTER:
      return action.payload.filter
    default:
      return state
  }
}

// 组合 reducer
const rootReducer = combineReducers({
  todos,
  visibilityFilter
})

export default rootReducer

通过 combineReducers 函数,我们将两个独立的 reducer 合并成一个根 reducer,各自只负责状态树的特定部分,保持关注点分离。

Store 配置

接下来,我们创建 Redux store 并配置必要的中间件:

javascript 复制代码
// store.js
import { createStore, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunk from 'redux-thunk'
import rootReducer from './reducers'

const store = createStore(
  rootReducer,
  composeWithDevTools(
    applyMiddleware(thunk)
    // 其他增强器可以在这里添加
  )
)

export default store

我们使用了:

  • redux-devtools-extension 支持时间旅行调试
  • redux-thunk 中间件处理异步操作(虽然本例中暂未使用)

与 React 集成

最后,我们将 Redux 与 React 应用集成,创建相应的组件:

javascript 复制代码
// index.js - 应用入口
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)
javascript 复制代码
// App.js - 应用主组件
import React from 'react'
import AddTodo from './components/AddTodo'
import TodoList from './components/TodoList'
import FilterButtons from './components/FilterButtons'

function App() {
  return (
    <div className="todo-app">
      <h1>Redux Todo App</h1>
      <AddTodo />
      <TodoList />
      <FilterButtons />
    </div>
  )
}

export default App
javascript 复制代码
// components/AddTodo.js - 添加待办表单
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { addTodo } from '../actions'

function AddTodo() {
  const [text, setText] = useState('')
  const dispatch = useDispatch()

  const handleSubmit = e => {
    e.preventDefault()
    if (!text.trim()) return
    dispatch(addTodo(text))
    setText('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
        placeholder="添加待办事项..."
      />
      <button type="submit">添加</button>
    </form>
  )
}

export default AddTodo
javascript 复制代码
// components/TodoList.js - 待办事项列表
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { toggleTodo, deleteTodo } from '../actions'
import { FILTERS } from '../actionTypes'

// 根据过滤器过滤待办事项
const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case FILTERS.SHOW_COMPLETED:
      return todos.filter(todo => todo.completed)
    case FILTERS.SHOW_ACTIVE:
      return todos.filter(todo => !todo.completed)
    default:
      return todos
  }
}

function TodoList() {
  const dispatch = useDispatch()
  
  // 从 store 获取状态
  const todos = useSelector(state => state.todos)
  const visibilityFilter = useSelector(state => state.visibilityFilter)
  
  // 应用过滤器
  const visibleTodos = getVisibleTodos(todos, visibilityFilter)

  return (
    <ul className="todo-list">
      {visibleTodos.map(todo => (
        <li key={todo.id}>
          <span
            style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              cursor: 'pointer'
            }}
            onClick={() => dispatch(toggleTodo(todo.id))}
          >
            {todo.text}
          </span>
          <button 
            className="delete-btn"
            onClick={() => dispatch(deleteTodo(todo.id))}
          >
            删除
          </button>
        </li>
      ))}
    </ul>
  )
}

export default TodoList
javascript 复制代码
// components/FilterButtons.js - 过滤按钮
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { setFilter } from '../actions'
import { FILTERS } from '../actionTypes'

function FilterButtons() {
  const currentFilter = useSelector(state => state.visibilityFilter)
  const dispatch = useDispatch()
  
  // 过滤器配置
  const filterConfig = [
    { type: FILTERS.SHOW_ALL, text: '全部' },
    { type: FILTERS.SHOW_ACTIVE, text: '未完成' },
    { type: FILTERS.SHOW_COMPLETED, text: '已完成' }
  ]

  return (
    <div className="filters">
      <span>显示: </span>
      {filterConfig.map(({ type, text }) => (
        <button
          key={type}
          className={currentFilter === type ? 'active' : ''}
          onClick={() => dispatch(setFilter(type))}
        >
          {text}
        </button>
      ))}
    </div>
  )
}

export default FilterButtons

这个例子展示了:

  • 如何定义 action、reducer 和 store
  • 如何使用 React-Redux 连接 React 组件与 Redux
  • 如何使用 useSelector 和 useDispatch hooks 获取状态和分发 action
  • 如何实现状态过滤等业务逻辑

Redux 实践指南

随着 Redux 的广泛应用,社区总结了许多技巧,帮助开发者充分发挥其优势,避免常见陷阱。

1. 规范化状态结构

当处理关系型数据时,建议将状态"规范化",类似于数据库表:

javascript 复制代码
// 不规范化(嵌套)
const state = {
  users: [
    {
      id: 1,
      name: 'Alice',
      posts: [
        { id: 101, title: 'Redux 介绍', content: '...' },
        { id: 102, title: '不可变数据', content: '...' }
      ]
    },
    // 更多用户...
  ]
}

// 规范化(扁平化)
const state = {
  users: {
    byId: {
      '1': { id: 1, name: 'Alice', postIds: [101, 102] }
      // 更多用户...
    },
    allIds: [1, 2, 3]  // 保持顺序
  },
  posts: {
    byId: {
      '101': { id: 101, title: 'Redux 介绍', content: '...', authorId: 1 },
      '102': { id: 102, title: '不可变数据', content: '...', authorId: 1 }
      // 更多文章...
    },
    allIds: [101, 102, 103]
  }
}

规范化结构的优势:

  • 避免数据冗余
  • 简化数据更新逻辑
  • 更容易对单个条目进行增删改
  • 简化关联数据的更新
  • 提高渲染性能(只有相关组件需要更新)

Redux Toolkit 的 createEntityAdapter 提供了规范化状态的工具:

javascript 复制代码
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

// 创建实体适配器
const todosAdapter = createEntityAdapter({
  // 可选:自定义如何获取 ID
  selectId: (todo) => todo.id,
  // 可选:自定义排序
  sortComparer: (a, b) => a.createdAt - b.createdAt
})

// 创建初始状态
const initialState = todosAdapter.getInitialState({
  // 可添加额外的状态字段
  loading: false,
  error: null
})

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    todoAdded: todosAdapter.addOne,
    todosReceived(state, action) {
      state.loading = false
      todosAdapter.setAll(state, action.payload)
    },
    // 其他 reducer...
  }
})

2. 使用 Selector 函数访问状态

将状态访问逻辑封装在 selector 函数中,而不是直接在组件内访问状态,这提供了几个重要优势:

javascript 复制代码
// selectors.js
export const getTodos = state => state.todos
export const getVisibilityFilter = state => state.visibilityFilter

export const getVisibleTodos = state => {
  const todos = getTodos(state)
  const filter = getVisibilityFilter(state)
  
  switch (filter) {
    case 'SHOW_COMPLETED':
      return todos.filter(todo => todo.completed)
    case 'SHOW_ACTIVE':
      return todos.filter(todo => !todo.completed)
    default:
      return todos
  }
}

// 与 reselect 结合使用,实现记忆化选择器
import { createSelector } from 'reselect'

export const getVisibleTodosOptimized = createSelector(
  [getTodos, getVisibilityFilter],
  (todos, filter) => {
    switch (filter) {
      case 'SHOW_COMPLETED':
        return todos.filter(todo => todo.completed)
      case 'SHOW_ACTIVE':
        return todos.filter(todo => !todo.completed)
      default:
        return todos
    }
  }
)

Selector 的优势:

  • 封装状态结构:组件不需要了解状态树的具体结构
  • 逻辑复用:同一选择逻辑可在多个组件中复用
  • 性能优化:通过记忆化(memoization)避免不必要的重计算
  • 易于测试:选择逻辑可以单独测试
  • 适应状态变更:可以在不影响组件的情况下重构状态结构

在组件中使用 selector:

javascript 复制代码
import React from 'react'
import { useSelector } from 'react-redux'
import { getVisibleTodos } from '../selectors'

function TodoList() {
  // 使用选择器获取状态,而非直接访问
  const visibleTodos = useSelector(getVisibleTodos)
  
  return (
    <ul>
      {visibleTodos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  )
}

3. 使用 Redux Toolkit 简化开发

Redux Toolkit 是官方推荐的 Redux 开发工具集,它简化了 Redux 应用的开发流程,减少了样板代码:

javascript 复制代码
import { createSlice, configureStore } from '@reduxjs/toolkit'

// 使用 createSlice 一次性创建 reducer 和 action
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // 直接在 reducer 中"修改"状态(实际上是不可变更新)
    addTodo: (state, action) => {
      state.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      })
    },
    toggleTodo: (state, action) => {
      const todo = state.find(todo => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    deleteTodo: (state, action) => {
      return state.filter(todo => todo.id !== action.payload)
    }
  }
})

// 自动生成的 action 创建函数
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions

// 配置 store,自动应用中间件和开发工具
const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
    // 其他 reducer...
  }
})

export default store

Redux Toolkit 的主要优势:

  • 减少样板代码:自动生成 action 类型和创建函数
  • 内置不可变更新:通过 Immer 库支持直观的状态更新语法
  • 简化异步逻辑 :通过 createAsyncThunk 简化异步操作
  • 内置最佳实践:默认包含 Redux DevTools、thunk 中间件等
  • 类型安全:良好的 TypeScript 支持

4. 小型应用考虑 useReducer + Context API

对于简单应用或组件局部状态管理,完整的 Redux 可能过于重量级。这种情况下,React 的 useReducer 和 Context API 是轻量级替代方案:

javascript 复制代码
// TodoContext.js
import React, { createContext, useReducer, useContext } from 'react'

// 初始状态
const initialState = { todos: [] }

// Reducer 函数(与 Redux reducer 类似)
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload,
          completed: false
        }]
      }
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      }
    default:
      return state
  }
}

// 创建 Context
const TodoContext = createContext()

// Context Provider 组件
export function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(todoReducer, initialState)
  
  return (
    <TodoContext.Provider value={{ state, dispatch }}>
      {children}
    </TodoContext.Provider>
  )
}

// 自定义 Hook 简化 Context 使用
export function useTodoContext() {
  const context = useContext(TodoContext)
  if (!context) {
    throw new Error('useTodoContext 必须在 TodoProvider 内使用')
  }
  return context
}

在组件中使用:

javascript 复制代码
// App.js - 根组件
import React from 'react'
import { TodoProvider } from './TodoContext'
import TodoList from './TodoList'
import AddTodo from './AddTodo'

function App() {
  return (
    <TodoProvider>
      <h1>Todo App</h1>
      <AddTodo />
      <TodoList />
    </TodoProvider>
  )
}

// AddTodo.js - 添加待办组件
import React, { useState } from 'react'
import { useTodoContext } from './TodoContext'

function AddTodo() {
  const [text, setText] = useState('')
  const { dispatch } = useTodoContext()
  
  const handleSubmit = e => {
    e.preventDefault()
    if (!text.trim()) return
    dispatch({ type: 'ADD_TODO', payload: text })
    setText('')
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
        placeholder="添加待办事项..."
      />
      <button type="submit">添加</button>
    </form>
  )
}

// TodoList.js - 待办列表组件
import React from 'react'
import { useTodoContext } from './TodoContext'

function TodoList() {
  const { state, dispatch } = useTodoContext()
  
  return (
    <ul>
      {state.todos.map(todo => (
        <li
          key={todo.id}
          style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
          onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
        >
          {todo.text}
        </li>
      ))}
    </ul>
  )
}

这种方法适用于:

  • 中小型应用
  • 组件树中局部的状态管理需求
  • 状态逻辑相对简单的场景
  • 原型开发阶段

当应用规模增长或状态管理需求变得复杂时,可以平滑迁移到完整的 Redux 方案。

最后的话

Redux 提供的不可变数据与纯函数模型已经超越了库本身,成为现代前端状态管理的基础范式。我们一起思考了:

核心价值

  • 可预测性:单向数据流和纯函数模型使状态变化可预测
  • 可调试性:时间旅行调试和明确的状态变更历史简化了问题定位
  • 可维护性:关注点分离和模块化设计提升了代码可维护性
  • 可测试性:纯函数和明确的数据流使测试变得简单

关键技术

  • 不可变更新模式:通过创建新对象而非修改原对象实现状态更新
  • 纯函数的实践:无副作用、确定性输出的函数设计
  • 副作用的隔离:使用中间件处理异步操作和副作用
  • 状态规范化:类数据库的扁平化状态结构设计

演进趋势

Redux 生态系统不断发展,从早期相对繁琐的写法到现代简洁的工具链:

  1. 原始 Redux:手动编写 action 类型、action 创建函数和 reducer
  2. 中间件扩展:Redux-Thunk、Redux-Saga 解决异步问题
  3. 辅助工具:Reselect、Normalizr 优化选择器和状态结构
  4. Redux Toolkit:官方工具集,大幅简化 Redux 开发
  5. React-Redux HooksuseSelectoruseDispatch 提供更简洁的组件集成

这种演进反映了前端状态管理的总体趋势:在保持核心原则的同时,追求更简洁直观的开发体验。

小建议

  1. 从需求出发:小应用可能不需要 Redux,可以先考虑 useReducer + Context
  2. 工具选择:新项目优先使用 Redux Toolkit,享受现代化简化体验
  3. 深入理解:掌握不可变性和纯函数的核心概念,而非仅停留在 API 层面
  4. 与时俱进:关注 Redux 生态的最新实践,但避免频繁重构
  5. 平衡取舍:记住 Redux 是工具而非目的,选择最适合项目的状态管理方案

Redux 的核心价值在于它提供的状态管理思维模式,即使在未来前端框架发生变革的情况下,其不可变数据和纯函数的理念仍将是构建可维护前端应用的重要基石。

参考资源

官方文档

  • Redux 官方文档 - 最权威的 Redux 学习资源,包含完整的概念解释、API 文档和最佳实践
  • Redux Toolkit 文档 - Redux 官方推荐的工具集,简化 Redux 开发流程
  • React-Redux 文档 - 官方 React 绑定库文档,详细介绍了 hooks API 和连接组件

进阶学习资源

工具和库

  • Immer - 简化不可变数据更新的库,Redux Toolkit 内置使用
  • Reselect - 用于创建记忆化选择器的库,避免不必要的重计算
  • Redux DevTools Extension - Redux 开发者工具,支持时间旅行调试
  • Redux-Saga - 基于 Generator 的副作用管理中间件
  • Redux-Observable - 基于 RxJS 的 Redux 中间件

社区资源

深入函数式编程

示例项目


如果你觉得这篇文章有帮助,欢迎点赞收藏,也期待在评论区看到你的想法和建议!👇

终身学习,共同成长。

咱们下一期见

💻

相关推荐
_r0bin_14 分钟前
前端面试准备-7
开发语言·前端·javascript·fetch·跨域·class
IT瘾君15 分钟前
JavaWeb:前端工程化-Vue
前端·javascript·vue.js
potender17 分钟前
前端框架Vue
前端·vue.js·前端框架
站在风口的猪11081 小时前
《前端面试题:CSS预处理器(Sass、Less等)》
前端·css·html·less·css3·sass·html5
程序员的世界你不懂1 小时前
(9)-Fiddler抓包-Fiddler如何设置捕获Https会话
前端·https·fiddler
MoFe11 小时前
【.net core】天地图坐标转换为高德地图坐标(WGS84 坐标转 GCJ02 坐标)
java·前端·.netcore
去旅行、在路上2 小时前
chrome使用手机调试触屏web
前端·chrome
Aphasia3112 小时前
模式验证库——zod
前端·react.js
lexiangqicheng3 小时前
es6+和css3新增的特性有哪些
前端·es6·css3
拉不动的猪4 小时前
都25年啦,还有谁分不清双向绑定原理,响应式原理、v-model实现原理
前端·javascript·vue.js