14.4 模板视图

在页面模板中嵌入动态标记并填充展示数据,用可测试的 HTML 输出承接页面结构。

学习目标

  • 能解释模板视图如何把页面结构、展示数据和业务决定分到不同边界
  • 能修改 TypeScript 模板渲染代码,使外部输入经过转义并能用快照验证最终 HTML
  • 能回答:当模板开始查询数据库或执行业务规则时,为什么应把职责移回应用层

先从一张订单页面开始

想象一家工厂要每天装配一张订单单据。前面的人只负责确认“要装哪一单”和“这单允许做什么”;中间的人把编号、金额和商品清单准备好;最后的人拿一张已经印好版式的纸,把空白处填上。版式不会因为某一单金额不同就重新设计。

如果最后填空的人顺手去仓库查库存、决定是否退款,下一次改规则就得翻遍所有单据。我们需要的是一条清楚的流水线:前面准备事实,最后一站只负责把事实摆成可读页面。

模板视图解决的边界问题

这条流水线对应的 ,重点不是某个框架的文件后缀,而是三种责任的分离:控制器解释请求,应用层决定业务,页面模板负责把已经决定好的数据呈现出来。

它通常位于“应用服务已经返回展示数据”和“HTTP 响应发出”之间。模板包含固定的 HTML 结构,以及变量、条件和列表等标记;渲染时把数据填进去,生成一次完整的页面。模板可以决定“这一块是否显示”,但不应决定“订单是否能取消”。

先把请求链画出来

对订单详情页,先写出一条可追踪的链:

GET /orders/42 → 页面控制器 → 应用服务 → 展示数据 → HTML 模板 → HTTP 响应

每个箭头都应该能回答一个问题:谁校验输入?谁读取订单?谁判断权限?谁把金额放到标题下?如果最后两个问题都由模板回答,模板就已经越过了表示边界。

为什么不直接在控制器里拼 HTML

控制器里拼字符串看起来很快,但页面结构会和路由、错误处理、数据查询混在一起。加一个商品字段会同时影响业务测试和 HTTP 测试;设计人员也不能独立修改页面。

模板视图把结构移到可阅读的文档中,让控制器只传递展示用数据。代价是多了一个模板运行时和一套标记语法,因此必须把模板渲染当作可测试的边界,而不是“字符串魔法”。

三个核心部件

1. Model 只携带展示事实

页面使用的 可以是订单编号、格式化后的金额、商品名称列表和导航状态。它不应该暴露数据库连接,也不应该让模板调用 cancel() 之类的业务方法。

把展示数据单独整理出来还有一个好处:模板测试可以构造固定 Model,不需要启动数据库。Model 的字段变化会让编译器、快照或契约测试尽早提醒我们,而不是等浏览器打开页面才发现空白。

2. 模板描述结构与显示条件

