Thymeleaf

Thymeleaf 页面布局

简介

通常网站会共享一些常见的页面组件,比如页头、页脚、菜单以及可能的其他组件。这些页面组件可以被相同或不同的布局所使用。在项目中组织布局主要有两种风格:包含(include)风格 和层级(hierarchical)风格。这两种风格都可以轻松地与Thymeleaf结合使用而不会失去其最大的优势:自然模板化.

包含式布局

在这种风格下,页面是通过将通用页面组件代码直接嵌入每个视图中来构建最终结果的。在Thymeleaf中,这可以通过Thymeleaf标准布局系统:

<body>
    <div th:insert="footer :: copy">...</div>
</body>

包含式布局很容易理解和实现,事实上它们在开发视图时提供了灵活性,这是其最大的优势。然而,这种解决方案的主要缺点是一些代码重复会被引入,所以在大型应用中修改大量视图的布局可能会变得有些麻烦。

层级式布局

在层级风格中,模板通常是通过父子关系创建的,从较通用的部分(布局)到最具体的部分(子视图;例如页面内容)。模板的各个组件可以根据模板片段的包含和替换动态地被引入。在Thymeleaf中,这可以通过Thymeleaf Layout 方言.

这种解决方案的主要优点是视图中的原子部分可重用及模块化设计,然而主要缺点是在使用它们时需要更多的配置,因此相对于更“自然”使用的包含式布局,视图复杂性更高。

示例应用

本文中展示的所有示例和代码片段可在GitHub上获取:https://github.com/thymeleaf/thymeleafexamples-layouts

Thymeleaf标准布局系统

Thymeleaf标准布局系统提供的页面片段包含功能类似于JSP 的 include 功能,但在多个方面有重要改进。

基本包含th:insertth:replace

Thymeleaf能以片段的形式包含其他页面的部分内容(而JSP只能包含完整的页面),使用方式为th:insert(它会简单地将指定的片段插入到宿主标签体中)或th:replace(实际是将宿主标签替换成该片段)。这样就可以将片段分组到一个或多个页面中。请看以下示例。

这个home/homeNotSignedIn.html当匿名用户进入我们应用程序的首页时,这个模板会被渲染。

thymeleafexamples.layouts.home.HomeController

@Controller
class HomeController {

    @GetMapping("/")
    String index(Principal principal) {
        return principal != null ? "home/homeSignedIn" : "home/homeNotSignedIn";
    }

}

模板home/homeNotSignedIn.html

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    ...
    <div th:replace="fragments/header :: header">
      <!-- ============================================================================ -->
      <!-- This content is only used for static prototyping purposes (natural templates)-->
      <!-- and is therefore entirely optional, as this markup fragment will be included -->
      <!-- from "fragments/header.html" at runtime.                                     -->
      <!-- ============================================================================ -->
      <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
          <div class="navbar-header">
            <a class="navbar-brand" href="#">Static header</a>
          </div>
          <div class="navbar-collapse collapse">
            <ul class="nav navbar-nav">
              <li class="active"><a href="#">Home</a></li>
            </ul>
          </div>
        </div>
      </div>
    </div>
    <div class="container">
      <div class="hero-unit">
        <h1>Test</h1>
        <p>
          Welcome to the Spring MVC Quickstart application!
          Get started quickly by signing up.
        </p>
        <p>
          <a href="/signup" th:href="@{/signup}" class="btn btn-large btn-success">Sign up</a>
        </p>
      </div>
      <div th:replace="fragments/footer :: footer">&copy; 2016 The Static Templates</div>
    </div>
    ...
  </body>
</html>

你可以直接在浏览器中打开该文件:

Home page when not signed in
未登录状态下的首页

在上面的例子中,我们正在构建一个包含页面头部和页面尾部的页面。在Thymeleaf中,所有片段可以在单个文件中定义(例如fragments.html),也可以像这个特定情况一样,在单独的文件中定义。

我们简要分析一下包含语句:

<div th:replace="fragments/header :: header">...</div>

语句的第一部分,fragments/header,是我们引用的模板名称。这可以是一个文件(如本例),或者也可以通过使用this关键字(例如this :: header)或不使用任何关键字(例如:: header)来引用同一文件。双冒号后的表达式是一个片段选择器(fragment selector)(要么是片段名称,要么是标记选择器)。你还可以看到,header 片段包含的标记仅供静态原型设计使用。

Header 和 footer 分别定义在以下文件中:

