《互联网产品的客户端架构·端形态与体验基础》4.1 视图、状态、领域与数据访问

把回声客户端的发现、会话、下单三个页面拆成视图、状态、领域、数据访问四层,逐条说明每层负责什么、明确不负责什么,用一段可运行的分层判定与调用审计代码暴露视图直连数据层、领域反向依赖视图、状态绕过领域三类越层,并给出本机 Node 26 真实测试输出与评审问题清单。

四层不是四个文件夹

很多客户端项目都有 view/、state/、domain/、data/ 四个目录,但打开文件会发现:视图里在算价格,状态里在拼 URL,领域里在弹提示,数据层里在判断会员等级。目录只是把文件分了堆,分层解决的是另一个问题:谁允许知道谁,谁不允许做什么。

「回声」的三个页面——发现页、会话页、下单页——正好覆盖三种最常见的越层。下面先把四层的职责边界写清楚,再把三个页面的调用链拆开,最后把判断写成可运行的 TypeScript。

每层负责什么,明确不负责什么

层负责明确不负责判定信号
视图 view渲染、输入事件、滚动位置这类短命 UI 状态业务规则、网络、持久化产出界面输出
状态 state页面状态、命令编排、派生投影渲染、I/O、业务规则协调一次页面流程
领域 domain业务规则、不变量、计算渲染、I/O、持久化、反向依赖视图输入相同则输出相同
数据 data网络、本地持久化、序列化、缓存业务规则、渲染、页面状态触碰进程外资源

视图层

视图层只回答“长什么样、用户点了哪里”。它可以持有展开/收起、滚动位置、输入框焦点这类短命状态,因为这些状态丢了也不影响业务正确性。它不负责判断“这个用户能不能下单”,也不负责决定“失败后要不要重试”。

视图层最容易越界的地方是格式化:把金额、时间、图片 URL 的拼装当成展示逻辑。格式化本身没问题,问题在于它常常需要读取领域规则或数据层协议。一旦视图知道了 CDN 的尺寸参数、货币精度或会员折扣,换一个后端或换一个图片服务就要改视图。

状态层

状态层负责把一次页面流程串起来:加载、成功、失败、重试、空态。它持有页面级状态,做派生投影,把用户意图翻译成领域层能理解的命令。它不渲染,也不直接碰网络和存储。

状态层最常见的越界是“顺手把 I/O 做了”。页面要保存草稿,状态层直接写本地存储;列表要刷新,状态层直接调仓库。代码能跑,但草稿的保留规则、序列化版本、冲突合并全被绕开了。

领域层

领域层只放业务规则、不变量和计算:订单金额怎么算、优惠能不能叠加、消息重发的上限是多少。它必须是纯的:同样的输入得到同样的输出,不读时钟、不读网络、不弹提示。

领域层最常见的越界是“顺手把结果告诉用户”。校验失败时直接调用 toast,看起来省了一次状态层转发,实际让领域层依赖了视图。结果是领域测试必须拉起 UI 环境,规则复用不到其他端。

数据访问层

数据访问层负责和进程外资源打交道:HTTP、WebSocket、本地数据库、缓存、序列化。它把外部格式翻译成领域层能用的结构,但不判断这个结构在业务上是否合法。

数据层最常见的越界是“顺手做业务判断”。仓库里写“如果用户是会员就打九折”,规则就同时存在于领域层和数据层,两边迟早不一致。

回声三个页面里的越层调用

发现页:视图自己取封面

发现页的卡片需要封面图。组件里顺手写了一句 feedRepo.resolveCover(id, width),把图片 CDN 的尺寸参数拼装放进了视图。它的理由是“只是一个字符串拼接”,但这条调用让视图直接依赖了数据层:换 CDN、改签名算法、加防盗链,都要改视图组件。

同一个页面里还有第二条越层:状态层为了“少写一个领域方法”,直接读仓库拿列表,再把排序规则复制了一份。排序规则本来属于领域层,现在状态层和数据层各有一份。

