FreeMarker 模板引擎支持通过 <#include><#import><#nested> 等指令实现模板的继承覆盖(更准确地说,是通过 宏(macro)模板包含/导入 机制来模拟类似面向对象的继承与覆盖行为)。虽然 FreeMarker 本身没有像 Java 那样严格的类继承语法,但通过巧妙的设计,可以实现强大的模板复用和结构化布局。


一、核心概念

  1. 模板继承 (Template Inheritance)

    • 本质:FreeMarker 并没有原生的 extends 关键字。所谓的“继承”是通过宏定义宏调用来模拟的。
    • 父模板 (Parent Template / Layout Template):定义一个基础模板,其中包含通用的页面结构(如 HTML 的 <head><header><footer>)和可被子模板填充的“占位符”区域(通过宏实现)。
    • 子模板 (Child Template):引用(导入或包含)父模板,并通过定义同名的宏来“覆盖”父模板中占位符区域的内容。
    • <#nested> 指令:这是实现“继承”逻辑的关键。它允许在父模板的宏内部执行子模板中定义的同名宏体(或内容块)。
  2. 覆盖 (Overriding)

    • 在子模板中定义一个与父模板中某个宏同名的宏。
    • 当父模板调用该宏时,实际执行的是子模板中定义的宏体,从而实现了“覆盖”父模板部分内容的效果。
  3. <#import> vs <#include>

    • <#import "template.ftl">:将指定模板导入为一个命名空间 (namespace)。导入模板中的宏、函数、变量等通过命名空间前缀访问(如 ns.macroName())。这是实现“继承”模式推荐的方式,因为它避免了命名冲突,结构更清晰。
    • <#include "template.ftl">:将指定模板的内容直接插入到当前位置。导入模板中的所有内容(宏、变量、文本)都成为当前模板的一部分。容易导致命名冲突,不推荐用于复杂的继承结构。
  4. <#nested>

    • 一个指令,只能在宏定义内部使用。
    • 当宏被调用时,<#nested> 会被替换为调用该宏时所提供的“嵌套内容”(即在 <@macroName>...</@macroName> 标签之间写的内容)。
    • 在“继承”模式中,父模板的宏使用 <#nested> 来定义一个“插槽”,子模板通过 <@parentMacro>...</@parentMacro> 调用并填充这个插槽。
  5. 命名空间 (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>&copy; 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!");
    }
}

三、常见错误

  1. 命名空间错误

    • 错误:忘记使用命名空间前缀调用宏,例如写了 <@content> 而不是 <@blog.content>
    • 解决:确保 <#import> 时使用 as nsName,并在调用宏时使用 nsName.macroName
  2. <#nested> 使用错误

    • 错误:在非宏定义内部使用 <#nested>,或在不需要的地方使用。
    • 解决<#nested> 只能在 <#macro><#function> 定义内部使用。确保父宏使用了 <#nested> 来定义插槽。
  3. 宏覆盖未生效

    • 错误:子模板定义了宏,但父模板没有调用它,或者调用的宏名不匹配。
    • 解决:检查宏名称拼写是否完全一致。确保父模板确实调用了该宏。
  4. 多层继承中断

    • 错误:在中间层模板(如 blogLayout.ftl)中覆盖了一个宏(如 content),但没有正确使用 <#nested><@parentMacro><#nested></@parentMacro> 将调用链传递下去,导致子模板无法再覆盖。
    • 解决:在中间层覆盖宏时,如果希望子模板还能覆盖,必须在宏体中使用 <#nested> 并通过 <@parentNamespace.macroName><#nested></@parentNamespace.macroName> 将嵌套内容传递给真正的父宏。见 blogLayout.ftlcontent 宏的实现。
  5. 导入 vs 包含混淆

    • 错误:使用 <#include> 代替 <#import>,导致命名冲突或无法正确访问宏。
    • 解决:对于实现“继承”模式,优先使用 <#import>
  6. 循环导入

    • 错误:模板 A 导入 B,B 又导入 A,导致无限循环。
    • 解决:检查导入关系,避免形成循环依赖。

四、注意事项

  1. 清晰的命名:为模板、命名空间和宏使用清晰、一致的命名约定。
  2. 避免过度复杂:虽然可以实现多层继承,但层级不宜过深(通常2-3层足够),否则维护困难。
  3. 理解调用栈:当子模板调用 <@blog.content> 时,实际上是 blogLayout.ftlcontent 宏被调用,该宏内部又调用了 layout.ftlcontent 宏,并将子模板的内容作为 <#nested> 传递。理解这个调用链很重要。
  4. 默认值:在父模板的宏中提供有意义的默认内容,提高健壮性。
  5. 文档化:为复杂的布局模板和宏编写文档,说明哪些宏可以被覆盖,以及它们的参数。
  6. <#compress>:在布局模板的 <html><body> 标签上使用 <#compress> 指令可以减少最终 HTML 的空白字符,但要小心使用,避免压缩掉必要的空白。

五、使用技巧

  1. 使用 <#assign> 在命名空间内定义变量:可以在父模板或中间模板中定义一些辅助变量或配置。
  2. 宏参数:为宏定义参数,增加灵活性。如 blogLayout.ftl 中的 postInfo 宏。
  3. 条件宏调用:在父模板中根据条件决定是否调用某个宏或传递不同的内容。
  4. 利用 ?has_content:检查宏体内容是否为空,避免输出空的 HTML 标签。
  5. 模块化:将通用组件(如导航、分页)也做成宏,放在单独的模板中,通过 <#import> 复用。

六、最佳实践与性能优化

  1. 使用 <#import> 而非 <#include>:这是实现可维护的“继承”结构的基础。
  2. 缓存配置Configuration 对象是线程安全的,应该在应用启动时创建单例并复用。它会自动缓存已解析的模板。
  3. 模板缓存:确保 Configuration 的模板缓存已启用(默认启用)。避免每次渲染都重新解析模板。
  4. 预编译:在可能的情况下,考虑预编译模板(虽然 FreeMarker 主要是运行时解析,但缓存机制起到了类似作用)。
  5. 减少复杂逻辑:模板中避免复杂的业务逻辑和循环嵌套过深。将复杂数据处理放在 Java 代码中,传递处理好的数据模型给模板。
  6. 监控模板加载:关注模板加载时间和内存使用,确保没有频繁重新加载模板(通常配置正确后不会发生)。
  7. 分离关注点
    • 布局模板:只负责页面结构和定义可覆盖区域。
    • 组件模板:包含可复用的 UI 组件(按钮、卡片等)。
    • 页面模板:负责具体页面的内容填充和数据展示。
  8. 版本控制:将模板文件纳入版本控制系统(如 Git)。
  9. 测试:编写模板测试用例,验证不同数据模型下的输出是否符合预期。

通过遵循以上详细的步骤和最佳实践,您可以在 FreeMarker 中有效地实现多层模板继承与覆盖,构建结构清晰、易于维护和复用的模板系统。记住,关键在于利用 <#import> 创建命名空间和 <#nested> 实现内容插槽。