从 Velocity 到 Thymeleaf:浅谈模板迁移

欧雷 发表于

0 条评论

谨以此文献给那些将要从 Velocity 跳到 Thymeleaf 这个坑的人。

欧雷

提到 Thymeleaf,想必大家对这个名字比较陌生,如果是在几天前我也是闻所未闻。然而,大佬突然一声令下:「我们要把仓储管理系统分离出去,用 Spring Boot 进行开发。」相伴而来的就是后端模板引擎的变更——不再支持 Velocity 了!

在接到这个消息后,第一时间到官网看下这首次听到的东西长个啥样。乍一看,觉得咋那么眼熟呢?哦~原来是跟 Vue 有点像!

先来瞅一瞅 Vue 的模板语法——

<!-- 对属性动态赋值 -->
<div v-bind:id="dynamicId"></div>
<!-- 条件渲染 -->
<div v-if="condition">在符合条件时才显示该元素</div>
<!-- 列表渲染 -->
<ul>
  <li v-for="(item, index) in items"> - </li>
</ul>

再看看 Thymeleaf——

<!-- 对属性动态赋值 -->
<div th:id="${dynamicId}"></div>
<!-- 条件渲染 -->
<div th:if="${condition}">在符合条件时才显示该元素</div>
<!-- 列表渲染 -->
<ul>
  <li th:each="item : ${items}" th:text="${item.message}">此处文本会被覆盖</li>
</ul>

我去!难道它们是失散多年的双胞胎?!

本文并非 Thymeleaf 使用教程,而是讲述如何以尽可能小的改动将页面从 Velocity 迁移到 Thymeleaf。若想了解 Thymeleaf 的用法,请看官方文档。

Spring MVC + Velocity

目前大部分项目是 Spring MVC + Velocity,但以后的新项目很可能都是 Spring Boot + Thymeleaf。不管怎么说,还是先看下 Velocity 中的模板用法吧。

布局模板

在我所参与的项目中,layout 的模板代码大概是这样的——

#set($timestamp = $dateTool.get("yyyyMMddHH"))

<!DOCTYPE html>
<html lang="zh-CN" dir="ltr" data-page="$!{primaryPage}-$!{secondaryPage}">
  <head>
    <meta charset="UTF-8">
    <!-- 页面标题 -->
    <title>#if($!pageTitle)$!{pageTitle} - #end后台系统</title>
    <!-- 网站图标 -->
    <link rel="icon" href="/bower_components/handle/dist/images/favicon.png">
    <!-- 全局样式 -->
    <link rel="stylesheet" href="/template/assets/admin/reset.css?t=$!timestamp">
    <!-- 各页面样式 -->
    $!headAssets
    <!-- 全局脚本 -->
    <script src="/template/assets/admin/global.js?t=$!timestamp"></script>
  </head>
  <body class="Page">
    <header class="Page-header Header">
      <div class="Header-brand">
        <a href="/"><img src="/bower_components/handle/dist/images/logo.png" srcset="/bower_components/handle/dist/images/logo-2x.png 2x" alt="卖好车"><span>后台</span></a>
      </div>
      <div class="Header-extra">
        <div class="Header-operations">
          <!-- 页头中的操作 -->
          $!headerActions
          <!-- 新增数据按钮 -->
          #if($!modal)<div class="Header-action Action"><button class="Action-trigger fa fa-plus js-add--header" type="button" data-toggle="modal" data-target=".js-addNewData" title="新增"><span class="sr-only">新增</span></button></div>#end
          <!-- 用户信息 -->
          #if($!user)
            #if($!user.realName.length() > 2)
              #set($startPos = $!user.realName.length() - 2)
              #set($displayName = $!user.realName.substring($startPos, $!user.realName.length()))
            #else
              #set($displayName = $!user.realName)
            #end
            <div class="Header-action Action Action--avatar"><a class="Action-trigger" href="javascript:void(0);"><span>$!displayName</span></a>
              <div class="Action-content Card">
                <div class="Card-content">
                  <ul>
                    <li>$!user.mobile</li>
                    <li>$!user.email</li>
                  </ul>
                </div>
                <div class="Card-footer">
                  <a href="/logout.htm" class="btn btn-default btn-xs">退出</a>
                </div>
              </div>
            </div>
          #end
        </div>
      </div>
    </header>
    <main class="Page-content">
      <div class="Page-sidebar Sidebar">
        <nav class="Sidebar-navs Navs">
          <ul>
            ...
          </ul>
        </nav>
      </div>
      <div class="Page-main">
        <div class="Content container-fluid">
          <div class="Content-header">
            <!-- 面包屑 -->
            <div class="Breadcrumb"><i class="fa fa-map-marker"></i>$!breadcrumb</div>
            <!-- 页面标题 -->
            <h1>$!pageTitle</h1>
          </div>
          <!-- 页面内容片段 -->
          $screen_content
          <!-- 条件筛选区域 -->
          $!queryArea
          <!-- 数据表格区域 -->
          <div class="Area Area--table">
            #if($!dataTableList)
              $dataTableList
            #else
              <table class="js-showDataTable"></table>
            #end
          </div>
        </div>
        <!-- 新增/修改数据对话框 -->
        $!modal
      </div>
    </main>
    <!-- 各页面脚本 -->
    $!bodyAssets
  </body>
