FreeMarker 是一个强大的 Java 模板引擎,广泛用于生成 HTML、XML、JSON 或纯文本。其模板继承机制是构建可复用、结构清晰的模板体系的核心功能,类似于面向对象编程中的继承。
一、核心概念
<#include>
vs<#import>
/<#import>
<#include>
: 包含 (Include)。将另一个模板文件的内容原封不动地插入到当前位置。被包含的模板是独立的,没有命名空间的概念。适用于包含静态片段(如页眉、页脚)。<#import>
/<#import>
: 导入 (Import)。将另一个模板作为一个“库”导入,创建一个命名空间。通过命名空间可以访问被导入模板中的宏、函数和变量。这是实现“继承”语义的基础。<#import>
是<#import>
的别名。- 关键区别:
<#include>
是内容复制粘贴,<#import>
是创建命名空间并按需调用。
<#macro>
(宏)- 定义可重用的代码块,可以带参数。
- 在模板继承中,父模板定义“占位”宏(如
header()
,content()
,footer()
),子模板通过<#nested>
覆盖这些宏的具体实现。
<#nested>
- 在宏定义中使用,表示“在此处插入调用该宏时提供的嵌套内容”。
- 继承的核心:父模板中的宏使用
<#nested>
定义“槽”(slot),子模板在调用该宏时,将其内容作为嵌套内容传入,从而“填充”槽位,实现内容覆盖。
<#compress>
- 用于压缩输出,移除多余的空白字符。在布局模板中常用,以避免继承引入的额外空行。
命名空间 (Namespace)
- 由
<#import>
创建。例如<#import "/layout/base.ftl" as layout>
创建了一个名为layout
的命名空间。通过layout.header()
调用父模板中的宏。
- 由
“继承”的本质
- FreeMarker 本身没有像 Java 那样的
extends
关键字。 - 所谓“模板继承”是通过
<#import>
导入一个包含通用结构和占位宏的“布局模板”(Layout Template),然后子模板(Page Template)覆盖(重定义)这些宏来实现的。这是一种基于宏和命名空间的模拟继承模式。
- FreeMarker 本身没有像 Java 那样的
二、操作步骤(非常详细)
假设我们要创建一个网站,有统一的页眉、导航、页脚,但每个页面的主内容不同。
步骤 1:创建父模板 (布局模板 - base.ftl
)
- 创建文件:在模板目录(如
/templates/layout/
)下创建base.ftl
。 - 定义通用结构:编写 HTML 基本结构。
- 定义“槽”宏:使用
<#macro>
定义需要被子页面覆盖的部分。通常使用<#nested>
定义默认内容或纯占位。
<#-- /templates/layout/base.ftl -->
<#-- 使用 compress 压缩输出,避免多余空行 -->
<#compress>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title><#-- 定义 title 宏,子页面可覆盖 --><#macro title><#nested> - 我的网站</#macro></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<#-- 定义 head 宏,用于插入页面特定的 CSS、JS 引用等 -->
<#macro head></#macro>
</head>
<body>
<#-- 页眉 -->
<header>
<h1>我的网站</h1>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
<a href="/contact">联系</a>
</nav>
</header>
<#-- 主内容区域,定义 content 宏作为“槽” -->
<main>
<#macro content>
<#-- 默认内容,通常为空或提示 -->
<p>此页面没有内容。</p>
</#macro>
</main>
<#-- 侧边栏(可选) -->
<aside>
<#macro sidebar>
<h3>侧边栏</h3>
<p>这里是默认侧边栏内容。</p>
</#macro>
</aside>
<#-- 页脚 -->
<footer>
<#macro footer>
<p>© 2025 我的公司. 保留所有权利。</p>
</#macro>
</footer>
</body>
</html>
</#compress>
步骤 2:创建子模板 (页面模板 - home.ftl
)
- 创建文件:在模板目录(如
/templates/pages/
)下创建home.ftl
。 - 导入布局:使用
<#import>
导入base.ftl
,并指定一个命名空间(如layout
)。 - 覆盖宏:使用
<#macro>
重新定义(覆盖)父模板中定义的宏(title
,head
,content
,sidebar
,footer
)。注意:这里不是调用宏,而是重新定义它。 - 调用父模板的
visit
宏(可选但推荐):虽然不是必须,但一个常见的模式是父模板定义一个visit()
宏来触发整个渲染流程。如果父模板有visit
,子模板最后需要调用layout.visit()
。在上面的base.ftl
中,我们没有显式定义visit
,所以子模板需要显式调用各个宏。
<#-- /templates/pages/home.ftl -->
<#-- 1. 导入布局模板,创建命名空间 'layout' -->
<#import "/layout/base.ftl" as layout>
<#-- 2. 覆盖 title 宏 -->
<#macro title>
<#nested> - 首页
</#macro>
<#-- 3. 覆盖 head 宏,添加页面特定的资源 -->
<#macro head>
<link rel="stylesheet" href="/css/home.css">
<script src="/js/home.js"></script>
</#macro>
<#-- 4. 覆盖 content 宏,定义首页主内容 -->
<#macro content>
<h2>欢迎来到首页!</h2>
<p>这是网站的主页内容。</p>
<ul>
<#list ["新闻", "产品", "服务"] as item>
<li>${item}</li>
</#list>
</ul>
</#macro>
<#-- 5. 覆盖 sidebar 宏(可选) -->
<#macro sidebar>
<h3>最新动态</h3>
<ul>
<#list ["活动1", "公告2"] as news>
<li>${news}</li>
</#list>
</ul>
</#macro>
<#-- 6. (可选) 覆盖 footer 宏,如果需要 -->
<#-- <#macro footer>
<p>首页专用页脚。</p>
</#macro> -->
<#-- 7. 触发渲染:由于 base.ftl 没有 visit 宏,我们需要在子模板中显式“使用”布局 -->
<#-- 这是关键:我们通过命名空间调用 layout 中的宏,这些宏内部会使用 <#nested> 插入我们上面定义的内容 -->
<@layout.title>首页</@layout.title>
<@layout.head/>
<@layout.content/>
<@layout.sidebar/>
<@layout.footer/>
步骤 3:(推荐) 改进父模板 - 添加 visit
宏
为了更清晰地分离“定义”和“执行”,可以在父模板中添加一个 visit
宏来封装渲染逻辑。
修改 base.ftl
:
<#-- /templates/layout/base.ftl (改进版) -->
<#compress>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title><#macro title><#nested> - 我的网站</#macro></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<#macro head></#macro>
</head>
<body>
<header>...</header> <!-- 保持不变 -->
<main>
<#macro content>
<p>此页面没有内容。</p>
</#macro>
</main>
<aside>
<#macro sidebar>
<h3>侧边栏</h3>
<p>这里是默认侧边栏内容。</p>
</#macro>
</aside>
<footer>
<#macro footer>
<p>© 2025 我的公司. 保留所有权利。</p>
</#macro>
</footer>
<#-- 新增:定义 visit 宏,负责调用所有需要的宏 -->
<#macro visit>
<@title/>
<@head/>
<@content/>
<@sidebar/>
<@footer/>
</#macro>
</body>
</html>
</#compress>
修改 home.ftl
:
<#-- /templates/pages/home.ftl (改进版) -->
<#import "/layout/base.ftl" as layout>
<#macro title>
<#nested> - 首页
</#macro>
<#macro head>
<link rel="stylesheet" href="/css/home.css">
<script src="/js/home.js"></script>
</#macro>
<#macro content>
<h2>欢迎来到首页!</h2>
<p>这是网站的主页内容。</p>
<ul>
<#list ["新闻", "产品", "服务"] as item>
<li>${item}</li>
</#list>
</ul>
</#macro>
<#macro sidebar>
<h3>最新动态</h3>
<ul>
<#list ["活动1", "公告2"] as news>
<li>${news}</li>
</#list>
</ul>
</#macro>
<#-- 不再需要显式调用 layout.title, layout.head 等 -->
<#-- 只需调用 layout.visit() 来启动整个渲染过程 -->
<@layout.visit/>
步骤 4:在 Java 代码中渲染
import freemarker.template.*;
import java.io.*;
import java.util.*;
public class FreeMarkerExample {
public static void main(String[] args) throws Exception {
// 1. 配置 FreeMarker
Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
cfg.setDirectoryForTemplateLoading(new File("/path/to/templates")); // 指向模板根目录
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
// 2. 获取模板 (home.ftl)
Template template = cfg.getTemplate("pages/home.ftl"); // 路径相对于 templateLoader 目录
// 3. 创建数据模型 (Data Model)
Map<String, Object> dataModel = new HashMap<>();
// dataModel.put("someVar", someValue); // 如果 home.ftl 需要额外数据
// 4. 合并模板和数据模型,输出到 Writer
Writer out = new OutputStreamWriter(System.out);
template.process(dataModel, out);
out.flush();
out.close();
}
}
三、常见错误
混淆
<#include>
和<#import>
:- 错误:在子模板中使用
<#include "/layout/base.ftl">
期望实现继承。 - 结果:
base.ftl
的内容被直接复制到home.ftl
开头,导致 HTML 结构重复或混乱,且无法覆盖宏。 - 解决:必须使用
<#import>
并配合命名空间。
- 错误:在子模板中使用
忘记使用命名空间:
- 错误:
<#import "/layout/base.ftl" as layout>
,但在调用时写成<@title/>
或<@layout.title>
(缺少括号)。 - 结果:
<@title/>
会调用当前命名空间(子模板)的title
宏(如果定义了),而不是父模板的。<@layout.title>
语法错误。 - 解决:调用父模板宏必须用
<@layout.title/>
或<@layout.title></@layout.title>
。
- 错误:
在子模板中错误地“调用”而非“覆盖”宏:
- 错误:在
home.ftl
中写<@layout.title>首页</@layout.title>
试图设置标题,但没有重新定义title
宏。 - 结果:
base.ftl
中的title
宏被调用,<#nested>
被替换为 "首页",但home.ftl
本身没有定义title
宏来 改变 这个行为。如果base.ftl
的title
宏是<#macro title><#nested> - 我的网站</#macro>
,输出会是首页 - 我的网站
,但这发生在base.ftl
被处理时,而不是通过继承机制。如果base.ftl
没有visit
宏,且home.ftl
没有显式调用,标题可能不会出现。 - 正确做法:要改变父模板宏的行为,必须在子模板中用
<#macro title>...<#nested>...</#macro>
重新定义它。然后通过layout.visit()
或显式调用<@layout.title>首页</@layout.title>
来使用这个被覆盖后的宏。
- 错误:在
<#nested>
使用不当:- 错误:在宏定义外部使用
<#nested>
。 - 结果:语法错误。
- 解决:
<#nested>
只能在<#macro>
定义的宏内部使用。
- 错误:在宏定义外部使用
路径错误:
- 错误:
<#import "base.ftl" as layout>
但base.ftl
在layout
子目录下。 - 结果:找不到模板。
- 解决:确保路径正确,相对于
Configuration
设置的模板加载目录。
- 错误:
忘记调用
visit
或显式宏:- 错误:只定义了宏,但没有
<@layout.visit/>
或<@layout.content/>
等调用。 - 结果:模板渲染后输出为空或只有子模板中非宏部分的内容。
- 解决:必须有触发点来执行宏调用。
- 错误:只定义了宏,但没有
四、注意事项
- 命名空间是隔离的:子模板中定义的宏/变量不会自动影响父模板的命名空间,反之亦然(除非显式传递)。覆盖是通过同名宏在子命名空间中“遮蔽”父命名空间的宏来实现的。
- 宏的覆盖是“就近原则”:当通过
layout.content()
调用时,FreeMarker 会先在当前命名空间(子模板)查找是否有content
宏定义,如果有则使用它(即覆盖版本),否则回退到layout
命名空间查找。 <#nested>
的内容来源:<#nested>
的内容来自于调用该宏时<@...>...</@...>
标签之间的内容。在继承模式下,这个内容通常就是子模板中定义的同名宏体本身(通过宏覆盖机制实现)。- 默认内容:父模板中宏内的
<#nested>
前后可以有默认内容,子模板覆盖时可以选择是否包含<#nested>
来保留默认部分。 - 避免循环导入:确保导入关系是单向的(子导入父),避免 A 导入 B,B 又导入 A。
- 编码:确保模板文件和
Configuration
设置的编码一致(通常是 UTF-8)。
五、使用技巧
- 多级继承:可以创建
base.ftl
->layout-with-sidebar.ftl
->home.ftl
。layout-with-sidebar.ftl
导入base.ftl
,覆盖或添加宏,home.ftl
再导入layout-with-sidebar.ftl
。 - 参数化宏:父模板的宏可以接受参数,使“槽”更灵活。
<#-- base.ftl --> <#macro content sectionTitle> <h1>${sectionTitle}</h1> <div class="content-body"><#nested></div> </#macro>
<#-- home.ftl --> <#macro content> <@layout.content sectionTitle="首页内容"> <p>这里是具体内容...</p> </@layout.content> </#macro>
- 条件渲染:在父模板宏中使用
<#if>
根据变量决定是否渲染某部分。 - 使用
visit
宏:强烈推荐使用visit
宏来封装渲染逻辑,使子模板只需一个<@layout.visit/>
即可,代码更清晰。 - 模块化:将页眉、页脚等也做成独立的宏库,通过
<#import>
在base.ftl
中引入,提高复用性。 - 调试:利用
Configuration.setTemplateExceptionHandler()
设置合适的处理器(如HTML_DEBUG_HANDLER
)帮助定位模板错误。
六、最佳实践与性能优化
清晰的目录结构:
/templates/layout/
(存放base.ftl
,admin-layout.ftl
等)/templates/partials/
(存放可复用的片段,如navigation.ftl
,pagination.ftl
)/templates/pages/
(存放具体的页面模板home.ftl
,about.ftl
)/templates/email/
(存放邮件模板)
单一职责:每个模板文件职责明确。
base.ftl
负责整体布局,page.ftl
负责页面特定内容。广泛使用宏:将可复用的 UI 组件(按钮、卡片、表单字段)定义为宏,提高一致性。
合理使用
<#compress>
:在布局模板的最外层使用<#compress>
可以有效减少 HTML 输出大小,提升页面加载速度。注意它会移除所有空白,确保不影响格式化文本。缓存模板:
Configuration
对象会自动缓存已解析的模板(基于TemplateLoader
)。确保TemplateLoader
的findTemplateSource
和getLastModified
实现高效,以便正确利用缓存。在生产环境,模板通常不会频繁修改,缓存非常有效。避免在模板中进行复杂计算:将数据处理逻辑放在 Java 代码或数据模型准备阶段。模板应专注于展示。避免在模板中进行耗时的数据库查询或复杂循环。
使用
?no_esc
谨慎:?no_esc
会禁用 HTML 转义,有 XSS 风险。仅在确定内容安全时使用,或使用更安全的?interpret
处理动态生成的 FTL 代码。预编译(FreeMarker 2.3.30+):对于极其高性能要求的场景,可以考虑使用
freemarker-core
模块进行模板预编译,但这会增加复杂性,通常缓存已足够。监控与日志:记录模板渲染时间和错误,便于性能分析和问题排查。
版本控制:将模板文件纳入版本控制系统(如 Git)。
通过遵循这些概念、步骤、注意事项和最佳实践,你可以有效地利用 FreeMarker 的模板继承机制,构建出结构良好、易于维护和高性能的模板系统。