1. 接手老项目后,模板代码是那根最扎手的刺
今年年初我接手了一个已经运行三年的CRM客户管理系统,系统不算大,但代码量将近二十万行。刚开始一周我还在按部就班地看业务逻辑,越往后越觉得不对劲:这个项目的模板代码怎么这么多,而且每一处都透着一股“能跑就行”的味道。
举几个我实在忍不了的例子。前端列表页里有一大段模板字符串,用+拼接出一整块HTML,变量名是x、d、tmp,中间还夹着几个三元运算符。后端邮件模块更夸张,一个模板文件里堆了四十多个${xxx}占位符,很多占位符在哪一层数据里根本看不出来,偶尔还会出现同一个字段被拼错的情况。更别提代码生成器生成的那些样板文件,光读一遍就要消耗不少耐心。
那段时间我每天花大量时间在“找代码”上:要改一个按钮的样式,得先在模板字符串里数第几个三元表达式是控制按钮颜色;要调整邮件内容,得先对着后端返回的JSON一层一层扒数据对应的字段。终于在第N次被模板代码坑了之后,我决定停下来做一次系统性的可读性改造。这篇文章就是这次改造的复盘记录,内容覆盖我踩过的坑、提炼出的方法论,以及几个不同场景下的具体改法。不管你是写前端模板、后端模板引擎、类模板还是代码生成器模板,我相信都有参考价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺着不可读的模板代码,挖出四个根因
动手改造之前,我先做了一件事:把这几十处“不舒服”的模板代码全部列出来,逐段问自己“为什么读起来这么费力”。我发现它们的问题可以归为四类,而且这四类问题在很多项目里都是同时出现的,并不是某一种代码风格导致。
2.1 模板里塞满了逻辑,模板语言本身却撑不起复杂度
模板代码最典型的失控方式,就是把本该在服务端或组件逻辑里完成的判断、循环、字段格式化统统塞进模板里。
我见过最夸张的一段前端模板字符串,里面包含了两层map、三个条件判断、外加一串}`拼接出来的列表项。能跑吗?能跑。但任何人想快速看出来“什么条件下列表显示什么样式”,都要花上小半天把表达式拆开再心算一遍。模板语言(不管是JS模板字符串、Thymeleaf还是FreeMarker)本质上是为“展示结构”设计的,它的表达能力和调试手段远不如完整编程语言。一旦把复杂业务逻辑压进去,可读性崩塌是必然的。
2.2 命名碎片化,模板成了“语义黑洞”
模板里出现的临时变量和占位符,往往是整个项目里命名质量最差的一批。因为写模板的人心里想的是“反正就是输出一段文本”,于是data、item、obj、tmp满天飞。更隐蔽的是,来自后端接口的字段名经常是缩写和拼音混合,比如custNm、xfDate,模板里直接引用,不查接口文档根本不知道含义。
命名问题的本质不是“不够文艺”,而是丢失了上下文。模板是数据和界面之间的桥梁,桥上没有路标,过桥的人自然迷路。
2.3 作用域不透明,变量出处全凭猜
一个模板文件越是复杂,你越难回答“这个变量到底从哪来”。以Thymeleaf页面为例,如果页面上用了${user.name},你可能需要翻三层Controller、Service才知道user是从Session里取还是从Model里塞的。模板引擎的上下文传递本身没有显式的类型声明,IDE也无法像普通Java代码一样帮你追寻引用。
有次我为了找邮件模板里一个${createDate}的赋值链路,从模板搜到工具类,再搜到定时任务,最后发现是另一个模板中的同名变量被缓存上下文带入的。这种隐式依赖让人相当崩溃。
2.4 缺少统一的“文体规范”,每个人写的模板风格都不一样
同一个项目里,A同事写的模板是缩进两格的HTML风格,B同事喜欢把字符串拆成五行然后串联,C同事坚持用模板引擎自带的宏。这些风格上的差异平时无伤大雅,可一旦合并到同一个文件、同一次迭代中,读起来就像在看一本每换一页就换一种口吻的翻译小说。
我把这四个根因写在便利贴上贴在显示器旁边。后续所有改造动作,最终都落在“减少逻辑”“修复命名”“显式作用域”“统一风格”这四句话上。
3. 可读性改造的三个层级:命名、结构、工具约束
理清根因后,我意识到“提升可读性”不能靠一次突击清理,而应该分三个层级推进。我把它们称为“表达层、组织层、约束层”。
3.1 表达层:让模板先像一份给人看的文档
表达层的核心是命名和注释。在模板代码里,命名不只是变量名,还包括模板片段的名称、占位符的写法、以及局部变量的命名。
我制定的基本规则很简单:
- 模板中出现的每个变量,都是它所表达含义的“全称”,禁止用
x、d、tmp之类的缩写。 - 占位符使用
{{ 变量路径 }}或${变量路径}时,路径必须和实际获取数据的语义对齐。例如后端返回customer.name,模板里就写customer.name,不要重新映射成userInfo[0].nick。 - 条件判断处必须写注释,解释这个分支对业务意味着什么。比如
vipFlag === '1'旁边要写“VIP会员”,否则之后没人看得懂。 - 模板片段(比如一个可复用的按钮、一个卡片)必须有名字,名字要能表达它在UI或文案中的位置,例如
user-card、refund-action-area。
这个阶段花的成本最低,收益却最直接。当我把项目里最大的一个页面模板中的tmp逐个改成productList、discountPrice之后,我发现很多原本需要反复翻接口文档才敢动的地方,现在一眼就能判断对错。
3.2 组织层:把大模板拆成“可以独立阅读的块”
很多模板的可读性问题来自“又长又密”。一段三百行的HTML模板里,既有页头、又有表格、又有弹窗,任何人在读的时候都需要不断切换上下文。组织层的目标是把一棵大树变成一片小树林,每一片都足够独立,读其中一块时不需要提前知道另外九块的细节。
我在实践中主要通过三种方式组织模板:
- 把重复出现的部分抽成局部模板/宏/组件。这是最朴素也最有效的做法。
- 把高阶模板中的业务分支细化成多个“针对场景的模板”,每个模板只负责一种状态。比如订单卡片模板,拆成“正常订单”和“退款订单”两个片段,而不是在一个模板里用
if refund then狂写。 - 将模板中需要从服务端获取的数据集中声明,并在模板文件的开头用一段注释写下数据契约。这样读模板的人先看数据契约,再看HTML结构,思路会非常顺。
在拆分的节奏上我也有个体会:不要一次拆得太碎。拆得太碎会导致查找片段本身也很费劲。我一般以“一个模板文件最多包含三到五个主要区域”为原则,超过就继续拆,低于就暂时保留。
3.3 约束层:靠工具把“可读性”变成一种硬性要求
人靠自觉是不可靠的,尤其是团队协作时。改造中后期,我陆续引入了几个工具,让模板代码可读性可检查、可度量。
- 前端模板字符串和JSX代码统一跑ESLint,并开启
no-multi-assign、max-depth、max-lines-per-function等相关规则。过去我靠肉眼发现的很多嵌套问题,现在在CI阶段就会被直接拦截。 - 模板引擎文件接入Prettier或对应的格式化插件,统一缩进、换行、引号风格。
- 后端模板加入自定义校验脚本,检查是否存在模糊变量名、超长模板、过多嵌套分支等。脚本逻辑很简单:用正则扫描模板文件里的占位符和关键字,超过阈值就报错。
工具约束的意义不仅是“强制规范”,它更大的价值在于降低代码评审的心理负担——当一个文件自动通过规则检查后,评审者可以把精力集中在业务逻辑和边界情况上,而不是纠结缩进是否统一、变量名是否够清晰。
这三个层级不是严格串行的。我在实际改造中经常是一边改命名,一边顺手拆结构,一边在工具配置里把对应规则加上。但思维上把它们分开,可以避免“今天改个变量名,明天觉得结构也要动,结果越改越乱”的窘境。
4. 实战案例一:我重构了一处快爆炸的前端模板字符串
前端模板字符串是我这次改造里动手最多的地方,因为它的自由度最高,写起来最随意,读起来也最痛苦。下面我贴一段真实存在过的代码,为了安全我替换了业务名,但问题原样保留。
4.1 改造前:一场变量名和逻辑的混战
javascript复制const html = '';
list.forEach((x, i) => {
html += `<div class="item ${x.isVip ? 'vip' : ''} ${x.stock > 0 ? '' : 'no-stock'}">
<span>${x.name}</span>
<span>${x.price * (x.discount ? x.discount : 1)}元</span>
${x.stock <= 0 ? '<span class="tag">无货</span>' : ''}
${x.isVip ? '<span class="tag">VIP</span>' : ''}
</div>`;
});
这段代码的问题一眼就能看出来:x是啥?x.isVip、x.stock、x.price背后的描述是什么?discount存在与否表达什么?更麻烦的是,${x.price * ...}这段还嵌着三元表达式和商品价格计算逻辑。如果要改促销规则,得先把这个三元表达式拆开,再脑补出“原价、折扣、实付”的关系。
4.2 改造后:每一步都有明确语义
我将这段模板拆成了两个部分。第一步,构造一个包含全部展示数据的productViewModel数组;第二步,在模板字符串里只做纯展示。
javascript复制const productViewModel = productList.map((product) => {
const realPrice = product.discount ? product.price * product.discount : product.price;
return {
name: product.name,
realPrice: formatPrice(realPrice),
isOutOfStock: product.stock <= 0,
isVipProduct: product.isVip,
};
});
const html = productViewModel.map((item) => {
const stockTag = item.isOutOfStock ? '<span class="tag">无货</span>' : '';
const vipTag = item.isVipProduct ? '<span class="tag">VIP</span>' : '';
const statusClass = item.isOutOfStock ? 'no-stock' : item.isVipProduct ? 'vip' : '';
return `
<div class="item ${statusClass}">
<span>${item.name}</span>
<span>${item.realPrice}元</span>
${stockTag}
${vipTag}
</div>
`;
}).join('');
看起来代码变长了,但可读性提升是实打实的:
- 每一行都只做一件事,变量名完整表达含义。
- 价格计算逻辑被提到
productViewModel中,以后改促销规则只需要动这一个地方。 - 模板部分只剩展示逻辑,条件分支一目了然。
- 通过
formatPrice统一处理货币格式,避免了模板中到处写.toFixed(2)。
我在改造时还额外做了一件事:把这段代码里用到的字段名和接口返回字段做了一次映射,写在文件头部注释里。这样后面维护的人不需要翻接口文档就能知道productList从何而来。
4.3 模板字符串的一个常见误区:拼接太多不如用模板数组
我还遇到过一种情况:模板本身不复杂,但大家习惯用html +=一个接一个拼下去。这种做法可读性差的根源在于,读代码的人需要自己维护一个“当前输出到哪了”的隐形状态。更好的做法是用一个数组收集所有片段,最后join(''),或者用模板数组配合map一次性生成。
这里有个很小的但很值钱的技巧:当我需要生成一段列表模板时,我倾向于这样写:
javascript复制const cardHtml = cards.map((card) => buildCardTemplate(card)).join('');
buildCardTemplate是一个独立的函数,专门负责把单个卡片对象变成一段HTML。这样把“整体生成”变成了“单卡生成”,测试单个卡片渲染时可以单独调用这个函数,排查问题非常方便。
5. 实战案例二:后端模板引擎里的可读性治理与数据契约
如果说前端模板字符串的问题是“变量名乱”,那么后端模板引擎的问题经常是“结构复杂”。我在这个项目里用得最多的是FreeMarker和Thymeleaf,体验各有差异,但治理思路殊途同归。
5.1 用数据契约代替模板里漫天的字段引用
我在改造邮件模板时发现一个规律:所有难维护的模板,都有一个共同的特征——模板里直接引用了大量原始数据字段,比如${order.custName}、${order.itemList[0].productName}。一旦后端数据结构调整,模板就成了重灾区。
我的处理方式是在Controller或Service层构建一个“模板专用DTO”,只包含模板需要的字段,并且给字段起一个模板可读的名字。这个名字不一定要和数据库字段一致,但它必须和模板里的占位符一一对应。
例如邮件模板需要展示一个订单摘要,我会定义如下结构:
java复制public class OrderMailTemplateDTO {
private String customerName;
private List<OrderItemTemplateDTO> items;
private String totalPrice;
private String expectArrivalDate;
// ...
}
然后在模板中只引用${orderMail.customerName}这种字段。这样有两个好处:一是模板编写者能通过DTO明确知道有哪些变量可用,而不是看着一堆数据瞎猜;二是后端结构调整时,我只需要修改构建DTO的代码,模板完全不受影响。
5.2 把模板里的复杂逻辑搬到Service中
模板中存在逻辑本身不是洪水猛兽,危险的是一段逻辑长到没法一眼看穿。我给自己定下一条规则:模板中只保留“单个字段的简单输出”或“基于一个布尔值的简单分支”,任何需要计算、拼接、比较才能得到结果的内容,都在后台提前算好。
举个典型例子:过去邮件模板里写着一长串${order.status == '1' ? '已支付' : order.status == '2' ? '已发货' : '其他'},三个以上三元嵌套,读起来就已经很吃力。我把它改成了在Service中预先完成状态转换,得到一个statusText字段,模板里直接${orderMail.statusText}。
不要小看这种“把逻辑往上层挪”的设计。它虽然不改变模板引擎的能力边界,但改变了人脑的阅读负担:模板就像是产品经理写的一份需求线稿,而复杂规则全部沉淀在代码里,可以用IDE跳转、单测覆盖。
5.3 宏、布局和高内聚片段,是模板的“函数”
FreeMarker里的宏、Thymeleaf里的th:fragment,本质上是模板语言提供的函数。很多团队不用它们,导致同样的页头、提示框、按钮样式在几十个模板里复制粘贴,粘贴次数越多,出错的概率越高,到最后想统一修改一个提示文案都要全局搜索替换。
我在这次改造中把出现两次以上的片段全部抽成独立文件或宏:
- 邮件模板的公共页头页脚,抽成
header.ftl、footer.ftl。 - 表格中的操作按钮栏,抽成宏
operationButtons,传入按钮配置列表生成对应按钮。 - 前端Thymeleaf页面的状态提示组件,抽成
fragment="statusBadge",在多个页面复用。
每次抽取后,模板的体积会肉眼可见地变小,阅读某个页面时不再被无关的公共部分干扰。
6. 延伸场景:类模板、WPF控件模板里,可读性也要讲究克制
“模板代码”不止存在于前端字符串和后端模板引擎,在C++的类模板、C#的WPF控件模板、甚至代码生成器的输出模板里,同样存在可读性问题。我在这次改造里虽然主要精力放在业务系统上,但也顺带重写了一个内部代码生成器的输出模板,以及一个WPF项目里的控件模板。这两个场景让我对“可读性”有了更深的理解。
6.1 C++类模板:泛型代码可读性靠的是“降低抽象噪音”
写类模板(比如容器、算法封装)的人容易犯一个毛病:因为追求通用性,把类型参数命名成极其抽象的T、U、V,用起来没问题,可读起来非常费力。
我后来在写泛型代码时给自己立了几条规矩:
- 使用有含义的模板参数名,比如
ElementType、CallbackFunc、StateType,虽然看起来冗长,但阅读时能减少猜测。 - 如果模板的尺寸超过三屏,考虑拆分。把和类型无关的部分移到非模板函数中,减少重复编译和阅读噪声。
- 对模板内的每个函数都写一行“职责声明”,因为泛型代码很难从名字完全判断行为。
这些做法也让我的代码生成器有了更好的输出质量——毕竟生成器生成的代码,如果不把变量名和注释配置清楚,出来的样板自然就是一团糊涂。
6.2 WPF控件模板:绑定和命名的可读性决定后续维护速度
WPF里写过控件模板(ControlTemplate)的人应该深有体会:一个复杂模板动辄一两百行XAML,里面全是{TemplateBinding Background}、{RelativeSource ...}、Storyboard,可读性很容易崩。
我的经验是:
- 给模板里的每个关键元素起可读的
x:Name,例如把Border命名为RootBorder,把触发叠加色块命名为HoverOverlay。 - 把控件模板中需要外部控制的属性尽量暴露为控件属性,并明确写注释。避免把一堆临时变量藏在模板里。
- 复杂动画拆分到
VisualStateManager的各个状态中,按状态阅读,比在一个大模板里连续写几十个TargetName要清晰得多。
这个场景再次证明:可读性是跨技术栈的普适需求,本质都在“让人通过名字和结构快速理解意图”。
7. 从“能跑”到“好改”:我的模板代码可读性检查清单
改造进行到第四周,我发现项目里的模板代码已经不再让我头大了,甚至有几个同事开始在评审时提醒新提交的模板“变量名是不是该改一下”。这让我意识到,可读性这东西虽然难以直接量化,但可以通过几个简单问题来快速评估。
我现在每看到一个模板文件,都会在脑子里过一遍这份检查清单:
- 不看接口文档和后台数据定义,能猜出模板中每个变量的含义吗?
- 模板文件中是否存在超过两层嵌套的条件或循环?
- 一个模板文件内是否包含多个可拆分复用的区域?
- 模板中是否存在需要计算才能理解的表达式?如果有,是否已经抽到后台ViewModel?
- 出现重复的标记或片段时,有没有用宏、Fragment或组件抽离?
- 模板文件的头部有没有一段数据契约注释,说明可用变量和含义?
- 能否在不影响模板其他部分的前提下,单独修改某一个区块?
- 格式化工具是否已经跑过?风格是否和项目其他模板一致?
这些问题每一项都不难满足,但合起来就能定义一份模板代码的“可读性基线”。我改造后的项目里,新提交的模板代码基本都能通过这份清单;即便偶尔因为业务紧急来不及完全达到要求,评审时也会明确记录待办,而不是含糊放过。
我个人的体会是,模板代码可读性提升不像功能开发那样有立竿见影的产出,但它产生的收益体现在后续每一次改需求、每一次排查线上问题、每一位新同事接手代码的时刻。那天我删掉邮件模板最后一处谜之占位符时,旁边实习生问我在干什么,我说我在给项目的未来降低点阅读成本。他愣了一下,然后笑了。我想,这大概就是模板代码可读性最重要的一层意义。