模板fragments/header.html

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <div class="navbar navbar-inverse navbar-fixed-top" th:fragment="header">
      <div class="container">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".nav-collapse">
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="#">My project</a>
        </div>
        <div class="navbar-collapse collapse">
          <ul class="nav navbar-nav">
            <li th:classappend="${module == 'home' ? 'active' : ''}">
              <a href="#" th:href="@{/}">Home</a>
            </li>
            <li th:classappend="${module == 'tasks' ? 'active' : ''}">
              <a href="#" th:href="@{/task}">Tasks</a>
            </li>
          </ul>
          <ul class="nav navbar-nav navbar-right">
            <li th:if="${#authorization.expression('!isAuthenticated()')}">
              <a href="/signin" th:href="@{/signin}">
                <span class="glyphicon glyphicon-log-in" aria-hidden="true"></span>&nbsp;Sign in
              </a>
            </li>
            <li th:if="${#authorization.expression('isAuthenticated()')}">
              <a href="/logout" th:href="@{#}" onclick="$('#form').submit();">
                <span class="glyphicon glyphicon-log-out" aria-hidden="true"></span>&nbsp;Logout
              </a>
             <form style="visibility: hidden" id="form" method="post" action="#" th:action="@{/logout}"></form>
            </li>
          </ul>
        </div>
      </div>
    </div>
  </body>
</html>

……我们可以直接在浏览器中打开:

Header page
头部页面

以及模板fragments/footer.html

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <div th:fragment="footer">
      &copy; 2016 Footer
    </div>
  </body>
</html>

注意引用的片段是如何通过th:fragment属性定义的。这样我们就可以在一个模板文件中定义多个片段,正如前面提到的那样。

重要的是,所有的模板仍然可以作为自然模板存在,并且可以在没有运行服务器的情况下在浏览器中查看。

使用标记选择器进行包含

在Thymeleaf中,提取页面中的片段不需要显式地通过th:fragment来声明。Thymeleaf可以通过类似于XPath表达式、CSS或jQuery选择器的标记选择器(Markup Selector)语法,从页面中任意选取一个部分作为片段(即使页面位于外部服务器上)。

<div th:insert="https://www.thymeleaf.org :: section.description" >...</div>

上面的代码将会包含一个section元素class="description"来自thymeleaf.org.

为了实现这一点,模板引擎必须配置启用UrlTemplateResolver:

@Bean
public SpringTemplateEngine templateEngine() {
    SpringTemplateEngine templateEngine = new SpringTemplateEngine();    
    templateEngine.addTemplateResolver(new UrlTemplateResolver());
    ...
    return templateEngine;
}

有关Markup Selector语法的参考,请查阅Thymeleaf文档中的这一章节:Markup Selector语法.

使用表达式

templatename :: selectortemplatenameselector可以是完整的表达式。在下面的例子中,我们需要根据条件包含不同的片段。如果已认证的用户是管理员,则显示不同的页脚:

<div th:replace="fragments/footer :: ${#authentication.principal.isAdmin()} ? 'footer-admin' : 'footer'">
  &copy; 2016 The Static Templates
</div>

fragments/footer.html已稍作更改,因为我们需要定义两个页脚:

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <!-- /*  Multiple fragments may be defined in one file */-->
    <div th:fragment="footer">
      &copy; 2016 Footer
    </div>
    <div th:fragment="footer-admin">
      &copy; 2016 Admin Footer
    </div>
  </body>
</html>

参数化包含

片段可以指定参数,就像方法一样。每当它们通过th:fragment属性显式声明时,它们可以提供一个参数签名,然后可以从调用端的th:insertth:replace属性中填充值。

最好的说明方式就是例子。我们可以在很多上下文中使用参数化包含,但一个真实的场景是在表单成功提交后,在我们应用程序的不同页面上显示消息。让我们看一下应用中的注册流程:

@PostMapping("signup")
String signup(@Valid @ModelAttribute SignupForm signupForm,
        Errors errors, RedirectAttributes ra) {
    
    if (errors.hasErrors()) {
        return SIGNUP_VIEW_NAME;
    }
    Account account = accountRepository.save(signupForm.createAccount());
    userService.signin(account);
    // see /WEB-INF/i18n/messages.properties and /WEB-INF/views/homeSignedIn.html
    MessageHelper.addSuccessAttribute(ra, "signup.success");
    
    return "redirect:/";
    
}

如你所见,在注册成功之后,用户将被重定向到首页,并带上填充好的flash属性。我们希望创建一个可复用并支持参数化的片段。这可以通过如下方式完成:

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <div class="alert alert-dismissable" th:fragment="alert (type, message)" th:assert="${!#strings.isEmpty(type) and !#strings.isEmpty(message)}" th:classappend="'alert-' + ${type}">      
      <button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
      <span th:text="${message}">Test</span>
    </div>
  </body>
