什么是 ‘Nested State Updates’?解析在处理复杂嵌套对象(如 JSON 树)时的 Reducer 优化技巧

各位同学,欢迎来到今天的技术讲座。今天我们将深入探讨一个在前端开发,尤其是在使用像 Redux 这类状态管理库时经常遇到的核心问题——“Nested State Updates”,即嵌套状态更新。我们还将解析在处理复杂嵌套对象(例如 JSON 树)时的 Reducer 优化技巧。

什么是 ‘Nested State Updates’?

在现代前端应用中,状态管理是一个核心议题。我们经常需要维护一个庞大而复杂的状态树,其中包含了用户数据、UI 配置、业务逻辑数据等等。这些状态往往不是扁平的,而是高度嵌套的,比如一个用户对象可能包含地址对象,地址对象又包含街道、城市等字段。

const initialState = {
  currentUser: {
    id: 'user123',
    name: 'Alice',
    email: '[email protected]',
    profile: {
      bio: 'Software Engineer',
      avatarUrl: 'https://example.com/avatar.jpg'
    },
    settings: {
      theme: 'dark',
      notifications: {
        email: true,
        sms: false,
        push: true
      }
    },
    address: {
      street: '123 Main St',
      city: 'Anytown',
      zip: '12345',
      coordinates: {
        lat: 34.0522,
        lng: -118.2437
      }
    }
  },
  products: [
    { id: 'prod001', name: 'Laptop', price: 1200, specs: { cpu: 'i7', ram: '16GB' } },
    { id: 'prod002', name: 'Mouse', price: 25, specs: { dpi: 1600 } }
  ],
  ui: {
    sidebarOpen: true,
    modal: {
      isOpen: false,
      type: null
    }
  }
};

当我们需要更新这个复杂状态树中的某个深层嵌套属性时,例如更改 currentUsersettings 中的 notifications.email 属性,或者更新某个 productspecs,这就是所谓的“Nested State Updates”。

这个问题的核心挑战在于,在许多状态管理范式中(尤其是那些强调不可变性 Immutable 的范式,如 Redux),我们不能直接修改现有状态对象。每次状态更新都必须返回一个新的状态对象。这不仅是为了避免副作用,更是为了让状态变化可预测、易于调试,并且能够配合 React 等 UI 库进行高效的性能优化(通过浅比较判断组件是否需要重新渲染)。

为什么不能直接修改状态?

让我们先看一个反例,即“糟糕”的直接修改方式,以及它带来的问题。

反例:直接修改状态 (Mutable Update)

假设我们要更新 currentUser.settings.notifications.emailfalse

// 假设这是我们的 Reducer 函数
function badReducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_EMAIL_NOTIFICATION_MUTABLE':
      // 直接修改了原始 state 对象
      state.currentUser.settings.notifications.email = action.payload;
      return state; // 返回了同一个 state 引用
    default:
      return state;
  }
}

let currentState = initialState;
console.log('Original state:', currentState.currentUser.settings.notifications.email); // true

// 派发一个 action
currentState = badReducer(currentState, {
  type: 'UPDATE_EMAIL_NOTIFICATION_MUTABLE',
  payload: false
});

console.log('Updated state (mutable):', currentState.currentUser.settings.notifications.email); // false
console.log('Does original state reference change?', currentState === initialState); // true

这段代码看似完成了任务,但它违反了不可变性原则。它直接修改了 initialState 对象,并且 badReducer 返回的 currentState 引用与 initialState 是同一个。

问题分析:

  1. 不可预测性与副作用: 任何持有 initialState 引用的部分都可能受到这个修改的影响,导致意料之外的行为。
  2. 时间旅行调试困难: 如果没有创建新的状态对象,就无法轻松地“回溯”到之前的状态,这使得调试变得极其困难。
  3. 性能问题(React/Redux): React 或 Redux 这样的库通常通过浅比较 (shallow comparison) 来判断一个组件或一个 Reducer 的输出是否发生了变化。如果返回的是同一个对象引用,即使内部属性改变了,浅比较也会认为没有变化,从而导致:
    • React 组件不更新: 依赖于这个深层属性的 React 组件可能不会重新渲染,因为它所在的父组件的 props 或 state 引用没有改变。
    • Redux DevTools 无法追踪: Redux DevTools 依赖于状态的不可变性来记录每次状态变化,如果状态被直接修改,DevTools 将无法正确追踪。

