《互联网产品的客户端架构·端形态与体验基础》2.1 Web、原生、小程序与桌面

把 Web、原生、小程序与桌面四种端形态拆成八项可判定能力,用一份 TypeScript 能力矩阵与测试固定「稳定支持、有条件支持、不支持」的边界,再复盘一次把小程序当成全平台方案的误判,说明能力矩阵为什么必须先于框架选型,避免团队用渠道优势掩盖能力缺口。

四个名字,四套默认假设

「回声」最早只有一个移动端原生应用。后来运营希望在微信里做分享落地页,市场希望有一个免安装的轻量版本,客服希望桌面端能常驻并接收通知。会议室里很快出现一句话:“都做成小程序不就好了?”

这句话的问题不在结论,而在它跳过了判断。四种端形态不是四个发布渠道,而是四套默认假设:Web 假设“打开就能用、更新即时、能力受限”;原生假设“安装之后长期存在、能力完整、更新受审核”;小程序假设“寄生在宿主里、分发最快、后台与设备能力被收窄”;桌面假设“常驻、窗口化、系统集成深、跨平台要额外成本”。

架构讨论如果从“哪个端更先进”开始,最后往往变成团队偏好之争。更可靠的做法是先定义一组可判定的能力列,逐格填写“稳定支持、有条件支持、不支持”,再让产品事实去筛。下面这份矩阵不追求覆盖所有平台细节,它追求的是:任何一格都能在评审里被追问证据。

文中出现的评分、阈值和比例,除非明确标注为本机运行输出,都是案例假设值,用来演示判断过程,不是行业基准。

能力矩阵先要定义“列”

矩阵的行是四种端形态,列是八项会被架构直接消费的能力。选列的标准不是“听起来重要”,而是“填错会导致架构返工”。

  • 免安装:用户能否在零安装成本下进入核心流程。
  • 离线核心:无网时核心任务是否还能完成,而不是只显示缓存页面。
  • 后台执行:退到后台后,上传、同步、定位等任务是否还能继续。
  • 设备 API:能否稳定访问摄像头、蓝牙、文件系统、传感器等完整能力。
  • 即时更新:修复与实验能否绕过商店审核,在多长时间内到达用户。
  • 高帧预算:是否有足够的主线程、GPU 与内存预算支撑重交互。
  • 多平台覆盖:一份代码能否同时覆盖目标平台,而不是“同一品牌的不同壳”。
  • 系统集成:通知、分享、文件关联、开机启动等系统级入口是否可用。

每格只允许三个值:yes 表示平台稳定支持,可以作为架构前提;partial 表示有条件支持,必须写降级路径与验证方式;no 表示不应假设可用。这里最关键的一条是:partial 在选型时按缺口处理,而不是按“也算支持”处理。因为降级路径本身就是成本,而且往往是最难估的那部分成本。

把四张表压成一张速查表,差异会立刻显现:

能力Web原生小程序桌面
免安装yesnoyesno
离线核心partialyespartialyes
后台执行partialpartialnoyes
设备 APIpartialyespartialyes
即时更新yesnopartialpartial
高帧预算partialyesnoyes
多平台覆盖yesnonopartial
系统集成partialyespartialyes

这张表最容易被误读的地方,是“Web 的多平台覆盖是 yes”。它只表示同一份 Web 代码能在多个系统的浏览器里运行,不表示体验与原生一致,也不表示离线、后台、设备能力一起被覆盖。矩阵按列独立判断,不允许用一列的优势去抵消另一列的缺口。

把矩阵写成可执行的判断

表格放在文档里会过期,写成函数才能被测试和评审复用。下面的文件是完整的矩阵与三个查询函数,不依赖 DOM、网络或第三方包。

// file: src/forms/capability-matrix.ts
// tier: L1
// verify: node --test test/forms/capability-matrix.test.ts

export type ClientForm = "web" | "native" | "miniapp" | "desktop";

export type Capability =
  | "install-free"
  | "offline-core"
  | "background-execution"
  | "native-device-api"
  | "instant-update"
  | "high-frame-budget"
  | "cross-platform-reach"
  | "deep-system-integration";