会话页:领域直接弹提示

会话页发送失败时,领域层的校验函数直接调用 showToast("发送失败")。这条调用让领域层反向依赖视图。原本应该由领域层返回失败原因,状态层决定用 toast、横幅还是重试按钮;现在领域层替产品做了交互决定,也把自己锁死在当前 UI 上。

反向依赖最隐蔽的后果是测试成本。领域层的单元测试原本只需要构造输入、断言输出,现在要 mock 一个视图模块,否则 import 就失败。

下单页:状态层自己存草稿

下单页在用户输入地址后,状态层直接调用 localStorage.setItem 保存草稿。它绕过了领域层的“只保留最近一单”规则,也绕过了数据层的序列化版本号。版本升级后字段改名,旧草稿恢复出来是 undefined,用户看到的是一张空表单。

注意这条调用在依赖方向上并不“反向”:状态层在内层之外,写存储也算向内。它的问题是跨层:状态层跳过了领域层和数据层,直接执行了本该由数据层封装的持久化。方向正确不等于分层正确,这是两种检查必须分开的原因。

把判定写成函数

下面的文件把“操作属于哪一层”和“调用边是否越层”写成两个纯函数。它不依赖 DOM、网络或任何框架,所有判断只由输入事实决定。

// file: src/layers/responsibility.ts
// tier: L1
// verify: node --test test/layers/responsibility.test.ts

export type Layer = "view" | "state" | "domain" | "data";

export interface LayerDuty {
  /** 这一层拥有的关注点 */
  owns: string[];
  /** 这一层明确不负责的关注点 */
  forbids: string[];
}

/**
 * 四层的职责表。判断一个操作属于哪一层,先看它触碰了哪些关注点,
 * 再看这些关注点是否被目标层明确禁止。
 */
export const DUTIES: Record<Layer, LayerDuty> = {
  view: {
    owns: ["render", "input-events", "ephemeral-ui-state"],
    forbids: ["business-rule", "remote-io", "persistence"],
  },
  state: {
    owns: ["page-state", "commands", "projection"],
    forbids: ["render", "remote-io", "persistence", "business-rule"],
  },
  domain: {
    owns: ["business-rule", "invariant", "calculation"],
    forbids: ["render", "remote-io", "persistence", "import-view"],
  },
  data: {
    owns: ["remote-io", "local-persistence", "serialization", "cache"],
    forbids: ["business-rule", "render", "page-state"],
  },
};

export interface OperationFacts {
  /** 操作名,用于报告与测试定位 */
  name: string;
  /** 是否产出界面输出 */
  renders: boolean;
  /** 是否持有滚动位置、展开状态这类短命 UI 状态 */
  keepsEphemeralUiState: boolean;
  /** 是否负责协调一次页面级流程 */
  coordinatesPage: boolean;
  /** 是否包含业务规则、约束或不变量 */
  enforcesBusinessRule: boolean;
  /** 是否执行网络、存储或序列化 I/O */
  performsIo: boolean;
}

export interface Placement {
  operation: string;
  layer: Layer;
  reasons: string[];
  /** 与目标层冲突的关注点,非空表示这个操作应当拆分 */
  split: string[];
}

/**
 * 把操作事实判给唯一一层。
 * 判定顺序渲染 > I/O > 规则 > 编排,因为越靠前的关注点越难被其他层安全吸收。
 */