因此,我们的目标是:在更新嵌套状态时,始终返回一个新的状态对象,并且在更新路径上的所有父级对象都应该是新的副本。

基础的不可变更新方式(逐层复制)

要实现不可变更新,我们必须沿着从要修改的属性到根状态的路径,对每一个对象或数组进行浅复制。JavaScript 中的展开运算符(...)是实现这一目标的关键工具。

让我们以更新 currentUser.settings.notifications.emailfalse 为例:

function immutableReducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_EMAIL_NOTIFICATION_IMMUTABLE':
      return {
        ...state, // 1. 复制根 state 对象
        currentUser: {
          ...state.currentUser, // 2. 复制 currentUser 对象
          settings: {
            ...state.currentUser.settings, // 3. 复制 settings 对象
            notifications: {
              ...state.currentUser.settings.notifications, // 4. 复制 notifications 对象
              email: action.payload // 5. 更新 email 属性
            }
          }
        }
      };
    case 'UPDATE_PRODUCT_SPEC_IMMUTABLE':
      // 假设 action.payload = { id: 'prod001', cpu: 'i9' }
      const { id, cpu } = action.payload;
      return {
        ...state,
        products: state.products.map(product =>
          product.id === id
            ? {
                ...product,
                specs: {
                  ...product.specs, // 复制 specs 对象
                  cpu: cpu // 更新 cpu 属性
                }
              }
            : product
        )
      };
    default:
      return state;
  }
}

let currentState = initialState;
console.log('Original email:', currentState.currentUser.settings.notifications.email); // true

currentState = immutableReducer(currentState, {
  type: 'UPDATE_EMAIL_NOTIFICATION_IMMUTABLE',
  payload: false
});

console.log('Updated email:', currentState.currentUser.settings.notifications.email); // false
console.log('Does root state reference change?', currentState === initialState); // false
console.log('Does currentUser reference change?', currentState.currentUser === initialState.currentUser); // false
console.log('Does settings reference change?', currentState.currentUser.settings === initialState.currentUser.settings); // false
console.log('Does notifications reference change?', currentState.currentUser.settings.notifications === initialState.currentUser.settings.notifications); // false
console.log('Does profile reference change?', currentState.currentUser.profile === initialState.currentUser.profile); // true (因为 profile 路径上没有修改,所以引用保持不变,这就是结构共享 structural sharing)

// 更新产品
currentState = immutableReducer(currentState, {
  type: 'UPDATE_PRODUCT_SPEC_IMMUTABLE',
  payload: { id: 'prod001', cpu: 'i9' }
});
console.log('Updated product CPU:', currentState.products[0].specs.cpu); // i9

优点:

  • 完全符合不可变性原则: 每次更新都返回新的对象引用,易于追踪和调试。
  • 结构共享 (Structural Sharing): 未修改的部分(例如 currentUser.profileproducts 数组中未被修改的产品对象)仍然引用旧的状态对象,这减少了内存消耗和复制开销。

缺点:

  • 冗长和重复: 对于深层嵌套的结构,你需要编写大量的 ... 展开运算符,代码会变得非常冗长且难以阅读。
  • 容易出错: 如果你忘记在某个层级进行复制,就可能导致意外的直接修改。
  • 可读性差: 多层嵌套的更新逻辑会使得 Reducer 变得复杂,难以理解其意图。

这正是我们需要优化 Reducer 的原因。

Reducer 优化技巧

为了应对上述缺点,社区发展出多种优化策略和工具。这些技巧旨在提高 Reducer 的可读性、可维护性和健壮性,同时仍然严格遵循不可变性原则。

A. 状态扁平化(Normalization)

核心思想: 将复杂、嵌套的实体数据结构转换为扁平的、以 ID 为键的对象,类似于关系型数据库的表结构。

为什么有效? 当数据扁平化后,无论你想更新哪个实体的哪个属性,你都只需要直接访问该实体,而不需要层层深入。

示例:initialState.products 数组扁平化。

// 原始的 products 结构
/*
products: [
  { id: 'prod001', name: 'Laptop', price: 1200, specs: { cpu: 'i7', ram: '16GB' } },
  { id: 'prod002', name: 'Mouse', price: 25, specs: { dpi: 1600 } }
]
*/