</html>

上述alert片段接受两个参数:typemessage。第一个type是用于设置消息样式的消息类型,而第二个message是将显示给用户的文本。我们通过使用th:assert属性

来确保参数存在且不为空。alert要在任何模板中包含

<div th:replace="fragments/alert :: alert (type='danger', message=${errorMessage})">...</div>

参数化片段让开发人员可以创建类似函数的片段,使其更易于重用。有关参数化片段的更多信息,请参阅Thymeleaf文档:可参数化的片段签名.

片段表达式

Thymeleaf 3.0 在其通用Thymeleaf标准表达式系统中引入了一种新的表达式类型:片段表达式:

    <div th:insert="~{fragments/footer :: footer}">...</div>

此语法的设计目的是能够将解析后的片段作为模板执行上下文中的其他对象一样使用,以便后续使用:

<div th:replace="${#authentication.principal.isAdmin()} ? ~{fragments/footer :: footer-admin} : ~{fragments/footer :: footer-admin}">
  &copy; 2016 The Static Templates
</div>

片段表达式允许以一种方式创建片段,使得它们可以使用来自调用模板的标记进行增强,从而形成一种比仅使用th:insertth:replace更灵活的布局机制。

灵活的布局示例

这个task/layout.html文件定义了调用模板将使用的全部片段。下面的header片段接受一个breadcrumb参数,该参数将替换掉ol标记,并以其解析后的值替代:

<!--/* Header fragment */-->
<div th:fragment="header(breadcrumb)">
    <ol class="breadcrumb container" th:replace="${breadcrumb}">
        <li><a href="#">Home</a></li>
    </ol>
</div>

在调用模板(task/task-list.html)中,我们将使用标记选择器语法来传递匹配.breadcrumb选择器的元素:

<!--/* The markup with breadcrumb class will be passed to the header fragment */-->
<header th:insert="task/layout :: header(~{ :: .breadcrumb})">
    <ol class="breadcrumb container">
        <li><a href="#">Home</a></li>
        <li><a href="#" th:href="@{/task}">Tasks</a></li>
    </ol>
</header>

结果是,为task/taks-list视图生成以下HTML:

<header>
    <div>
        <ol class="breadcrumb container">
            <li><a href="#">Home</a></li>
            <li><a href="[...]">Tasks</a></li>
        </ol>
    </div>
</header>

类似地,我们可以在这个片段中使用另一个视图(task/task.html):

<header th:insert="task/layout :: header(~{ :: .breadcrumb})">
    <ol class="breadcrumb container">
        <li><a href="#">Home</a></li>
        <li><a href="#" th:href="@{/task}">Tasks</a></li>
        <li th:text="${'Task ' + task.id}">Task</li>
    </ol>
</header>

如果没有内容要传递给片段,则可以使用特殊的空片段表达式——~{}。它将传递一个空值,在header片段中会被忽略:

<header th:insert="task/layout :: header(~{})">
    
</header>

新片段表达式的另一个特性是所谓的无操作标记,它允许在需要时使用片段的默认标记:

<header th:insert="task/layout :: header(_)">
    
</header>

最终我们将得到:

<header>
    <ol class="breadcrumb container">
        <li><a href="#">Home</a></li>
    </ol>
</header>

片段表达式支持以各种方式进行片段定制,而这种方式在此之前只有第三方Layout方言才能实现。更多关于此主题的信息,请查阅Thymeleaf文档:灵活布局:超越简单的片段插入

从Spring中包含片段@Controller

片段可以直接由Spring MVC控制器指定,例如:signup :: signupForm,这对于只向浏览器返回一小段HTML内容的AJAX控制器非常有用。在下面的例子中,当接收到AJAX请求时会加载注册表单片段,而普通请求则会加载整个注册页面:

@RequestMapping(value = "signup")
public String signup(Model model,
        @RequestHeader("X-Requested-With") String requestedWith) {
        
    model.addAttribute(new SignupForm());
    if (AjaxUtils.isAjaxRequest(requestedWith)) {
        return SIGNUP_VIEW_NAME.concat(" :: signupForm");
    }
    return SIGNUP_VIEW_NAME;
    
}

该片段是在signup/signup.html:

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <form method="post"
          th:action="@{/signup}" th:object="${signupForm}" th:fragment="signupForm">
      ...
    </form>
  </body>
</html>

当新用户想从主页注册时,会加载上述片段。点击Signup按钮后,模态对话框将展示出来,内容将通过AJAX调用加载(请参见home/homeNotSignedIn.html)。