export function placeOperation(facts: OperationFacts): Placement {
  const reasons: string[] = [];
  const split = new Set<string>();
  let layer: Layer;

  if (facts.renders) {
    layer = "view";
    reasons.push("产出界面输出,归视图层");
    if (facts.performsIo) split.add("view+io");
    if (facts.enforcesBusinessRule) split.add("view+rule");
    if (facts.coordinatesPage) split.add("view+state");
  } else if (facts.performsIo) {
    layer = "data";
    reasons.push("执行 I/O,归数据访问层");
    if (facts.enforcesBusinessRule) split.add("data+rule");
    if (facts.coordinatesPage) split.add("data+state");
    if (facts.keepsEphemeralUiState) split.add("data+ui-state");
  } else if (facts.enforcesBusinessRule) {
    layer = "domain";
    reasons.push("包含业务规则,归领域层");
    if (facts.coordinatesPage) split.add("domain+state");
    if (facts.keepsEphemeralUiState) split.add("domain+ui-state");
  } else if (facts.coordinatesPage || facts.keepsEphemeralUiState) {
    layer = "state";
    reasons.push("只做页面编排或短命 UI 状态,归状态层");
  } else {
    layer = "state";
    reasons.push("没有命中渲染、I/O 或业务规则,按编排归入状态层");
    split.add("unclassified");
  }

  return {
    operation: facts.name,
    layer,
    reasons,
    split: [...split].sort(),
  };
}

/** 层等级:数字越大越靠外,依赖只允许从大指向小 */
export const LAYER_RANK: Record<Layer, number> = {
  view: 3,
  state: 2,
  domain: 1,
  data: 0,
};

export type CallViolationKind = "view-to-data" | "reverse-dependency" | "skip-layer";

export interface LayerCall {
  page: string;
  from: Layer;
  to: Layer;
  operation: string;
}

export interface CallViolation {
  page: string;
  operation: string;
  kind: CallViolationKind;
  path: string;
}

function classifyEdge(from: Layer, to: Layer): CallViolationKind | undefined {
  if (from === "view" && to === "data") return "view-to-data";
  if (LAYER_RANK[to] > LAYER_RANK[from]) return "reverse-dependency";
  if (LAYER_RANK[from] - LAYER_RANK[to] > 1) return "skip-layer";
  return undefined;
}

/**
 * 审计运行期调用边。只允许相邻向内:view -> state -> domain -> data。
 * 同层调用放行,因为层内协作不改变依赖方向。
 */
export function auditCalls(calls: LayerCall[]): CallViolation[] {
  const violations: CallViolation[] = [];
  for (const call of calls) {
    const kind = classifyEdge(call.from, call.to);
    if (!kind) continue;
    violations.push({
      page: call.page,
      operation: call.operation,
      kind,
      path: `${call.from}->${call.to}`,
    });
  }
  return violations.sort(
    (a, b) =>
      a.page.localeCompare(b.page) ||
      a.operation.localeCompare(b.operation) ||
      a.path.localeCompare(b.path),
  );
}

export interface PagePlan {
  page: string;
  placements: Placement[];
  violations: CallViolation[];
}

/** 为一个页面同时给出操作归属与调用审计结果 */
export function planPage(
  page: string,
  operations: OperationFacts[],
  calls: LayerCall[] = [],
): PagePlan {
  return {
    page,
    placements: operations.map(placeOperation),
    violations: auditCalls(calls.filter((call) => call.page === page)),
  };
}

placeOperation 的判定顺序是渲染 > I/O > 规则 > 编排。顺序不是随意的:一个操作如果既渲染又发请求,不能因为“它主要是在画界面”就整体归给视图;split 会记录这个冲突,提示调用方拆成两个操作。auditCalls 只允许相邻向内,同层调用放行,因为层内协作不改变依赖方向。

这里有一个刻意保留的空白:auditCalls 不检查模块 import,只检查运行期调用边。import 层面的方向与循环由 4.2、4.3 负责。两种检查的输入不同,混在一起会让报错定位变得困难。

测试与真实输出

测试文件 test/layers/responsibility.test.ts 覆盖十种情况:四类单一职责归属、渲染与 I/O 混合、规则与 I/O 混合、无关注点、职责表禁止项、干净调用链、三类越层,以及回声三个页面的汇总审计。