// 扁平化后的 products 结构
const normalizedState = {
  // ... 其他状态保持不变
  products: {
    byId: {
      'prod001': { id: 'prod001', name: 'Laptop', price: 1200, specs: { cpu: 'i7', ram: '16GB' } },
      'prod002': { id: 'prod002', name: 'Mouse', price: 25, specs: { dpi: 1600 } }
    },
    allIds: ['prod001', 'prod002']
  },
  // ...
};

// 如何更新扁平化后的产品
function normalizedReducer(state = normalizedState, action) {
  switch (action.type) {
    case 'UPDATE_PRODUCT_SPEC_NORMALIZED':
      // 假设 action.payload = { id: 'prod001', cpu: 'i9' }
      const { id, cpu } = action.payload;
      return {
        ...state,
        products: {
          ...state.products,
          byId: {
            ...state.products.byId,
            [id]: { // 直接通过 ID 访问并更新特定产品
              ...state.products.byId[id],
              specs: {
                ...state.products.byId[id].specs,
                cpu: cpu
              }
            }
          }
        }
      };
    default:
      return state;
  }
}

let currentNormalizedState = normalizedState;
currentNormalizedState = normalizedReducer(currentNormalizedState, {
  type: 'UPDATE_PRODUCT_SPEC_NORMALIZED',
  payload: { id: 'prod001', cpu: 'i9' }
});

console.log('Normalized product CPU:', currentNormalizedState.products.byId['prod001'].specs.cpu); // i9

虽然更新单个产品内部的 specs 仍然需要多层展开,但至少我们避免了遍历整个 products 数组。更重要的是,如果 products 数组中存储的是复杂对象,并且这些对象之间存在引用关系,扁平化可以避免数据冗余和一致性问题。

优点:

  • 更新更简单: 直接通过 ID 访问并更新实体,无需遍历数组或深层嵌套。
  • 减少数据冗余: 如果同一个实体在状态树的多个地方被引用,扁平化可以确保它只存储一份。
  • 提高一致性: 任何对实体的修改都只在一个地方进行,更容易维护数据的一致性。
  • 性能提升: 对于大型列表,更新单个项避免了 O(N) 的遍历操作。

缺点:

  • 增加复杂性: 引入 byIdallIds 结构,增加了状态的组织复杂性。
  • 需要选择器: 在 UI 层消费这些扁平化数据时,通常需要编写选择器(Selectors)将它们重新组合成所需的形状。
  • 不适用于所有情况: 对于那些本身没有明确 ID 且不经常被独立更新的简单嵌套对象,过度扁平化反而会增加不必要的开销。

何时使用: 当你的状态包含大量具有唯一 ID 的实体(如用户、文章、产品),并且这些实体可能会在应用中被多次引用或独立更新时,强烈推荐使用扁平化。

B. 使用实用工具库

有许多库旨在简化不可变更新,其中最流行和推荐的是 Immer。

1. Immer

Immer 库允许你以“可变”的方式编写 Reducer 逻辑,但它会在幕后处理所有的不可变更新,为你生成一个新的、不可变的状态。它通过使用 Proxy 对象实现这一点。

安装: npm install immeryarn add immer

使用示例:

import { produce } from 'immer';

// 假设我们有之前的 initialState
// const initialState = { ... };

function immerReducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_EMAIL_NOTIFICATION_IMMER':
      return produce(state, draft => {
        // 在 draft 对象上进行“可变”操作
        draft.currentUser.settings.notifications.email = action.payload;
      });
    case 'UPDATE_PRODUCT_SPEC_IMMER':
      // 假设 action.payload = { id: 'prod001', cpu: 'i9' }
      const { id, cpu } = action.payload;
      return produce(state, draft => {
        // 查找并修改特定产品
        const productToUpdate = draft.products.find(p => p.id === id);
        if (productToUpdate) {
          productToUpdate.specs.cpu = cpu;
        }
      });
    case 'ADD_NEW_PRODUCT_IMMER':
      return produce(state, draft => {
        draft.products.push(action.payload); // 直接 push
      });
    case 'REMOVE_PRODUCT_IMMER':
      return produce(state, draft => {
        draft.products = draft.products.filter(p => p.id !== action.payload.id); // 直接 filter
      });
    default:
      return state;
  }
}

