勾选状态为什么值得单独建模
购物车页面上,用户最常做的动作不是改数量,而是勾选。全选、取消全选、单独勾一件、勾到一半——这些动作看起来是 UI 细节,实际上决定了结算金额。
把勾选写成一个布尔字段,散落在每个行组件里,很快会出现这些问题:
- 全选时把已下架商品也勾上,结算金额多算一笔。
- 取消全选后,失效商品的勾选状态没清掉,下次全选又冒出来。
- 限购商品数量被手动改超,输入框显示 10,实际只能买 3。
- 服务端拒绝某件商品后,端上只弹一个错误,勾选和数量原样保留,用户反复提交。
这些问题共同说明:勾选不是一行的私有状态,而是一个需要汇总、校验、回退的集合状态。它值得一个独立模块。
三态与不可选行
先定义清楚哪些行可以被勾选。
| 行状态 | 含义 | 能否勾选 | 全选时 |
|---|---|---|---|
active | 可售 | 能 | 勾选 |
offline | 已下架 | 不能 | 跳过并清零 |
soldout | 已售罄 | 不能 | 跳过并清零 |
规则只有一条:只有可售行参与勾选。 失效行可以留在购物车里让用户看到,但它的 selected 必须永远是 false。
全选状态也不是简单的「所有行都勾选」。它只统计可售行:
- 可售行全部勾选:
all。 - 可售行全部未勾选:
none。 - 部分勾选:
partial。 - 没有可售行:
none,而不是all。
最后一条容易写错。空购物车或全是失效商品时,every 在空数组上返回 true,如果直接用它判断,页面会显示「已全选」,而金额是 0。
勾选与结算金额联动
结算金额只汇总「可售且勾选」的行:
勾选小计 = Σ(单价 × 数量),仅限 active && selected
实付预演 = 勾选小计 + 运费
这里的实付仍然是预演。真正的优惠、税费与最终金额由 3.2 的明细对象负责。勾选模块只回答「哪些行进入计算」,不回答「最终多少钱」。
这个分工很重要。如果把优惠也塞进勾选模块,两个模块都会试图修改金额,最后没人说得清哪个是权威。
限购不是输入框的 max
限购上限经常被实现成 <input max="3">。它只能拦住键盘输入,拦不住这些情况:
- 用户从商品页带过来 5 件,直接进入购物车。
- 服务端下调了限购,端上还显示旧上限。
- 用户粘贴数量、用步进器连点、或者接口返回超限数量。
所以限购必须是数据层校验:任何设置数量的入口都调用同一个 setQuantity,它负责截断并返回原因。UI 只负责展示原因,不负责判断。
截断后的数量仍要满足「至少 1 件」。如果服务端把上限降到 0,正确做法是把行标记为不可售,而不是保留一个数量为 0 的行。
服务端拒绝后的最小回退
结算时服务端可能拒绝部分商品:下架、售罄、限购下调、地区不售。端上的回退原则是只改被拒绝的行。
常见错误有两种:
- 整单回退:任何一件商品被拒,就把整个购物车恢复成上一次快照。用户刚勾好的其他商品全部丢失。
- 只弹提示:提示「商品不可用」,但勾选和数量不变,用户再点结算还是失败。
正确做法是接收服务端的逐行结果,对每个被拒绝的 SKU:
- 用服务端状态覆盖本地状态。
- 失效行取消勾选。
- 数量超过新上限的截断。
- 记录一条说明,供 UI 展示。
其他行不动。这样用户只需要处理真正有问题的商品,而不是重新勾一遍。
一个可运行的勾选模块
下面的模块属于 L1:纯函数,不读网络、不读时钟,所有状态通过参数传入。
// file: src/cart/selection.ts
// tier: L1
// verify: node --test test/cart/selection.test.ts
export type LineStatus = "active" | "offline" | "soldout";
export type SelectAllState = "all" | "none" | "partial";
export interface SelectableLine {
skuId: string; unitCents: number; quantity: number;
maxQuantity: number; status: LineStatus; selected: boolean;
}
export interface SelectionSummary {
state: SelectAllState; selectedCount: number;
selectedQuantity: number; selectedSubtotalCents: number;
}
export interface QuantityResult { lines: SelectableLine[]; clamped: boolean; reason: string; }
export interface ServerRejection { skuId: string; status: LineStatus; maxQuantity?: number; }
export interface RejectionNote {
skuId: string;
action: "unselected" | "clamped" | "removed" | "unchanged";
detail: string;
}
export interface RejectionResult { lines: SelectableLine[]; notes: RejectionNote[]; changed: boolean; }
export function isSelectable(line: SelectableLine): boolean {
return line.status === "active" && line.quantity >= 1;
}
// 全选只对可售行生效;失效行既不参与「全选」,也不参与「半选」判定。
export function selectAll(lines: SelectableLine[], checked: boolean): SelectableLine[] {
assertLines(lines);
return lines.map((line) => ({ ...line, selected: isSelectable(line) && checked }));
}
export function selectLine(
lines: SelectableLine[],
skuId: string,
checked: boolean,
): SelectableLine[] {
assertLines(lines);
const target = lines.find((line) => line.skuId === skuId);
if (!target) throw new Error(`购物车没有该商品:${skuId}`);
// 取消勾选永远允许,勾选失效商品属于调用方错误。
if (checked && !isSelectable(target)) throw new Error(`失效商品不可勾选:${skuId}`);
return lines.map((line) =>
line.skuId === skuId ? { ...line, selected: checked } : { ...line },
);
}
export function selectionState(lines: SelectableLine[]): SelectAllState {
const active = lines.filter(isSelectable);
if (active.length === 0) return "none";
const selected = active.filter((line) => line.selected).length;
if (selected === 0) return "none";
return selected === active.length ? "all" : "partial";
}
// 服务端返回的失效状态优先落盘,同时清掉残留勾选。
export function sanitizeSelection(lines: SelectableLine[]): SelectableLine[] {
assertLines(lines);
return lines.map((line) => ({
...line,
selected: isSelectable(line) && line.selected,
}));
}
export function summarize(lines: SelectableLine[]): SelectionSummary {
let selectedCount = 0;
let selectedQuantity = 0;
let selectedSubtotalCents = 0;
for (const line of lines) {
if (!isSelectable(line) || !line.selected) continue;
selectedCount += 1;
selectedQuantity += line.quantity;
selectedSubtotalCents += line.unitCents * line.quantity;
}
if (!Number.isSafeInteger(selectedSubtotalCents)) {
throw new Error("勾选金额溢出");
}
return {
state: selectionState(lines),
selectedCount,
selectedQuantity,
selectedSubtotalCents,
};
}
// 勾选与结算金额联动:端上只汇总已勾选的可售行,运费原样加上。
export function payableCents(lines: SelectableLine[], shippingCents: number): number {
if (!Number.isSafeInteger(shippingCents) || shippingCents < 0) {
throw new Error("运费必须是非负安全整数");
}
return summarize(lines).selectedSubtotalCents + shippingCents;
}
export function setQuantity(
lines: SelectableLine[],
skuId: string,
quantity: number,
): QuantityResult {
assertLines(lines);
if (!Number.isInteger(quantity) || quantity < 1) {
throw new Error(`数量必须是大于 0 的整数:${skuId}`);
}
const target = lines.find((line) => line.skuId === skuId);
if (!target) throw new Error(`购物车没有该商品:${skuId}`);
const nextQuantity = Math.min(quantity, target.maxQuantity);
return {
lines: lines.map((line) =>
line.skuId === skuId ? { ...line, quantity: nextQuantity } : { ...line },
),
clamped: nextQuantity !== quantity,
reason:
nextQuantity === quantity
? "未触及限购"
: `超过限购 ${target.maxQuantity} 件,已按上限保留`,
};
}
// 服务端拒绝后只回退被拒绝的行;其余勾选与数量保持不变。
export function applyServerRejections(
lines: SelectableLine[],
rejections: ServerRejection[],
): RejectionResult {
assertLines(lines);
const bySku = new Map(rejections.map((item) => [item.skuId, item]));
const notes: RejectionNote[] = [];
const next = lines.map((line) => {
const rejection = bySku.get(line.skuId);
if (!rejection) return { ...line };
const rawMax = rejection.maxQuantity ?? line.maxQuantity;
if (!Number.isInteger(rawMax) || rawMax < 0) {
throw new Error(`服务端限购非法:${line.skuId}`);
}
const maxQuantity = Math.max(1, rawMax);
const quantity =
rejection.status !== "active" || line.quantity > maxQuantity
? Math.min(Math.max(1, line.quantity), maxQuantity)
: line.quantity;
const changed =
rejection.status !== line.status ||
quantity !== line.quantity ||
(rejection.status !== "active" && line.selected);
if (!changed) {
notes.push({ skuId: line.skuId, action: "unchanged", detail: "与本地状态一致" });
return { ...line };
}
if (rejection.status !== "active") {
notes.push({
skuId: line.skuId,
action: "unselected",
detail: `服务端标记为 ${rejection.status},取消勾选`,
});
}
if (quantity !== line.quantity) {
notes.push({
skuId: line.skuId,
action: "clamped",
detail: `数量 ${line.quantity} -> ${quantity}`,
});
}
return {
...line,
status: rejection.status,
maxQuantity,
quantity,
selected: rejection.status === "active" && line.selected,
};
});
return {
lines: next,
notes,
changed: notes.some((note) => note.action !== "unchanged"),
};
}
function assertLines(lines: SelectableLine[]): void {
const seen = new Set<string>();
for (const line of lines) {
if (!line.skuId || seen.has(line.skuId)) {
throw new Error(`SKU 非法或重复:${line.skuId}`);
}
seen.add(line.skuId);
if (!Number.isSafeInteger(line.unitCents) || line.unitCents < 0) {
throw new Error(`单价非法:${line.skuId}`);
}
if (!Number.isInteger(line.quantity) || line.quantity < 1) {
throw new Error(`数量非法:${line.skuId}`);
}
if (!Number.isInteger(line.maxQuantity) || line.maxQuantity < 1) {
throw new Error(`限购数量非法:${line.skuId}`);
}
}
}
几个函数的职责:
isSelectable:唯一的可选性判定,避免各组件各写一套。selectAll/selectLine:全选与单行勾选,失效行强制清零。selectionState:只统计可售行,空集合返回none。summarize/payableCents:勾选与金额联动,只汇总可售且勾选的行。setQuantity:限购截断,返回是否截断与原因。applyServerRejections:按 SKU 最小回退,返回变更说明。sanitizeSelection:把服务端返回的失效状态落盘,同时清掉残留勾选。
applyServerRejections 里有一个容易忽略的细节:它只处理被点名的 SKU。没有被拒绝的行原样复制,连对象引用都不共享,避免调用方改一个行影响整份列表。
测试与真实输出
本机运行结果如下:
$ node --test test/cart/selection.test.ts
✔ 全选只作用于可售商品 (1.449125ms)
✔ 全部可售行勾选后状态为 all (0.112042ms)
✔ 没有勾选时状态为 none (0.0605ms)
✔ 部分勾选时状态为 partial (0.055958ms)
✔ 只有失效行时状态为 none (0.060208ms)
✔ 勾选失效商品被拒绝 (0.255792ms)
✔ 取消勾选失效商品是允许的 (0.078917ms)
✔ 取消全选清空所有勾选 (0.071542ms)
✔ 汇总只计算已勾选的可售行 (0.107334ms)
✔ 空车汇总为零且状态为 none (0.111583ms)
✔ 实付金额等于勾选小计加运费 (0.083375ms)
✔ 数量超过限购时按上限截断 (0.145791ms)
✔ 未触及限购时保持原数量 (0.05075ms)
✔ 数量为零或非整数被拒绝 (0.072584ms)
✔ 服务端标记下架后取消勾选并记录 (0.206375ms)
✔ 服务端下调限购后截断数量 (0.084833ms)
✔ 服务端拒绝只影响被点名的行 (0.045333ms)
✔ 服务端拒绝与本地一致时不产生变更 (0.053542ms)
✔ 清洗勾选会清掉失效行的残留勾选 (0.056417ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.065625
十九条用例覆盖:全选过滤、三态判定、失效不可选、取消勾选、汇总、空车、金额联动、限购截断、非法数量、下架回退、限购下调、只影响被拒行、无变更、清洗残留勾选。
一次「全选把失效商品算进金额」的复盘
「回声」的购物车曾经用一个 allSelected 布尔值控制全选。点击全选时,端上把所有行的 selected 设为 true,包括已经下架但还留在列表里的商品。
结算页按 selected 汇总金额,于是失效商品也被算进去。用户看到实付 238 元,提交后服务端只认 199 元。差额不大,但用户会问:「为什么页面写的和扣款不一样?」
更麻烦的是回退。服务端拒绝整单后,端上把 allSelected 设为 false,但逐行的 selected 没有清。用户再点一次全选,失效商品又被勾上,形成循环。
复盘发现三个问题:
- 全选没有过滤不可售行。
- 金额汇总没有再次校验可售状态。
- 服务端拒绝后只改了汇总开关,没有改行状态。
修复对应三点:全选只作用于 active 行;汇总时再次过滤;服务端拒绝按 SKU 回退并取消勾选。修复后,失效商品仍显示在列表里,但永远不会进入金额。
这个复盘留下的规则是:勾选是一个集合状态,必须有一个统一的入口做过滤。 只要允许某个组件直接改 selected,就一定会有人忘记判断可售性。
为什么不用一个布尔值表示全选
allSelected 的问题在于它把「结果」当成了「输入」。真正的输入是每一行的勾选状态,全选态是它的派生值。
派生值的好处是永远不会和行状态矛盾。用户单独取消一件商品,selectionState 自动从 all 变成 partial,不需要额外同步一个布尔值。反过来,如果用布尔值驱动 UI,就必须在每次行变化时手动维护它,漏一处就出现「显示全选但实际少一件」。
同样,半选态也不应该单独存储。它只是 partial 的展示形式,由 selectionState 计算得出。
小结
勾选、失效与限购共同决定「哪些商品进入结算」。勾选是集合状态,不是行组件的私有布尔值;失效行可以展示但不能被选中;限购必须在数据层截断;服务端拒绝后只回退被拒绝的行。
selectAll 与 selectLine 统一过滤可售性,selectionState 只统计可售行,summarize 与 payableCents 把勾选映射成金额,applyServerRejections 做最小回退。它们都是纯函数,结果可测试、可复现。
下一章进入提交环节:用户点下「提交订单」之后,如何保证只产生一张订单。
阅读导航:上一节:3.2 价格明细的端上重算边界 · 下一节:4.1 提交防重:幂等键与按钮状态 。
常见错误与边界
- 用
every判断全选,空数组返回true,空车显示「已全选」。 - 全选把下架、售罄商品一起勾上,结算金额多算。
- 汇总金额时不再次校验可售状态,只信任
selected。 - 限购只写在输入框
max上,接口返回的超限数量直接进入购物车。 - 服务端拒绝后整单回退,用户刚勾好的其他商品全部丢失。
- 服务端拒绝后只弹提示,不改勾选与数量,用户反复提交。
- 单独存一个
allSelected布尔值,与逐行状态不同步。 - 失效行被直接删除,用户不知道商品为什么消失。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。