FreeMarker 通过其宏(<#macro>)和导入(<#import>)机制,巧妙地实现了“默认布局”与“可重写区域”的功能,这并非语言内置的继承,而是一种设计模式。它允许你定义一个包含通用结构和默认内容的主布局模板,然后让具体的页面模板继承此布局,并有选择地覆盖(重写)特定区域的内容。


一、核心概念

  1. 布局模板 (Layout Template)

    • 一个定义了网站整体 HTML 结构(如 <html>, <head>, <body>)和通用组件(页眉、导航、页脚)的 FreeMarker 模板文件(如 default_layout.ftl)。
    • 它包含了多个使用 <#macro> 定义的“区域”(或“槽位”),这些区域代表了可以被子页面修改的部分。
  2. 可重写区域 (Overridable Region / Slot)

    • 在布局模板中使用 <#macro> 定义的特定部分。
    • 这些宏内部通常包含 <#nested> 指令,用于指示子模板内容的插入点。
    • 子模板通过重新定义同名的宏来“覆盖”这些区域的默认内容。
  3. <#macro> (宏)

    • 定义可重用代码块的指令。在布局中,它定义了区域的“接口”和默认实现。
    • 语法:<#macro macroName> ... <#nested> ... </#macro>
  4. <#nested>

    • 关键指令。在宏定义内部使用,表示“在此处插入调用此宏时提供的嵌套内容”。
    • 在继承模式中,当子模板“覆盖”一个宏时,它实际上是定义了一个同名的新宏,而父布局在调用这个宏时,<#nested> 就会被子模板中该宏定义的内容所替换。
  5. <#import> (导入)

    • 将另一个模板文件作为“库”导入,并为其创建一个命名空间
    • 语法:<#import "path/to/layout.ftl" as ns>ns 是命名空间别名(如 layout)。
    • 通过命名空间可以访问被导入模板中的宏、函数和变量,例如 <@layout.header/>
  6. “继承”的本质

    • 不是真正的继承:FreeMarker 没有 extends 关键字。
    • 基于宏覆盖的模拟:子模板通过 <#import> 获取布局模板的引用(命名空间)。当子模板定义一个与布局模板中同名的宏时,在渲染上下文中,子模板的宏“遮蔽”了布局模板的宏。当布局模板(通过其命名空间)调用这个宏时,实际执行的是子模板中定义的版本,从而实现了“可重写”。
  7. <#compress>

    • 用于压缩模板输出,移除不必要的空白、换行和制表符。在布局模板中包裹整个结构,可以避免因模板文件格式化(换行、缩进)导致的 HTML 输出中出现多余的空行。

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

目标:创建一个带默认页眉、页脚和可重写主内容区的布局。

步骤 1:创建默认布局模板 (default_layout.ftl)

  1. 创建文件:在模板目录(如 /WEB-INF/templates/layout/)下创建 default_layout.ftl
  2. 定义基础 HTML 结构:编写标准的 HTML 文档结构。
  3. 定义可重写区域:使用 <#macro> 为希望子页面能修改的部分创建“槽位”。最常见的是 title, head, content
  4. 使用 <#nested>:在每个宏中使用 <#nested> 来标记子页面内容的插入点。
  5. (推荐) 添加 render:创建一个宏(如 rendervisit)来封装所有区域的调用,简化子模板的操作。
<#-- /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>&copy; 2025 我的公司. All rights reserved.</p>
        </#macro>
    </footer>

    <!-- (推荐) 定义 render 宏: 负责触发所有区域的渲染 -->
    <#macro render>
        <!-- 调用各个可重写区域的宏 -->
        <@title/>
        <@head/>
        <@content/>
        <@sidebar/>
        <@footer/>
    </#macro>
</body>
</html>
</#compress>

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

  1. 创建文件:在模板目录(如 /WEB-INF/templates/pages/)下创建 home_page.ftl
  2. 导入布局:使用 <#import>default_layout.ftl 导入,并指定一个命名空间(如 layout)。这是实现“继承”的第一步
  3. 覆盖可重写区域:使用 <#macro> 重新定义你在布局中想要修改的宏(如 title, content)。注意,这里不是调用宏,而是定义一个同名的新宏。
  4. (推荐) 调用 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 方法...
    }
}

