《互联网产品的客户端架构·端形态与体验基础》2.2 同构与多端的技术边界

把同构与多端复用拆成领域模型、数据层、视图、导航、生命周期、输入与存储七层,用可运行的 TypeScript 分类器判定复用、适配与分叉,给出分叉率阈值与退出条件,并复盘一次强行统一导航的失败,说明复用为什么是一项需要成本口径与退出机制的经济决策。

复用不是目标,是一项成本决策

“一套代码跑多端”是客户端架构里最有吸引力的口号之一。它承诺更少的人、更快的迭代、更一致的行为。但复用从来不是免费的:共享层每增加一个平台分支,测试矩阵就扩大一圈;每接受一个平台特性,其他平台就要写降级;每修一个跨端 bug,都要确认所有端没有被带坏。

真正的问题不是“能不能复用”,而是“复用哪一层、复用到什么程度、什么时候停止”。这三个问题都需要可判定的边界,而不是一句“尽量复用”。

「回声」的 Web、原生和小程序共用一套领域模型,但导航、生命周期、输入与存储完全分开。这不是妥协,而是分层判断后的结果。本节先把层拆开,再把判断写成函数,最后给“什么时候该拆”一个数字。

先分层,再谈复用

跨端代码可以粗分成七层。层与层之间的复用价值差别很大,因为每层依赖的平台事实不同。

  • 领域模型:实体、值对象、业务规则、状态机。只要不读系统时间、不碰设备、不发请求,就可以整层复用。
  • 数据层:接口契约、字段校验、DTO 映射。数据形状可以共享,但传输、重试、缓存与鉴权实现通常要适配。
  • 视图:视图模型、格式化、交互状态可以共享;布局、动画、渲染管线往往要分叉或适配。
  • 导航:路由表可以生成,但返回栈、深链、系统返回键属于平台语义,必须分叉。
  • 生命周期:前后台切换、恢复、销毁、资源释放由系统调度,必须分叉。
  • 输入:手势、焦点、软键盘、悬停与触摸的默认行为不同,必须分叉。
  • 存储:接口可以统一,但事务、容量、加密、清理策略由平台决定,必须分叉。

这里有一个容易忽略的顺序问题:领域模型看起来最“纯”,但一旦它开始依赖“当前是否在前台”“是否有网络”“用户点了系统返回键”,它就不再是纯逻辑,复用结论也要跟着变。所以判断不能只看层的名字,要看这一层实际拥有什么职责。

判定顺序:平台事实优先于代码形态

下面的分类器把“这层拥有什么职责”作为输入,按固定顺序判定。顺序是生命周期、导航、输入、存储、重渲染、纯逻辑:越靠前的职责越接近平台事实,越应该推翻“看起来可以共享”的结论。

// file: src/forms/reuse-boundary.ts
// tier: L1
// verify: node --test test/forms/reuse-boundary.test.ts

export type LayerKey =
  | "domain-model"
  | "data-access"
  | "view"
  | "navigation"
  | "lifecycle"
  | "input"
  | "storage";

export type Verdict = "reuse" | "adapt" | "fork";

export interface LayerFacts {
  layer: LayerKey;
  /** 是否只包含不依赖平台的纯逻辑 */
  pureLogic?: boolean;
  /** 是否拥有返回栈、深链或路由生命周期 */
  ownsNavigation?: boolean;
  /** 是否负责前后台切换、恢复与销毁 */
  ownsLifecycle?: boolean;
  /** 是否处理手势、焦点、软键盘等输入差异 */
  ownsInput?: boolean;
  /** 是否直接读写本地存储并承担事务语义 */
  ownsStorage?: boolean;
  /** 是否包含重渲染或逐帧计算 */
  heavyRender?: boolean;
}

export interface BoundaryDecision {
  layer: LayerKey;
  verdict: Verdict;
  sharedPart: string;
  forkPart: string;
  reasons: string[];
  /** 什么条件下可以重新评估这个判断 */
  exitCondition: string;
}