</html>

其中所使用的变量都是具体页面中定义的,有的是用 #set() 定义的简单的值:

变量名 含义 是否必须
primaryPage 一级页面标记
secondaryPage 二级页面标记
pageTitle 当前页面标题

有的是用 #define() 定义的代码片段:

变量名 含义 是否必须
headAssets 各页面样式
headerActions 页头中的操作
breadcrumb 面包屑
queryArea 条件筛选区域
dataTableList 数据表格
modal 新增/修改数据对话框
bodyAssets 各页面脚本

页面模板

每个具体页面的模板中所写的代码,除了在 layout 中指定位置引用的 #define() 定义的片段会显示在相应的位置,其他的不在 #define() 中的代码都会被渲染到 $screen_content 的位置——

#set($primaryPage = "example")
#set($secondaryPage = "demo")
#set($pageTitle = "示例页面")

#define($headAssets)
<link rel="stylesheet" href="/template/views/admin/example/demo.css?t=$!timestamp">
#end

#define($bodyAssets)
<script src="/template/views/admin/example/demo.js?t=$!timestamp"></script>
#end

#define($breadcrumb)
<ul>
  <li>使用案例</li>
  <li>$pageTitle</li>
</ul>
#end

#define($queryArea)
<div class="Area Area--query">
  <form class="Card">
    <div class="Card-content">
      <div class="row">
        <div class="form-group col-xs-6 col-sm-4 col-lg-3">
          <label>查询条件</label>
          <select name="selectDemo" class="form-control input-sm" multiple data-placeholder="请选择">
            #foreach($o in $opts)
              <option value="${o.value}">${o.text}</option>
            #end
          </select>
        </div>
      </div>
    </div>
    <div class="Card-footer">
      <button type="submit" class="btn btn-primary btn-sm"><i class="fa fa-filter"></i><span>筛选</span></button><button type="reset" class="btn btn-default btn-sm"><i class="fa fa-refresh"></i><span>重置</span></button>
    </div>
  </form>
</div>
#end

#define($modal)
<div class="modal fade js-addNewData">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
        <h4 class="modal-title">填写信息</h4>
      </div>
      <div class="modal-body">
        <form>
          ...
        </form>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-default" data-dismiss="modal">关闭</button>
        <button type="button" class="btn btn-primary js-saveNewData">提交</button>
      </div>
    </div>
  </div>
</div>
#end

<section><p>这是一个示例页面</p></section>

Spring Boot + Thymeleaf

虽然 Themeleaf 的语法比较「友好」,但完全靠自己去把原来用 Velocity 写的页面完全成功迁移过来,至少得用半天到一天的时间去踩坑探索。但有了这篇文章就不一样了,看完之后基本不用去看官方文档就能够完成!

不管怎么说,Thymeleaf 的模板语法还是要先叨咕叨咕的。

模板语法