参考资料

请查阅Thymeleaf文档,其中对这个主题进行了详尽描述。

Thymol

当Thymeleaf模板被用作静态原型时,我们无法看到使用th:insert/th:replace宿主标签所包含的片段。我们只能看到这些片段单独存在于各自的模板文档中。

然而,在原型设计阶段,有一种方法可以看到实际包含在页面中的片段。这可以通过Thymol实现,这是一个非官方JavaScript库,实现了Thymeleaf的标准片段包含功能,并为一些Thymeleaf属性提供了静态支持,例如th:insertth:replace、带有th:if/th:unless的条件显示等。

正如Thymol作者所述:Thymol的创建是为了通过提供一个可静态访问的JavaScript库,更准确地展现Thymeleaf动态模板的功能。

Thymol的文档和示例可在其官方项目网站找到:Thymol.

Thymeleaf Layout 方言

布局方言为人提供了使用分层方法的可能,但从纯Thymeleaf的角度来看,并且无需使用外部库(如Apache Tiles)。Thymeleaf布局方言使用布局/装饰器模板来美化内容,并且还可以将整个片段元素传递给已包含的页面。这个库的概念与SiteMesh或使用Facelets的JSF类似。

配置说明

要开始使用布局方言,我们需要将其添加到pom.xml中。相关依赖项如下:

<dependency>
  <groupId>nz.net.ultraq.thymeleaf</groupId>
  <artifactId>thymeleaf-layout-dialect</artifactId>
  <version>2.0.5</version>
</dependency>

我们还需要通过向模板引擎添加额外的方言来配置集成:

@Bean
public SpringTemplateEngine templateEngine() {
    SpringTemplateEngine templateEngine = new SpringTemplateEngine();
    ...
    templateEngine.addDialect(new LayoutDialect());
    return templateEngine;
}

不需其他更改。

创建布局

布局文件定义在/WEB-INF/views/task/layout.html:

<!DOCTYPE html>
<html>
  <head>
    <!--/*  Each token will be replaced by their respective titles in the resulting page. */-->
    <title layout:title-pattern="$LAYOUT_TITLE - $CONTENT_TITLE">Task List</title>
    ...
  </head>
  <body>
    <!--/* Standard layout can be mixed with Layout Dialect */-->
    <div th:replace="fragments/header :: header">
      ...
    </div>
    <div class="container">
      <div layout:fragment="content">
        <!-- ============================================================================ -->
        <!-- This content is only used for static prototyping purposes (natural templates)-->
        <!-- and is therefore entirely optional, as this markup fragment will be included -->
        <!-- from "fragments/header.html" at runtime.                                     -->
        <!-- ============================================================================ -->
        <h1>Static content for prototyping purposes only</h1>
        <p>
          Lorem ipsum dolor sit amet, consectetur adipiscing elit.
          Praesent scelerisque neque neque, ac elementum quam dignissim interdum.
          Phasellus et placerat elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
          Praesent scelerisque neque neque, ac elementum quam dignissim interdum.
          Phasellus et placerat elit.
        </p>
      </div>
      <div th:replace="fragments/footer :: footer">&copy; 2014 The Static Templates</div>
    </div>
  </body>
</html>

我们可以直接在浏览器中打开该文件:

Layout page
布局页面

上述文件是我们将在应用程序中创建的内容页面的装饰器。关于上面示例最重要的是layout:fragment="content"这是装饰器页面(布局)的核心部分。您也可以注意到,头部和底部是使用标准的Thymeleaf布局系统包含进来的。

内容页面如下所示(WEB-INF/views/task/list.html):

<!DOCTYPE html>
<html layout:decorate="~{task/layout}">
  <head>
    <title>Task List</title>
    ...
  </head>
  <body>
    <!-- /* Content of this page will be decorated by the elements of layout.html (task/layout) */ -->
    <div layout:fragment="content">
      <table class="table table-bordered table-striped">
        <thead>
          <tr>
            <td>ID</td>
            <td>Title</td>
            <td>Text</td>
            <td>Due to</td>
          </tr>
        </thead>
        <tbody>
          <tr th:if="${tasks.empty}">
            <td colspan="4">No tasks</td>
          </tr>
          <tr th:each="task : ${tasks}">
            <td th:text="${task.id}">1</td>
            <td><a href="view.html" th:href="@{'/' + ${task.id}}" th:text="${task.title}">Title ...</a></td>
            <td th:text="${task.text}">Text ...</td>
            <td th:text="${#calendars.format(task.dueTo)}">July 11, 2012 2:17:16 PM CDT</td>
          </tr>
        </tbody>
      </table>
    </div>
  </body>
