3154 字
约 10 分钟
17
S27-X07 · 模板引擎选型:Beetl、Enjoy 与 Thymeleaf

前后端分离把"渲染"这件事整体推到了浏览器,但服务端渲染没有消失,只是退到了几个明确的角落:邮件正文、导出文档、静态页生成、以及一批不方便重写的老系统。选模板引擎的争论往往从"哪个快"开始,最后落在"谁能维护"上。

这篇按"要解决什么 → 剩下哪些场景 → 三个候选各自的哲学 → 判据 → 性能问题出在哪"的顺序排。


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. 自测

  1. Thymeleaf 说"自然模板",具体指模板文件在什么状态下能直接被浏览器打开?
  2. 生产环境 spring.thymeleaf.cache 应该设成什么,为什么?
  3. 模板名用 "p/" + type 动态拼出来,长期运行会有什么后果?
  4. Beetl 与 Enjoy 的语法差异,用一句话概括?
  5. 页面把 <b> 显示成字面文本,是 bug 还是预期行为?
  6. 什么情况下该放弃模板引擎,改成静态生成?
S27-X07 · 模板引擎选型:Beetl、Enjoy 与 Thymeleaf
https://www.clxhxhhr.top/posts/5780/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。