FreeMarker 通过其宏(<#macro>
)和导入(<#import>
)机制,巧妙地实现了“默认布局”与“可重写区域”的功能,这并非语言内置的继承,而是一种设计模式。它允许你定义一个包含通用结构和默认内容的主布局模板,然后让具体的页面模板继承此布局,并有选择地覆盖(重写)特定区域的内容。
一、核心概念
布局模板 (Layout Template):
- 一个定义了网站整体 HTML 结构(如
<html>
,<head>
,<body>
)和通用组件(页眉、导航、页脚)的 FreeMarker 模板文件(如default_layout.ftl
)。 - 它包含了多个使用
<#macro>
定义的“区域”(或“槽位”),这些区域代表了可以被子页面修改的部分。
- 一个定义了网站整体 HTML 结构(如
可重写区域 (Overridable Region / Slot):
- 在布局模板中使用
<#macro>
定义的特定部分。 - 这些宏内部通常包含
<#nested>
指令,用于指示子模板内容的插入点。 - 子模板通过重新定义同名的宏来“覆盖”这些区域的默认内容。
- 在布局模板中使用
<#macro>
(宏):- 定义可重用代码块的指令。在布局中,它定义了区域的“接口”和默认实现。
- 语法:
<#macro macroName> ... <#nested> ... </#macro>
<#nested>
:- 关键指令。在宏定义内部使用,表示“在此处插入调用此宏时提供的嵌套内容”。
- 在继承模式中,当子模板“覆盖”一个宏时,它实际上是定义了一个同名的新宏,而父布局在调用这个宏时,
<#nested>
就会被子模板中该宏定义的内容所替换。
<#import>
(导入):- 将另一个模板文件作为“库”导入,并为其创建一个命名空间。
- 语法:
<#import "path/to/layout.ftl" as ns>
。ns
是命名空间别名(如layout
)。 - 通过命名空间可以访问被导入模板中的宏、函数和变量,例如
<@layout.header/>
。
“继承”的本质:
- 不是真正的继承:FreeMarker 没有
extends
关键字。 - 基于宏覆盖的模拟:子模板通过
<#import>
获取布局模板的引用(命名空间)。当子模板定义一个与布局模板中同名的宏时,在渲染上下文中,子模板的宏“遮蔽”了布局模板的宏。当布局模板(通过其命名空间)调用这个宏时,实际执行的是子模板中定义的版本,从而实现了“可重写”。
- 不是真正的继承:FreeMarker 没有
<#compress>
:- 用于压缩模板输出,移除不必要的空白、换行和制表符。在布局模板中包裹整个结构,可以避免因模板文件格式化(换行、缩进)导致的 HTML 输出中出现多余的空行。
二、操作步骤(非常详细)
目标:创建一个带默认页眉、页脚和可重写主内容区的布局。
步骤 1:创建默认布局模板 (default_layout.ftl
)
- 创建文件:在模板目录(如
/WEB-INF/templates/layout/
)下创建default_layout.ftl
。 - 定义基础 HTML 结构:编写标准的 HTML 文档结构。
- 定义可重写区域:使用
<#macro>
为希望子页面能修改的部分创建“槽位”。最常见的是title
,head
,content
。 - 使用
<#nested>
:在每个宏中使用<#nested>
来标记子页面内容的插入点。 - (推荐) 添加
render
宏:创建一个宏(如render
或visit
)来封装所有区域的调用,简化子模板的操作。
<#-- /WEB-INF/templates/layout/default_layout.ftl -->
<#-- 使用 compress 压缩输出 -->
<#compress>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 可重写区域: 页面标题 -->
<title><#macro title><#nested> - 我的网站</#macro></title>
<!-- 可重写区域: 额外的头部资源 (CSS, meta等) -->
<#macro head></#macro>
</head>
<body>
<!-- 固定区域: 页眉 (通常不重写,或用宏但提供默认) -->
<header>
<h1>我的网站</h1>
<nav>
<a href="/">首页</a>
<a href="/products">产品</a>
<a href="/contact">联系</a>
</nav>
</header>
<!-- 可重写区域: 主要内容 -->
<main class="main-content">
<#macro content>
<p>这是默认的主内容区域。每个页面应重写此区域。</p>
</#macro>
</main>
<!-- 可重写区域: 侧边栏 -->
<aside class="sidebar">
<#macro sidebar>
<h3>侧边栏</h3>
<p>这是默认侧边栏内容。</p>
</#macro>
</aside>
<!-- 固定区域: 页脚 -->
<footer>
<#macro footer>
<p>© 2025 我的公司. All rights reserved.</p>
</#macro>
</footer>
<!-- (推荐) 定义 render 宏: 负责触发所有区域的渲染 -->
<#macro render>
<!-- 调用各个可重写区域的宏 -->
<@title/>
<@head/>
<@content/>
<@sidebar/>
<@footer/>
</#macro>
</body>
</html>
</#compress>
步骤 2:创建子页面模板 (home_page.ftl
)
- 创建文件:在模板目录(如
/WEB-INF/templates/pages/
)下创建home_page.ftl
。 - 导入布局:使用
<#import>
将default_layout.ftl
导入,并指定一个命名空间(如layout
)。这是实现“继承”的第一步。 - 覆盖可重写区域:使用
<#macro>
重新定义你在布局中想要修改的宏(如title
,content
)。注意,这里不是调用宏,而是定义一个同名的新宏。 - (推荐) 调用
render
宏:在子模板的末尾,通过命名空间调用布局模板中定义的render
宏(如<@layout.render/>
)。这会触发整个布局的渲染流程,并在过程中使用你覆盖后的新宏。
<#-- /WEB-INF/templates/pages/home_page.ftl -->
<#-- 1. 导入默认布局,创建命名空间 'layout' -->
<#import "/layout/default_layout.ftl" as layout>
<#-- 2. 覆盖 'title' 区域 -->
<#macro title>
<#-- <#nested> 会被传入的文本替换 -->
<#nested> - 欢迎页
</#macro>
<#-- 3. 覆盖 'head' 区域,添加首页特有的 CSS/JS -->
<#macro head>
<link rel="stylesheet" href="/css/home.css">
<script src="/js/home-slider.js"></script>
</#macro>
<#-- 4. 覆盖 'content' 区域,这是首页的主要内容 -->
<#macro content>
<h1>欢迎来到首页!</h1>
<p>这里是网站的主介绍内容。</p>
<div class="featured-products">
<h2>特色产品</h2>
<#-- 假设 dataModel 中有 products 列表 -->
<ul>
<#list products as product>
<li>${product.name} - ${product.price?string.currency}</li>
</#list>
</ul>
</div>
</#macro>
<#-- 5. 覆盖 'sidebar' 区域 (可选) -->
<#macro sidebar>
<h3>最新消息</h3>
<ul>
<#list news as item>
<li><a href="${item.url}">${item.title}</a></li>
</#list>
</ul>
</#macro>
<#-- 6. (推荐) 调用 layout 的 render 宏来启动渲染 -->
<@layout.render/>
步骤 3:(备选) 不使用 render
宏的子模板
如果你的布局模板没有 render
宏,你必须在子模板中显式地按顺序调用各个区域。不推荐,因为容易出错且不清晰。
<#-- /WEB-INF/templates/pages/about_page.ftl (不使用 render 宏) -->
<#import "/layout/default_layout.ftl" as layout>
<#macro title>About Us</#macro>
<#macro content>
<h1>关于我们</h1>
<p>公司历史、团队介绍等。</p>
</#macro>
<#-- 必须显式调用每个宏来触发渲染 -->
<@layout.title/>
<@layout.head/>
<@layout.content/>
<@layout.sidebar/>
<@layout.footer/>
步骤 4:Java 代码渲染模板
import freemarker.template.*;
import java.io.*;
import java.util.*;
public class FreeMarkerLayoutExample {
public static void main(String[] args) throws Exception {
// 1. 配置 FreeMarker
Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
// 设置模板加载目录 (例如: webapp/WEB-INF/templates)
cfg.setDirectoryForTemplateLoading(new File("/path/to/webapp/WEB-INF/templates"));
cfg.setDefaultEncoding("UTF-8");
// 生产环境用 RETHROW_HANDLER, 开发可用 HTML_DEBUG_HANDLER
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
// 启用模板缓存 (默认开启)
cfg.setTemplateUpdateDelaySeconds(20); // 检查更新的间隔,生产环境可设为0或更大值
// 2. 获取子页面模板 (home_page.ftl)
Template template = cfg.getTemplate("pages/home_page.ftl"); // 路径相对于 templateLoader 目录
// 3. 准备数据模型 (Data Model)
Map<String, Object> dataModel = new HashMap<>();
// 添加首页需要的数据
List<Product> products = Arrays.asList(
new Product("产品A", 99.99),
new Product("产品B", 149.50)
);
List<News> news = Arrays.asList(
new News("新品发布", "/news/new-product"),
new News("优惠活动", "/news/sale")
);
dataModel.put("products", products);
dataModel.put("news", news);
// 4. 合并模板和数据,输出
Writer out = new OutputStreamWriter(System.out); // 可替换为 HttpServletResponse.getWriter()
template.process(dataModel, out);
out.flush();
out.close();
}
// 简单的示例类
static class Product {
String name;
double price;
Product(String name, double price) { this.name = name; this.price = price; }
// getter 方法...
}
static class News {
String title;
String url;
News(String title, String url) { this.title = title; this.url = url; }
// getter 方法...
}
}
三、常见错误
使用
<#include>
而非<#import>
:- 错误:
<#include "/layout/default_layout.ftl">
期望“继承”布局。 - 后果:
default_layout.ftl
的内容被直接复制到home_page.ftl
开头,导致 HTML 结构错乱(如两个<html>
标签),且无法实现区域覆盖。 - 解决:必须使用
<#import>
。
- 错误:
忘记命名空间或使用错误:
- 错误:
<#import "... as layout">
,但在调用时写成<@title/>
或<@layout.title>
(缺少括号)。 - 后果:
<@title/>
会调用当前模板(子模板)的title
宏(如果定义了),而不是布局的。<@layout.title>
语法错误。 - 解决:调用布局的宏必须用
<@layout.title/>
或<@layout.content/>
等完整形式。
- 错误:
在子模板中调用而非覆盖宏:
- 错误:在
home_page.ftl
中写<@layout.title>首页</@layout.title>
试图设置标题,但没有<#macro title>...</#macro>
定义。 - 后果:这会使用布局中
title
宏的默认实现(<#nested> - 我的网站
),输出首页 - 我的网站
,但这发生在home_page.ftl
被处理时。如果布局没有render
宏且子模板没有其他调用,最终输出可能不包含这个标题。 - 正确做法:要改变标题的生成逻辑,必须在子模板中用
<#macro title>...<#nested>...</#macro>
重新定义title
宏。
- 错误:在
<#nested>
位置错误或缺失:- 错误:在宏定义外使用
<#nested>
,或在需要插入子内容的地方忘记写<#nested>
。 - 后果:语法错误或子模板内容无法显示。
- 解决:确保
<#nested>
只在<#macro>
内部,并放置在期望子内容出现的位置。
- 错误:在宏定义外使用
路径错误:
- 错误:
<#import "default_layout.ftl" as layout>
,但文件在layout/
子目录下。 - 后果:
TemplateNotFoundException
。 - 解决:检查路径是否正确,相对于
Configuration
设置的模板加载根目录。
- 错误:
忘记调用
render
或显式宏:- 错误:只定义了宏,但没有
<@layout.render/>
或<@layout.content/>
。 - 后果:模板渲染后输出为空白。
- 解决:必须有语句触发宏的执行。
- 错误:只定义了宏,但没有
四、注意事项
- 命名空间隔离:子模板和布局模板的变量、宏在各自的命名空间内。子模板不能直接访问布局模板中宏内部的局部变量。
- 覆盖机制:覆盖依赖于宏名称匹配。确保子模板中
<#macro>
的名称与布局中完全一致。 <#nested>
的内容:<#nested>
的内容来源于调用宏时<@macroName>content here</@macroName>
标签之间的文本。在继承模式中,这个“调用”通常发生在布局的render
宏内部,而content here
就是子模板中同名宏的体。- 默认内容:布局模板中宏内的内容(在
<#nested>
前后)是默认内容。子模板可以选择是否在覆盖时包含<#nested>
来保留这些默认部分。 - 避免循环导入:A 导入 B,B 又导入 A 会导致错误。
- 编码一致:确保模板文件编码与
Configuration
设置的defaultEncoding
一致(推荐 UTF-8)。
五、使用技巧
- 多级布局:创建
base_layout.ftl
->admin_layout.ftl
(导入base
并添加管理菜单) ->admin_dashboard.ftl
(导入admin
并覆盖content
)。 - 参数化区域:让宏接受参数,增加灵活性。
<#-- 布局中 --> <#macro pageHeader title level=1> <h${level}>${title}</h${level}> </#macro>
<#-- 子页面中覆盖 --> <#macro pageHeader> <@layout.pageHeader title="首页" level=1> <#nested> </@layout.pageHeader> </#macro>
- 条件渲染:在布局宏中使用
<#if hasSidebar>...<@sidebar/></#if>
,子模板可通过设置hasSidebar=true
来控制。 - 使用
render
宏:强烈推荐。它使子模板只需一个<@layout.render/>
,逻辑清晰,不易遗漏。 - 模块化组件:将导航、分页等做成独立宏文件(
/partials/nav.ftl
),在布局或页面中<#import>
使用。 - 调试:开发时使用
TemplateExceptionHandler.HTML_DEBUG_HANDLER
,它会在错误处生成 HTML 注释,便于定位。
六、最佳实践与性能优化
- 清晰的目录结构:如
/layout/
,/partials/
,/pages/
,/emails/
。 - 单一职责:布局负责结构,页面负责内容,组件负责复用。
- 广泛使用宏:将按钮、卡片、表单等 UI 组件化。
<#compress>
:在布局外层使用,减小 HTML 输出体积。- 模板缓存:
Configuration
默认缓存模板。确保TemplateLoader
高效(如FileTemplateLoader
,WebappTemplateLoader
)。生产环境设置合适的templateUpdateDelaySeconds
(如 3600 秒)。 - 避免复杂逻辑:数据处理、业务逻辑放在 Java 层。模板只做展示和简单格式化。
- 安全转义:默认开启自动 HTML 转义 (
cfg.setTemplateExceptionHandler(...)
通常会处理)。使用?no_esc
时务必确认内容安全。 - 预编译 (Advanced):FreeMarker 2.3.30+ 支持
freemarker-core
模块进行预编译,生成 Java 字节码,极致性能,但增加复杂性。 - 监控:记录模板渲染时间,监控性能瓶颈。
- 版本控制:模板是代码的一部分,纳入 Git 等版本控制系统。
遵循这些指南,你就能高效、安全地利用 FreeMarker 实现强大的默认布局与可重写区域功能,构建可维护的前端模板系统。