/**
 * 判断一个层能不能跨端复用。
 * 顺序固定:生命周期 > 导航 > 输入 > 存储 > 重渲染 > 纯逻辑。
 * 越靠前的判断越接近平台事实,越容易推翻「看起来可以共享」的结论。
 */
export function classifyLayer(facts: LayerFacts): BoundaryDecision {
  const reasons: string[] = [];

  if (facts.ownsLifecycle) {
    reasons.push("生命周期由系统调度,前后台、恢复与销毁时机因平台而异");
    return {
      layer: facts.layer,
      verdict: "fork",
      sharedPart: "状态形状与事件命名",
      forkPart: "事件绑定、恢复时机与资源释放",
      reasons,
      exitCondition: "当所有目标平台的生命周期事件能映射到同一状态机,且恢复时机可由外部注入时重新评估",
    };
  }

  if (facts.ownsNavigation) {
    reasons.push("导航拥有返回栈与深链,平台的路由历史和系统返回键语义不同");
    return {
      layer: facts.layer,
      verdict: "fork",
      sharedPart: "路由表生成规则与参数校验",
      forkPart: "返回栈、深链解析与页面转场",
      reasons,
      exitCondition: "当产品只保留单页、不需要深链与系统返回键时重新评估",
    };
  }

  if (facts.ownsInput) {
    reasons.push("输入涉及手势、焦点与软键盘,不同平台的默认行为无法统一");
    return {
      layer: facts.layer,
      verdict: "fork",
      sharedPart: "输入语义枚举与校验规则",
      forkPart: "手势识别、焦点管理与键盘避让",
      reasons,
      exitCondition: "当目标平台只保留一种输入语义且手势无差异时重新评估",
    };
  }

  if (facts.ownsStorage) {
    reasons.push("存储的容量、事务与清理策略由平台决定,接口相似不代表语义相同");
    return {
      layer: facts.layer,
      verdict: "fork",
      sharedPart: "存取接口与数据版本迁移契约",
      forkPart: "事务、容量上限、加密与清理实现",
      reasons,
      exitCondition: "当所有平台提供同一事务语义与容量保证时重新评估",
    };
  }

  if (facts.heavyRender) {
    reasons.push("重渲染依赖各平台的布局与合成管线,共享的是数据而不是绘制路径");
    return {
      layer: facts.layer,
      verdict: "adapt",
      sharedPart: "视图模型、格式化与交互状态",
      forkPart: "渲染管线、动画调度与降级策略",
      reasons,
      exitCondition: "当渲染瓶颈被移到共享的数据层,视图只剩声明式映射时重新评估",
    };
  }

  if (facts.pureLogic) {
    reasons.push("不依赖平台事实,输入输出可以由类型约束");
    return {
      layer: facts.layer,
      verdict: "reuse",
      sharedPart: "全部领域规则与数据结构",
      forkPart: "无",
      reasons,
      exitCondition: "当领域规则开始依赖平台能力或本地政策时重新评估",
    };
  }

  reasons.push("共享数据契约,但传输、重试与缓存实现仍随平台变化");
  return {
    layer: facts.layer,
    verdict: "adapt",
    sharedPart: "数据模型、字段校验与接口契约",
    forkPart: "传输实现、重试预算与缓存策略",
    reasons,
    exitCondition: "当平台差异只出现在传输层且已被适配器隔离时保持现状",
  };
}

export interface SharingFacts {
  /** 共享层的总行数(不含测试) */
  sharedLines: number;
  /** 各平台差异行的合计,包含条件分支与平台专用实现 */
  perPlatformDiffLines: number;
}

export interface SharingDecision {
  keepSharing: boolean;
  divergenceRatio: number;
  threshold: number;
  advice: string;
}

/** 分叉率阈值是案例假设值,团队应按维护成本调整并写进决策记录。 */
export const DIVERGENCE_LIMIT = 0.25;

/**
 * 用分叉率判断共享层是否还值得维持。
 * 分叉率 = 各平台差异行合计 / 共享层总行数。
 */
