规格选择不是一排按钮
用户看到的是两排按钮:颜色、尺码。端上要回答的却是四个问题:这个组合存在吗?有货吗?价格是多少?切换一个维度后,旧的选择还能不能保留?
最常见的错误实现,是把每个维度的值独立渲染成按钮,全部可点。用户选了「黑」再选「L」,页面看起来一切正常;点提交时端上才发现目录里根本没有「黑 + L」这条 SKU,请求带着一个空 skuId 发了出去。
规格选择的本质不是 UI 控件,而是一组组合约束。约束的源头不是端上的规则,而是商品目录里真实存在的 SKU。
先定义「什么组合存在」
| 概念 | 含义 | 端上用途 |
|---|---|---|
| 维度 | 颜色、尺码 | 渲染分组与选择顺序 |
| 维度值 | 黑、白、S、M、L | 渲染按钮 |
| SKU | 一个真实存在的组合 | 价格与库存的载体 |
| 缺省组合 | 目录里没有这条 SKU | 禁用并提示,而不是猜一个 |
| 缺货组合 | SKU 存在但库存为 0 | 禁用并提示「暂时无货」 |
关键结论:互斥组合不是端上算出来的,而是目录里根本没有这条 SKU。 端上不应自行推断「黑色应该有 L」,也不应把「目录里没有」和「库存为 0」显示成同一种状态。前者是选不了,后者是暂时买不到,用户的下一步动作不同。
四个判断
可选值只看其他已选维度。 判断「白色能不能选」时,当前已选的颜色不应参与约束,否则切换颜色时会把旧颜色自己算成限制。代码里的 matches(option, selection, dimension) 就是这条规则。
缺货与不存在分开。 valueState 返回 available、out_of_stock、nonexistent 三种状态。缺货可以提示补货,不存在只能让用户换组合。
切换维度只清理后面的维度。 用户先选颜色再选尺码,颜色是已确认的;切换颜色后,后面不再成立的尺码回到「待选择」。反过来,切换尺码不应清掉颜色——尺码列表本来就是按当前颜色算出来的。
默认选中优先有货组合。 第一个有货组合作为默认值;全部缺货时保留第一个组合,让页面显示「已售罄」,而不是一片空白。
规格约束的实现
下面的模块属于 L1:输入是目录与选择,输出是唯一 SKU、价格、库存、禁用值与提示文案。
// file: src/product/sku.ts
// tier: L1
// verify: node --test test/product/sku.test.ts
export interface SpecDimension {
name: string;
values: string[];
}
export interface SkuOption {
id: string;
specs: Record<string, string>;
priceCents: number;
stock: number;
}
export interface SkuCatalog {
dimensions: SpecDimension[];
options: SkuOption[];
}
export type Selection = Record<string, string>;
export type ValueState = "available" | "out_of_stock" | "nonexistent";
export interface SkuSummary {
complete: boolean;
sku: SkuOption | null;
priceCents: number | null;
stock: number;
priceRange: { min: number; max: number } | null;
purchasable: boolean;
reason: string;
disabled: Record<string, string[]>;
}
function selectedValue(selection: Selection, name: string): string | undefined {
const value = selection[name];
if (value === undefined || value === "") return undefined;
return value;
}
function matches(
option: SkuOption,
selection: Selection,
ignore?: string,
): boolean {
for (const name of Object.keys(selection)) {
if (name === ignore) continue;
if (selectedValue(selection, name) === undefined) continue;
if (option.specs[name] !== selection[name]) return false;
}
return true;
}
function pickSpecs(catalog: SkuCatalog, option: SkuOption): Selection {
const result: Selection = {};
for (const dimension of catalog.dimensions) {
const value = option.specs[dimension.name];
if (value !== undefined) result[dimension.name] = value;
}
return result;
}
export function isComplete(catalog: SkuCatalog, selection: Selection): boolean {
return catalog.dimensions.every(
(dimension) => selectedValue(selection, dimension.name) !== undefined,
);
}
export function missingDimensions(
catalog: SkuCatalog,
selection: Selection,
): string[] {
return catalog.dimensions
.filter((dimension) => selectedValue(selection, dimension.name) === undefined)
.map((dimension) => dimension.name);
}
export function findSku(catalog: SkuCatalog, selection: Selection): SkuOption | null {
if (!isComplete(catalog, selection)) return null;
const hit = catalog.options.find((option) =>
catalog.dimensions.every(
(dimension) => option.specs[dimension.name] === selection[dimension.name],
),
);
return hit ?? null;
}
// 某个值是否可选,只看「其他已选维度」;本维度当前选中值不参与判断,
// 否则切换颜色时会把旧颜色自己算成约束。
export function valueState(
catalog: SkuCatalog,
selection: Selection,
dimension: string,
value: string,
): ValueState {
const candidates = catalog.options.filter(
(option) =>
option.specs[dimension] === value && matches(option, selection, dimension),
);
if (candidates.length === 0) return "nonexistent";
return candidates.some((option) => option.stock > 0)
? "available"
: "out_of_stock";
}
export function valueStates(
catalog: SkuCatalog,
selection: Selection,
dimension: string,
): Record<string, ValueState> {
const target = catalog.dimensions.find((item) => item.name === dimension);
if (target === undefined) return {};
const result: Record<string, ValueState> = {};
for (const value of target.values) {
result[value] = valueState(catalog, selection, dimension, value);
}
return result;
}
export function disabledValues(
catalog: SkuCatalog,
selection: Selection,
): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const dimension of catalog.dimensions) {
const states = valueStates(catalog, selection, dimension.name);
const disabled = dimension.values.filter(
(value) => states[value] !== "available",
);
if (disabled.length > 0) result[dimension.name] = disabled;
}
return result;
}
// 切换某个维度后,只清理排在它后面的维度:前面的选择是用户已经确认的,
// 后面的组合若不存在则回到「待选择」,而不是替用户猜一个值。
export function chooseValue(
catalog: SkuCatalog,
selection: Selection,
dimension: string,
value: string,
): Selection {
const order = catalog.dimensions.map((item) => item.name);
const index = order.indexOf(dimension);
const next: Selection = { ...selection, [dimension]: value };
if (index === -1) return next;
for (let i = index + 1; i < order.length; i += 1) {
const name = order[i];
if (selectedValue(next, name) === undefined) continue;
const possible = catalog.options.some((option) => matches(option, next));
if (possible) continue;
delete next[name];
}
return next;
}
// 默认选中的规则:优先第一个有货组合;全部缺货时保留第一个组合,
// 让页面显示「已售罄」而不是一片空白。
export function defaultSelection(catalog: SkuCatalog): Selection {
const option =
catalog.options.find((item) => item.stock > 0) ?? catalog.options[0];
if (option === undefined) return {};
return pickSpecs(catalog, option);
}
export function priceRange(catalog: SkuCatalog): { min: number; max: number } | null {
const inStock = catalog.options.filter((option) => option.stock > 0);
const pool = inStock.length > 0 ? inStock : catalog.options;
if (pool.length === 0) return null;
let min = pool[0].priceCents;
let max = pool[0].priceCents;
for (const option of pool) {
if (option.priceCents < min) min = option.priceCents;
if (option.priceCents > max) max = option.priceCents;
}
return { min, max };
}
export function summarize(catalog: SkuCatalog, selection: Selection): SkuSummary {
const complete = isComplete(catalog, selection);
const sku = complete ? findSku(catalog, selection) : null;
let reason = "";
if (!complete) {
reason = `请选择${missingDimensions(catalog, selection).join("、")}`;
} else if (sku === null) {
reason = "该组合不存在,请更换规格";
} else if (sku.stock <= 0) {
reason = "该规格暂时无货";
}
return {
complete,
sku,
priceCents: sku === null ? null : sku.priceCents,
stock: sku === null ? 0 : sku.stock,
priceRange: priceRange(catalog),
purchasable: sku !== null && sku.stock > 0,
reason,
disabled: disabledValues(catalog, selection),
};
}
// 目录本身也要校验:重复组合与未知规格值会让「可选值」计算失去意义。
export function validateCatalog(catalog: SkuCatalog): string[] {
const issues: string[] = [];
const combos = new Set<string>();
for (const option of catalog.options) {
if (!Number.isSafeInteger(option.priceCents) || option.priceCents < 0) {
issues.push(`价格非法:${option.id}`);
}
if (!Number.isInteger(option.stock) || option.stock < 0) {
issues.push(`库存非法:${option.id}`);
}
const key = catalog.dimensions
.map((dimension) => option.specs[dimension.name] ?? "?")
.join("/");
if (combos.has(key)) issues.push(`重复组合:${key}`);
combos.add(key);
for (const dimension of catalog.dimensions) {
const value = option.specs[dimension.name];
if (value === undefined) {
issues.push(`缺少规格:${option.id}/${dimension.name}`);
} else if (!dimension.values.includes(value)) {
issues.push(`未知规格值:${option.id}/${value}`);
}
}
}
return issues;
}
四个函数的分工值得单独说明。
valueState 是整套逻辑的地基。它先按「其他已选维度」筛出候选 SKU,再看候选里有没有有货的。没有候选是 nonexistent,有候选但全部缺货是 out_of_stock。
chooseValue 只清理排在当前维度之后的维度。它不替用户猜新值,也不回头修改已经确认的前序选择。这样切换行为是可预测的:用户改了什么,就只有依赖它的后续选择会变化。
summarize 把「能不能买」与「为什么不能买」放在同一个返回值里。purchasable 给按钮用,reason 给提示用,disabled 给每个按钮的置灰状态用。三者来自同一次计算,不会出现按钮可点但提示说无货的矛盾。
priceRange 只统计有货组合。列表页展示「¥99–¥129」时,不应该把缺货的高价 SKU 算进去,否则用户点进来会发现最高价根本买不到。
测试与真实输出
本机运行结果如下:
$ node --test test/product/sku.test.ts
✔ 完整选择命中唯一 SKU (0.623ms)
✔ 缺一个维度时不算完整选择 (0.436292ms)
✔ 目录里不存在的组合返回 null (0.075041ms)
✔ 缺货组合不可购买并给出提示 (0.218834ms)
✔ 不存在的组合与缺货区分文案 (0.084542ms)
✔ 未选完整时提示缺哪些维度 (0.081541ms)
✔ 可选值只看其他已选维度 (0.062584ms)
✔ 缺货与不存在在状态上可区分 (0.04825ms)
✔ 禁用列表同时包含缺货与不存在的值 (0.07325ms)
✔ 切换颜色会清掉不再成立的尺码 (0.155416ms)
✔ 切换颜色时保留仍然成立的尺码 (0.071083ms)
✔ 切换后面的维度不会清掉前面的颜色 (0.056833ms)
✔ 默认选中第一个有货组合 (0.081667ms)
✔ 全部缺货时默认仍停在第一个组合 (0.067125ms)
✔ 价格区间只统计有货组合 (0.086167ms)
✔ 全部缺货时价格区间回退到全部组合 (0.042292ms)
✔ 价格与库存随选择联动 (0.072625ms)
✔ 空目录返回空选择与空区间 (0.101959ms)
✔ 目录校验能发现重复组合 (0.112458ms)
✔ 目录校验能发现未知规格值 (0.054083ms)
✔ 目录校验能发现非法价格与库存 (0.046583ms)
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 129.098375
二十一条用例覆盖:命中、缺维度、不存在、缺货、状态区分、禁用值、三种切换场景、默认选中、价格区间、联动、空目录与目录校验。
一次「切换颜色后仍带着旧尺码」的复盘
某交易客户端的规格组件把每个维度的选择存在三个独立变量里。切换颜色时只更新图片与价格,没有重算尺码的可选性,也没有清理旧尺码。
用户原本选的是「黑 / M」,切换到「白」后,页面仍保留 M。白色没有 M 这条 SKU,findSku 返回空,但购买按钮只判断「三个维度是否都有值」,于是仍然可点。提交请求带着空 skuId 发出,服务端拒绝,用户看到的是「商品不存在」。
复盘发现两个问题叠加:
- 选中状态与可选状态是两套数据,切换时只更新了前者。
- 按钮可点性依赖「有没有值」,而不是「有没有 SKU」。
修复做了三件事:切换维度时用 chooseValue 清理后续失效值;按钮以 purchasable 为准;提交前再调用一次 findSku,为空则不发请求。修复后,同类问题从「提交后被拒」提前到了「切换时自动回到待选择」。
这个复盘留下一条规则:规格选择的真相只有 SKU 目录,选中值只是查询条件。 任何绕过 findSku 直接提交选中值的实现,都会在组合缺失时失败。
小结
规格选择是一组组合约束:哪些 SKU 存在、哪些缺货、切换维度后哪些旧值失效。端上不该自己推断组合,而应把目录当作唯一事实,用 valueState 算可选值、用 chooseValue 做联动、用 summarize 统一按钮与提示。
下一节从 SKU 走到库存:选中了一个存在的组合,也不等于现在就能买到。
阅读导航:上一节:2.1 商详页的三份数据:价格、库存与时效 · 下一节:2.3 库存展示:可售不等于有货 。
常见错误与边界
- 把所有维度值都渲染成可点,提交时才发现组合不存在。
- 把「缺货」和「不存在」用同一种置灰与同一句文案。
- 切换维度时不清理失效的后续选择,选中状态与目录脱节。
- 默认选中价格最低的 SKU,而不是第一个有货组合。
- 价格区间把缺货 SKU 算进去,用户点进来发现最高价买不到。
- 用 SKU 数量反推规格值,而不是用目录声明维度与取值。
- 提交时只信按钮状态,不重新调用
findSku校验。 - 目录里存在重复组合或未知规格值,可选值计算从此不可信。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。