從 Velocity 到 Thymeleaf:淺談模板遷移

本文首發於 歐雷流。因爲我會時不時對文章進行補充、修正和潤色,爲了保證所看到的是最新版本,請閱讀 原文

本文並不是 Thymeleaf 使用教程,而是講述如何以儘量小的改動將頁面從 Velocity 遷移到 Thymeleaf。若想了解 Thymeleaf 的用法,請看官方文檔。javascript

謹以此文獻給那些將要從 Velocity 跳到 Thymeleaf 這個坑的人。

提到 Thymeleaf,想必你們對這個名字比較陌生,若是是在幾天前我也是聞所未聞。然而,大佬忽然一聲令下:「咱們要把倉儲管理系統分離出去,用 Spring Boot 進行開發。」相伴而來的就是後端模板引擎的變動——再也不支持 Velocity 了!css

在接到這個消息後,第一時間到官網看下這首次聽到的東西長個啥樣。乍一看,以爲咋那麼眼熟呢?哦~原來是跟 Vue 有點像!html

先來瞅一瞅 Vue 的模板語法——java

<!-- 對屬性動態賦值 -->
<div v-bind:id="dynamicId"></div>
<!-- 條件渲染 -->
<div v-if="condition">在符合條件時才顯示該元素</div>
<!-- 列表渲染 -->
<ul>
  <li v-for="(item, index) in items">{{ index }} - {{ item.message }}</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>

我去!難道它們是失散多年的雙胞胎?!ide

Spring MVC + Velocity

目前大部分項目是 Spring MVC + Velocity,但之後的新項目極可能都是 Spring Boot + Thymeleaf。無論怎麼說,仍是先看下 Velocity 中的模板用法吧。佈局

佈局模板

在我所參與的項目中,layout 的模板代碼大概是這樣的——post

#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() 定義的代碼片斷:ui

變量名 含義 是否必須
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"

至此,本文已接近尾聲,若是你在看過以後茅塞頓開,那我這就是一篇成功的文章!

相關文章
相關標籤/搜索