《互联网产品的客户端架构·端形态与体验基础》3.3 不可能三角与优先级

首屏快、功能全、包体小为什么无法同时满足,如何把三者的取舍写成可复核的权重与排序函数,并复盘一次「回声」客户端的真实取舍记录,说明排序结果与评审判断的边界;所有案例数值均标注为假设值,附完整可运行 TypeScript 实现与真实测试输出。

三个目标为什么互相拉扯

「回声」客户端的产品评审上,三个目标经常同时出现:首屏要快、功能要全、包体要小。单独看每个目标都合理,放在一起就会互相拆台。

  • 首屏快,要求首包小、执行少、请求少。
  • 功能全,要求更多代码、更多依赖、更多初始化逻辑进入首屏。
  • 包体小,要求删代码、拆包、延后加载,功能只能分期到达。

两两组合也会冲突。首屏快加功能全,通常意味着首包变大;首屏快加包体小,通常意味着功能被砍或延后;功能全加包体小,通常意味着大量懒加载,用户跳到某个功能时仍要等待,而且拆包本身会增加请求与缓存复杂度。

这不是项目管理问题,而是端上资源的物理约束。CPU、网络、内存都是有限的,代码不会因为写在需求文档里就自动变轻。承认这一点,才能开始谈优先级。

文中所有耗时、功能数、体积、得分都是案例假设值,用来演示判断过程,不是任何真实产品的实测。唯一真实的是代码运行输出,它会明确标注。

把取舍写成可比较的输入

要排序,先把方案写成同一组字段。每个方案至少要有三个量:

  • startupMs:首屏耗时,越低越好。
  • featureCount:已覆盖的核心功能数,越高越好。
  • bundleKb:首包体积,越低越好。

再加一组目标:首屏上限、功能下限、首包上限。目标不是“理想值”,而是“这次发布愿意承诺的底线”。低于底线的方案可以被排序,但必须带上缺口说明。

三个轴的方向不同

首屏与体积是“越小越好”,功能是“越大越好”。直接把三个原始值加权没有意义:毫秒、功能个数、KB 是三种单位,量纲不同。必须先归一化。

归一化有两种常见做法:相对绝对目标,或相对候选人集合。相对绝对目标的问题是,一旦所有方案都超过上限,所有得分都会被压到 0,排序失去区分度。相对候选人集合的 min-max 归一化保留“谁相对更好”,更适合做方案比较。

代价是得分只在当前候选人集合内有意义。换一批方案,同一个方案的得分会变。因此得分不能跨版本比较,也不能拿去当 KPI;它只回答“在这一组方案里,按这组权重,谁排前面”。

可行性与得分是两件事

feasible 标记方案是否满足全部底线。它不直接否决方案,因为否决权属于产品和评审,不属于排序函数。函数能做的是把缺口列清楚:首屏超了多少毫秒、功能少了几项、首包超了多少 KB。

这也是为什么排序结果里可能第一名并不可行。它只说明“在当前权重下相对最好”,不说明“可以直接发布”。把这两个判断分开,评审时就不会被一个分数绑架。

优先级是权重,不是口号

“体验优先”或“功能优先”这种口号无法执行,因为不同人对“优先”的理解不同。可执行的做法是把它写成三个权重,并满足两个约束:

  • 三个轴各出现一次,不能重复,也不能遗漏。
  • 权重非负,且总和为 1。

权重之和必须为 1,是为了让得分保持在 0 到 1 之间,便于横向比较。重复轴会让同一个维度被计算两次,等于偷偷加倍;遗漏轴会让某个维度完全不出现在决策里。三种情况都应该直接抛错,而不是“尽量修正”。

权重从哪来?它不是技术团队拍脑袋定的,而是产品目标的量化表达。首屏权重 0.6,意味着团队愿意为了首屏牺牲一部分功能与体积;功能权重 0.65,意味着功能完整度是这次发布的主要矛盾。权重一旦写下,就要和取舍记录一起存档,下一次复盘时才能回答“当时为什么这么排”。

排序函数

下面的实现包含归一化、缺口计算、权重校验与排序。它不依赖 DOM、网络、定时器或第三方库,候选人以普通对象传入。

// file: src/quality/tradeoff.ts
// tier: L1
// verify: node --test test/quality/tradeoff.test.ts

export type QualityAxis = "startup" | "features" | "size";

export interface OptionProfile {
  id: string;
  /** 首屏耗时,越低越好 */
  startupMs: number;
  /** 已覆盖的核心功能数,越高越好 */
  featureCount: number;
  /** 首包体积,越低越好 */
  bundleKb: number;
}

export interface QualityTargets {
  startupMs: number;
  minFeatures: number;
  maxBundleKb: number;
}

export interface PriorityWeight {
  axis: QualityAxis;
  weight: number;
}

export interface RankedOption {
  id: string;
  score: number;
  feasible: boolean;
  deficits: string[];
  normalized: Record<QualityAxis, number>;
}