export type Support = "yes" | "partial" | "no";

export const CLIENT_FORMS: ClientForm[] = ["web", "native", "miniapp", "desktop"];

export const CAPABILITIES: Capability[] = [
  "install-free",
  "offline-core",
  "background-execution",
  "native-device-api",
  "instant-update",
  "high-frame-budget",
  "cross-platform-reach",
  "deep-system-integration",
];

export const CAPABILITY_LABELS: Record<Capability, string> = {
  "install-free": "免安装,打开即用",
  "offline-core": "无网时核心流程可用",
  "background-execution": "退到后台仍能执行任务",
  "native-device-api": "完整访问设备与系统 API",
  "instant-update": "更新不经过商店审核",
  "high-frame-budget": "高帧率与重计算预算",
  "cross-platform-reach": "一份代码覆盖多平台",
  "deep-system-integration": "通知、分享、文件关联等系统集成",
};

/**
 * 能力矩阵只回答「这个端形态能不能稳定提供这项能力」。
 * yes = 平台稳定支持;partial = 有条件支持,必须写降级路径;no = 不应假设可用。
 * 判断依据是平台公开能力边界,不是某个框架的宣传页。
 */
export const FORM_MATRIX: Record<ClientForm, Record<Capability, Support>> = {
  web: {
    "install-free": "yes",
    "offline-core": "partial",
    "background-execution": "partial",
    "native-device-api": "partial",
    "instant-update": "yes",
    "high-frame-budget": "partial",
    "cross-platform-reach": "yes",
    "deep-system-integration": "partial",
  },
  native: {
    "install-free": "no",
    "offline-core": "yes",
    "background-execution": "partial",
    "native-device-api": "yes",
    "instant-update": "no",
    "high-frame-budget": "yes",
    "cross-platform-reach": "no",
    "deep-system-integration": "yes",
  },
  miniapp: {
    "install-free": "yes",
    "offline-core": "partial",
    "background-execution": "no",
    "native-device-api": "partial",
    "instant-update": "partial",
    "high-frame-budget": "no",
    "cross-platform-reach": "no",
    "deep-system-integration": "partial",
  },
  desktop: {
    "install-free": "no",
    "offline-core": "yes",
    "background-execution": "yes",
    "native-device-api": "yes",
    "instant-update": "partial",
    "high-frame-budget": "yes",
    "cross-platform-reach": "partial",
    "deep-system-integration": "yes",
  },
};

export function supports(form: ClientForm, capability: Capability): Support {
  return FORM_MATRIX[form][capability];
}

/**
 * 列出某个端形态在给定必备能力上的缺口。
 * required 表示「产品必须有」,partial 也算缺口,因为降级路径本身就是成本。
 */
export function gaps(
  form: ClientForm,
  required: Capability[],
): Array<{ capability: Capability; support: Support }> {
  return required
    .filter((capability) => FORM_MATRIX[form][capability] !== "yes")
    .map((capability) => ({ capability, support: FORM_MATRIX[form][capability] }));
}

/**
 * 返回所有必备能力都稳定支持的端形态。
 * allowPartial 只适合做早期探索,正式选型不应打开。
 */
export function formsMeeting(
  required: Capability[],
  options: { allowPartial?: boolean } = {},
): ClientForm[] {
  const allowPartial = options.allowPartial ?? false;
  return CLIENT_FORMS.filter((form) =>
    required.every((capability) => {
      const level = FORM_MATRIX[form][capability];
      return level === "yes" || (allowPartial && level === "partial");
    }),
  );
}

/** 两个端形态在哪些能力上判断不同,用于解释选型差异。 */
export function differingCapabilities(a: ClientForm, b: ClientForm): Capability[] {
  return CAPABILITIES.filter((capability) => FORM_MATRIX[a][capability] !== FORM_MATRIX[b][capability]);
}

/** 统计一个端形态的 yes / partial / no 数量,便于快速比较。 */
export function summarize(form: ClientForm): Record<Support, number> {
  const counts: Record<Support, number> = { yes: 0, partial: 0, no: 0 };
  for (const capability of CAPABILITIES) counts[FORM_MATRIX[form][capability]] += 1;
  return counts;
}