虽然说的时候只说「Thymeleaf」,但在实际使用时却是 Thymeleaf 和 Thymeleaf Layout Dialect。前者提供核心功能,其语法为 th:*;后者专解决布局及模板继承问题,语法是 layout:*本文中所用示例是基于 Thymeleaf 2.x 和 Thymeleaf Layout Dialog 1.x 实现,有的用法在新版本中可能已不被支持。

在迁移的过程中,主要用到的语法如下:

语法 作用
layout:decorator 指定所继承的布局模板
layout:fragment 定义用于布局的代码片段
th:fragment 定义通用的非布局代码片段
th:replace 用指定片断替换当前元素
th:with 向代码片段中传入参数
th:if 条件判断
th:each 遍历
th:text 覆盖文本

在访问变量时要用 ${variable} 形式,文件路径用 @{/path/to/your/file} 的形式。另外,Thymeleaf 中提供了一个不被渲染的可用作占位符的虚拟元素——<th:block>

在了解了这些语法之后,就可以开展迁移工作了!

布局模板

用上面所介绍的语法,将 Velocity 的 layout 改造为——

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      lang="zh-CN" dir="ltr"
      th:attr="data-page=(${primaryPage} and ${secondaryPage} ? (${primaryPage} + '-' + ${secondaryPage}) : '')">
  <head th:with="timestamp=${#dates.format(#dates.createNow(),'yyyyMMddHH')}">
    <meta charset="UTF-8" />
    <!-- 页面标题 -->
    <title th:text="${pageTitle} + '- 后台系统'"></title>
    <!-- 网站图标 -->
    <link rel="icon" th:href="@{/bower_components/handle/dist/handle/images/favicon.png}" />
    <!-- 全局样式 -->
    <link rel="stylesheet" th:href="@{/assets/admin/reset.css(t=${timestamp})}" />
    <!-- 全局脚本 -->
    <script th:src="@{/assets/admin/global.js(t=${timestamp})}"></script>
  </head>
  <body class="Page" th:with="timestamp=${#dates.format(#dates.createNow(),'yyyyMMddHH')}">
    <header class="Page-header Header">
      <div class="Header-brand">
        <a href="/"><img th:src="@{/bower_components/handle/dist/handle/images/logo.png(t=${timestamp})}" th:attr="srcset=(@{/bower_components/handle/dist/handle/images/logo-2x.png(t=${timestamp})} + ' 2x')" alt="卖好车" /><span>后台</span></a>
      </div>
      <div class="Header-extra">
        <div class="Header-operations">
          <!-- 页头中的操作 -->
          <th:block layout:fragment="headerActions"></th:block>
          <!-- 新增数据按钮 -->
          <div class="Header-action Action" th:if="${creatable}"><button class="Action-trigger fa fa-plus js-add--header" type="button" data-toggle="modal" data-target=".js-addNewData" title="新增"><span class="sr-only">新增</span></button></div>
          <!-- 用户信息 -->
          <th:block th:if="${user != null}">
            <div class="Header-action Action Action--avatar"><a class="Action-trigger" href="javascript:void(0);"><span th:text="${user.realName.substring((user.realName.length() - 2), user.realName.length())}"></span></a>
              <div class="Action-content Card">
                <div class="Card-content">
                  <ul th:object="${user}">
                    <li th:if="*{mobile}" th:text="*{mobile}"></li>
                    <li th:if="*{email}" th:text="*{email}"></li>
                  </ul>
                </div>
                <div class="Card-footer">
                  <a th:href="@{/logout.htm}" class="btn btn-default btn-xs">退出</a>
                </div>
              </div>
            </div>
          </th:block>
        </div>
      </div>
    </header>
    <main class="Page-content">
      <div class="Page-sidebar Sidebar">
        <nav class="Sidebar-navs Navs">
          <ul>
            ...
          </ul>
        </nav>
      </div>
      <div class="Page-main">
        <div class="Content container-fluid">
          <div class="Content-header">
            <!-- 面包屑 -->
            <div class="Breadcrumb"><i class="fa fa-map-marker"></i><th:block layout:fragment="breadcrumb"></th:block></div>
            <!-- 页面标题 -->
            <h1 th:text="${pageTitle}"></h1>
          </div>
          <!-- 页面内容片段 -->
          <th:block layout:fragment="content"></th:block>
          <!-- 条件筛选区域 -->
          <th:block layout:fragment="query"></th:block>
          <!-- 数据表格区域 -->
          <div class="Area Area--table">
            <table class="js-showDataTable"></table>
          </div>
        </div>
        <!-- 新增/修改数据对话框 -->
        <th:block layout:fragment="modal"></th:block>
      </div>
    </main>
    <!-- 各页面脚本 -->
    <th:block layout:fragment="bodyAssets"></th:block>
  </body>
