FreeMarker 模板引擎支持通过 <#include>
、<#import>
和 <#nested>
等指令实现模板的继承与覆盖(更准确地说,是通过 宏(macro) 和 模板包含/导入 机制来模拟类似面向对象的继承与覆盖行为)。虽然 FreeMarker 本身没有像 Java 那样严格的类继承语法,但通过巧妙的设计,可以实现强大的模板复用和结构化布局。
一、核心概念
模板继承 (Template Inheritance):
- 本质:FreeMarker 并没有原生的
extends
关键字。所谓的“继承”是通过宏定义和宏调用来模拟的。 - 父模板 (Parent Template / Layout Template):定义一个基础模板,其中包含通用的页面结构(如 HTML 的
<head>
、<header>
、<footer>
)和可被子模板填充的“占位符”区域(通过宏实现)。 - 子模板 (Child Template):引用(导入或包含)父模板,并通过定义同名的宏来“覆盖”父模板中占位符区域的内容。
<#nested>
指令:这是实现“继承”逻辑的关键。它允许在父模板的宏内部执行子模板中定义的同名宏体(或内容块)。
- 本质:FreeMarker 并没有原生的
覆盖 (Overriding):
- 在子模板中定义一个与父模板中某个宏同名的宏。
- 当父模板调用该宏时,实际执行的是子模板中定义的宏体,从而实现了“覆盖”父模板部分内容的效果。
<#import>
vs<#include>
:<#import "template.ftl">
:将指定模板导入为一个命名空间 (namespace)。导入模板中的宏、函数、变量等通过命名空间前缀访问(如ns.macroName()
)。这是实现“继承”模式推荐的方式,因为它避免了命名冲突,结构更清晰。<#include "template.ftl">
:将指定模板的内容直接插入到当前位置。导入模板中的所有内容(宏、变量、文本)都成为当前模板的一部分。容易导致命名冲突,不推荐用于复杂的继承结构。
<#nested>
:- 一个指令,只能在宏定义内部使用。
- 当宏被调用时,
<#nested>
会被替换为调用该宏时所提供的“嵌套内容”(即在<@macroName>...</@macroName>
标签之间写的内容)。 - 在“继承”模式中,父模板的宏使用
<#nested>
来定义一个“插槽”,子模板通过<@parentMacro>...</@parentMacro>
调用并填充这个插槽。
命名空间 (Namespace):
- 由
<#import>
创建,是一个包含从被导入模板中导出的所有变量、宏、函数等的容器。 - 使用命名空间可以清晰地组织代码,避免不同模板间的命名冲突。
- 由
二、操作步骤(非常详细)
步骤 1:创建基础父模板 (layout.ftl)
这是所有页面的基础布局,定义了通用结构和可覆盖的区域。
<#-- layout.ftl: 基础布局模板 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><#-- 可被覆盖的标题区域 --><@title /></title>
<#-- 可被覆盖的额外CSS链接区域 -->
<@extraHead />
</head>
<body>
<header>
<h1>My Website</h1>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
<main>
<#-- 主要内容区域,使用 <#nested> 定义插槽 -->
<@content />
</main>
<aside>
<#-- 侧边栏区域,可被覆盖 -->
<@sidebar />
</aside>
<footer>
<p>© 2025 My Company. All rights reserved.</p>
</footer>
<#-- 可被覆盖的脚本区域 -->
<@extraScripts />
</body>
</html>
<#-- 定义可被覆盖的宏 (占位符) -->
<#-- 标题宏 -->
<#macro title>
<#-- 默认标题 -->
Default Page Title
</#macro>
<#-- 内容宏 (使用 <#nested> 定义主内容插槽) -->
<#macro content>
<#-- 子模板的内容将填充在这里 -->
<#nested>
</#macro>
<#-- 侧边栏宏 -->
<#macro sidebar>
<#-- 默认侧边栏内容 -->
<p>Default Sidebar Content</p>
</#macro>
<#-- 额外头部内容宏 (CSS等) -->
<#macro extraHead>
<#-- 子模板可以添加额外的CSS链接 -->
</#macro>
<#-- 额外脚本内容宏 (JS等) -->
<#macro extraScripts>
<#-- 子模板可以添加额外的JS脚本 -->
</#macro>
步骤 2:创建中间层模板 (可选,实现多层继承)
为了实现多层继承,可以创建一个继承自 layout.ftl
的中间模板,例如 blogLayout.ftl
,它本身也可以定义新的可覆盖区域或提供默认实现。
<#-- blogLayout.ftl: 博客专用布局,继承自 layout.ftl -->
<#-- 导入基础布局模板,创建命名空间 'layout' -->
<#import "layout.ftl" as layout>
<#-- 覆盖基础布局的某些宏,或定义新的宏 -->
<#-- 覆盖标题宏,提供博客页面的默认标题 -->
<#macro title>
<#-- 注意:这里我们调用了父布局的 title 宏,实现了“增强”而非完全替换 -->
Blog: <@layout.title />
</#macro>
<#-- 覆盖侧边栏宏,提供博客专用的侧边栏 -->
<#macro sidebar>
<h3>Blog Categories</h3>
<ul>
<li><a href="/blog/tech">Technology</a></li>
<li><a href="/blog/life">Life</a></li>
<li><a href="/blog/travel">Travel</a></li>
</ul>
<#-- 仍然可以调用父布局的 sidebar 宏来包含默认内容 -->
<@layout.sidebar />
</#macro>
<#-- 定义博客特有的宏,例如文章信息 -->
<#macro postInfo date author>
<div class="post-info">
<p>Posted on: ${date?string("yyyy-MM-dd")}</p>
<p>By: ${author}</p>
</div>
</#macro>
<#-- 关键:将未覆盖的宏(如 content, extraHead, extraScripts)代理回父布局 -->
<#-- 这样子模板才能继续覆盖这些宏 -->
<#macro content>
<#-- 重要:这里必须使用 <#nested> 并调用父布局的 content 宏 -->
<@layout.content>
<#-- 这个 <#nested> 会执行 blogLayout 的 content 宏体,但我们需要它执行最终子模板的 content -->
<#-- 正确的做法是:blogLayout 的 content 宏体就是 <#nested>,它会直接传递到 layout 的 content 宏 -->
<#nested>
</@layout.content>
</#macro>
<#-- 代理 extraHead -->
<#macro extraHead>
<@layout.extraHead>
<#nested>
</@layout.extraHead>
</#macro>
<#-- 代理 extraScripts -->
<#macro extraScripts>
<@layout.extraScripts>
<#nested>
</@layout.extraScripts>
</#macro>
<#-- 注意:我们没有重新定义 title 宏的代理,因为我们已经覆盖了它。 -->
<#-- 如果子模板想进一步覆盖 title,它需要知道最终的结构。 -->
<#-- 对于 title,如果我们想允许子模板覆盖,我们可以这样设计: -->
<#--
<#-- 覆盖标题宏,允许子模板进一步覆盖 -->
<#-- <#macro title>
<#-- Blog: -->
<#-- <#nested> -- 子模板的 title 内容会放在这里 -->
<#-- </#macro>
-->
<#-- 但这样会丢失 layout.title 的默认值。更灵活的方式是让子模板直接覆盖 title。 -->
<#-- 通常,中间层覆盖后,子模板再覆盖时,需要了解中间层的逻辑。 -->
<#-- 为了简化,本例中 blogLayout 完全控制了 title 前缀。 -->
步骤 3:创建子模板 (home.ftl)
这是具体的页面模板,它“继承”自中间层模板 blogLayout.ftl
(或直接继承 layout.ftl
),并覆盖所需的宏。
<#-- home.ftl: 首页,继承自 blogLayout.ftl -->
<#-- 导入中间层布局模板,创建命名空间 'blog' -->
<#import "blogLayout.ftl" as blog>
<#-- 覆盖标题宏 -->
<#macro title>
Welcome to My Blog
</#macro>
<#-- 覆盖主要内容宏 (content) -->
<#-- 注意:这里我们调用的是 blog 命名空间下的 content 宏 -->
<@blog.content>
<#-- 这里的内容就是 <#nested> 的内容,将被插入到 blogLayout 的 content 宏中 -->
<article>
<h2>Welcome!</h2>
<p>This is the home page of my awesome blog.</p>
<#-- 使用 blogLayout 定义的特有宏 -->
<@blog.postInfo date="2025-07-29" author="Qwen" />
<p>Check out my latest posts!</p>
</article>
</@blog.content>
<#-- 覆盖额外头部内容宏 -->
<#macro extraHead>
<link rel="stylesheet" href="/css/home.css">
</#macro>
<#-- 覆盖额外脚本内容宏 -->
<#macro extraScripts>
<script src="/js/home.js"></script>
</#macro>
<#-- 注意:我们没有覆盖 sidebar,所以会使用 blogLayout.ftl 中定义的默认侧边栏 -->
步骤 4:创建另一个子模板 (about.ftl)
<#-- about.ftl: 关于页面,直接继承基础布局 layout.ftl (演示直接继承) -->
<#-- 导入基础布局模板 -->
<#import "layout.ftl" as layout>
<#-- 覆盖标题宏 -->
<#macro title>
About Me
</#macro>
<#-- 覆盖主要内容宏 -->
<@layout.content>
<section>
<h2>About Me</h2>
<p>I am an AI assistant created by Alibaba Cloud.</p>
</section>
</@layout.content>
<#-- 覆盖侧边栏宏 -->
<#macro sidebar>
<h3>Connect With Me</h3>
<p>Email: qwen@example.com</p>
</#macro>
<#-- 覆盖额外头部内容 -->
<#macro extraHead>
<link rel="stylesheet" href="/css/about.css">
</#macro>
步骤 5: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_32);
cfg.setDirectoryForTemplateLoading(new File("path/to/your/templates")); // 设置模板目录
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setLogTemplateExceptions(false);
cfg.setWrapUncheckedExceptions(true);
// 2. 准备数据模型 (通常包含动态数据)
Map<String, Object> dataModel = new HashMap<>();
// dataModel.put("someVariable", someValue); // 如果模板需要动态数据
// 3. 获取并处理子模板 (FreeMarker 会自动处理导入和宏调用)
// 注意:我们处理的是子模板 (home.ftl, about.ftl),而不是 layout.ftl 或 blogLayout.ftl
Template homeTemplate = cfg.getTemplate("home.ftl");
Template aboutTemplate = cfg.getTemplate("about.ftl");
// 4. 渲染模板到输出流
Writer out = new FileWriter(new File("output/home.html"));
homeTemplate.process(dataModel, out);
out.close();
out = new FileWriter(new File("output/about.html"));
aboutTemplate.process(dataModel, out);
out.close();
System.out.println("Templates processed successfully!");
}
}
三、常见错误
命名空间错误:
- 错误:忘记使用命名空间前缀调用宏,例如写了
<@content>
而不是<@blog.content>
。 - 解决:确保
<#import>
时使用as nsName
,并在调用宏时使用nsName.macroName
。
- 错误:忘记使用命名空间前缀调用宏,例如写了
<#nested>
使用错误:- 错误:在非宏定义内部使用
<#nested>
,或在不需要的地方使用。 - 解决:
<#nested>
只能在<#macro>
或<#function>
定义内部使用。确保父宏使用了<#nested>
来定义插槽。
- 错误:在非宏定义内部使用
宏覆盖未生效:
- 错误:子模板定义了宏,但父模板没有调用它,或者调用的宏名不匹配。
- 解决:检查宏名称拼写是否完全一致。确保父模板确实调用了该宏。
多层继承中断:
- 错误:在中间层模板(如
blogLayout.ftl
)中覆盖了一个宏(如content
),但没有正确使用<#nested>
和<@parentMacro><#nested></@parentMacro>
将调用链传递下去,导致子模板无法再覆盖。 - 解决:在中间层覆盖宏时,如果希望子模板还能覆盖,必须在宏体中使用
<#nested>
并通过<@parentNamespace.macroName><#nested></@parentNamespace.macroName>
将嵌套内容传递给真正的父宏。见blogLayout.ftl
中content
宏的实现。
- 错误:在中间层模板(如
导入 vs 包含混淆:
- 错误:使用
<#include>
代替<#import>
,导致命名冲突或无法正确访问宏。 - 解决:对于实现“继承”模式,优先使用
<#import>
。
- 错误:使用
循环导入:
- 错误:模板 A 导入 B,B 又导入 A,导致无限循环。
- 解决:检查导入关系,避免形成循环依赖。
四、注意事项
- 清晰的命名:为模板、命名空间和宏使用清晰、一致的命名约定。
- 避免过度复杂:虽然可以实现多层继承,但层级不宜过深(通常2-3层足够),否则维护困难。
- 理解调用栈:当子模板调用
<@blog.content>
时,实际上是blogLayout.ftl
的content
宏被调用,该宏内部又调用了layout.ftl
的content
宏,并将子模板的内容作为<#nested>
传递。理解这个调用链很重要。 - 默认值:在父模板的宏中提供有意义的默认内容,提高健壮性。
- 文档化:为复杂的布局模板和宏编写文档,说明哪些宏可以被覆盖,以及它们的参数。
<#compress>
:在布局模板的<html>
或<body>
标签上使用<#compress>
指令可以减少最终 HTML 的空白字符,但要小心使用,避免压缩掉必要的空白。
五、使用技巧
- 使用
<#assign>
在命名空间内定义变量:可以在父模板或中间模板中定义一些辅助变量或配置。 - 宏参数:为宏定义参数,增加灵活性。如
blogLayout.ftl
中的postInfo
宏。 - 条件宏调用:在父模板中根据条件决定是否调用某个宏或传递不同的内容。
- 利用
?has_content
:检查宏体内容是否为空,避免输出空的 HTML 标签。 - 模块化:将通用组件(如导航、分页)也做成宏,放在单独的模板中,通过
<#import>
复用。
六、最佳实践与性能优化
- 使用
<#import>
而非<#include>
:这是实现可维护的“继承”结构的基础。 - 缓存配置:
Configuration
对象是线程安全的,应该在应用启动时创建单例并复用。它会自动缓存已解析的模板。 - 模板缓存:确保
Configuration
的模板缓存已启用(默认启用)。避免每次渲染都重新解析模板。 - 预编译:在可能的情况下,考虑预编译模板(虽然 FreeMarker 主要是运行时解析,但缓存机制起到了类似作用)。
- 减少复杂逻辑:模板中避免复杂的业务逻辑和循环嵌套过深。将复杂数据处理放在 Java 代码中,传递处理好的数据模型给模板。
- 监控模板加载:关注模板加载时间和内存使用,确保没有频繁重新加载模板(通常配置正确后不会发生)。
- 分离关注点:
- 布局模板:只负责页面结构和定义可覆盖区域。
- 组件模板:包含可复用的 UI 组件(按钮、卡片等)。
- 页面模板:负责具体页面的内容填充和数据展示。
- 版本控制:将模板文件纳入版本控制系统(如 Git)。
- 测试:编写模板测试用例,验证不同数据模型下的输出是否符合预期。
通过遵循以上详细的步骤和最佳实践,您可以在 FreeMarker 中有效地实现多层模板继承与覆盖,构建结构清晰、易于维护和复用的模板系统。记住,关键在于利用 <#import>
创建命名空间和 <#nested>
实现内容插槽。