在实验工程根目录执行:

node --test test/layers/responsibility.test.ts

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

✔ 四类单一职责操作分别落到四层 (0.559833ms)
✔ 渲染同时发请求时必须拆分,不能整体塞进视图层 (0.852833ms)
✔ 计算价格同时落库时必须拆分,数据层不吸收业务规则 (0.203ms)
✔ 没有任何关注点的操作被标为未分类,而不是假装有归属 (0.148458ms)
✔ 职责表把领域层的反向依赖写成明确禁止项 (0.179208ms)
✔ 逐层向内的调用链不产生违规 (0.187292ms)
✔ 视图直接访问数据层被判为 view-to-data (0.070791ms)
✔ 领域层反向依赖视图被判为 reverse-dependency (0.053958ms)
✔ 状态层绕过领域层直连数据层被判为 skip-layer (0.080542ms)
✔ 回声三个页面的越层调用可以被一次性列出来 (8.453584ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 180.507959

测试里有一处值得注意:sendMessage 同时带业务规则和 I/O,判定结果是数据层加 data+rule,而不是“因为它主要是发消息,所以归领域层”。这说明判定函数回答的是“这个操作混了哪些关注点”,不是“这段代码放在哪个文件里看起来更顺眼”。真正落地时应该拆成领域层的校验函数和数据层的发送函数,由状态层编排。

越层为什么总是从顺手开始

越层调用几乎都不是设计出来的,而是三次“顺手”累积出来的:

  • 第一次,为了少写一个方法,视图直接拿了仓库。
  • 第二次,为了少传一个参数,领域层直接弹了提示。
  • 第三次,为了赶一个截止时间,状态层直接写了存储。

每一次都能通过 code review,因为改动很小、功能正常、测试也绿。问题在三个月后出现:换图片服务要改十几个视图组件,领域规则在三个地方各有一份,草稿格式升级后老用户恢复失败。

把分层写成函数不能阻止第一次顺手,但能让它在评审时被指出来。评审时问四个问题就够了:

  1. 这个操作产出界面吗?
  2. 它碰网络、存储或序列化吗?
  3. 它包含“如果是会员就打折”这类规则吗?
  4. 它协调多个步骤吗?

四个问题里命中两个以上,就应该拆分。命中一个,按对应的层归属。一个都不命中,说明这段代码可能还没有想清楚职责,先别急着放进任何一层。

小结

四层的关系不是目录嵌套,而是职责与知识的边界:视图知道怎么画,状态知道流程走到哪,领域知道规则是什么,数据层知道怎么和外部世界交换。每层明确不做什么,比它做什么更重要。

越层调用通常以“顺手”开始,以“改不动”结束。把归属判定和调用审计写成纯函数,能让越层在测试里暴露,而不是等到换后端、换 UI 或升级存储格式时才暴露。

下一节把视角从单次调用抬到模块图:依赖为什么只能指向内层,反向边和循环依赖如何在构建前被拦住。

阅读导航:上一节:3.3 不可能三角与优先级 · 下一节:4.2 依赖方向与包边界 。

常见错误与边界

  • 把分层当成目录规范。目录可以一夜之间建好,知识边界需要靠代码和测试守。
  • 让视图做格式化就顺手读数据层。格式化可以留在视图,数据获取必须由状态层发起。
  • 让状态层直接写存储。草稿规则、序列化版本和冲突合并会被绕开。
  • 让领域层弹提示。领域层应返回原因,交互决定属于状态层或视图层。
  • 在数据层写业务判断。同一规则出现两份,迟早不一致。
  • 只检查依赖方向,不检查调用路径。状态层写存储方向正确,但仍然是跨层。
  • 认为同层调用也要禁止。同层协作不改变依赖方向,禁止它只会制造无意义的转发层。
  • 把 split 当成错误码。它是拆分提示,说明这个操作混了多个关注点,不是运行失败。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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