三个函数分别回答三类问题。supports 用于单点查询,适合在架构决策记录里引用某一格。gaps 用于把“产品必备能力”翻译成“这个端形态还缺什么”,partial 也会出现在缺口里。formsMeeting 用于反向筛选:给定一组必须稳定支持的能力,哪些端形态还能留在候选池。

differingCapabilities 和 summarize 是辅助工具。前者回答“Web 与桌面到底差在哪”,后者给出一个端形态的 yes/partial/no 计数。它们不能替代逐格判断,但能让评审快速定位争议格。比如原生是 4 个 yes、1 个 partial、3 个 no,桌面是 5 个 yes、2 个 partial、1 个 no,数量接近,但缺口位置完全不同:原生的 no 集中在安装与更新,桌面的 no 集中在免安装。

测试与真实输出

测试共七条,覆盖矩阵完整性、单点能力、partial 缺口、反向筛选、差异对称与统计一致性。完整测试文件如下。

// file: test/forms/capability-matrix.test.ts
// tier: L1
// verify: node --test test/forms/capability-matrix.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import {
  CAPABILITIES,
  CLIENT_FORMS,
  FORM_MATRIX,
  differingCapabilities,
  formsMeeting,
  gaps,
  summarize,
  supports,
} from "../../src/forms/capability-matrix.ts";

test("四种端形态都必须填满八项能力,不能留空", () => {
  for (const form of CLIENT_FORMS) {
    assert.deepEqual(Object.keys(FORM_MATRIX[form]).sort(), [...CAPABILITIES].sort());
  }
});

test("原生满足离线与设备 API,但更新必须走商店", () => {
  assert.equal(supports("native", "offline-core"), "yes");
  assert.equal(supports("native", "native-device-api"), "yes");
  assert.equal(supports("native", "instant-update"), "no");
  assert.equal(supports("native", "install-free"), "no");
});

test("Web 的离线能力是 partial,不能当成稳定支持", () => {
  const missing = gaps("web", ["install-free", "offline-core", "instant-update"]);
  assert.deepEqual(missing, [{ capability: "offline-core", support: "partial" }]);
});

test("后台执行与完整设备 API 同时成立时只剩桌面端", () => {
  assert.deepEqual(
    formsMeeting(["background-execution", "native-device-api"]),
    ["desktop"],
  );
});

test("允许 partial 时原生会进入候选,但这是探索口径", () => {
  const strict = formsMeeting(["background-execution", "native-device-api"]);
  const relaxed = formsMeeting(["background-execution", "native-device-api"], {
    allowPartial: true,
  });
  assert.deepEqual(strict, ["desktop"]);
  assert.deepEqual(relaxed, ["web", "native", "desktop"]);
});

test("差异列表对称,且能解释 Web 与桌面的取舍", () => {
  const ab = differingCapabilities("web", "desktop");
  const ba = differingCapabilities("desktop", "web");
  assert.deepEqual(ab, ba);
  assert.ok(ab.includes("install-free"));
  assert.ok(ab.includes("background-execution"));
});

test("统计结果与矩阵一致", () => {
  assert.deepEqual(summarize("web"), { yes: 3, partial: 5, no: 0 });
  assert.deepEqual(summarize("miniapp"), { yes: 1, partial: 4, no: 3 });
});

在实验工程根目录执行:

node --test test/forms/capability-matrix.test.ts

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

