前后端分离把"渲染"这件事整体推到了浏览器,但服务端渲染没有消失,只是退到了几个明确的角落:邮件正文、导出文档、静态页生成、以及一批不方便重写的老系统。选模板引擎的争论往往从"哪个快"开始,最后落在"谁能维护"上。
这篇按"要解决什么 → 剩下哪些场景 → 三个候选各自的哲学 → 判据 → 性能问题出在哪"的顺序排。
1. 模板引擎到底在解决什么
本质只有一句话:把"数据"和"呈现"分成两份文件,让改样式的人不必改 Java 代码。 它必须提供三样东西:变量求值、流程控制(循环/条件)、以及转义策略。
| 能力 | 缺失的后果 | 谁负责 |
|---|---|---|
| 变量求值 | 只能字符串拼接 | 引擎 |
| 循环与条件 | 模板里要预先拼好结构 | 引擎 |
| 自动转义 | 用户数据进 HTML 就是 XSS | 引擎默认策略 + 开发者确认 |
| 模板继承/片段 | 头部尾部到处复制 | 引擎的 layout 机制 |
| 编译缓存 | 每次请求重新解析 | 引擎配置 |
| 国际化 | 文案写死在模板里 | 引擎 + 消息源 |
字符串拼接写 HTML 在 JDK 17 时代依然大量存在,问题不在性能而在转义与可维护性:一旦有人在拼 "<td>" + name + "</td>",XSS 就只是时间问题。
2. 服务端渲染剩下的四个位置
| 场景 | 为什么还在服务端 | 典型约束 |
|---|---|---|
| 邮件模板 | 邮件客户端不执行你的前端框架 | 内联样式,table 布局,不能依赖 JS |
| 导出文档 | Word/PDF/Excel 需要服务端生成 | 分页、页眉页脚、字体 |
| 静态页生成 | 文档站、构建产物、SEO | 一次性渲染大量页面,可离线 |
| 老系统维护 | 重写成本高于维护 | 页面数量大,改动以局部为主 |
反过来,纯管理后台、内部工具这类场景现在更常见的选择是前端框架 + 后端纯 API,模板引擎在这里只剩"登录页""错误页"这类边角。先判断自己属于哪一类,再谈引擎。
3. Thymeleaf:自然模板,能被浏览器直接打开
Thymeleaf 的核心卖点是"模板即原型":.html 文件用浏览器直接打开时,th:text 之类的属性被忽略,页面显示占位文案;服务端渲染时才被替换。这让设计与开发可以并行,也让模板可以脱离 Java 进程做评审。
<!-- src/main/resources/templates/order/list.html -->
<table>
<tr th:each="o : ${orders}">
<td th:text="${o.id}">1001</td>
<td th:text="${o.amount}">99.00</td>
<td th:text="${#temporals.format(o.createdAt,'yyyy-MM-dd HH:mm')}">2026-01-01 00:00</td>
</tr>
</table>
| 特征 | 说明 |
|---|---|
| 语法 | 属性驱动(th:text、th:each、th:if),HTML 结构不被破坏 |
| 自然模板 | 未渲染时浏览器可见、可评审 |
| Spring 集成 | spring-boot-starter-thymeleaf 开箱即用,与 Spring Security 的方言集成成熟 |
| 表达能力 | 偏保守,故意不鼓励在模板里写复杂逻辑 |
| 性能取向 | 解析后缓存模板与表达式;首次渲染有编译成本,之后走缓存 |
| 调试 | 报错信息带模板名与行号,定位直观 |
性能特征要讲清一点:Thymeleaf 的开销主要在首次解析与表达式求值。开启缓存后,每个模板只解析一次;表达式对象也会被缓存。真正的性能问题几乎总是"缓存被关了"或"每次请求传了不同的模板名"。
# application.yml — 生产配置要点
spring:
thymeleaf:
cache: true # 生产必须为 true
prefix: classpath:/templates/
suffix: .html
mode: HTML
encoding: UTF-8
4. Beetl:国内生态、性能取向、语法接近脚本
Beetl 的定位很清楚:在 JVM 上做一个"像脚本一样顺手"的模板语言,同时把性能当成一等公民(编译成字节码执行)。语法上它更接近 JS/脚本,循环、判断、定义宏都很直接,学习曲线对写过前端的人很平。
<!-- order/list.btl -->
<% layout("/layout/base.html"){ %>
<table>
<% for(o in orders){ %>
<tr>
<td>${o.id}</td>
<td>${o.amount, format="0.00"}</td>
<td>${o.createdAt, dateFormat="yyyy-MM-dd HH:mm"}</td>
</tr>
<% } %>
</table>
<% } %>
| 特征 | 说明 |
|---|---|
| 语法 | <% %> 脚本块 + ${} 输出,接近 JS |
| 性能取向 | 编译执行,官方强调渲染速度 |
| 布局 | layout() 指令做模板继承 |
| 安全输出 | 默认输出需注意转义设置,${} 与 !{} 的语义不同 |
| 生态 | 国内文档与案例较多,Spring Boot starter 由社区维护 |
| 适合 | 模板里有中等复杂度逻辑、又不想上 Freemarker 的场景 |
Beetl 的语法自由度是双刃剑:能写 for 和 if 嵌套,也就意味着很容易把业务逻辑搬进模板。团队里要有一条规矩:模板只做展示与格式化,取数与判断回到 Service。
5. Enjoy:JFinal 系的默认模板
Enjoy 是 JFinal 作者写的模板引擎,设计目标是"极简 + 快"。它的语法比 Beetl 更克制,指令用 # 开头,输出用 #(...),模板继承与包含都有专门指令。
<!-- order/list.html -->
#@layout()
#define main()
<table>
#for(o : orders)
<tr>
<td>#(o.id)</td>
<td>#(o.amount)</td>
<td>#date(o.createdAt, "yyyy-MM-dd HH:mm")</td>
</tr>
#end
</table>
#end
| 特征 | 说明 |
|---|---|
| 语法 | #指令 + #(表达式),#for/#if/#define |
| 输出转义 | #() 默认转义,#() 的原始输出有单独写法,需按当前版本文档核对 |
| 性能取向 | 直接编译为字节码,强调启动快、渲染快 |
| 生态 | 与 JFinal 强绑定;脱离 JFinal 单独用也可以,但资料集中在其生态内 |
| 适合 | JFinal 项目,或想要"指令风格 + 极少语法糖"的团队 |
Enjoy 的资料密度明显低于 Thymeleaf,遇到边界问题时搜索命中率低,这是选型时必须算进去的成本。
6. 与 Freemarker、Velocity 的对照
| 维度 | Thymeleaf | Beetl | Enjoy | Freemarker | Velocity |
|---|---|---|---|---|---|
| 语法风格 | HTML 属性 | 脚本块 <% %> |
#指令 |
<#ftl> 指令 |
#set 引用 |
| 自然模板 | 支持(核心卖点) | 否 | 否 | 否 | 否 |
| 性能取向 | 中等,靠缓存 | 快(编译) | 快(编译) | 中等 | 快(解释) |
| Spring 官方集成 | 一等公民 | 社区 starter | 无官方 | 官方支持 | 早期支持 |
| 维护活跃度 | 高 | 中 | 中 | 高 | 低(近年后继乏力) |
| 学习成本 | 低(会 HTML 就会) | 低(会 JS 就会) | 低 | 中(宏与命名空间) | 低 |
| 文档与社区 | 英文为主,量大 | 中文为主 | 中文为主 | 英文,量大 | 英文,老旧 |
Velocity 的位置要单独说一句:它曾是 Spring MVC 的默认视图技术之一,如今在新项目里已不适合作为首选,历史项目维护时按当前版本核对兼容性即可。
7. 选型判据:四个问题定方向
| 判据 | 问法 | 指向 |
|---|---|---|
| 团队熟悉度 | 团队里谁改模板?前端还是后端? | 前端参与 → Thymeleaf(自然模板) |
| 模板复杂度 | 需要多少循环/条件/宏? | 逻辑重 → Beetl / Freemarker |
| 是否要静态可预览 | 设计稿能否直接看模板? | 必须能 → Thymeleaf |
| 与现有栈的贴合 | 是否已在 Spring Boot / JFinal 生态? | Spring → Thymeleaf;JFinal → Enjoy |
| 出问题时的检索能力 | 搜得到答案吗? | 团队英文检索弱 → 中文生态更稳 |
一个务实的结论:Spring Boot 3.3.x 项目默认选 Thymeleaf,因为它不需要额外决策(依赖、方言、安全集成都已就位);只有当模板逻辑重到 Thymeleaf 写起来别扭时,才考虑 Beetl 或 Freemarker。Enjoy 基本只在 JFinal 项目里出现。
8. 性能问题通常出在哪
渲染慢的时候,先分清是"引擎慢"还是"用法错"。
| 症状 | 常见真因 | 处理 |
|---|---|---|
| 每次请求都慢一点,压测 QPS 上不去 | 模板缓存关闭(cache: false) |
生产打开缓存 |
| 首屏慢、之后正常 | 首次编译成本,属正常 | 预热:启动后渲染一次 |
| 内存持续涨 | 模板名动态拼接(return "p/" + type)导致缓存条目无限增长 |
模板名收敛为有限集合 |
| 单页渲染耗时随数据量线性上升 | 模板里循环内做了取数/字符串拼接 | 数据在 Service 侧算好,模板只输出 |
| CPU 高但 QPS 不高 | 模板里嵌套深循环 + 大量格式化 | 减少嵌套,格式化前置 |
| 偶尔卡顿 | 模板目录热加载在生产开启,文件监听开销 | 生产关闭热加载 |
// 预热写法:启动完成后渲染一遍,把编译成本移出请求路径
@Component
public class TemplateWarmup implements ApplicationRunner {
private final TemplateEngine engine;
public TemplateWarmup(TemplateEngine engine) { this.engine = engine; }
@Override
public void run(ApplicationArguments args) {
Context ctx = new Context(Locale.SIMPLIFIED_CHINESE);
ctx.setVariable("orders", List.of());
engine.process("order/list", ctx); // 抛异常即视为模板有错,启动期暴露
}
}
TemplateEngine.process() 在缓存开启时会把解析结果留在内存,重复调用不再走解析。上面这段同时起了另一个作用:模板语法错误在启动期就炸,而不是等第一个用户点到那页。
9. 排错:七个常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
页面把 <b> 原样显示成文本 |
输出被转义,属于默认行为 | 确认数据来源可信后再用不转义写法,别全局关转义 |
| 用户输入的内容在页面里弹出脚本 | 用了不转义输出 | 改回转义输出;富文本走白名单过滤 |
| 改模板不生效 | 缓存开着 | 开发关缓存 + 开热加载;生产保持缓存 |
模板找不到(TemplateInputException) |
prefix/suffix 配错,或模板不在 classpath:/templates/ |
核对路径与打包结果(jar tf 看是否打进去了) |
| 时间字段渲染报错 | 类型是 LocalDateTime,用了 #dates 而非 #temporals |
换 #temporals,或先格式化成字符串 |
| 生产环境 CPU 莫名升高 | 热加载开着,文件监听持续扫描 | 生产 spring.thymeleaf.cache=true 且不配热加载 |
| 循环里数字格式化结果不对 | 区域(Locale)不同,小数点/千分位不一致 | 显式指定 Locale |
# 确认模板真的打进了 jar
unzip -l app.jar | grep 'templates/order/list'
10. 边界:什么时候不该用模板引擎
| 场景 | 更合适 | 理由 |
|---|---|---|
| 纯 API 后端 | 不用模板 | 前端框架负责渲染,服务端只出 JSON |
| 高并发静态内容 | 静态站生成(Astro 等) | 渲染一次、CDN 分发 |
| 复杂报表/PDF | 专用报表库或转换工具 | 分页、表格跨页、字体嵌入模板引擎不擅长 |
| 邮件 | 模板引擎 + 内联样式工具 | 引擎只管内容,样式内联要另做 |
| 需要前端交互的页面 | 前端框架 | 模板渲染出的页面交互能力有限 |
11. 自测
- Thymeleaf 说"自然模板",具体指模板文件在什么状态下能直接被浏览器打开?
- 生产环境
spring.thymeleaf.cache应该设成什么,为什么? - 模板名用
"p/" + type动态拼出来,长期运行会有什么后果? - Beetl 与 Enjoy 的语法差异,用一句话概括?
- 页面把
<b>显示成字面文本,是 bug 还是预期行为? - 什么情况下该放弃模板引擎,改成静态生成?