</html>

浏览器中的效果如下:

Layout page
布局页面

本页的task/list内容将由task/layout页面的元素进行装饰。请注意layout:decorate="~{task/layout}"属性在<html>元素上。该属性告诉布局方言,应该使用哪个布局来装饰当前视图。并请注意,它使用了Thymeleaf片段表达式的语法。

那么如何使用自然模板并同时使用布局方言呢?同样可行!只需在模板中包含的片段周围添加一些仅供原型设计使用的标记即可!

包含样式的方法与布局方言

布局方言不仅支持层级结构方法——它还提供了一种以包含式方式使用它的方法(layout:include)。与标准的 Thymeleaf 包含相比,使用布局方言你可以将 HTML 元素传递给被包含的页面。如果你有一些想要重用但内容过于复杂以至于无法通过标准 Thymeleaf 方言中的参数化包含来传递的 HTML,这会非常有用。

下面是一个使用可重用的警告片段示例:layout:fragment (task/alert.html):

<!DOCTYPE html>
<html>
  <body>
    <th:block layout:fragment="alert-content">
        <p>Duis mollis, est non commodo luctus, nisi erat porttitor ligula...</p>
        <p>
            <button type="button" class="btn btn-danger">Take this action</button>
            <button type="button" class="btn btn-default">Or do this</button>
        </p>
    </th:block>
  </body>
</html>

上述片段的调用可能如下所示(task/list.html):

    <div layout:insert="~{task/alert :: alert}" th:with="type='info', header='Info'" th:remove="tag">
        <!--/* Implements alert content fragment with simple content */-->
        <th:block layout:fragment="alert-content">
            <p><em>This is a simple list of tasks!</em></p>
        </th:block>
    </div>

或者:

    <div layout:insert="~{task/alert :: alert}" th:with="type='danger', header='Oh snap! You got an error!'" th:remove="tag">
        <!--/* Implements alert content fragment with full-blown HTML content */-->
        <th:block layout:fragment="alert-content">
           <p>Duis mollis, est non commodo luctus, nisi erat porttitor ligula...</p>
            <p>
                <button type="button" class="btn btn-danger">Take this action</button>
                <button type="button" class="btn btn-default">Or do this</button>
            </p>
        </th:block>
    </div>

在这种情况下,alert-contenttask/alert (/WEB-INF/views/task/alert.html) 模板将被上面自定义的 HTML 替换。

参考资料

请查看布局方言文档,它对这个主题进行了非常详尽的描述。你肯定能找到比本文更多高级示例。

你可以在这里找到该文档:布局方言.

其他布局选项

对于某些开发人员来说,前面介绍的解决方案都不够好。Thymeleaf 标准布局系统不够强大,而使用外部库不是一个选项。在这种情况下,自定义解决方案可能是可行的办法。

Thymeleaf 自定义布局

这样的解决方案之一在这篇博客文章中做了很好的描述:在不使用扩展的情况下,在 Spring MVC 应用程序中使用 Thymeleaf 模板布局。该方案的理念非常简单。我们通过一个例子来形象化:

示例视图文件(1):

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <div class="container" th:fragment="content">
      <p>
        Hello <span th:text="${#authentication.name}">User</span>!
        Welcome to the Spring MVC Quickstart application!
      </p>
    </div>
  </body>
</html>

以及布局文件(2):

<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <div th:replace="fragments/header :: header">Header</div>
    <div th:replace="${view} :: content">Page Content</div>
    <div th:replace="fragments/footer :: footer">Footer</div>
  </body>
</html>

将会发生什么?

  • 控制器返回视图名称,映射到单个 Thymeleaf 视图文件(1)
  • 在渲染视图之前,原始的viewName属性在ModelAndView对象中会被替换为布局视图的名称,并且原来的viewName将成为一个属性并存放于ModelAndView.
  • 布局视图(2)包含多个 include 元素:<div th:replace="${view} :: content">Page Content</div>
  • 实际视图文件包含一些片段,这些片段被模板拉取嵌入到实际视图中

该项目可在以下地址找到:GitHub.

总结

在本文中,我们描述了很多实现相同目标的方法:布局。你可以使用基于包含样式的 Thymeleaf 标准布局系统来构建布局。你也可以使用功能强大的布局方言,它使用装饰器模式处理布局文件。最后,你还可以轻松创建自己的解决方案。

希望这篇文章能让你对此主题有更深入的理解,并根据你的需求找到最适合的方法。

无噪 Logo
无噪文档
中文文档 · 复刻官网
查看所有 ↗