FreeMarker 是一个强大的 Java 模板引擎,广泛用于生成 HTML、XML、JSON 或纯文本。其模板继承机制是构建可复用、结构清晰的模板体系的核心功能,类似于面向对象编程中的继承。


一、核心概念

  1. <#include> vs <#import> / <#import>

    • <#include>: 包含 (Include)。将另一个模板文件的内容原封不动地插入到当前位置。被包含的模板是独立的,没有命名空间的概念。适用于包含静态片段(如页眉、页脚)。
    • <#import> / <#import>: 导入 (Import)。将另一个模板作为一个“库”导入,创建一个命名空间。通过命名空间可以访问被导入模板中的宏、函数和变量。这是实现“继承”语义的基础。<#import><#import> 的别名。
    • 关键区别<#include> 是内容复制粘贴,<#import> 是创建命名空间并按需调用。
  2. <#macro> (宏)

    • 定义可重用的代码块,可以带参数。
    • 在模板继承中,父模板定义“占位”宏(如 header(), content(), footer()),子模板通过 <#nested> 覆盖这些宏的具体实现。
  3. <#nested>

    • 在宏定义中使用,表示“在此处插入调用该宏时提供的嵌套内容”。
    • 继承的核心:父模板中的宏使用 <#nested> 定义“槽”(slot),子模板在调用该宏时,将其内容作为嵌套内容传入,从而“填充”槽位,实现内容覆盖。
  4. <#compress>

    • 用于压缩输出,移除多余的空白字符。在布局模板中常用,以避免继承引入的额外空行。
  5. 命名空间 (Namespace)

    • <#import> 创建。例如 <#import "/layout/base.ftl" as layout> 创建了一个名为 layout 的命名空间。通过 layout.header() 调用父模板中的宏。
  6. “继承”的本质

    • FreeMarker 本身没有像 Java 那样的 extends 关键字。
    • 所谓“模板继承”是通过 <#import> 导入一个包含通用结构和占位宏的“布局模板”(Layout Template),然后子模板(Page Template)覆盖(重定义)这些宏来实现的。这是一种基于宏和命名空间的模拟继承模式

二、操作步骤(非常详细)

假设我们要创建一个网站,有统一的页眉、导航、页脚,但每个页面的主内容不同。

步骤 1:创建父模板 (布局模板 - base.ftl)

  1. 创建文件:在模板目录(如 /templates/layout/)下创建 base.ftl
  2. 定义通用结构:编写 HTML 基本结构。
  3. 定义“槽”宏:使用 <#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>&copy; 2025 我的公司. 保留所有权利。</p>
        </#macro>
    </footer>
</body>
</html>
</#compress>

步骤 2:创建子模板 (页面模板 - home.ftl)

  1. 创建文件:在模板目录(如 /templates/pages/)下创建 home.ftl
  2. 导入布局:使用 <#import> 导入 base.ftl,并指定一个命名空间(如 layout)。
  3. 覆盖宏:使用 <#macro> 重新定义(覆盖)父模板中定义的宏(title, head, content, sidebar, footer)。注意:这里不是调用宏,而是重新定义它。
  4. 调用父模板的 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>&copy; 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();
    }
}

三、常见错误

  1. 混淆 <#include><#import>

    • 错误:在子模板中使用 <#include "/layout/base.ftl"> 期望实现继承。
    • 结果base.ftl 的内容被直接复制到 home.ftl 开头,导致 HTML 结构重复或混乱,且无法覆盖宏。
    • 解决:必须使用 <#import> 并配合命名空间。
  2. 忘记使用命名空间

    • 错误<#import "/layout/base.ftl" as layout>,但在调用时写成 <@title/><@layout.title> (缺少括号)。
    • 结果<@title/> 会调用当前命名空间(子模板)的 title 宏(如果定义了),而不是父模板的。<@layout.title> 语法错误。
    • 解决:调用父模板宏必须用 <@layout.title/><@layout.title></@layout.title>
  3. 在子模板中错误地“调用”而非“覆盖”宏

    • 错误:在 home.ftl 中写 <@layout.title>首页</@layout.title> 试图设置标题,但没有重新定义 title 宏。
    • 结果base.ftl 中的 title 宏被调用,<#nested> 被替换为 "首页",但 home.ftl 本身没有定义 title 宏来 改变 这个行为。如果 base.ftltitle 宏是 <#macro title><#nested> - 我的网站</#macro>,输出会是 首页 - 我的网站,但这发生在 base.ftl 被处理时,而不是通过继承机制。如果 base.ftl 没有 visit 宏,且 home.ftl 没有显式调用,标题可能不会出现。
    • 正确做法:要改变父模板宏的行为,必须在子模板中用 <#macro title>...<#nested>...</#macro> 重新定义它。然后通过 layout.visit() 或显式调用 <@layout.title>首页</@layout.title>使用这个被覆盖后的宏。
  4. <#nested> 使用不当

    • 错误:在宏定义外部使用 <#nested>
    • 结果:语法错误。
    • 解决<#nested> 只能在 <#macro> 定义的宏内部使用。
  5. 路径错误

    • 错误<#import "base.ftl" as layout>base.ftllayout 子目录下。
    • 结果:找不到模板。
    • 解决:确保路径正确,相对于 Configuration 设置的模板加载目录。
  6. 忘记调用 visit 或显式宏

    • 错误:只定义了宏,但没有 <@layout.visit/><@layout.content/> 等调用。
    • 结果:模板渲染后输出为空或只有子模板中非宏部分的内容。
    • 解决:必须有触发点来执行宏调用。