let currentState = initialState;
console.log('Original email:', currentState.currentUser.settings.notifications.email); // true

currentState = immerReducer(currentState, {
  type: 'UPDATE_EMAIL_NOTIFICATION_IMMER',
  payload: false
});

console.log('Updated email (Immer):', currentState.currentUser.settings.notifications.email); // false
console.log('Root state reference changed?', currentState === initialState); // false

currentState = immerReducer(currentState, {
  type: 'UPDATE_PRODUCT_SPEC_IMMER',
  payload: { id: 'prod001', cpu: 'i9' }
});
console.log('Updated product CPU (Immer):', currentState.products[0].specs.cpu); // i9

const newProduct = { id: 'prod003', name: 'Keyboard', price: 75, specs: { layout: 'US' } };
currentState = immerReducer(currentState, {
  type: 'ADD_NEW_PRODUCT_IMMER',
  payload: newProduct
});
console.log('Added new product:', currentState.products.length); // 3
console.log('Added product name:', currentState.products[2].name); // Keyboard

优点:

  • 极简代码: Reducer 逻辑变得非常简洁和直观,几乎与编写可变代码无异。
  • 完全不可变: Immer 保证了生成的新状态是完全不可变的,并且会进行结构共享,性能优秀。
  • 减少错误: 几乎消除了手动展开运算符可能导致的错误。
  • 与 TypeScript 良好集成: Immer 的 produce 函数可以很好地推断类型,提供出色的类型安全。
  • 适用性广: 无论是对象还是数组的更新、添加、删除,都可以用直观的方式处理。

缺点:

  • 引入依赖: 增加了项目依赖。
  • Proxy 对象: 在一些非常老的 JavaScript 环境中可能需要 polyfill (但现代浏览器和 Node.js 都原生支持)。
  • 魔法感: 对于不了解其内部机制的人,可能会觉得有点“魔法”,但其工作原理并不复杂。

推荐程度: 强烈推荐!Immer 几乎成为了 Redux 状态管理中处理复杂嵌套状态更新的黄金标准。

2. Lodash/Ramda (函数式工具库)

这些库提供了大量实用的函数,其中一些可以帮助我们以函数式的方式处理不可变更新。

Lodash (_.set, _.update, _.cloneDeep):

Lodash 的 _.set_.update 可以根据路径字符串或数组来设置/更新值。然而,需要注意的是,_.set_.update 默认会修改传入的对象。为了保持不可变性,我们通常需要先对原始对象进行深拷贝,或者结合其他函数。

import _ from 'lodash';

// 假设我们有之前的 initialState
// const initialState = { ... };

function lodashReducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_EMAIL_NOTIFICATION_LODASH':
      // 先深拷贝,再修改,确保不可变性
      const newState1 = _.cloneDeep(state);
      _.set(newState1, 'currentUser.settings.notifications.email', action.payload);
      return newState1;
    case 'UPDATE_PRODUCT_SPEC_LODASH':
      const { id, cpu } = action.payload;
      const newState2 = _.cloneDeep(state);
      const productIndex = _.findIndex(newState2.products, { id: id });
      if (productIndex !== -1) {
        _.set(newState2.products[productIndex], 'specs.cpu', cpu);
      }
      return newState2;
    default:
      return state;
  }
}

let currentState = initialState;
currentState = lodashReducer(currentState, {
  type: 'UPDATE_EMAIL_NOTIFICATION_LODASH',
  payload: false
});
console.log('Updated email (Lodash):', currentState.currentUser.settings.notifications.email); // false
console.log('Root state reference changed (Lodash)?', currentState === initialState); // false (因为深拷贝)

问题: _.cloneDeep 进行了完全的深拷贝,这打破了结构共享,可能带来性能开销,尤其对于大型状态树。它比 Immer 效率低,且没有 Immer 那样简洁的语法。

Ramda (R.assocPath, R.pathOr, R.lensPath, R.over):

Ramda 是一个纯函数式的 JavaScript 实用工具库,它的所有函数都是柯里化的,并且数据最后传入。它更适合处理不可变数据,因为它不会产生副作用。

import * as R from 'ramda';

// 假设我们有之前的 initialState
// const initialState = { ... };

function ramdaReducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_EMAIL_NOTIFICATION_RAMDA':
      // R.assocPath(path, value, object)
      // 根据路径设置值,并返回一个新对象,路径上的所有父级也会被复制
      return R.assocPath(['currentUser', 'settings', 'notifications', 'email'], action.payload, state);
    case 'UPDATE_PRODUCT_SPEC_RAMDA':
      const { id, cpu } = action.payload;
      // R.adjust(index, fn, list) - 调整列表中指定索引的元素
      // R.map(fn, list) - 映射列表
      // R.when(predicate, fn) - 当条件满足时应用函数
      // R.propEq(propName, value) - 检查属性是否相等
      // R.set(lens, value, object) - 设置 lens 指向的值
      // R.lensPath(path) - 创建一个 lens
      // R.over(lens, fn, object) - 使用函数更新 lens 指向的值

      // 找到要更新的产品索引
      const productIndex = R.findIndex(R.propEq('id', id), state.products);

      if (productIndex !== -1) {
        // 构建更新 specs.cpu 的 lens
        const cpuLens = R.lensPath([productIndex, 'specs', 'cpu']);
        // 使用 R.set 和 lens 更新状态
        return R.set(cpuLens, cpu, state);
      }
      return state;
    default:
      return state;
  }
}

let currentState = initialState;
currentState = ramdaReducer(currentState, {
  type: 'UPDATE_EMAIL_NOTIFICATION_RAMDA',
  payload: false
});
console.log('Updated email (Ramda):', currentState.currentUser.settings.notifications.email); // false
console.log('Root state reference changed (Ramda)?', currentState === initialState); // false
console.log('Does profile reference change (Ramda)?', currentState.currentUser.profile === initialState.currentUser.profile); // true (结构共享)

currentState = ramdaReducer(currentState, {
  type: 'UPDATE_PRODUCT_SPEC_RAMDA',
  payload: { id: 'prod001', cpu: 'i9' }
});
console.log('Updated product CPU (Ramda):', currentState.products[0].specs.cpu); // i9

优点:

  • 函数式纯粹: Ramda 强制你以纯函数式的方式思考和编写代码,避免副作用。
  • 简洁表达: 对于一些特定操作,Ramda 函数可以非常简洁地表达复杂逻辑。
  • 强大的组合能力: 函数可以轻松组合以构建更复杂的转换。
  • 结构共享: Ramda 许多函数(如 assocPath)会进行结构共享,避免不必要的深拷贝。

缺点:

  • 学习曲线: 对于不熟悉函数式编程概念(如柯里化、Lenses)的开发者来说,学习曲线较陡峭。
  • 代码可读性: 如果团队对函数式编程不熟悉,Ramda 代码可能难以理解和维护。
  • 不适合所有场景: 对于简单的状态更新,可能过于“重”或“抽象”。

何时使用: 当你的团队对函数式编程有深入理解,并且希望在整个应用中推行函数式范式时,Ramda 是一个强大的选择。对于仅仅为了解决嵌套状态更新问题,Immer 通常是更简单、更直接的方案。

C. 自定义辅助函数 / Lenses

如果你不想引入大型库,或者有非常特定的更新模式,可以编写自己的辅助函数。

简单的 updateIn 辅助函数:

这个函数可以根据路径数组和更新函数来更新嵌套状态。

/**
 * @param {object} obj - 要更新的对象
 * @param {Array<string|number>} path - 路径数组,例如 ['a', 'b', 0, 'c']
 * @param {function|any} updater - 更新函数 (接收旧值,返回新值) 或直接的新值
 * @returns {object} 新的不可变对象
 */
function updateIn(obj, path, updater) {
  if (path.length === 0) {
    return typeof updater === 'function' ? updater(obj) : updater;
  }

  const [head, ...rest] = path;
  const currentVal = obj[head];

  const newHeadVal = updateIn(currentVal, rest, updater);

  // 如果新值与旧值相同,则返回原始对象以进行结构共享
  if (newHeadVal === currentVal) {
    return obj;
  }

  // 根据当前层级是数组还是对象来创建新容器
  if (Array.isArray(obj)) {
    const newArr = [...obj];
    newArr[head] = newHeadVal;
    return newArr;
  } else {
    return {
      ...obj,
      [head]: newHeadVal,
    };
  }
}

function customHelperReducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_EMAIL_NOTIFICATION_CUSTOM':
      return updateIn(state, ['currentUser', 'settings', 'notifications', 'email'], action.payload);
    case 'UPDATE_PRODUCT_SPEC_CUSTOM':
      // 假设 action.payload = { id: 'prod001', cpu: 'i9' }
      const { id, cpu } = action.payload;
      const productIndex = state.products.findIndex(p => p.id === id);
      if (productIndex !== -1) {
        return updateIn(state, ['products', productIndex, 'specs', 'cpu'], cpu);
      }
      return state;
    case 'TOGGLE_SIDEBAR_CUSTOM':
        return updateIn(state, ['ui', 'sidebarOpen'], (oldVal) => !oldVal);
    default:
      return state;
  }
}

let currentState = initialState;
currentState = customHelperReducer(currentState, {
  type: 'UPDATE_EMAIL_NOTIFICATION_CUSTOM',
  payload: false
});
console.log('Updated email (Custom):', currentState.currentUser.settings.notifications.email); // false
console.log('Root state reference changed (Custom)?', currentState === initialState); // false

currentState = customHelperReducer(currentState, {
  type: 'UPDATE_PRODUCT_SPEC_CUSTOM',
  payload: { id: 'prod001', cpu: 'i9' }
});
console.log('Updated product CPU (Custom):', currentState.products[0].specs.cpu); // i9

currentState = customHelperReducer(currentState, {
  type: 'TOGGLE_SIDEBAR_CUSTOM',
});
console.log('Sidebar open (Custom):', currentState.ui.sidebarOpen); // false (之前是 true)

优点:

  • 无额外依赖: 不需要安装第三方库。
  • 结构共享: 实现了结构共享,性能较好。
  • 高度定制化: 可以根据自己的需求精确控制更新逻辑。
  • 路径清晰: 通过路径数组明确指定更新位置。

缺点:

  • 需要自己维护: 编写和测试这些辅助函数需要时间和精力。
  • 不如 Immer 简洁: 对于复杂的数组操作(如插入、删除),updateIn 可能会变得复杂。
  • 可能重复造轮子: 很多时候,你编写的功能可能已经存在于 Immer 或其他库中。

Lenses (更高级的自定义模式):

Lenses 是函数式编程中一种更强大的抽象,用于聚焦于数据结构的一部分,并提供获取 (view)、设置 (set) 和转换 (over) 该部分的统一接口。它们比简单的 updateIn 更加灵活和可组合。Ramda 就内置了 Lenses。如果你想在不引入 Ramda 的情况下实现 Lenses,需要投入更多精力去理解和实现其概念。

何时使用: 当你对项目依赖有严格限制,或者你的更新模式非常特定且重复,以至于编写一个小型、高效的自定义辅助函数是最佳选择时。对于大多数情况,Immer 是更省力的选择。

D. 结合 Reselect 进行选择性重渲染 (Redux 生态)

虽然这严格来说不是 Reducer 内部的优化技巧,但它与嵌套状态更新的处理结果紧密相关。即使你的 Reducer 完美地进行了不可变更新,如果你的 React 组件没有正确地处理 props 的变化,仍然可能导致不必要的重渲染。

问题: 当 Redux store 中的状态发生变化时,mapStateToProps 会被重新执行。如果 mapStateToProps 返回的新 props 对象中的某些属性(即使其内部值未变)的引用发生了变化,连接的 React 组件就会重新渲染。这对于深层嵌套的状态尤其明显,即使你只改动了最深处的一个小值,如果 mapStateToProps 简单地返回了整个 currentUser 对象,那么所有依赖 currentUser 的组件都会重新渲染。

解决方案:reselect

reselect 允许你创建“记忆化”的选择器 (memoized selectors)。这些选择器只有当它们的输入发生变化时才会重新计算输出。

import { createSelector } from 'reselect';

// 原始选择器
const getUserSettings = (state) => state.currentUser.settings;
const getNotifications = (state) => state.currentUser.settings.notifications;

// 记忆化选择器
const getEmailNotificationStatus = createSelector(
  getNotifications, // 输入选择器
  (notifications) => notifications.email // 输出选择器 (只有当 getNotifications 返回的 notifications 引用变化时才执行)
);

// 在 mapStateToProps 中使用
// function mapStateToProps(state) {
//   return {
//     emailNotification: getEmailNotificationStatus(state)
//   };
// }