模板文件保留 HTML 的文档形状,动态位置使用标记,例如 {{orderId}}{{#each items}}。简单的条件和循环有助于描述“这组数据如何显示”;越过显示边界的查询、授权和状态转换应回到应用服务。

不要把模板标记误认为完整编程语言。标记越强大,越容易在页面里藏进不可测试的规则;当一个条件需要读取多个聚合、写审计事件或决定事务边界时,它已经不属于模板。

3. 模板引擎负责绑定与转义

负责把 {{ orderId }} 替换成 42,把商品列表展开成多个节点,并按约定执行

转义不是业务规则,却是输出边界的安全契约。订单备注来自用户输入时,模板引擎应该把尖括号当作文字;只有经过明确审查的可信片段才允许使用特殊 HTML。应用层仍负责判断“备注能否保存”,模板层负责“备注怎样安全显示”。

专属交互:观察一份 Model 如何变成 HTML

先预测:把下面的“注入模板越界”打开,哪一段会先失去可测试性?是数据准备、标记绑定,还是最终响应?再按播放或单步观察责任从左到右的变化。

专属 Template View 图 · 数据已准备
Template View:Model 数据进入 HTML 标记准备 Model · 控制器只整理订单展示数据;业务决定仍在应用层。1. 准备 Model数据已准备2. 填充模板标记标记正在渲染3. 验收 HTML输出可验收页面控制器GET /orders/42读取并校验输入调用应用服务return ViewModel不拼 HTML,不改状态提供ModelorderId: 42total: 597.00items: [2]只含展示所需数据无查询与状态变更绑定HTML 模板 + 引擎<h1>{{orderId}}</h1><li>{{item.name}}</li>escape(value)标记控制展示,不承载规则列表/条件只读 Model输出HTTP 响应<h1>订单 #42</h1><p>¥597.00</p>输入已转义快照可复现允许返回模板责任边界模板可以做条件、循环和格式化;应用服务负责查询、授权与状态转换。验收证据:输入转义、输出快照、模板不依赖数据库,换布局时业务测试仍然通过。选择信号:页面结构变化多、需要设计人员协作;拒绝信号:模板开始拥有业务工作流。模板视图 = HTML 模板中的动态标记 + Model 数据;业务决定留在模板外

第 1 / 3 步 · 页面控制器准备展示用 Model

按步观察:数据、标记与输出各自承担什么责任。

Template View 将展示规则写在页面模板的标记中,用 Model 填充出 HTML; 模板不应替应用层查询数据或执行业务决策。
分步1 / 3

1. 控制器准备展示 Model

页面控制器接收请求,应用服务完成查询、授权和状态判断,最后只交给模板一份展示数据。此时模板还没有碰到数据库,也没有机会替订单作决定。

专属 Template View 图 · 数据已准备
Template View:Model 数据进入 HTML 标记准备 Model · 控制器只整理订单展示数据;业务决定仍在应用层。1. 准备 Model数据已准备2. 填充模板标记标记正在渲染3. 验收 HTML输出可验收页面控制器GET /orders/42读取并校验输入调用应用服务return ViewModel不拼 HTML,不改状态提供ModelorderId: 42total: 597.00items: [2]只含展示所需数据无查询与状态变更绑定HTML 模板 + 引擎<h1>{{orderId}}</h1><li>{{item.name}}</li>escape(value)标记控制展示,不承载规则列表/条件只读 Model输出HTTP 响应<h1>订单 #42</h1><p>¥597.00</p>输入已转义快照可复现允许返回模板责任边界模板可以做条件、循环和格式化;应用服务负责查询、授权与状态转换。验收证据:输入转义、输出快照、模板不依赖数据库,换布局时业务测试仍然通过。选择信号:页面结构变化多、需要设计人员协作;拒绝信号:模板开始拥有业务工作流。模板视图 = HTML 模板中的动态标记 + Model 数据;业务决定留在模板外
Template View 将展示规则写在页面模板的标记中,用 Model 填充出 HTML; 模板不应替应用层查询数据或执行业务决策。

代码实践:把渲染边界写成函数

下面的 TypeScript 草图把原始订单转换成展示 Model。真正的系统还会在应用服务里完成权限与状态检查;这里故意只保留页面需要的字段,使模板无法借助对象引用反向访问持久化层。

type Order = {
  id: number;
  totalCents: number;
  items: Array<{ name: string; quantity: number }>;
};
 
type OrderViewModel = {
  orderId: string;
  total: string;
  items: Array<{ label: string }>;
};
 
export function toOrderViewModel(order: Order): OrderViewModel {
  return {
    orderId: String(order.id),
    total: `¥${(order.totalCents / 100).toFixed(2)}`,
    items: order.items.map((item) => ({
      label: `${item.name} × ${item.quantity}`,
    })),
  };
}

这里的 OrderViewModel 没有 savecancelrepository 字段。模板收到它后只能消费数据,不能把数据库调用藏在一个“方便的属性”后面。

渲染函数的关键不是自己发明一套模板语言,而是明确转义发生在哪里。以下伪实现把文本插值和 HTML 片段区分开:

function renderOrderPage(model: OrderViewModel): string {
  const title = escapeHtml(`订单 #${model.orderId}`);
  const total = escapeHtml(model.total);
  const items = model.items
    .map((item) => `<li>${escapeHtml(item.label)}</li>`)
    .join("");
 
  return orderTemplate
    .replace("{{title}}", title)
    .replace("{{total}}", total)
    .replace("{{items}}", items);
}

生产代码应使用经过审计的模板引擎,而不是依赖多个 replace 的顺序。测试至少要覆盖三件事:正常订单能得到稳定结构;商品名中的特殊字符只作为文本出现;模板不能通过隐式调用改变订单状态。

选择、拒绝与相邻模式

模板视图适合页面结构稳定、展示字段较多、需要让页面协作者直接编辑 HTML 的场景。它把“页面长什么样”放在模板中,比在控制器里拼接字符串更容易评审,也比让 Model 暴露完整领域对象更容易测试。

它并不意味着所有页面都必须共享一份大模板。若多个页面重复导航和页脚,可以进一步考虑两步视图;若同一份结构化数据需要稳定地转换成 HTML、PDF 或邮件,转换视图可能更自然;若请求入口本身需要集中认证和路由,那是前端控制器的责任。

评审问题采用模板视图的证据应拒绝的信号
数据边界模板消费窄 Model,查询在应用服务完成模板持有 Repository 或领域对象
输出安全默认插值转义,可信 HTML 有明确审查用户输入直接拼进 HTML
结构变化页面协作者可修改模板并用快照验收一个模板塞入多个页面的业务分支
模式选择主要输出是页面,结构与数据需要协作需要多格式转换或独立全局布局

常见误区

本章小结

  • 模板视图让 HTML 结构和展示数据在渲染边界汇合。
  • Model 只携带页面事实,不暴露查询和业务动作。
  • 模板引擎负责绑定与转义,业务决定留在应用层。
  • 快照与安全输入测试为最终 HTML 提供可复现证据。
  • 模板拥有业务工作流时,应拆分职责或更换相邻模式。

练习

练习

问题 1:修改代码。renderOrderPage 增加一个订单备注字段。要求备注中的 <> 仍显示为普通文字,并说明测试应断言什么。

问题 2:诊断边界。 设计人员希望在模板里调用 order.isOverdue(),以便决定是否显示“催款”按钮。你会保留这个调用吗?请指出它可能带来的两个问题。

问题 3:选择方案。 同一份订单数据要稳定输出 HTML 页面、PDF 和邮件正文,且三种输出由不同团队维护。应该继续扩展模板视图,还是评估转换视图?请给出判断证据。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

模板视图

把 HTML 结构和动态标记放在页面模板中,再用展示数据填出最终页面的一种表示方式。

Model

专门给页面使用的一小份数据,例如订单编号、金额和商品名称;它不负责查库或改变状态。

模板引擎

读取模板标记、绑定数据并生成 HTML 的程序,通常也负责默认转义文本。

输出转义

把输入里的特殊字符变成普通文字,避免浏览器把它误认为标签或脚本。

前后导航

参考资料

资料与写作方式声明

本章以Patterns of Enterprise Application Architecture权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…