四、注意事项

  1. 命名空间是隔离的:子模板中定义的宏/变量不会自动影响父模板的命名空间,反之亦然(除非显式传递)。覆盖是通过同名宏在子命名空间中“遮蔽”父命名空间的宏来实现的。
  2. 宏的覆盖是“就近原则”:当通过 layout.content() 调用时,FreeMarker 会先在当前命名空间(子模板)查找是否有 content 宏定义,如果有则使用它(即覆盖版本),否则回退到 layout 命名空间查找。
  3. <#nested> 的内容来源<#nested> 的内容来自于调用该宏时 <@...>...</@...> 标签之间的内容。在继承模式下,这个内容通常就是子模板中定义的同名宏体本身(通过宏覆盖机制实现)。
  4. 默认内容:父模板中宏内的 <#nested> 前后可以有默认内容,子模板覆盖时可以选择是否包含 <#nested> 来保留默认部分。
  5. 避免循环导入:确保导入关系是单向的(子导入父),避免 A 导入 B,B 又导入 A。
  6. 编码:确保模板文件和 Configuration 设置的编码一致(通常是 UTF-8)。

五、使用技巧

  1. 多级继承:可以创建 base.ftl -> layout-with-sidebar.ftl -> home.ftllayout-with-sidebar.ftl 导入 base.ftl,覆盖或添加宏,home.ftl 再导入 layout-with-sidebar.ftl
  2. 参数化宏:父模板的宏可以接受参数,使“槽”更灵活。
    <#-- 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>
    
  3. 条件渲染:在父模板宏中使用 <#if> 根据变量决定是否渲染某部分。
  4. 使用 visit:强烈推荐使用 visit 宏来封装渲染逻辑,使子模板只需一个 <@layout.visit/> 即可,代码更清晰。
  5. 模块化:将页眉、页脚等也做成独立的宏库,通过 <#import>base.ftl 中引入,提高复用性。
  6. 调试:利用 Configuration.setTemplateExceptionHandler() 设置合适的处理器(如 HTML_DEBUG_HANDLER)帮助定位模板错误。

六、最佳实践与性能优化

  1. 清晰的目录结构

    • /templates/layout/ (存放 base.ftl, admin-layout.ftl 等)
    • /templates/partials/ (存放可复用的片段,如 navigation.ftl, pagination.ftl)
    • /templates/pages/ (存放具体的页面模板 home.ftl, about.ftl)
    • /templates/email/ (存放邮件模板)
  2. 单一职责:每个模板文件职责明确。base.ftl 负责整体布局,page.ftl 负责页面特定内容。

  3. 广泛使用宏:将可复用的 UI 组件(按钮、卡片、表单字段)定义为宏,提高一致性。

  4. 合理使用 <#compress>:在布局模板的最外层使用 <#compress> 可以有效减少 HTML 输出大小,提升页面加载速度。注意它会移除所有空白,确保不影响格式化文本。

  5. 缓存模板Configuration 对象会自动缓存已解析的模板(基于 TemplateLoader)。确保 TemplateLoaderfindTemplateSourcegetLastModified 实现高效,以便正确利用缓存。在生产环境,模板通常不会频繁修改,缓存非常有效。

  6. 避免在模板中进行复杂计算:将数据处理逻辑放在 Java 代码或数据模型准备阶段。模板应专注于展示。避免在模板中进行耗时的数据库查询或复杂循环。

  7. 使用 ?no_esc 谨慎?no_esc 会禁用 HTML 转义,有 XSS 风险。仅在确定内容安全时使用,或使用更安全的 ?interpret 处理动态生成的 FTL 代码。

  8. 预编译(FreeMarker 2.3.30+):对于极其高性能要求的场景,可以考虑使用 freemarker-core 模块进行模板预编译,但这会增加复杂性,通常缓存已足够。

  9. 监控与日志:记录模板渲染时间和错误,便于性能分析和问题排查。

  10. 版本控制:将模板文件纳入版本控制系统(如 Git)。

通过遵循这些概念、步骤、注意事项和最佳实践,你可以有效地利用 FreeMarker 的模板继承机制,构建出结构良好、易于维护和高性能的模板系统。