✔ 四种端形态都必须填满八项能力,不能留空 (0.887625ms)
✔ 原生满足离线与设备 API,但更新必须走商店 (0.10575ms)
✔ Web 的离线能力是 partial,不能当成稳定支持 (0.112459ms)
✔ 后台执行与完整设备 API 同时成立时只剩桌面端 (0.092458ms)
✔ 允许 partial 时原生会进入候选,但这是探索口径 (0.081167ms)
✔ 差异列表对称,且能解释 Web 与桌面的取舍 (0.5295ms)
✔ 统计结果与矩阵一致 (0.124791ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.820208

这里有一个值得注意的测试结果:把 allowPartial 打开后,Web 也会进入“后台执行 + 完整设备 API”的候选。这不是矩阵写错了,而是放松口径后的真实后果。Web 的后台执行是 partial,设备 API 也是 partial,允许 partial 就等于允许两个降级路径同时存在。正式选型时不应打开这个开关;它只适合早期探索,用来列出“如果愿意承担降级成本,还有哪些可能”。

一次“小程序覆盖所有端”的误判

某团队要给「回声」做一个“全平台轻量版”:用户在微信里打开小程序,浏览内容、下单、上传售后凭证。上线后两个问题集中爆发。

第一,售后凭证上传在弱网下频繁失败。小程序的网络请求受宿主生命周期影响,用户切到聊天窗口再回来,上传任务可能已经中断;而产品要求在后台继续上传。矩阵里小程序的后台执行是 no,这不是实现技巧能补的,属于平台边界。

第二,运营希望把同一套页面投到桌面端做客服工作台。小程序的运行环境绑定宿主,桌面端没有对应宿主;矩阵里多平台覆盖是 no。团队真正需要的是 Web 工作台或桌面应用,而不是把小程序代码“打包一下”。

复盘时发现,选型阶段只比较了“开发速度”和“用户触达”,没有把离线、后台、设备、多平台覆盖这些列拉出来。小程序在免安装与触达上确实最强,但这两列不能抵消后台执行与多平台覆盖的缺口。如果当时跑了 formsMeeting(["background-execution", "cross-platform-reach"]),候选池会直接为空;如果产品愿意降级,也必须把降级路径和验证方式写进决策记录,而不是等到上线后才发现。

矩阵回答不了什么

能力矩阵能拦住“把 partial 当 yes”的错误,但它有明确边界。

它不衡量质量。两个原生应用都可以写“设备 API = yes”,一个接入了完整能力,另一个只用了相机;矩阵只看平台可能性,不看团队实现。

它不衡量成本。原生能力最全,但双端维护、审核周期、招聘难度都不在矩阵里。成本要放到 2.3 的判据矩阵里,用团队规模、发布节奏与平台覆盖去加权。

它有时效。平台能力会变,今天是 partial 的格子明年可能变成 yes,也可能因为政策收紧变成 no。矩阵必须带版本与复核日期,不能当成永久真理。

它不替产品做取舍。矩阵输出的是缺口清单,不是“选哪个”的答案。产品可以接受缺口,但必须显式接受,并说明补偿方案。

小结

四种端形态的差异,可以先收敛成八项可判定能力。每格用 yes、partial、no 三值表达,其中 partial 在正式选型里按缺口处理。把矩阵写成代码后,单点查询、缺口计算与反向筛选都变成可测试的函数,“小程序能不能覆盖所有端”这类问题就有了可复核的答案。

矩阵的价值不是给出唯一解,而是把“平台能不能稳定提供”与“团队愿不愿意承担降级成本”分开讨论。前者是事实,后者是决策。混在一起谈,最后通常由声音最大的人拍板。

下一节处理另一个常见幻觉:既然有这么多端,能不能用一套同构代码全部复用?答案同样是先分层,再判断哪些层可以共享、哪些层必须分叉。

阅读导航:上一节:实验工程的三级视图 · 下一节:同构与多端的技术边界 。

常见错误与边界

  • 把端形态当发布渠道。渠道只影响触达,端形态会改变能力边界与架构形状。
  • 把 partial 当成“差不多支持”。有条件支持意味着必须写降级路径、验证方式与失败兜底。
  • 用一列优势抵消另一列缺口。多平台覆盖再好,也不能补上后台执行或离线核心的 no。
  • 在正式选型时打开 allowPartial。它会把多个降级路径同时放进候选,成本被严重低估。
  • 把矩阵当成永久结论。平台能力与政策会变,矩阵要带版本、复核日期和证据链接。
  • 只看平台可能性,不看团队实现。矩阵写 yes 不代表现有代码真的用到了这项能力。
  • 跳过成本讨论直接选能力最全的端。能力最全通常也意味着维护面最大。
  • 用矩阵替代产品取舍。矩阵给缺口,产品决定是否接受缺口以及如何补偿。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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