export function evaluateSharing(facts: SharingFacts): SharingDecision {
  if (facts.sharedLines <= 0) {
    return {
      keepSharing: false,
      divergenceRatio: 1,
      threshold: DIVERGENCE_LIMIT,
      advice: "共享层为空,先明确要共享的契约,再谈复用",
    };
  }
  const divergenceRatio = facts.perPlatformDiffLines / facts.sharedLines;
  const keepSharing = divergenceRatio <= DIVERGENCE_LIMIT;
  return {
    keepSharing,
    divergenceRatio,
    threshold: DIVERGENCE_LIMIT,
    advice: keepSharing
      ? "维持共享层,但把差异点收敛到适配器"
      : "分叉成本已超过阈值,拆出平台实现并保留契约测试",
  };
}

/** 按结论分组,便于生成评审材料。 */
export function partitionLayers(
  decisions: BoundaryDecision[],
): Record<Verdict, LayerKey[]> {
  const groups: Record<Verdict, LayerKey[]> = { reuse: [], adapt: [], fork: [] };
  for (const decision of decisions) groups[decision.verdict].push(decision.layer);
  return groups;
}

这段代码有两个设计取舍值得说明。

第一,fork 不等于“完全重写”。导航层的路由表生成规则、存储层的存取接口与迁移契约仍然可以共享,分叉的是平台语义相关的实现。把 sharedPart 和 forkPart 分开写,是为了让评审能看见“共享的到底是什么”,而不是用一句“导航不复用”把可共享的部分也扔掉。

第二,判定顺序是硬的。一个层即使标了 pureLogic: true,只要它同时拥有生命周期职责,结论仍然是 fork。这看起来反直觉,但它拦住了一类常见错误:把平台回调包装成“纯函数”,然后在里面偷偷读写前后台状态。纯逻辑只有在真的不依赖平台事实时才是纯的。

测试与真实输出

测试覆盖六层判定、顺序优先级、分叉率阈值与分组输出。完整测试文件是 test/forms/reuse-boundary.test.ts,与源码放在同一实验工程里。

在实验工程根目录执行:

node --test test/forms/reuse-boundary.test.ts

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

