1. 模板代码为什么总是写着写着就失控了
做后台管理系统、CMS、或者任何带管理界面的项目,与模板代码打交道几乎躲不开。我见过太多项目,一开始模板代码挺清爽,页面也就三五个,往后加需求加到二十几个页面的时候,代码就开始变得难以收拾。怎么个难收拾法?最典型的就是:
- 同一个模块的列表页样式被复制了五六遍,参数改了,结构几乎没变
- 新增一个业务页面,第一反应不是复用已有结构,而是“照着那个页面抄一份改改”
- 修改公共头部的时候,要全局搜索十几个模板文件挨个改
- 模板里混着一堆业务判断,
if user.type == 'vip'这种逻辑到处都是,抬头看不到模板主体是什么
这些问题的本质,不是代码写得不够好,而是从一开始就把模板当成“一个页面一份文件”来组织,没想过它也是有架构的。
模板代码模块化设计,简单说就是把模板按照职责边界拆开,让页面与组件复用、数据与展示分离、结构与表现解耦。它的核心收益不是代码变短,而是改动成本下降:改一个地方,所有相关页面同步生效;加一个新页面,不用再复制粘贴再改三处。
这个主题适合谁?写过后端模板(Jinja2、Freemarker、Thymeleaf)的前后端同学,写前端模板(Vue SFC、React JSX、EJS)的工程师,以及任何一个正在被复制粘贴式开发折磨的团队。内容不挑框架,核心方法论是通用的。
这篇文章的核心目的,是把我实际做模板模块化改造时用过的拆解方法、边界判断标准、接口设计套路,以及踩过的坑和最后总结出来的执行清单,完整梳理清楚。我不是在讲理论,而是告诉你我实际怎么做的、为什么这么做。
2. 模板失控的四个阶段:你对号入座一下
模板代码从整洁到失控,几乎都遵循同一个演进路径。我拆成四个阶段,每个阶段都有明显的代码特征和组织特征,你可以对照自己的项目看看处于哪个阶段。
2.1 阶段一:单文件膨胀期
项目早期,每个页面一个模板文件,文件内从上到下依次是头部、导航、侧栏、主体、页脚。业务复杂度低的时候,一个文件几百行,看起来还算规整。
这个阶段的隐患非常好识别:一旦整页模板超过800行,里面出现了超过两个层级的复制片段(比如两个列表区块用同一套 HTML 结构,只是标题不同),单文件的维护成本就开始非线性增长。我碰到过最夸张的是一个订单管理模板文件,接近3000行,包含搜索区、表格区、批量操作栏、审核弹窗、详情抽屉、分页条、统计卡片——十几个区块挤在一个文件里,任何改动都要在长滚动条里来回找定位。
2.2 阶段二:复制粘贴期
单文件膨胀到一定规模后,新的页面需求出现了。直觉的做法是:复制一份最近的模板文件,改一下标题、字段和接口地址。
这个阶段表面上看效率很高——新页面上线快。但代价是重复代码开始以几何级数积累。我做过一次统计:一个6个页面的后台模块,搜索表单区块被复制了5次,表格操作列被复制了6次,弹窗表单被复制了4次。等到产品说“搜索区加一个日期范围选择器”,我改了5个文件;说“表格最后一列加一个复制链接按钮”,我改了6个文件。这些改动还容易漏——漏掉的那个页面,用户就会在某个角落发现功能不一致。
2.3 阶段三:补丁嵌套期
复制粘贴带来的不一致问题太痛了,于是开始有人打补丁:把公共的头部拆成一个header.html,把分页条拆成pagination.html,页面文件里通过 include 引进来。
这是向正确方向迈出的一步,但如果拆得很随意,就会进入阶段三的典型症状:模板里到处是 include 判断,传参规则不统一。举个例子:
{% if page_type == 'list' %} {% include 'components/table.html' with { columns: page_columns } %} {% elif page_type == 'card' %} {% include 'components/cards.html' with { items: card_items } %} {% endif %}这段代码的问题在于:组件的复用不是靠稳定的接口,而是靠页面的if/else分支来切换。新业务类型来了,不是新增一个组件,而是在页面模板里再加一个 elif 分支。业务判断最终会渗透到模板的每一层,负责人想改一个组件行为,得先理清楚多少个页面在判断哪种类型。
2.4 阶段四:伪组件化期
为了应对补丁嵌套的复杂度,团队开始引入前端框架或者更高阶的模板能力。页面模板确实拆出了组件目录:components/文件夹里十几个文件整齐排列,项目看起来有模有样。
但打开这些“组件”你会发现:
- 组件里有大量针对具体业务页面编码的硬判断
- 组件的 props/参数设计没有文档,靠读代码猜
- 同级组件之间还有隐式依赖(一个组件改了命名,另一个组件引用报错)
- 组件数量膨胀,但复用率很低
这个阶段最迷惑人,因为表面上有“设计”,实际上是把复制粘贴从页面级别下沉到了组件级别,根本问题是没变:模块的边界划分不按职责,而按页面归属。
模板代码的模块化设计,就是要打破以上四个阶段的循环。需要说清楚一个概念:模块化的目标不是“拆出很多文件”,而是“可独立替换、可单独维护、可跨项目复用”。如果拆出来的组件仍然和业务页面强耦合,拆了跟没拆一样。
3. 模块边界怎么划:我总结的三个判断标准
拆模板模块之前,最核心的问题是边界。边界错了,后面所有设计都是空中楼阁。我用过很多维度去划分,最后沉淀下来三个判断标准。这三个标准解决的是同一个问题:什么内容该放一起,什么内容该拆开。
3.1 按“变更频率”划分
模块划分的第一条标准,是看变动的频率和动机。
一个模板区域经常因为同类原因变动,说明它的业务内聚度高,应该独立成模块。比如订单列表的“状态标签”,在不同页面都要显示,且状态枚举变了所有页面都要跟着变——这个就应该抽成一个展示组件。反过来,一个区域只是恰好在多个页面中出现,但每次变动的动机各不相同(A 页面因为布局调整改它,B 页面因为文案调整改它),那把它抽出来反而是负担,因为抽出来之后接口设计会很别扭。
我做一个后台项目时,最初把“用户头像”抽成了组件,发现改起来很痛苦——有的页面要显示在线状态,有的要显示等级边框,有的要显示名称悬浮窗。每次需求差异都不一样,接口参数越加越多,最后组件代码比内联代码还长。后来我把头像组件按场景拆成了三个不同组件,每个组件只做好一件事,反而清爽。
实际划分的时候,我会先列出项目里所有模板区块,给每个区块标上“最近三个月被改动了几次”和“每次改动的原因是否相同”。变更频率高且原因一致的区块,优先抽模块;变更频率低或者原因分散的区块,先保持内联,不要为了拆而拆。
3.2 按“数据闭环”划分
第二个标准是看这个区块是否拥有完整的数据闭环。所谓数据闭环,是指区块接收的数据输入明确、产出的展示效果完整,不依赖太多外部状态。
比如说,分页条组件。输入是currentPage、totalPages、pageUrlPattern,输出是渲染好的分页链接。数据输入明确,输出完整,这就是一个标准的数据闭环模块。再比如搜索表单,输入是字段配置数组和初始值,输出是表单 HTML,提交时回调给外层——这也是数据闭环。
反过来,如果一个区块需要读取页面上的十几个变量,而且每个变量的来源都不同、生命周期也不同——比如“侧边栏菜单”(需要根据用户权限、当前路由、菜单配置三个数据源计算显示逻辑),这种区块如果直接拆成组件,接口会非常难设计。正确的做法是:在它外面先包一层数据聚合层(业务 Service 或者 ViewModel),把页面用到的变量先计算出结果,再传给模板组件。模板层的组件只接收“已经算好的数据”,不负责核心业务判断。
这个原则在后端模板场景下尤其重要。Jinja2 或者 Freemarker 这类模板引擎不是前端框架,没有响应式绑定,模板里做大量数据判断会导致模板文件里堆满业务逻辑。正确姿势是在 Controller/Service 层把数据准备好——已经算好当前用户有无权限、已经算好菜单树——模板只做渲染。
3.3 按“复用场景”划分
第三条标准:这个模块被谁复用?在哪里被复用?
- 只在一个页面内重复出现的区块 → 拆分优先级低,这种情况用循环或者宏定义就够了
- 在多个页面间出现的区块 → 应该拆出来,并设计稳定的传参接口
- 在多个项目间都可能复用的区块 → 应该拆到单独的公共组件包,与当前项目的业务结构解耦
优先级是反向的:跨项目复用的优先级最高,跨页面次之,页内复用靠宏即可。很多团队把页内重复出现的表格列定义抽成了组件,反而导致渲染性能下降和调用链变长,这就是分不清优先级。
做模块化设计时,我会先把所有可复用区块分到这三个等级里,然后从最高等级开始处理。低优先级的模块,宁可暂时保持冗余,也不要过早抽象。
4. 三个真实案例:从混乱到模块化的完整改造过程
光讲理论不够,我拿三个真实改造案例说明整个思考过程和操作步骤。这三个案例覆盖不同模板引擎和不同业务类型,但核心思路一致。
4.1 案例一:Jinja2 后台管理——搜索表单模块化
背景:一个 Flask 后台,12个列表页面,每个页面都有搜索区域。搜索区域结构相似:左侧一个表单,里面若干表单项,右侧一个搜索按钮和一个重置按钮。
改造前的问题:12个页面重复同样的表单结构,最痛苦的是“新增筛选条件”。
改造方案:设计一个search_panel宏,接收一个字段配置列表:
{% macro search_panel(form_action, fields, placeholder='搜索') %} <form action="{{ form_action }}" method="get" class="search-panel"> {% for field in fields %} {% if field.type == 'text' %} <input type="text" name="{{ field.name }}" value="{{ field.value or '' }}" placeholder="{{ field.placeholder }}" class="search-input"> {% elif field.type == 'select' %} <select name="{{ field.name }}" class="search-input"> <option value="">全部</option> {% for option in field.options %} <option value="{{ option.value }}" {% if field.value == option.value %}selected{% endif %}> {{ option.label }} </option> {% endfor %} </select> {% endif %} {% endfor %} <button type="submit" class="btn-search">{{ placeholder }}</button> </form> {% endmacro %}调用方式:
{% import 'components/search_panel.html' as ui %} {{ ui.search_panel('/admin/orders', [ {'type': 'text', 'name': 'order_no', 'placeholder': '订单号'}, {'type': 'select', 'name': 'status', 'options': status_options} ]) }}每个页面的 Controller 负责提供fields数据,模板层只做遍历渲染。
这里有一个关键心得:字段配置要直接放在 Controller 或视图函数里,避免写在模板中。这样才能做到“模板不感知业务字段”,新增筛选项只改 Python 代码,不需要动模板。
4.2 案例二:Freemarker 电商站点——楼层组件化
背景:一个电商首页,由多个楼层组成,每个楼层包含商品列表、栏目标题和背景色。运营经常调整楼层顺序和内容。
改造前的问题:首页模板一个文件写死了12个楼层,运营要调整顺序必须让开发改模板文件,一天能提三次需求。
改造方案:把楼层定义完全数据化。
开发一个floor.ftl宏组件:
<#macro floor title items bgColor> <section class="floor" style="background-color: ${bgColor}"> <h2 class="floor-title">${title}</h2> <div class="floor-grid"> <#list items as item> <a class="floor-item" href="${item.url}"> <img src="${item.imageUrl}" alt="${item.title}" /> <span>${item.title}</span> </a> </#list> </div> </section> </#macro>然后在模板中通过循环渲染所有楼层:
<#list floorConfig as floor> <@floor title=floor.title items=floor.items bgColor=floor.bgColor /> </#list>floorConfig的数据来源从 Controller 动态传入,可以来自数据库配置,也可以来自运营后台的 JSON 文件。这样一个模板文件覆盖所有楼层场景,运营改内容不用再碰模板。
改造中踩过的一个细节坑:Freemarker 宏的命名空间冲突。引入多个宏模板时,如果两个宏文件都定义了floor或item这种通用名称,导入会直接冲突。后来我规范了宏命名前缀:ui_floor、ui_productCard,所有公共组件统一用ui_前缀,避免业务组件互相覆盖。
4.3 案例三:Vue 后台——表格列配置化
背景:一个 Vue 3 + Element Plus 搭建的中后台,列表页占了系统的一半以上。每个列表页都有一张表格,列定义不同,但操作列(编辑、删除、查看)基本一致。
改造前的问题:每个页面的el-table-column都写一遍操作列,改操作列的宽度或按钮样式要全局搜索替换。
改造方案:封装一个useTableColumns组合式函数和TableContainer组件。
列配置示例:
// columns.js export const orderColumns = [ { prop: 'orderNo', label: '订单号', width: 180 }, { prop: 'customerName', label: '客户', width: 120 }, { prop: 'amount', label: '金额', width: 120, align: 'right' }, { type: 'operation', label: '操作', width: 160, actions: ['view', 'edit', 'delete'] } ]TableContainer组件内部读取操作列配置,统一渲染操作按钮和确认弹窗逻辑。页面调用时只传columns数组和tableData,不再关心操作列内部怎么实现。
<TableContainer :columns="orderColumns" :data="orderList" @edit="openEditDialog" @delete="handleDelete" />这次改造带来的实际收益:操作列逻辑从20多个页面中抽离到一个组件里。后来产品要求操作列增加一个“复制订单号”功能,我只改了一个组件,全局生效。
5. 模块接口设计:传参、默认值与事件回调
模块边界的划分是第一步,接口设计是第二步。接口设计的糟糕程度,直接决定模块能不能被顺畅使用。我见过太多模块拆得不差,但接口设计得稀烂,用起来比内联代码还痛苦。
5.1 参数设计:尽量传“显示对象”,不要传“业务对象”
模板组件的参数最好是页面上可显示的直接数据结构,而不是需要模板层再加工的原始业务对象。
一个典型反例:组件接收一个user对象,内部判断user.status == 1来显示“在职”还是“离职”,判断user.role == 'admin'来显示不同的标签颜色。这就是业务逻辑泄漏到模板了。
正例是 Controller 层把数据算好,传给模板的是userDisplayName、userStatusText、userStatusType(这个 type 对应的是 UI 层的 danger/warning/success,而不是业务枚举值)。这样一来,组件内部不再有if/else业务判断。
做接口设计时,我反复提醒自己和团队一句话:模板组件不负责“理解业务规则”,它只负责“把传进来的东西展示出来”。谁的逻辑复杂,就在谁那里处理,别让模板承担。
5.2 默认值与可选参数:让调用方少写配置
一个组件暴露的接口越多,使用成本就越高——调用方必须读懂所有参数才能正确使用。合理的策略是:核心参数必须传,非核心参数提供默认值。
拿分页条组件来说,至少要暴露currentPage和totalPages两个核心参数。至于“是否显示总数”“上一页下一页文案”“页码按钮数量”,这些都可以给默认值。调用方绝大多数场景不需要配置这些细节,少数特殊场景再按需覆盖。
比较实用的默认值设计方式:
- 基础样式类名统一放组件内部,不在使用方重复传
- 布尔型开关默认关闭,只在需要时打开
- 回调事件尽量统一命名(
onEdit、onDelete、onChange),不要一个组件里有的叫onEdit,有的叫editHandler
5.3 事件回调:单向数据流,组件不改外部状态
模板组件(尤其是后端模板渲染出来的)最容易犯的毛病,是组件内部直接拼接完 HTML 就完事,事件绑定散落在使用方。模块化设计时建议明确:组件只渲染结构和展示逻辑,所有交互事件向使用方暴露,使用方自己处理。
后端模板场景下没有现代框架的事件绑定,可以约定:组件渲染的按钮都带><button class="btn-action">