</html>

如果细心观察就会发现,迁移后与迁移前相比,少了 headAssets 变量并多了个 creatable 变量。

去掉了 headAssets 是因为 Thymeleaf Layout Dialect 提供了一种机制,可以将具体页面模板的 <head> 标签中的 <link><script> 自动插入到布局模板的 <head> 标签的底部,即闭合标签 </head> 前。

增加了 creatable 则是因为 Thymeleaf 中无法对某个代码片段判断是否存在。(也许是我不会……)

页面模板

只要布局模板的继承及排列逻辑搞定了,具体页面模板的迁移就小菜一碟儿了~

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorator="layouts/admin"
      th:with="pageTitle='示例页面', primaryPage='example', secondaryPage='demo', creatable=true">
  <head>
    <link rel="stylesheet" th:href="@{/template/views/admin/example/demo.css(t=${timestamp})}">
  </head>
  <body>
    <th:block layout:fragment="content">
      <section><p>这是一个示例页面</p></section>
    </th:block>

    <th:block layout:fragment="query">
      <div class="Area Area--query">
        <form class="Card">
          <div class="Card-content">
            <div class="row">
              <div class="form-group col-xs-6 col-sm-4 col-lg-3">
                <label>查询条件</label>
                <select name="selectDemo" class="form-control input-sm" multiple data-placeholder="请选择">
                  <option th:each="o : $opts" th:value="${o.value}" th:text="${o.text}"></option>
                </select>
              </div>
            </div>
          </div>
          <div class="Card-footer">
            <button type="submit" class="btn btn-primary btn-sm"><i class="fa fa-filter"></i><span>筛选</span></button><button type="reset" class="btn btn-default btn-sm"><i class="fa fa-refresh"></i><span>重置</span></button>
          </div>
        </form>
      </div>
    </th:block>

    <th:block layout:fragment="modal">
      <div class="modal fade js-addNewData">
        <div class="modal-dialog">
          <div class="modal-content">
            <div class="modal-header">
              <button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
              <h4 class="modal-title">填写信息</h4>
            </div>
            <div class="modal-body">
              <form>
                ...
              </form>
            </div>
            <div class="modal-footer">
              <button type="button" class="btn btn-default" data-dismiss="modal">关闭</button>
              <button type="button" class="btn btn-primary js-saveNewData">提交</button>
            </div>
          </div>
        </div>
      </div>
    </th:block>

    <th:block layout:fragment="bodyAssets">
      <script th:src="@{/template/views/admin/example/demo.js(t=${timestamp})}"></script>
    </th:block>

    <ul layout:fragment="breadcrumb">
      <li>使用案例</li>
      <li th:text="${pageTitle}"></li>
    </ul>
  </body>
</html>

后语

重要的部分都已经说完了,但在迁移过程中有几点需要注意的,否则 Thymeleaf 在解析时会报错:

  • <img><input> 这类单标签需要有斜杠关闭标签:<img /><input />
  • requiredmultiple 等属性需要有值:required="required"multiple="multiple"

至此,本文已接近尾声,如果你在看过之后茅塞顿开,那我这就是一篇成功的文章!

创作不易,若本文给你提供了价值,还请不吝欧雷充电

左为微信,右为支付宝;充电累计 ¥88 以上可在付款时备注或邮件告知昵称和需要被链接的网址,会列在「赞助」页。其他方式与具体规则请见「资助」。