三、常见错误

  1. 使用 <#include> 而非 <#import>

    • 错误<#include "/layout/default_layout.ftl"> 期望“继承”布局。
    • 后果default_layout.ftl 的内容被直接复制到 home_page.ftl 开头,导致 HTML 结构错乱(如两个 <html> 标签),且无法实现区域覆盖。
    • 解决必须使用 <#import>
  2. 忘记命名空间或使用错误

    • 错误<#import "... as layout">,但在调用时写成 <@title/><@layout.title> (缺少括号)。
    • 后果<@title/> 会调用当前模板(子模板)的 title 宏(如果定义了),而不是布局的。<@layout.title> 语法错误。
    • 解决:调用布局的宏必须用 <@layout.title/><@layout.content/> 等完整形式。
  3. 在子模板中调用而非覆盖宏

    • 错误:在 home_page.ftl 中写 <@layout.title>首页</@layout.title> 试图设置标题,但没有 <#macro title>...</#macro> 定义。
    • 后果:这会使用布局中 title 宏的默认实现<#nested> - 我的网站),输出 首页 - 我的网站,但这发生在 home_page.ftl 被处理时。如果布局没有 render 宏且子模板没有其他调用,最终输出可能不包含这个标题。
    • 正确做法:要改变标题的生成逻辑,必须在子模板中用 <#macro title>...<#nested>...</#macro> 重新定义 title 宏。
  4. <#nested> 位置错误或缺失

    • 错误:在宏定义外使用 <#nested>,或在需要插入子内容的地方忘记写 <#nested>
    • 后果:语法错误或子模板内容无法显示。
    • 解决:确保 <#nested> 只在 <#macro> 内部,并放置在期望子内容出现的位置。
  5. 路径错误

    • 错误<#import "default_layout.ftl" as layout>,但文件在 layout/ 子目录下。
    • 后果TemplateNotFoundException
    • 解决:检查路径是否正确,相对于 Configuration 设置的模板加载根目录。
  6. 忘记调用 render 或显式宏

    • 错误:只定义了宏,但没有 <@layout.render/><@layout.content/>
    • 后果:模板渲染后输出为空白。
    • 解决:必须有语句触发宏的执行。

四、注意事项

  1. 命名空间隔离:子模板和布局模板的变量、宏在各自的命名空间内。子模板不能直接访问布局模板中宏内部的局部变量。
  2. 覆盖机制:覆盖依赖于宏名称匹配。确保子模板中 <#macro> 的名称与布局中完全一致。
  3. <#nested> 的内容<#nested> 的内容来源于调用宏时 <@macroName>content here</@macroName> 标签之间的文本。在继承模式中,这个“调用”通常发生在布局的 render 宏内部,而 content here 就是子模板中同名宏的
  4. 默认内容:布局模板中宏内的内容(在 <#nested> 前后)是默认内容。子模板可以选择是否在覆盖时包含 <#nested> 来保留这些默认部分。
  5. 避免循环导入:A 导入 B,B 又导入 A 会导致错误。
  6. 编码一致:确保模板文件编码与 Configuration 设置的 defaultEncoding 一致(推荐 UTF-8)。

五、使用技巧

  1. 多级布局:创建 base_layout.ftl -> admin_layout.ftl (导入 base 并添加管理菜单) -> admin_dashboard.ftl (导入 admin 并覆盖 content)。
  2. 参数化区域:让宏接受参数,增加灵活性。
    <#-- 布局中 -->
    <#macro pageHeader title level=1>
        <h${level}>${title}</h${level}>
    </#macro>
    
    <#-- 子页面中覆盖 -->
    <#macro pageHeader>
        <@layout.pageHeader title="首页" level=1>
            <#nested>
        </@layout.pageHeader>
    </#macro>
    
  3. 条件渲染:在布局宏中使用 <#if hasSidebar>...<@sidebar/></#if>,子模板可通过设置 hasSidebar=true 来控制。
  4. 使用 render强烈推荐。它使子模板只需一个 <@layout.render/>,逻辑清晰,不易遗漏。
  5. 模块化组件:将导航、分页等做成独立宏文件(/partials/nav.ftl),在布局或页面中 <#import> 使用。
  6. 调试:开发时使用 TemplateExceptionHandler.HTML_DEBUG_HANDLER,它会在错误处生成 HTML 注释,便于定位。

六、最佳实践与性能优化

  1. 清晰的目录结构:如 /layout/, /partials/, /pages/, /emails/
  2. 单一职责:布局负责结构,页面负责内容,组件负责复用。
  3. 广泛使用宏:将按钮、卡片、表单等 UI 组件化。
  4. <#compress>:在布局外层使用,减小 HTML 输出体积。
  5. 模板缓存Configuration 默认缓存模板。确保 TemplateLoader 高效(如 FileTemplateLoader, WebappTemplateLoader)。生产环境设置合适的 templateUpdateDelaySeconds(如 3600 秒)。
  6. 避免复杂逻辑:数据处理、业务逻辑放在 Java 层。模板只做展示和简单格式化。
  7. 安全转义:默认开启自动 HTML 转义 (cfg.setTemplateExceptionHandler(...) 通常会处理)。使用 ?no_esc 时务必确认内容安全。
  8. 预编译 (Advanced):FreeMarker 2.3.30+ 支持 freemarker-core 模块进行预编译,生成 Java 字节码,极致性能,但增加复杂性。
  9. 监控:记录模板渲染时间,监控性能瓶颈。
  10. 版本控制:模板是代码的一部分,纳入 Git 等版本控制系统。

遵循这些指南,你就能高效、安全地利用 FreeMarker 实现强大的默认布局与可重写区域功能,构建可维护的前端模板系统。