为何 Redux 和 Zustand 更受开发者青睐而非普通读者?
阅读量不足以成为开设客户端商店的理由。当有多个编写者需要共享诸如购物车之类的不变状态时, reducer、重放机制以及直接测试才真正发挥作用。
早前已经逐一驳斥了引入 Redux、Zustand 或类似库的四种常见理由:prop drilling 问题、客户端更新、自动向所有订阅者同步数据,以及将 Context 视为更安全的默认方案。实际上 React 本身就已能处理这些情况,无需额外的状态管理库。
上述论点均未触及决定某个库是否必要的关键问题——并非关注有多少组件会“读取”某个值,而是有多少无关的部分可以“修改”该值,以及这些修改之后是否必须保持一致性。
主题切换功能只有一个数据写入者——一个控制按钮,一次 setTheme 调用。正因如此,无论有多少界面使用该主题结果,都无需额外的库。而购物车则情况不同:存在多个数据写入者,它们会从不同方向修改数据,这就需要用不同的例子来说明。
单一写入者与多个写入者
在产品卡片上,购物车以“加入购物车”按钮的形式出现;在抽屉界面中,则表现为数量调节器、删除链接、可重新计算总价的优惠券输入框以及清空所有内容的控制按钮。共有五个文件、五个变异点,每个都能改变相同的状态,但彼此之间互不知情。
再看看主题标志:一个按钮,一个写入者。每个读取器仅显示最后的数值。由于只有一个人在操作,因此无需协调。
购物车包含三个不变量。不变量是指在任何操作之后都必须保持为真的事实。对于这个购物车而言:总价始终等于各商品单价乘以数量之和再减去当前有效的折扣;数量永远不会为负数;两个商品条目绝不会拥有相同的SKU——添加现有产品的另一单位只会增加数量,而不会生成重复的行。
五位编写者,每位都必须保留的三个事实。 这正是 Redux、Zustand 或 MobX 存在的目的,与仅有多少组件会读取购物车内容毫无关系。
当五位独立的编写者在没有约束机制的情况下同时修改这三个事实时,会出现什么问题?
缺乏统一管理会引发什么问题
假设这五个修改点中有三个,各自按照文件所有者的风格直接对购物车对象进行修改:
// components/AddToCartButton.jsx
function addItem(item) {
cart.items.push(item);
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/QuantityStepper.jsx
function changeQuantity(sku, newQty) {
const item = cart.items.find(i => i.sku === sku);
item.quantity = newQty;
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/CouponInput.jsx
function applyCoupon(code) {
cart.discount = getDiscountFor(code);
}
getDiscountFor只是一种简单的查找功能:输入代码,输出折扣金额。AddToCartButton.jsx和QuantityStepper.jsx都会重新计算cart.total,而CouponInput.jsx则不会。原本“总金额等于商品总价减去折扣”的规则被打破,但代码系统并未采取任何措施——因为没有哪个部分负责维护这条规则。这原本是三个文件都应遵守的惯例,却有一个文件忘记了。
reducer通过要求所有状态变更都必须通过同一个函数来完成,从而消除了这种对特定文件的依赖。 Reducer接收当前状态和一个动作(一个描述发生了什么事件的普通对象),然后返回全新的状态。它绝不会直接修改原有的对象。
// store/cartReducer.js
function computeTotal(items, discount) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return subtotal * (1 - discount);
}
export function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const items = [...state.items, action.item];
return { ...state, items, total: computeTotal(items, state.discount) };
}
case 'CHANGE_QUANTITY': {
const items = state.items.map(i =>
i.sku === action.sku ? { ...i, quantity: action.qty } : i
);
return { ...state, items, total: computeTotal(items, state.discount) };
}
case 'APPLY_COUPON': {
const discount = getDiscountFor(action.code);
return { ...state, discount, total: computeTotal(state.items, discount) };
}
}
}
computeTotal会在每个开关分支中执行——并非因为每位开发者都记得它,而是因为使用一个函数和一个开关会导致跳过操作变得困难。那三位曾经的直接编写者现在负责描述事件而非实际执行它们:
// components/AddToCartButton.jsx
import { useCartDispatch } from '../store/CartContext';
function AddToCartButton({ item }) {
const dispatch = useCartDispatch();
return <button onClick={() => dispatch({ type: 'ADD_ITEM', item })}>Add to Cart</button>;
}
export default AddToCartButton;
// components/QuantityStepper.jsx
import { useCartDispatch } from '../store/CartContext';
function QuantityStepper({ sku, qty }) {
const dispatch = useCartDispatch();
return (
<input
type="number"
value={qty}
onChange={e => dispatch({ type: 'CHANGE_QUANTITY', sku, qty: Number(e.target.value) })}
/>
);
}
export default QuantityStepper;
dispatch来自一个小的CartContext.jsx文件,该文件将useReducer(用于返回状态及dispatch功能的内置钩子)与Context结合,从而使任何组件都能调用该dispatch功能。现在无论是按钮还是进度条都不再编写cart.total = ...这样的代码,它们也无需知道computeTotal的存在。
读取操作保持开启状态。组件仍可读取cart.total以显示价格、结算汇总信息或未保存商品的提示。所有写入操作都必须通过同一路径进行:先触发某个动作,再由还原器生成新的状态。这一规则约束存在于该关键节点上,而非取决于调用它的具体函数。
需要明确的是:单个写入函数并不能保证规则的正确性,它只能确保规则的执行一致性。如果computeTotal函数本身忽略了折扣,那么所有写入路径都会每次都生成相同的错误总金额。这仍然比分散式的实现要好:统一的错误更容易被定位。而只有当某个文件遗漏了某一行代码时才会出现的错误则更难发现,因为在遇到缺失情况之前,正确与错误的路径看起来是完全一致的。
当优惠券计算路径忘记重新计算时,界面可能看起来仍然正常,直到后续数量变化触发重新计算——或者直到结账时显示的总金额与各商品明细不再匹配。正是这种间歇性的问题导致常规做法失效:出错的路径虽然罕见到足以让随意点击不会发现问题,但又常见到足以在实际使用中出现问题。
将规则集中管理并不能免除对computeTotal逻辑的精心设计。但它能确保每个计算路径都使用相同的函数,这样错误的公式也会保持一致,从而便于排查。分散式的更新则会让同样的错误被“基本正常”的表象所掩盖。
一个规范统一的计算路径本身就极具价值。当后续出现问题时,它还能带来什么好处?
每个操作都会成为可复现的快照
将两个 reducer 属性叠加使用,其效果比日志文件更为强大。
首先,动作是可序列化的:仅为普通的 JSON 格式,不包含函数、类实例或隐藏引用——只有 { type: 'APPLY_COUPON', code: 'SAVE10' }。其次,cartReducer 是纯函数:相同的状态加上相同的动作总会产生相同的下一状态,且不会读取外部数据。
综上所述,从同一起始点重新执行一系列动作时,最终结果始终一致。正是这种确定性让 Redux DevTools 能够正常工作。它不仅仅会显示某件事发生了,还会将动作列表作为数据存储起来,每当用户点击某个步骤时,就会重新计算出该时刻的精确状态快照。
以之前的那个漏洞为例——使用优惠券后又删除商品后,总价完全错误。打开开发者工具后,操作序列为ADD_ITEM、ADD_ITEM、APPLY_COUPON、REMOVE_ITEM。在执行APPLY_COUPON之后总价显示正确,但执行REMOVE_ITEM之后就不对了。该漏洞的具体出错位置就在REMOVE_ITEM这条分支上,无需通过添加console.log语句或手动重放用户操作流程即可定位。
这一优势并非在所有库中都存在。 Redux DevTools 是更为成熟的原始版本。Zustand 的 devtools 中间件将 set() 接入同一扩展,因此更新会以命名动作的形式呈现。MobX 通常通过代理直接修改可观察对象,而非通过一系列可序列化的动作,因此其工具更侧重于反应图——即计算重新执行的过程及原因——而非完整的时光旅行日志。
Replay 需要一个确定性函数,能够始终将相同的输入映射为相同的输出。除了在浏览器扩展中之外,这还能带来什么好处?
Reducer 只是一个可被调用的函数
cartReducer(startState, action) 只是一次普通的函数调用:传入参数,输出完整的下一状态。测试它无需浏览器、点击操作或渲染后的组件。
// store/cartReducer.test.js
import { cartReducer } from './cartReducer';
test('APPLY_COUPON recomputes total', () => {
const startState = {
items: [{ sku: 'A1', price: 20, quantity: 2 }],
discount: 0,
total: 40,
};
const nextState = cartReducer(startState, { type: 'APPLY_COUPON', code: 'SAVE10' });
expect(nextState.discount).toBe(0.10);
expect(nextState.total).toBe(36); // 40 minus 10 percent
});
在没有渲染库的情况下,该测试可在几毫秒内完成。它能直接捕获早期的错误:如果APPLY_COUPON跳过了computeTotal,那么nextState.total仍会为40,此时断言会在出问题的分支上失败,而不会表现为模糊的界面不匹配问题。
在点击处理函数中使用与useState设置器相同的逻辑是无法以那种方式被测试的——原因并非JSX:
// hooks/useCoupon.js
import { useState } from 'react';
function useCoupon() {
const [discount, setDiscount] = useState(0);
const applyCoupon = code => setDiscount(getDiscountFor(code));
return { discount, applyCoupon };
}
export default useCoupon;
在普通测试中调用useCoupon()会引发错误。useState这类钩子仅在React渲染过程中(或是在渲染过程中调用的其他钩子内部)有效,且会与该组件实例相关联。这就是钩子的规则:每次渲染时都必须以相同的顺序无条件调用,因为React是通过调用的位置而非名称来匹配钩子状态的。如果在某些渲染中跳过某个钩子,状态记录就会出现不一致。
applyCoupon 也无法像 cartReducer 那样返回有用的状态。 setDiscount 返回的是 undefined。它只会安排重新渲染,新值只有在组件再次执行时才会显示出来。这种机制没有可验证的返回值——其作用是“要求 React 重新渲染”,而非“计算并返回一个值”。
测试它意味着先进行渲染,然后查看屏幕上显示的内容:
// CartSummary.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import CartSummary from './CartSummary';
test('applying coupon updates the displayed total', () => {
render(<CartSummary />);
fireEvent.click(screen.getByText('Apply SAVE10'));
expect(screen.getByTestId('total')).toHaveTextContent('36');
});
测试的仍是同一个内容——使用优惠券后的总价——但断言检查的是在完整渲染并模拟点击之后的显示文本。
一个折中的方法是使用 React Testing Library 中的 renderHook:无需 JSX 或点击操作即可调用钩子,随后检查 result.current。该方法仍需要在 act 下使用 React 的测试渲染器,以便在断言之前刷新更新和副作用。由于钩子状态仍存在于已挂载的(即便是最简单的)实例中,因此仍然需要框架结构。而 cartReducer 则无需这些功能,因为它从未与任何组件绑定。
可测试性关乎耦合度。当确定一个状态管理器的结束位置与另一个的开始位置时,同样的理念会呈现怎样的面貌?
边界实际所在的位置
初始不变量同样回答了另一个问题:一个状态管理器应在何处结束,下一个又应从何处开始。
购物车中的items、discount和total是相互关联的,因为它们彼此绑定——更改其中一个可能会使依赖于其他项的计算结果失效。theme不应包含在那个还原器中:theme无法决定cart.total,而购物车的相关信息也无法决定theme。它们只是在同一应用中共存的两个独立状态。
一个实际的产品通常会发展出多个这样的规则集,彼此之间互不知情:
cartStore → items, discount, total
authStore → user, session, permissions
themeStore → theme
notifyStore → toasts, unread count
在Redux中,这表现为多个slice——树结构的每个部分对应一个还原器——它们被聚合在根节点下,但逻辑并不合并。在Zustand中则是通过独立的create()函数来创建(如useCartStore、useAuthStore等)。在MobX中则是独立的可观察类,拥有各自的动作和不变量,没有共享的还原器。
一个组件在单次渲染过程中可以读取多个存储中的数据。例如,购物车摘要可能需要读取cartStore.total和authStore.user.currency来格式化金额。读取操作可以自由组合,但写入操作不得跨存储进行:购物车还原器不应访问认证状态,反之亦然。
如果为了保持数据一致性而必须修改某个存储的同时也修改另一个存储——比如切换货币时需要重新计算购物车总金额——那就说明边界划分有误。这些数据要么应统一存储在同一个存储中,要么需要一个专门的桥梁来确保它们保持同步,而非依靠无人记录的隐式依赖关系。
在团队规范、中间件以及生态系统规模方面,库的选择依然重要,但这些属于次要因素。最关键的考量在于架构:多个编写者加上共享的不变量。没有这样的架构时,React 自带的 Context、reducers 和 props 已经能够解决大多数“需要全局读取数据”的需求。而有了这样的架构后,通过使用专门的存储方案——如 Redux Toolkit、Zustand、MobX,或是精心设计的共享 useReducer——就能将规则统一管理在一处,从而发挥其作用。
团队往往很晚才发现这一界限,通常是在购物车、预订流程或权限矩阵已经出现五个修改点之后。此时再为 reducer 做改造,仍然比调试那些时好时坏的总数要便宜。如果在代码中尽早为不变量命名,UI 就无需通过临时的修补措施来弥补缺陷。
如果某个功能仅由一名作者负责且没有跨领域规则,那就将其保留在本地。如果该功能有多名作者,并且存在必须对所有作者都有效的规则,那么就要为这些写入操作设置统一的入口。
真正的答案
之前的讨论表明,仅凭阅读量并不能成为创建数据库的充分理由——React已经能够处理这类情况。本文探讨的是另一方面的问题:有多少个独立的写入位置,以及这些写入是否必须保留相同的事实,这才是真正的考验。
主题标志在所有方面都未能通过该测试:仅有一名编写者,没有跨领域的不变量,也没有任何需要协调的内容。而购物车在所有方面都能一次性通过测试:有五名编写者,三个任何一人都可以修改的事实,一个将“五个人必须记住规则”转化为“规则无条件生效”的简化逻辑,再加上两个实用的副作用——通过可回放的时间线进行调试而非凭猜测行事,以及通过调用函数而非渲染界面来获取数值的测试。
关键在于此,而非组件的数量。而是编写者的数量,以及所有编写者都必须遵守的规则。
换种说法:用于管理客户端状态的库是协调编写代码的工具,而非分配读取者的工具。处理读取者数量的扩展则是 React 的职责。而编写代码的协调——以及这些编写者必须遵守的不变规则——正是 Redux、Zustand、MobX 或类似状态管理库能够成为可靠解决方案而非单纯习惯的体现。