interface Range {
  min: number;
  max: number;
}

function rangeOf(values: readonly number[]): Range {
  return { min: Math.min(...values), max: Math.max(...values) };
}

/** 越小越好的指标:集合内最慢得 0,最快得 1。 */
function inverseScore(value: number, range: Range): number {
  if (range.max === range.min) {
    return 1;
  }
  return (range.max - value) / (range.max - range.min);
}

/** 越大越好的指标:集合内最少得 0,最多得 1。 */
function directScore(value: number, range: Range): number {
  if (range.max === range.min) {
    return 1;
  }
  return (value - range.min) / (range.max - range.min);
}

export function findDeficits(
  option: OptionProfile,
  targets: QualityTargets,
): string[] {
  const deficits: string[] = [];
  if (option.startupMs > targets.startupMs) {
    deficits.push(`首屏 ${option.startupMs}ms 超过上限 ${targets.startupMs}ms`);
  }
  if (option.featureCount < targets.minFeatures) {
    deficits.push(`功能 ${option.featureCount} 项低于下限 ${targets.minFeatures} 项`);
  }
  if (option.bundleKb > targets.maxBundleKb) {
    deficits.push(`首包 ${option.bundleKb}KB 超过上限 ${targets.maxBundleKb}KB`);
  }
  return deficits;
}

function validateWeights(weights: readonly PriorityWeight[]): void {
  const seen = new Set<QualityAxis>();
  let total = 0;
  for (const item of weights) {
    if (item.weight < 0) {
      throw new Error(`权重不能为负:${item.axis}`);
    }
    if (seen.has(item.axis)) {
      throw new Error(`权重轴重复:${item.axis}`);
    }
    seen.add(item.axis);
    total += item.weight;
  }
  if (seen.size !== 3) {
    throw new Error("权重必须覆盖 startup、features、size 三个轴");
  }
  if (Math.abs(total - 1) > 1e-9) {
    throw new Error(`权重之和必须为 1,当前为 ${total}`);
  }
}

/**
 * 三个维度先在候选人集合内做 min-max 归一化,再加权求和。
 * 归一化是相对当前候选人,不是相对绝对标准:
 * 这样即使所有方案都“不够快”,排序仍然能回答“谁相对更快”。
 * feasible 只标记是否满足目标,不直接否决;否决权留给评审。
 */
export function rankOptions(
  options: readonly OptionProfile[],
  targets: QualityTargets,
  weights: readonly PriorityWeight[],
): RankedOption[] {
  validateWeights(weights);
  if (options.length === 0) {
    return [];
  }
  const ids = new Set(options.map((option) => option.id));
  if (ids.size !== options.length) {
    throw new Error("候选人 id 必须唯一");
  }

  const startupRange = rangeOf(options.map((option) => option.startupMs));
  const featureRange = rangeOf(options.map((option) => option.featureCount));
  const sizeRange = rangeOf(options.map((option) => option.bundleKb));

  const ranked = options.map((option) => {
    const normalized: Record<QualityAxis, number> = {
      startup: inverseScore(option.startupMs, startupRange),
      features: directScore(option.featureCount, featureRange),
      size: inverseScore(option.bundleKb, sizeRange),
    };
    const score = weights.reduce(
      (sum, item) => sum + normalized[item.axis] * item.weight,
      0,
    );
    const deficits = findDeficits(option, targets);
    return {
      id: option.id,
      score,
      feasible: deficits.length === 0,
      deficits,
      normalized,
    };
  });

  return ranked.sort((left, right) => {
    if (right.score !== left.score) {
      return right.score - left.score;
    }
    if (left.feasible !== right.feasible) {
      return left.feasible ? -1 : 1;
    }
    return left.id.localeCompare(right.id);
  });
}

实现里有三个容易争议的点,需要提前说清楚。

第一,得分相同时 feasible 优先。两个方案得分一样,一个满足底线、一个不满足,当然选满足的那个。但 feasible 只在同分时作为次序,不会把低分的可行方案抬到高分但不可行的方案前面。否则排序就不再反映权重。

第二,id 必须唯一。候选人来自不同方案,id 重复会让排序结果无法对应回输入,也会让同分时的稳定排序失去意义。

第三,min-max 对异常值敏感。如果某个方案的首屏是 30000 毫秒,它会把其他方案的差异压缩到很小的区间。实际使用时应先剔除明显不可行的异常方案,或者改用更稳健的归一化;本节保留 min-max,是因为它足够透明,评审能手工复算。

测试与真实输出

实验工程里有 7 个测试,覆盖三轴缺口、三种优先级下的第一名变化、同分稳定排序、权重校验和 id 唯一性。在工程根目录执行:

node --test test/quality/tradeoff.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 三个目标可以分别指出缺口 (0.834917ms)
✔ 首屏优先时 fast 排第一 (0.271667ms)
✔ 功能优先时 full 排第一 (0.084291ms)
✔ 体积优先时 small 排第一 (0.0895ms)
✔ 完全相同的方案同时可行时按 id 稳定排序 (8.632417ms)
✔ 权重缺失、重复、为负或和不为 1 都会抛错 (0.331958ms)
✔ 候选人 id 重复会抛错 (0.077ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.349416

三个“排第一”的用例使用同一组候选人,只改权重,第一名就换了。这说明排序函数确实在响应优先级,而不是被某个固定指标主导。测试不证明权重选得对,只证明权重被正确执行。

一次真实的取舍记录

下面是一次案例复盘,所有数值都是案例假设值,不是真实产品数据。目标是首屏不超过 2000 毫秒、至少 10 个核心功能、首包不超过 300KB。三个候选方案:

方案首屏核心功能首包主要缺口
A 首屏优先900ms8 项420KB功能少 2 项,首包超 120KB
B 功能优先2400ms14 项380KB首屏超 400ms,首包超 80KB
C 体积优先2100ms10 项190KB首屏超 100ms

按首屏 0.5、功能 0.3、体积 0.2 的权重运行排序函数,真实输出如下:

A-首屏优先	score=0.500	feasible=false	缺口=功能 8 项低于下限 10 项;首包 420KB 超过上限 300KB
C-体积优先	score=0.400	feasible=false	缺口=首屏 2100ms 超过上限 2000ms
B-功能优先	score=0.335	feasible=false	缺口=首屏 2400ms 超过上限 2000ms;首包 380KB 超过上限 300KB

排序第一名是 A,但评审没有选 A。原因不是分数算错了,而是 A 的功能缺口是 2 个核心功能,属于产品层面的硬伤;首包超 120KB 也会让低端机雪上加霜。权重可以表达“相对重要”,但不能替代“哪些缺口不可接受”的判断。

最终选择 C。它只差 100 毫秒首屏,功能刚好达到下限,首包比上限还低 110KB。团队把“首屏 2100 到 1950 毫秒”作为下一个迭代的明确目标,并把 A 的功能缺口记录为“本版本明确不做”,而不是假装它不存在。

这个记录的价值在于三件事都被写下来了:权重、每个方案的缺口、以及为什么没有选第一名。没有这三样,三个月后没人能解释“当时为什么上了 C”。取舍记录不是写给审计看的,是写给下一次决策用的。

取舍之后:留回滚与复盘

优先级一旦执行,就会产生技术债。选了 C,意味着两个核心功能延后,首屏还超 100 毫秒;这些债必须进入待办,而不是消失。

复盘时至少回答四个问题:

  • 权重是否仍然代表当前产品目标?产品阶段变了,权重应该跟着变。
  • 被牺牲的维度是否真的可以延后?有些功能延后会影响留存,不是纯技术问题。
  • 缺口是否在缩小?C 的首屏从 2100 降到 1950 的计划有没有被执行?
  • 有没有出现新的候选方案?拆包、预渲染、缓存策略变化都可能改写三角。

不可能三角不是“永远只能选两个”,而是“每次只能为当前版本选一个组合”。技术手段可以移动三角形的边界,但不能让边界消失。懒加载把成本从首屏移到后续路由,预渲染把成本从客户端移到构建期,缓存把成本从首次访问移到后续访问;每一种都只是重新分配,不是免费消除。

小结

首屏快、功能全、包体小无法在同一版本里同时最大化。把三者写成可比较的字段,用相对候选人的 min-max 归一化与显式权重排序,可以让取舍从口号变成可复核的计算。

排序函数不替团队做决定。它给出相对顺序与缺口清单,feasible 只标记底线是否满足。真正的决定还要回答“哪些缺口不可接受”,并把权重、缺口和选择理由一起存档。下一章进入客户端架构的分层:视图、状态、领域与数据访问各自负责什么,以及为什么分层不是为了好看。

阅读导航:上一节:3.2 包体与内存预算 · 下一节:4.1 视图、状态、领域与数据访问 。

常见错误与边界

  • 把三个原始值直接加权。毫秒、功能数、KB 量纲不同,必须先归一化。
  • 用绝对目标做归一化。所有方案都超限时,得分会全部压到 0,排序失效。
  • 把得分跨版本比较。min-max 得分只对当前候选人集合有意义。
  • 让 feasible 直接否决方案。否决权属于评审,函数只负责列缺口。
  • 权重之和不等于 1。得分不再落在可比较的区间,应该直接抛错。
  • 权重轴重复或遗漏。重复等于偷偷加倍,遗漏等于某个维度不参与决策。
  • 只看第一名,不看缺口。第一名可能正好踩中产品不可接受的那条线。
  • 取舍记录只写结论,不写权重与理由。三个月后无法复盘。
  • 把懒加载当成免费优化。它只是把成本从首屏移到后续路由。
  • 认为三角可以被技术永久消除。技术只能移动边界,不能取消边界。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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