✔ 纯领域模型可以整层复用 (0.56575ms)
✔ 导航层必须分叉,但路由表生成规则可以共享 (0.116584ms)
✔ 生命周期优先于纯逻辑判断,不能被 pureLogic 洗白 (0.059959ms)
✔ 存储层即使接口相同也必须分叉实现 (0.058792ms)
✔ 数据层默认是适配而不是复用 (0.157583ms)
✔ 重渲染层共享视图模型,不共享绘制路径 (0.071167ms)
✔ 分叉率低于阈值时维持共享 (0.084584ms)
✔ 分叉率超过阈值时拆出平台实现 (0.060542ms)
✔ 共享层为空时不应宣称复用 (0.074ms)
✔ 分组结果保留层名,便于直接写进评审材料 (0.530333ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 153.079667

测试里有一条专门验证“生命周期优先于纯逻辑”。它不是在测试实现细节,而是在固定一条评审规则:只要一个层拥有生命周期职责,就不能用 pureLogic 把它洗白。规则被测试固定后,后续重构就很难悄悄绕过它。

分叉率:给退出条件一个数字

“什么时候停止复用”如果只靠感觉,通常会拖到维护成本已经失控。更可操作的办法是算分叉率:共享层里,各平台差异行的合计除以共享层总行数。

evaluateSharing 把这件事变成一个可测试的函数。共享层 1000 行、各平台差异合计 80 行时,分叉率是 0.08,低于 0.25 的阈值,维持共享并把差异收敛到适配器。差异合计 400 行时,分叉率是 0.4,超过阈值,应该拆出平台实现,但保留契约测试。

0.25 这个阈值是案例假设值,不是行业标准。它的作用是把“差异已经很多了”变成可讨论的数字。团队可以按维护成本调整,但必须写进决策记录,并在每次复盘时检查是否仍然成立。

分叉率还有两个使用边界。第一,它只看行数,不看复杂度;一个 20 行的平台分支如果涉及支付或权限,风险可能高于 200 行的样式差异。第二,它假设共享层的边界是稳定的;如果边界本身还在频繁变化,先稳定契约,再谈分叉率。行数只是触发器,不是判决书。

一次强行统一导航的失败

某团队希望 Web、原生和小程序共用一套导航组件。他们抽出了路由表、页面栈和转场动画,用一个 Navigator 接口包住三端实现。前两周进展顺利,第三周开始出现三类问题。

第一,系统返回键。原生和小程序都有系统级返回,Web 只有浏览器后退。团队在 Navigator 里加了 handleSystemBack,然后发现 Web 端永远收不到这个事件,只能靠 popstate 模拟,行为与原生不一致。

第二,深链恢复。原生应用从通知进入详情页时,需要先恢复页面栈再跳转;Web 直接打开 URL 只有一页;小程序有页面栈上限。共享的路由表能生成路径,但“如何恢复上下文”三端完全不同。

第三,转场动画。共享组件要求三端传入相同的动画配置,结果原生端为了性能关掉了部分动画,小程序端受宿主限制只能用默认转场,Web 端又要处理减少动态效果的系统设置。配置项越来越多,最后变成了三套实现加一个很薄的共享壳。

复盘时用分类器重新判定,导航层的结论是 fork,但 sharedPart 明确写了“路由表生成规则与参数校验”。团队真正应该共享的是路径规则和参数校验,而不是页面栈与转场。把共享范围缩小后,代码量没有显著增加,三端的返回行为反而稳定了。

这个案例说明,复用失败往往不是“共享得太少”,而是“共享错了层”。把必须分叉的部分硬塞进共享层,最后会得到一个充满 if (platform === ...) 的抽象,维护成本高于三份独立实现。

退出条件要写进评审

每一个“决定复用”的结论,都应该配一个退出条件。分类器给每层返回 exitCondition,分叉率给共享层一个阈值,两者结合,才能让复用变成可撤销的决策。

评审时至少要回答四个问题:共享的是契约还是实现?平台差异集中在适配器还是散落在业务代码?分叉率达到多少就拆?拆开之后靠什么保证行为一致?前两个问题决定当前结构是否健康,后两个问题决定未来是否还能低成本退出。

退出条件不写,复用就会变成沉没成本。团队明知共享层越来越难维护,却因为“已经投入很多”继续加分支。把阈值和触发条件提前写下来,下一次评审才有机会做减法。

小结

同构与多端的边界可以按层判定:领域模型可以整层复用,数据层与视图层适合适配,导航、生命周期、输入与存储必须分叉,但它们的契约与规则仍可共享。判断顺序必须以平台事实为先,不能让 pureLogic 之类的标签掩盖职责。

复用是一项经济决策,需要成本口径与退出机制。分叉率把“差异是否已经过多”变成可测试的数字,退出条件把“什么时候拆”提前到设计阶段。两者都不能保证复用成功,但能让失败更早被发现、更容易回退。

下一节把这些单点判断收拢成一次完整选型:团队规模、发布节奏、离线要求、性能敏感度与平台覆盖,如何转成硬门槛与加权评分,并留下可复核的决策记录。

阅读导航:上一节:Web、原生、小程序与桌面 · 下一节:选型的判据矩阵 。

常见错误与边界

  • 把“一套代码”当成目标。复用是手段,维护成本与行为一致性才是目标。
  • 用层的名字判断复用。叫“领域模型”的层也可能读写生命周期或本地存储。
  • 把 fork 理解成完全重写。契约、路由规则、迁移逻辑仍然可以共享。
  • 让 pureLogic 洗白平台职责。拥有生命周期、导航、输入或存储职责的层必须先分叉。
  • 用行数代替复杂度判断。支付、权限、合规相关的少量分支可能比大量样式差异更危险。
  • 分叉率阈值拍脑袋且不记录。阈值是案例假设值,要写进决策记录并定期复核。
  • 只决定复用,不写退出条件。没有退出机制的共享层会变成沉没成本。
  • 把共享层做成平台开关集合。if (platform === ...) 增多时,说明共享边界选错了。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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