1. 这个后台项目为什么会选中FreeMarker,而不是继续用JSP
前阵子要给一个后台管理系统做页面改造,原方案用的是JSP,配合JSTL标签库做数据渲染。项目规模不大,但页面数量不少,每次改版都要在Java代码里拼HTML片段,维护起来真的头疼。后来我决定尝试Spring Boot整合FreeMarker,替换掉原来的JSP方案。
先说结论:FreeMarker这种模板引擎,它的核心定位就是“模板 + 数据模型 = 输出”。Java后端把数据准备好,塞给模板引擎,它负责生成最终的HTML页面。相比JSP,FreeMarker有几个非常明显的优势——它不依赖Servlet容器,不要求在web.xml里配置一堆东西,而且模板语法更干净,写起来也更接近真正的“页面模板”而不是“嵌了Java代码的HTML”。
1.1 我最初对模板引擎的误解
我之前一直觉得,模板引擎这玩意儿不就是JSP换了个写法吗?反正都是把Java变量塞进HTML里输出。后来真正用了FreeMarker才发现,这个理解太肤浅了。
JSP本质上是“Java代码 + HTML”混写,页面里能写<% %>脚本片段,能写各种Java逻辑。这种做法的优势是灵活,想干嘛干嘛,代价是页面污染严重——前端要改个样式,后端要改个逻辑,都得在一个文件里绕来绕去。而且JSP的编译依赖容器,出了错往往要等整个应用启动了才能发现。
FreeMarker的思路完全不同。它把“页面样式”和“业务逻辑”做了强制隔离。模板文件里只能使用FreeMarker自己定义的指令、表达式,不能写任意Java代码。这个约束看起来死板,实际上反而保证了模板的纯粹性——页面设计人员只管模板结构,后端开发人员只管数据模型,两边可以并行工作。
1.2 FreeMarker和JSP、Thymeleaf的本质差异
我用一个很实际的对比来讲。同样渲染一个用户列表,JSP的写法大致是循环加<c:forEach>标签,页面里偶尔还要嵌一小段Java脚本来做判断。FreeMarker的写法是用<#list>指令遍历集合,然后用${item.name}输出字段。Thymeleaf的话,它是纯HTML标签属性扩展,写出来是th:each这种形式。
这三种技术做出来的效果都能实现动态页面渲染,但适用场景完全不一样:
- JSP:老牌方案,和前端的耦合程度高,项目里有大量历史遗留代码时迁移成本很大。
- Thymeleaf:Spring Boot官方推荐的模板引擎之一,最大的特点是“模板即HTML”,哪怕不渲染数据,直接打开静态文件也能看到完整页面骨架,很适合前端原型和后端开发并行。
- FreeMarker:语法轻量、渲染速度快、模板内容可以灵活复用,适合后台管理系统、报表页面、邮件模板这类需要频繁拼接HTML的场景。
我当时选择FreeMarker,核心原因有两个。第一,后台管理系统的页面结构相对固定,列表页、表单页、详情页占了绝大多数,用模板引擎的宏和公共片段抽取能省不少重复代码。第二,FreeMarker的模板文件就是一个普通的.ftl文件,不依赖任何服务器环境,本地打开也能看到模板内容,调试起来比JSP舒服太多。
1.3 什么样的项目适合用FreeMarker
不是所有项目都适合上FreeMarker。如果是前后端分离架构,前端Vue或React负责渲染,后端只提供JSON接口,那FreeMarker基本没你什么事。如果项目里全是纯静态页面,也不需要任何模板渲染,那更没必要引入。
FreeMarker最合适的场景,是那些依然需要服务端渲染的项目——比如后台权限管理系统、CMS内容发布系统、邮件订阅发送服务、报表数据导出页面。这类系统的特点是“页面量大但结构规律”,非常需要模板复用能力。另外,如果项目里已经有Spring Boot基础,又不想用JSP那些老掉牙的配置,FreeMarker确实是个很顺手的方案。
2. 先打通核心机制:模板 + 数据模型 = 输出
进了门之后,我第一件做的事是搞清楚FreeMarker的渲染原理。理解了机制,后面配置和使用才会顺畅,不然只是照着文档抄代码,稍微换个场景就懵。
2.1 渲染本质:三个角色一条流水线
FreeMarker的运行机制,可以拆成三个部分:
- 模板文件:以
.ftl结尾的文件,里面是HTML骨架加上FreeMarker自己的指令和插值表达式。 - 数据模型:Java后端提供的数据对象,在FreeMarker看来,就是一个树状结构的数据源,根节点往下扩展各个分支。
- 渲染输出:模板引擎把数据模型填充进模板的对应位置,最终产出一段完整的HTML字符串,交给Spring MVC发送给浏览器。
用流水线来比喻:模板是生产线的图纸,数据模型是原材料,FreeMarker是执行生产的那个人。图纸上每个插槽对应的原料到了,它就把原料填进去,最后交付一个组装好的成品。
这里有一个关键认知:数据模型在FreeMarker眼里不是Java对象,而是一个树形结构。模板里写${user.name},它实际上是在数据模型的树结构里,沿着user节点往下找到name这个叶子节点,然后把值取出来。Java里的User对象会被框架自动包装成这个树形结构的一个节点。
2.2 插值表达式:从一行变量到复杂嵌套
FreeMarker最常用的语法就是插值表达式,格式是${...}。比如:
<p>欢迎您,${username},登录成功!</p>这段模板配套的Java代码里,只要往数据模型里放一个username字段,页面上就会自动替换成实际值。
插值表达式不只是取变量这么简单。它内部可以做运算:
<p>单价:${price} 元,数量:${count} 个,总价:${price * count} 元</p>也可以调用FreeMarker内置的方法,比如字符串处理:
<p>用户名:${username?upper_case}</p> <p>用户名长度:${username?length}</p>如果某个对象是空值,直接插值会导致模板报错,FreeMarker提供了默认值语法:
<p>备注:${remark!''}</p>这个!''表示如果remark为null或者不存在,就输出空字符串。这个语法我后来几乎每个模板都用到了,因为后台数据经常有空值,不加处理就是满屏的报错红。
2.3 控制逻辑指令:if判断和list循环
模板里除了取数据,还必须具备控制逻辑的能力。FreeMarker的控制逻辑靠指令来实现,格式是<#指令名>...指令体...</#指令名>。
条件判断用<#if>:
<#if user.level == 1> <span>普通会员</span> <#elseif user.level == 2> <span>高级会员</span> <#else> <span>VIP会员</span> </#if>循环遍历用<#list>:
<#list userList as user> <tr> <td>${user.id}</td> <td>${user.name}</td> <td>${user.phone}</td> </tr> </#list>循环里还可以拿到当前下标:
<#list userList as user> <tr> <td>${user_index + 1}</td> <td>${user.name}</td> </tr> </#list>user_index是FreeMarker内置的循环索引变量,从0开始计数,我一般会在表格里用它生成行号。另外还有一个user_has_next,判断当前元素是不是最后一个,做列表分隔线或者特殊样式时非常好用。
2.4 数据模型的本质结构:为什么会有Map套Map
FreeMarker的数据模型设计,对新手不太友好的一点是“包装”。如果往Model里放一个JavaBean,模板里可以直接通过属性名取字段。但如果往里放一个Map,模板里也可以直接用key取值。
实际上这两种方式最终都会被FreeMarker包装成自己的数据模型对象。所以你要理解的是:模板里取值的路径,本质上是树结构的节点路径。${user.name},就是先从数据模型的根节点找到user这个子节点,再往下找name子节点。
这就引出了一个实际开发中的技巧:把数据组织好,能省掉模板里大量的复杂逻辑。有时候我在Controller里组装一个嵌套的Map结构,模板里只需要遍历两层,页面逻辑变得非常清爽:
Map<String, Object> result = new HashMap<>(); result.put("userList", userService.listAll()); result.put("orderCount", orderService.countToday()); model.addAttribute("data", result);模板里这样写:
<#list data.userList as user> <td>${user.name}</td> <td>${data.orderCount}</td> </#list>先想清楚数据长什么样,再写模板,比自己写一行扣一行要顺得多。
3. Spring Boot里跑通FreeMarker的最小工程
理解完原理,来看看实际操作。这一节我会从零开始,搭一个Spring Boot + FreeMarker的完整Demo,保证能直接跑起来。
3.1 引入依赖:一个starter解决一切
Spring Boot里整合FreeMarker,最大的好处是不用手动配置一堆东西。spring-boot-starter-freemarker这个起步依赖,相当于把核心依赖和自动配置都打包好了。
在pom.xml里加:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> </dependency> </dependencies>我用的Spring Boot 2.7.x版本,对应FreeMarker的版本是2.3.x,这个组合在我项目里跑得很稳定。如果你是Spring Boot 3.x,依赖坐标一样,只是底层Java版本要求更高,代码写法几乎没区别。
3.2 配置文件:记住这几个关键参数
Spring Boot的自动配置让FreeMarker大多数情况下开箱即用,但有几个配置项是我觉得无论如何都要主动设置的,放进application.yml:
spring: freemarker: template-loader-path: classpath:/templates/ suffix: .ftl cache: true charset: UTF-8 check-template-location: true expose-request-attributes: false expose-session-attributes: false这几个参数的含义和大坑,我整理成表:
| 配置项 | 作用 | 个人建议 |
|---|---|---|
template-loader-path | 模板存放路径,默认就是classpath:/templates/ | 目录名保持templates,不要随意改 |
suffix | 模板文件后缀 | 用.ftl,区分Html和模板 |
cache | 是否缓存模板 | 开发环境设为false,生产环境改成true |
charset | 模板文件编码 | 必须UTF-8,不然中文全乱 |
expose-request-attributes | 是否暴露Request属性到模板 | 我设成false,强制所有数据通过Model传递 |
开发环境里,我建议一定要把cache设为false,这样改完模板刷新浏览器就能看到效果,不用重启应用。否则改一个页面要等上几分钟,开发效率低到让人崩溃。
3.3 编写Controller:返回逻辑视图名
Spring Boot + FreeMarker的流程中,Controller负责往Model里塞数据,并返回一个逻辑视图名。Spring MVC根据逻辑视图名拼接出物理模板路径,交给FreeMarker渲染。
示例Controller:
@Controller public class IndexController { @GetMapping("/index") public String index(Model model) { // 组装数据模型 model.addAttribute("pageTitle", "用户管理"); List<UserVO> userList = new ArrayList<>(); userList.add(new UserVO(1L, "张三", "138****1234", 1)); userList.add(new UserVO(2L, "李四", "139****5678", 2)); userList.add(new UserVO(3L, "王五", "137****9012", 3)); model.addAttribute("userList", userList); return "index"; // 对应 templates/index.ftl } }写到这里有个初始化细节顺便提醒一下:UserVO这种对象,如果它的字段没有getter方法,FreeMarker输出的时候会报错。模板引擎解析${user.name},本质上是调用getName()方法,所以实体类的属性必须有标准getter。这个问题新手特别容易踩,我用过一个内部类做数据载体,字段全是私有的,结果模板一渲染就抛异常。
3.4 模板文件:第一眼看到渲染结果
在resources/templates目录下新建index.ftl:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>${pageTitle}</title> </head> <body> <h2>${pageTitle}</h2> <table border="1"> <tr> <th>编号</th> <th>姓名</th> <th>手机号</th> <th>等级</th> </tr> <#list userList as user> <tr> <td>${user_index + 1}</td> <td>${user.name}</td> <td>${user.phone}</td> <td>${user.level}</td> </tr> </#list> </table> </body> </html>启动应用,浏览器访问http://localhost:8080/index,就能看到表格渲染出来。注意Controller返回的是"index"这个逻辑视图名,Spring MVC会自动拼接成classpath:/templates/index.ftl,这套默认映射是Spring Boot自动配置帮我们完成的,不需要额外写ViewResolver配置。
4. 真实项目里的配置细节与模板复用
最小Demo跑通之后,真正的挑战才开始。我前前后后在这个后台系统里改了二十多个页面,总结了一些模板复用和配置细节的经验,这部分才是实战中最值钱的地方。
4.1 日期格式处理:别让时间字段自己“裸奔”
数据库里存的时间字段,如果直接从后端塞给FreeMarker输出,页面上默认显示的是一段时间戳或者一串很丑的格式。处理日期格式,FreeMarker提供了?date、?time、?datetime三个内置函数,分别按日期、时间、日期时间格式输出。
<p>创建时间:${createTime?datetime}</p> <p>创建日期:${createTime?date}</p> <p>精确到秒:${createTime?string("yyyy-MM-dd HH:mm:ss")}</p>第三种写法最灵活,可以用?string("指定格式")完全控制输出格式。数据显示层面,我建议统一使用?string("yyyy-MM-dd HH:mm:ss"),这样不受服务器时区影响,也不会因为默认格式不符合业务要求而返工。
4.2 页面公共部分的抽取:header、footer、菜单
后台管理系统有几十个页面,页面的头部导航、左侧菜单、底部版权信息长得一模一样。如果每个.ftl文件都复制一份,改一个菜单项就要翻遍所有模板,这个工作量绝对不能忍。
FreeMarker提供了<#include>指令,可以把公共片段抽出来:
<#-- 公共头部 --> <#include "/common/header.ftl"> <#-- 页面主体内容 --> <div class="main-content"> ... </div> <#-- 公共底部 --> <#include "/common/footer.ftl">用<#include>做的是静态包含,每个页面加载时都会把公共片段完整地渲染进去。还有一种更高级的方式是宏,后面我会讲到。
我实际项目里的公共片段提取方案是这样的:
header.ftl:放<head>标签和顶部导航栏,页面标题通过变量传入sidebar.ftl:放左侧菜单,根据当前权限动态渲染菜单项footer.ftl:放版权信息和通用脚本
这样改一次公共部分,全站生效。
4.3 静态资源路径:为什么模板里不能写死相对路径
模板里引用CSS、JS、图片等静态资源时,有一个容易被忽视的坑。比如页面放在/user/list,模板里写<link rel="stylesheet" href="css/style.css">,浏览器会把这个相对路径解析成/user/css/style.css,结果404。
正确做法是使用Spring Boot内置的/static目录作为静态资源根目录,模板里引用时以绝对路径形式访问:
<link rel="stylesheet" href="/static/css/style.css"> <script src="/static/js/common.js"></script>默认情况下,Spring Boot把classpath:/static/映射到根路径/,所以实际访问路径是/static/css/style.css,请求会被正确转发到resources/static/css/style.css文件。
一个更稳妥的方案是在模板里定义全局变量,统一拼接静态资源前缀:
<#assign basePath = "/static"> <link rel="stylesheet" href="${basePath}/css/style.css">这样做的好处是,以后如果想换CDN或者升级静态资源目录,只需要改一处basePath就行。
4.4 空值处理策略:不想看到满屏报错,就做好防御
FreeMarker对空值相当敏感,模板里取出一个不存在的变量,或者对象的某个属性为null,直接渲染就会抛出InvalidReferenceException。这类异常不会返回一个空字符串,而是直接把整个请求搞挂,页面变成500。
我处理空值的方案有三种,按场景选:
- 默认值语法:
${user.nickname!''},适合字段可能为null的情况。注意!后面跟的是整个表达式的默认值。 - 空值判断包裹:先判断再渲染。
- 数据源头预处理:在Controller层就把null转换成空字符串或者默认值,模板里不做任何处理。
第三种是我目前最推荐的。模板保持干净,业务逻辑由Java代码统一处理,维护起来更清楚。比如字符串类型字段,我统一用org.apache.commons.lang3.StringUtils.defaultString()把null替换成空串,模板里完全不需要防御式写法。
5. 模板开发中踩过的坑与排查思路
FreeMarker用久了,自然会积累一些踩坑经验。这章我挑几个最有代表性的问题,写出完整的排查链路,而不是直接给答案,让读者也能掌握排查方法。
5.1 缓存导致的“幽灵”页面:改完模板不生效
现象:修改了一个.ftl文件的样式,刷新浏览器页面没有任何变化。一开始我以为是浏览器缓存,按了Ctrl+F5还是不行,又以为是Spring Boot热部署没生效,重启应用之后页面才更新。
排查链路往下走,我意识到问题出在模板缓存上。Spring Boot的FreeMarker自动配置默认开启缓存,它把解析后的模板对象放在内存里,第二次访问直接走缓存,完全不会重新读取磁盘上的文件。
解决方案很简单,开发环境把缓存关掉:
spring: freemarker: cache: false还有一个更细的坑:即使关掉缓存,如果你的IDE在保存文件时没有触发事件通知,Spring Boot DevTools的静态资源热部署也不会生效。我实际开发中,关掉cache之后再配合spring-boot-devtools,基本能做到改完模板刷新即生效,不用重启应用。
生产环境务必把缓存打开,否则每个请求都要重复解析模板文件,性能会打折扣。
5.2 中文全部变成问号:乱码问题排查
现象:模板里的中文静态文本显示正常,但通过${}输出的中文数据全部变成???。
这个问题我在第一次整合时就踩了。逐层排查的链路是这样的:
第一步看数据库编码。确认数据源连接串已经加了characterEncoding=utf8,数据库里数据没问题。
第二步看Controller。确认数据从数据库取出来的时候,日志打印是正常的中文。
第三步看模板解析。问题其实出在模板加载和响应输出的编码上。FreeMarker从磁盘读取模板文件时,如果没指定编码,用的是平台默认编码,在中文Windows上很可能是GBK;输出到浏览器的响应头如果不指定字符集,浏览器也可能解码错误。
解决方案是配置里强制指定UTF-8:
spring: freemarker: charset: UTF-8同时,模板文件本身的保存编码也要是UTF-8。IDE右下角可以看到文件编码,如果不是UTF-8,另存为的时候改一下就解决。所有中文乱码问题,最终都可以归结为“三处编码是否一致”——文件保存编码、模板读取编码、响应输出编码,三处统一UTF-8就不会乱。
5.3 循环里的下标和嵌套循环:变量名冲突
现象:在一个页面里同时写了两个循环,内层循环里用item_index,发现输出的是内层下标没错,但想在外层循环里再取外层下标时,发现同一个变量名已经被内层覆盖了。
FreeMarker的循环状态变量是跟着循环名走的。<#list list as item>,状态变量就是item_index,它属于item这个循环变量。两个不同的循环变量名,它们的_index相互独立,不会冲突。
但嵌套循环时要注意,如果内外层循环用了同一个循环变量名,比如都是item,内层循环会覆盖外层循环变量的访问,内层循环里访问item_index拿到的是内层的下标,外层下标就访问不到了。
解决办法是用不同的循环变量名:
<#list categoryList as category> <#list category.goodsList as goods> <tr> <!-- category_index 是外层下标,goods_index 是内层下标 --> <td>${category_index + 1} - ${goods_index + 1}</td> <td>${goods.name}</td> </tr> </#list> </#list>5.4 Controller用@RestController还是@Controller
这个坑特别隐蔽。我一开始图省事,在Controller上写了个@RestController,结果访问页面路径,浏览器返回的是index这个字符串,而不是渲染后的HTML。
@RestController是@Controller+@ResponseBody的组合注解,也就是说Controller方法的返回值会被当成HTTP响应体内容直接输出。返回"index"时,浏览器看到的就是字符串“index”,根本没有经过模板引擎。
排查链路很简单:看方法上有没有@ResponseBody或类上是不是@RestController。页面跳转应该用@Controller,只有返回JSON数据接口才用@RestController。
这个坑充分说明了为什么我不建议在同一个Controller里同时写页面路由和JSON接口。项目里我把两类接口做了严格区分:PageController处理页面跳转,ApiController处理数据接口,各司其职,减少误用。
6. 再往下走一步:FreeMarker的进阶玩法
基础功能跑通后,我开始折腾一些更灵活的特性。这几个玩法让我的后台页面代码量直接降了三分之一。
6.1 宏(macro):模板里的函数
宏是FreeMarker最强大的复用工具。它像Java里的方法一样,定义一段模板逻辑,传参调用,返回渲染结果。和<#include>的静态包含不同,宏是参数化的——同一个宏,传入不同数据,输出不同内容。
定义一个宏:
<#macro pageTitle title> <div class="page-header"> <h3>${title}</h3> </div> </#macro>页面里调用:
<@pageTitle title="用户管理"/> <@pageTitle title="订单管理"/>上面两个调用,会分别渲染出标题为“用户管理”和“订单管理”的页面头部。换在没有宏之前,同样一段HTML片段要写两遍。
宏的用途不止于标题。我实际项目里把分页组件、操作按钮、状态标签都做成了宏。比如分页组件,传入总页数和当前页,就能渲染出完整的分页条:
<#macro pagination currentPage totalPages> <#if totalPages gt 1> <div class="pagination"> <#if currentPage gt 1> <a href="?page=${currentPage - 1}">上一页</a> </#if> <span>${currentPage} / ${totalPages}</span> <#if currentPage lt totalPages> <a href="?page=${currentPage + 1}">下一页</a> </#if> </div> </#if> </#macro>宏定义可以放在独立模板文件里,页面通过<#import>导入后使用。我建了一个common/macros.ftl专门存放所有公共宏,各页面按需导入,代码复用率大幅提升。
6.2 自定义指令:超出模板语法时主动扩展
FreeMarker的指令体系很完整,但总有一些场景是内置指令覆盖不到的。比如我想在模板里输出当前登录用户的操作记录,但这段记录需要从数据库实时查询,模板引擎本身做不到。
FreeMarker允许通过Java代码实现自定义指令。做法是继承TemplateDirectiveModel接口:
@Component public class CurrentUserDirective implements TemplateDirectiveModel { @Override public void execute(Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body) throws TemplateException, IOException { // 获取当前登录用户 User user = SecurityUtils.getCurrentUser(); // 输出用户信息到模板 env.getOut().write(user.getName()); } }然后在模板中使用这个自定义指令。自定义指令适合做那些“需要Java逻辑参与渲染”的场景,比如从数据库读取菜单权限、动态生成下拉选项、输出当前用户的部门信息。不过说实话,这类场景我一般还是倾向于在Controller层把数据组装好再塞给模板,保持模板的简单性。只有一些跨页面通用的逻辑,比如“当前用户信息”,才会用自定义指令来做。
6.3 模板串拼接HTML片段
除了渲染整页,FreeMarker还能在Java代码里直接渲染字符串模板,这对生成邮件内容、导出报表标题等场景特别有用。
核心API是FreeMarkerTemplateUtils.processTemplateIntoString(),前提是把String包成Template对象。不过实际开发中,我更推荐把邮件模板做成.ftl文件放在templates/email/目录下,用StringTemplateLoader加载,反而是用FreeMarkerTemplateUtils处理。
Map<String, Object> model = new HashMap<>(); model.put("username", "张三"); model.put("orderNo", "202501010001"); String content = FreeMarkerTemplateUtils.processTemplateIntoString( freeMarkerConfigurer.getConfiguration().getTemplate("/email/order.ftl"), model );生成好的content字符串,既可以通过JavaMail发送邮件,也可以转成PDF,甚至可以返回给前端异步渲染。我项目里的订阅通知邮件、定时报表邮件都用的这套方案。
6.4 从FreeMarker到前后端分离的过渡思路
最后聊一个关于技术选型的话题。很多项目和我这个后台系统一样,早期是服务端渲染,后面又想逐步往前后端分离方向迁移。FreeMarker在这里可以承担一个“过渡桥梁”的角色。
一种思路是:先用FreeMarker把页面骨架渲染出来,页面加载后再通过Ajax调用后端API填充动态数据。这种方式的好处是,页面框架和SEO相关的内容由服务端输出,但核心交互数据走接口,前端逐步接手渲染逻辑。
实际落地时,我会把接口调用统一封装在页面底部的公共JS里,同时保留FreeMarker渲染兜底方案。等前端框架成熟了,再把模板渲染的部分逐步替换成纯静态页面,此时Freemarker配置的改动非常小——因为Controller层返回的数据结构已经约定好了,前端只需要改成从接口读取同一个数据结构即可。
7. 开发效率提升的最后几个小建议
这部分是前面所有经验的总结梳理,条理性放在最后,但实用性一点不低,都是我在项目里反复验证过的。
7.1 模板目录按模块划分
页面多的时候,templates目录下如果不分类,全是平铺的xxx.ftl文件,管理和维护都费劲。我建议按模块建子目录:
templates/ ├── common/ # 公共宏、公共片段 ├── user/ # 用户模块页面 ├── order/ # 订单模块页面 ├── report/ # 报表模块页面 └── email/ # 邮件模板Controller返回的视图名,带上目录前缀:
return "user/list"; // 对应 templates/user/list.ftl return "order/detail"; // 对应 templates/order/detail.ftl这个习惯一旦建立,新页面加到对应目录,老页面通过目录名就能快速定位,基本不用想半天“这个页面放哪了”。
7.2 常用内置函数速查
FreeMarker内置了一些很常用的函数,整理成表方便查找:
| 函数名 | 作用 | 示例 |
|---|---|---|
?upper_case | 字符串转大写 | abc->ABC |
?lower_case | 字符串转小写 | ABC->abc |
?trim | 去除首尾空格 | ' abc '->'abc' |
?length | 字符串长度 | 'abc'->3 |
?default('xx') | 空值提供默认值 | ${a?default('')} |
?string("格式") | 日期格式化 | ${date?string("yyyy-MM-dd")} |
?size | 集合大小 | ${list?size} |
?contains("x") | 判断是否包含子串 | ${"abc"?contains("b")} |
?substring(a,b) | 截取子串 | ${"abc"?substring(0,2)} |
记这几个就够覆盖绝大多数场景了,不需要把整个官方文档背下来。
7.3 一个调试模板的好习惯
FreeMarker报错时,异常信息里会带上模板文件路径和行号。但有些错误信息写得比较晦涩,尤其是类型不匹配、无法解析属性这类问题。我养成了一个习惯:先在Controller里打印数据模型的完整结构(直接打印Map/JSON),再对照模板里取值的路径。多数问题一眼就能看出来——要么是字段名拼错了,要么是数据结构层级没对上。
配合Spring Boot的spring-boot-devtools,可以在页面报错时看到完整的异常堆栈和模板行号。记住报错信息和行号,回到.ftl文件对应行检查,排查速度会快很多。
7.4 千万别把FreeMarker当Java来写
最后这句算是最核心的体会:FreeMarker的定位是简单模板,它不适合承载复杂业务逻辑。模板里如果出现很深的嵌套判断、复杂的字符串拼接、大量的计算,那大概率是后端数据准备得不够好。
遇到模板越写越复杂的时候,我的处理原则是“让Java代码多干活,让模板尽量无脑”。把数据结构整理好、计算逻辑提前完成、默认值先填好,模板里基本就只剩<#if>、<#list>和${}三种操作,代码自然清爽可维护。
这套方案整套跑下来,我的后台项目页面开发效率提升了不少,和前端同学的协作也更顺畅。如果你正准备在Spring Boot项目里使用FreeMarker,或者正被JSP纠缠着想换方案,建议直接按上面的思路搭一个最小Demo跑一遍,比看文档强得多。