在这个例子中,即使 currentUser 对象的引用发生了变化(因为它的深层属性被更新了),但只要 notifications 对象的引用没有变化(因为你更新的不是 notifications 对象本身,而是它内部的 email 属性),getEmailNotificationStatus 就不会重新计算,从而避免了不必要的组件重渲染。

优点:

  • 性能优化: 显著减少不必要的计算和组件重渲染。
  • 解耦: 将状态选择逻辑与组件分离。
  • 可组合性: 可以将小选择器组合成大选择器。

缺点:

  • 增加抽象层: 引入了额外的概念和代码。
  • 调试复杂性: 在某些情况下,理解选择器的记忆化行为可能需要一些经验。

何时使用: 在任何大型 Redux 应用中,当状态结构复杂,且需要从 store 中派生数据或优化组件渲染性能时,reselect 都是必不可少的。

各种优化技巧的比较

特性/技巧 基础不可变更新(展开运算符) 状态扁平化(Normalization) Immer Lodash (_.cloneDeep, _.set) Ramda (R.assocPath, R.lensPath) 自定义 updateIn
简洁性 中(更新扁平部分) 中(深拷贝后操作) 中(需要熟悉 FP 概念)
可读性 低(非 FP 风格,深拷贝) 中(需要熟悉 FP 概念)
性能 高(结构共享) 高(局部更新,结构共享) 高(结构共享,Proxy 优化) 低(全量深拷贝) 高(结构共享) 高(结构共享)
boilerplate
学习曲线 低(对 Lodash 熟悉)
依赖 无(但通常配合选择器) immer lodash ramda
适用场景 小型应用,简单嵌套 实体列表,复杂关联数据 绝大多数嵌套状态更新场景 不推荐,除非有特殊需求 纯函数式编程项目 特定、重复的更新模式,限制依赖
推荐程度 不推荐(作为最终方案) 强烈推荐(配合 Immer 或其他) 强烈推荐(首选) 不推荐 推荐(函数式编程爱好者) 谨慎推荐(需自行维护)

最佳实践与考量

处理嵌套状态更新并非一劳永逸,它需要根据项目规模、团队熟悉度以及性能要求进行权衡。以下是一些最佳实践和考量:

  1. 优先遵循不可变性: 这是最核心的原则。无论你选择哪种技术,都要确保 Reducer 总是返回一个新的状态对象,并且更新路径上的所有父级对象都是新的副本。
  2. 拥抱 Immer: 对于大多数现代 JavaScript 和 TypeScript 项目,Immer 提供了一个简洁、高效且易于理解的解决方案,极大降低了处理嵌套状态更新的复杂性。它通常是处理复杂 Reducer 的首选。
  3. 合理进行状态扁平化: 对于包含大量实体(如用户、产品、订单)的状态,考虑进行扁平化。这能简化单个实体的更新逻辑,提高数据一致性,并能配合 reselect 实现高效的组件渲染。但不要过度扁平化,对于简单、不常更新的嵌套对象,可能得不偿失。
  4. 利用选择器 (Selectors): 无论状态是否扁平化,都应该使用选择器(如 reselect)来从 Redux store 中提取和派生数据。这不仅可以提高组件的渲染性能,还能将数据访问逻辑与组件解耦,提高代码可维护性。
  5. 编写清晰的 Reducer 结构: 即使使用了 Immer,也要保持 Reducer 的逻辑清晰。考虑使用 Redux Toolkit 的 createSlice 来组织 Reducer,它内置了 Immer,并提供了更简洁的 action 和 reducer 定义方式。
  6. 测试 Reducer: 复杂的 Reducer 逻辑需要严格的测试。确保你的测试覆盖了所有可能的更新路径和边缘情况,以验证不可变性是否得到正确维护。
  7. 性能监控与优化: 对于性能敏感的应用,使用 React DevTools Profiler 和 Redux DevTools 来监控组件的渲染情况和状态变化。如果发现性能瓶颈,再考虑进一步的优化,例如调整扁平化策略或更精细地使用 React.memo

总结

处理嵌套状态更新是前端状态管理中的一项基本挑战。通过理解不可变性的重要性,并掌握如 Immer、状态扁平化、函数式工具库以及自定义辅助函数等优化技巧,我们可以编写出更健壮、可维护和高性能的 Reducer。选择合适的工具和策略,不仅能简化开发流程,也能确保应用状态的稳定性和可预测性。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注