14.6 两步视图

先生成页面语义,再套入共享布局,让内容变化与站点外观变化各自有清晰边界。

学习目标

  • 能沿着一次订单请求画出内容生成、共享布局和最终响应三段责任,并指出每段可以读取什么
  • 能修改一个 TypeScript 两步渲染函数,让页面主体与布局模板分别可测试、可替换,并保持 HTML 转义边界
  • 能诊断内容缓存键、用户权限和布局读取领域数据之间的冲突,给出采用或拒绝两步视图的证据

为什么 14.6 两步视图值得单独学习

后台订单系统常常同时面对三种变化:订单详情字段在变,导航和页脚在变,站点还可能增加紧凑、打印或移动版外观。如果每个页面模板都把这三种变化揉在一起,新增一条导航就会触碰大量页面;如果先把所有内容输出成完整页面,再强行包一层布局,又会出现重复标题、重复导航或错误的缓存复用。

把问题拆成两个时间点:第一步回答“这次请求要表达什么”,第二步回答“站点怎样包住这段表达”。它不是某个框架的渲染 API,而是一条可以追踪输入、产物和责任人的渲染约定。

先画出订单详情的请求链:HTTP 请求 → 路由 → 应用服务 → 内容模板 → 布局模板 → HTTP 响应。第一步应当保留订单编号、行项目和状态等页面语义;第二步应当补上全站导航、页脚、主题和公共资源。只要布局模板反过来查询订单或决定订单能否取消,分层就开始漏水。

目录单元到教学证据

14.6 两步视图

本章精确对应 manifest 单元 poeaa24-pattern-30-two-step-view,范围限定为:将页面主体的生成与共享外观的组装拆成两个可检验阶段。订单后台多视图贯穿正文,用同一份订单数据观察“主体是否可单独测试”“布局是否可跨页面复用”“失败是否还能定位到具体阶段”。

完成本单元的证据不是记住“先内容、后布局”八个字,而是能交出三样东西:一张标明数据流和所有者的两步图;一段把两步写成独立函数的 TypeScript 草图;一个故障样本,证明错误缓存键或布局越界会在哪个边界被发现。若只能展示最终 HTML,却说不出它是在哪一步产生的,就没有完成模式级判断。

两步各自拥有的责任

第一步:生成页面语义

第一步接收路由已经解释过的订单查询结果,生成一个只描述页面主体的 。它可以选择订单详情模板、计算显示用的金额和排序行项目,但不应复制全站导航,也不应为了画页脚去读取站点设置。

第一步的输出可以是 HTML 片段,也可以是更结构化的页面模型;重要的是契约稳定。对于 GET /orders/42,测试可以只给它一个订单和语言,断言输出包含订单状态;测试不必启动完整站点,也不必知道当前站点的导航主题。这样,页面语义变化不会自动扩大为全站布局变化。

第二步:组装共享外观

第二步把逻辑页面放进 。它可以读取站点品牌、当前语言和页面上下文中的导航状态,但不应重新查询订单、执行库存判断或决定是否允许退款。布局的工作是“包住并呈现”,不是“再做一次业务用例”。

这里的 必须有清单。把 localethemeactiveNav 放进上下文是可审计的;把整个 Order 聚合偷偷塞进上下文,通常意味着布局正在承担第一步或应用服务的责任。上下文越窄,布局越容易被多个页面和多种设备外观复用。

专属可视化实验:看同一份内容如何换壳

主图把订单数据的路径固定成三段:第一步生成页面主体,第二步接收主体并加入共享壳层,最后形成浏览器响应。先预测:如果导航改名,哪一个阶段必须重新测试?如果布局模板读取订单权限,内容能否继续只按路由缓存?再用按钮切换阶段,并注入布局故障,观察红色边界出现在哪里。

专属两步视图图 · 第一步只回答“这页讲什么”
Two Step View:先生成内容,再套入共享布局同一份订单数据在两步之间传递;每一步只拥有自己的责任1. 生成内容2. 套入布局3. 输出响应第一步:逻辑页面body = renderContent(order)订单详情 + 页面语义不包含导航、页脚、主题可按路由与数据独立测试传递 body第二步:布局模板page = layout(body, context)导航 + body + 页脚只组装共享外观布局可被多页复用发送浏览器响应┌ 全站导航 ┐│ 订单内容 │└ 全站页脚 ┘同一外观,多种内容当前观察:第一步只回答“这页讲什么”领域数据先变成页面主体;导航、页脚和主题不进入内容结果。两步视图的边界:内容模板决定页面语义,布局模板决定共享外观
两步视图把页面语义与站点外观拆开:内容模板先产出主体,布局模板再负责共享壳层;故障开关展示第二步越界后的缓存风险。

三个阶段快照

下面的 Stepper 将同一张图拆成三个确定性快照。每步都保留对象、箭头和当前证据,读者可以先写下预测,再用图核对,而不是依靠连续动画猜测状态。

分步1 / 3

1. 先产出页面主体

路由和应用服务已经得到订单数据,内容模板只生成订单详情主体。此时没有导航和页脚,因此它可以按订单数据与语言单独做快照测试。

专属两步视图图 · 第一步只回答“这页讲什么”
Two Step View:先生成内容,再套入共享布局同一份订单数据在两步之间传递;每一步只拥有自己的责任1. 生成内容2. 套入布局3. 输出响应第一步:逻辑页面body = renderContent(order)订单详情 + 页面语义不包含导航、页脚、主题可按路由与数据独立测试传递 body第二步:布局模板page = layout(body, context)导航 + body + 页脚只组装共享外观布局可被多页复用发送浏览器响应┌ 全站导航 ┐│ 订单内容 │└ 全站页脚 ┘同一外观,多种内容当前观察:第一步只回答“这页讲什么”领域数据先变成页面主体;导航、页脚和主题不进入内容结果。两步视图的边界:内容模板决定页面语义,布局模板决定共享外观
两步视图把页面语义与站点外观拆开:内容模板先产出主体,布局模板再负责共享壳层;故障开关展示第二步越界后的缓存风险。

代码实践:把两步写成可测试函数

下面的 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,请改写调用契约,使布局只能拿到 bodyPageContext。你会为哪两个函数各写一条断言?

问题 2:诊断缓存故障。 用户甲以中文主题访问 /orders/42,用户乙以英文主题访问同一地址,却命中了同一份主体缓存。请写出至少两个缺失维度,并指出应该放在第一步还是第二步处理。

问题 3:选择或拒绝。 一个后台只有三个页面,每页都有不同的导航、不同的页脚和不同的权限判断。团队仍想强行加一个共享布局层。你会要求哪些运行证据后再决定?

名词解释

名词解释

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

两步视图

先生成页面主体,再把主体交给共享布局模板的呈现方式;它把页面语义和站点外观分成两个责任点。

逻辑页面

已经说明这页要展示什么、但还没有导航和页脚的页面主体结果。

布局模板

把主体包进全站导航、页脚和主题外观的模板,不应重新执行订单业务判断。

页面上下文

布局所需的窄信息,例如语言、主题和当前导航项,而不是完整领域对象。

内容缓存

保存第一步主体结果的缓存;键必须覆盖会改变主体的语言、权限和版本等输入。

前后导航

参考资料

资料与写作方式声明

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

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

讨论

评论区加载中…