14.6 两步视图
先生成页面语义,再套入共享布局,让内容变化与站点外观变化各自有清晰边界。
学习目标
- 能沿着一次订单请求画出内容生成、共享布局和最终响应三段责任,并指出每段可以读取什么
- 能修改一个 TypeScript 两步渲染函数,让页面主体与布局模板分别可测试、可替换,并保持 HTML 转义边界
- 能诊断内容缓存键、用户权限和布局读取领域数据之间的冲突,给出采用或拒绝两步视图的证据
为什么 14.6 两步视图值得单独学习
后台订单系统常常同时面对三种变化:订单详情字段在变,导航和页脚在变,站点还可能增加紧凑、打印或移动版外观。如果每个页面模板都把这三种变化揉在一起,新增一条导航就会触碰大量页面;如果先把所有内容输出成完整页面,再强行包一层布局,又会出现重复标题、重复导航或错误的缓存复用。
↡先生成页面主体,再把主体交给共享布局模板完成外壳的呈现模式。把问题拆成两个时间点:第一步回答“这次请求要表达什么”,第二步回答“站点怎样包住这段表达”。它不是某个框架的渲染 API,而是一条可以追踪输入、产物和责任人的渲染约定。
先画出订单详情的请求链:HTTP 请求 → 路由 → 应用服务 → 内容模板 → 布局模板 → HTTP 响应。第一步应当保留订单编号、行项目和状态等页面语义;第二步应当补上全站导航、页脚、主题和公共资源。只要布局模板反过来查询订单或决定订单能否取消,分层就开始漏水。
目录单元到教学证据
14.6 两步视图
本章精确对应 manifest 单元 poeaa24-pattern-30-two-step-view,范围限定为:将页面主体的生成与共享外观的组装拆成两个可检验阶段。订单后台多视图贯穿正文,用同一份订单数据观察“主体是否可单独测试”“布局是否可跨页面复用”“失败是否还能定位到具体阶段”。
完成本单元的证据不是记住“先内容、后布局”八个字,而是能交出三样东西:一张标明数据流和所有者的两步图;一段把两步写成独立函数的 TypeScript 草图;一个故障样本,证明错误缓存键或布局越界会在哪个边界被发现。若只能展示最终 HTML,却说不出它是在哪一步产生的,就没有完成模式级判断。
两步各自拥有的责任
第一步:生成页面语义
第一步接收路由已经解释过的订单查询结果,生成一个只描述页面主体的 ↡已经由请求语义决定、但尚未套入全站导航与页脚的页面主体结果。。它可以选择订单详情模板、计算显示用的金额和排序行项目,但不应复制全站导航,也不应为了画页脚去读取站点设置。
第一步的输出可以是 HTML 片段,也可以是更结构化的页面模型;重要的是契约稳定。对于 GET /orders/42,测试可以只给它一个订单和语言,断言输出包含订单状态;测试不必启动完整站点,也不必知道当前站点的导航主题。这样,页面语义变化不会自动扩大为全站布局变化。
第二步:组装共享外观
第二步把逻辑页面放进 ↡负责统一放置导航、页脚、公共资源和主题外观的共享呈现模板。。它可以读取站点品牌、当前语言和页面上下文中的导航状态,但不应重新查询订单、执行库存判断或决定是否允许退款。布局的工作是“包住并呈现”,不是“再做一次业务用例”。
这里的 ↡第二步组装布局时所需的非业务信息,例如语言、主题和当前导航项。必须有清单。把 locale、theme 和 activeNav 放进上下文是可审计的;把整个 Order 聚合偷偷塞进上下文,通常意味着布局正在承担第一步或应用服务的责任。上下文越窄,布局越容易被多个页面和多种设备外观复用。
专属可视化实验:看同一份内容如何换壳
主图把订单数据的路径固定成三段:第一步生成页面主体,第二步接收主体并加入共享壳层,最后形成浏览器响应。先预测:如果导航改名,哪一个阶段必须重新测试?如果布局模板读取订单权限,内容能否继续只按路由缓存?再用按钮切换阶段,并注入布局故障,观察红色边界出现在哪里。
三个阶段快照
下面的 Stepper 将同一张图拆成三个确定性快照。每步都保留对象、箭头和当前证据,读者可以先写下预测,再用图核对,而不是依靠连续动画猜测状态。
1. 先产出页面主体
路由和应用服务已经得到订单数据,内容模板只生成订单详情主体。此时没有导航和页脚,因此它可以按订单数据与语言单独做快照测试。
代码实践:把两步写成可测试函数
下面的 TypeScript 草图把第一步和第二步的输入分开。renderContent 只负责订单主体,renderLayout 只接收已生成的主体和窄页面上下文;真实项目可以把字符串换成模板引擎结果,但不要因此放弃边界。
type Order = { id: number; status: "paid" | "pending"; totalCents: number };
type PageContext = { locale: string; activeNav: "orders" | "reports" };
function renderContent(order: Order, locale: string): string {
const total = (order.totalCents / 100).toFixed(2);
return (
`<main><h1>Order ${order.id}</h1>` +
`<p data-locale="${escapeHtml(locale)}">${order.status}</p>` +
`<strong>${total}</strong></main>`
);
}
function renderLayout(body: string, context: PageContext): string {
const nav = context.activeNav === "orders" ? "订单" : "报表";
return (
`<html lang="${escapeHtml(context.locale)}">` +
`<body><nav>${nav}</nav>${body}<footer>后台</footer></body></html>`
);
}这里的 escapeHtml 是安全边界的占位接口,不能把用户输入直接插入标签属性或文本节点。第一步可以改变订单主体的字段排版,第二步可以把导航换成侧栏;只要两边都遵守输入契约,修改不会被迫扩散到另一层。布局仍可以根据 activeNav 高亮当前栏目,但不应根据一个未声明的订单对象自行决定权限。
再看调用顺序和缓存位置。内容缓存应建立在第一步的真实输入上;布局缓存则必须考虑语言、主题和用户可见性。若页面没有个性化,第二步可以复用同一个外壳;若布局包含权限结果,就必须把那个维度放进键,或者把权限判断移回应用服务。
function renderOrderPage(order: Order, context: PageContext): string {
const body =
contentCache.get(order.id, context.locale) ??
renderContent(order, context.locale);
contentCache.set(order.id, context.locale, body);
return renderLayout(body, context);
}
const page = renderOrderPage(
{ id: 42, status: "paid", totalCents: 12900 },
{ locale: "zh-CN", activeNav: "orders" },
);这个示例故意没有把 renderLayout 的结果放进只按订单编号索引的缓存。页面外壳可能随语言、主题或用户权限变化;先缓存主体再按上下文组装,能把共享内容与个性化外观的风险分开。工程实现还要明确过期、失效和错误回退,但这些策略都应落在可观察的阶段上。
缓存、错误与替代方案
↡保存第一步页面主体结果、以便相同输入不必重复生成的缓存边界。的键至少要覆盖会改变主体的输入,例如订单编号、语言、权限过滤结果和内容版本。只用路由做键很诱人,因为
/orders/42
看起来足够稳定;但同一路由可能被不同用户、语言或实验主题访问。缓存命中后若跳过了第一步,错误就会变成跨用户泄露,而不是一个容易定位的模板异常。
错误也应带着阶段返回。内容模板找不到订单时,应产生“第一步未完成”的明确结果;布局模板缺少页脚配置时,应报告“第二步组装失败”。把所有异常都包成一张空白错误页,会让运维只能看到 HTTP 状态,无法判断应修复查询、内容模板还是站点外壳。
两步视图适合多个页面共享同一个外观、页面主体变化频繁,且两步之间有稳定数据契约的场景。若每个页面都需要完全不同的外壳,第二步只剩一层无意义的转发;若布局必须参与业务决策,应该把业务判断提前放到应用服务或页面模型,而不是让共享模板长出隐蔽分支。也要与模板视图、转换视图和前端控制器比较:它们可以协作,但不能用“两步”这个名称替代责任分析。
| 评审问题 | 采用两步视图的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 复用 | 多个页面共享第二步外壳,第一步仍保留页面语义 | 每个页面都要复制一套布局,第二步没有稳定职责 |
| 缓存 | 主体键覆盖语言、权限与内容版本 | 只按路由命中,导致不同用户收到同一主体 |
| 错误 | 能区分内容生成失败与布局组装失败 | 只返回最终错误页,无法定位首个失败阶段 |
| 边界 | 布局只使用窄页面上下文 | 布局反向查询订单并决定领域状态 |
常见误区
本章小结
- 两步视图先生成页面主体,再用布局模板补上共享外观;两步的输入和产物都应该可追踪。
- 逻辑页面负责页面语义,布局模板负责导航、页脚和主题;布局不应重新拥有订单业务决定。
- 内容缓存必须覆盖真实输入,尤其是语言、权限和内容版本;错误应指出发生在第一步还是第二步。
- 当多个页面共享外壳且边界稳定时,两步视图能隔离变化;当布局开始查询领域数据时,应拆出应用服务或拒绝该方案。
练习与答案
练习
问题 1:补全代码边界。 如果 renderLayout 收到了完整的 Order,请改写调用契约,使布局只能拿到 body 和 PageContext。你会为哪两个函数各写一条断言?
问题 2:诊断缓存故障。 用户甲以中文主题访问 /orders/42,用户乙以英文主题访问同一地址,却命中了同一份主体缓存。请写出至少两个缺失维度,并指出应该放在第一步还是第二步处理。
问题 3:选择或拒绝。 一个后台只有三个页面,每页都有不同的导航、不同的页脚和不同的权限判断。团队仍想强行加一个共享布局层。你会要求哪些运行证据后再决定?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 两步视图
先生成页面主体,再把主体交给共享布局模板的呈现方式;它把页面语义和站点外观分成两个责任点。
- 逻辑页面
已经说明这页要展示什么、但还没有导航和页脚的页面主体结果。
- 布局模板
把主体包进全站导航、页脚和主题外观的模板,不应重新执行订单业务判断。
- 页面上下文
布局所需的窄信息,例如语言、主题和当前导航项,而不是完整领域对象。
- 内容缓存
保存第一步主体结果的缓存;键必须覆盖会改变主体的语言、权限和版本等输入。
前后导航
参考资料
- Martin Fowler:Two Step View 模式摘要:核对模式的公开定义与内容/布局两步边界。
- Martin Fowler:Patterns of Enterprise Application Architecture:核对图书主题、章节范围和模式目录关系。
- Pearson:Patterns of Enterprise Application Architecture:交叉核对出版信息与 Web Presentation Patterns 所在章节。