《互联网产品的客户端架构·交易、增长与平台化》2.2 SKU 选择与规格约束

规格选择看起来只是几个按钮,实际是一组组合约束:哪些组合存在、哪些组合缺货、切换一个维度后哪些已选值必须失效。本节把规格目录、可选值、默认选中、价格库存联动与提交前校验写成可测试的纯函数,并复盘一次「切换颜色后仍带着旧尺码下单」的典型问题。

规格选择不是一排按钮

用户看到的是两排按钮:颜色、尺码。端上要回答的却是四个问题:这个组合存在吗?有货吗?价格是多少?切换一个维度后,旧的选择还能不能保留?

最常见的错误实现,是把每个维度的值独立渲染成按钮,全部可点。用户选了「黑」再选「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 发出,服务端拒绝,用户看到的是「商品不存在」。

复盘发现两个问题叠加:

  1. 选中状态与可选状态是两套数据,切换时只更新了前者。
  2. 按钮可点性依赖「有没有值」,而不是「有没有 SKU」。

修复做了三件事:切换维度时用 chooseValue 清理后续失效值;按钮以 purchasable 为准;提交前再调用一次 findSku,为空则不发请求。修复后,同类问题从「提交后被拒」提前到了「切换时自动回到待选择」。

这个复盘留下一条规则:规格选择的真相只有 SKU 目录,选中值只是查询条件。 任何绕过 findSku 直接提交选中值的实现,都会在组合缺失时失败。

小结

规格选择是一组组合约束:哪些 SKU 存在、哪些缺货、切换维度后哪些旧值失效。端上不该自己推断组合,而应把目录当作唯一事实,用 valueState 算可选值、用 chooseValue 做联动、用 summarize 统一按钮与提示。

下一节从 SKU 走到库存:选中了一个存在的组合,也不等于现在就能买到。

阅读导航:上一节:2.1 商详页的三份数据:价格、库存与时效 · 下一节:2.3 库存展示:可售不等于有货 。

常见错误与边界

  • 把所有维度值都渲染成可点,提交时才发现组合不存在。
  • 把「缺货」和「不存在」用同一种置灰与同一句文案。
  • 切换维度时不清理失效的后续选择,选中状态与目录脱节。
  • 默认选中价格最低的 SKU,而不是第一个有货组合。
  • 价格区间把缺货 SKU 算进去,用户点进来发现最高价买不到。
  • 用 SKU 数量反推规格值,而不是用目录声明维度与取值。
  • 提交时只信按钮状态,不重新调用 findSku 校验。
  • 目录里存在重复组合或未知规格值,可选值计算从此不可信。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查