第14章:用Leaf进行模板设计¶
在本书的前一部分,你学习了如何使用Vapor和Fluent创建一个API。然后,你学会了如何创建一个iOS客户端来消费该API。在本节中,你将创建另一个客户端--一个网站。你将看到如何使用Leaf在Vapor应用程序中创建动态网站。
Leaf¶
Leaf是Vapor的模板语言。模板语言允许你向页面传递信息,这样它就可以在不预先知道一切的情况下生成最终的HTML。例如,在TIL应用程序中,你不知道用户在部署你的应用程序时将创建的每个缩写。模板化使你能够轻松地处理这个问题。
模板语言还允许你减少网页中的重复。你可以创建一个单一的模板,并设置特定的属性来显示一个特定的缩写,而不是为缩写设置多个页面。如果你决定改变显示缩略语的方式,你只需要在一个地方改变你的代码,所有的缩略语页面都会显示新的格式。
最后,模板语言允许你将模板嵌入其他模板中。例如,如果你的网站上有导航,你可以创建一个单一的模板,为你的导航生成代码。你将导航模板嵌入所有需要导航的模板中,而不是重复代码。
配置Leaf¶
要使用Leaf,你需要把它作为一个依赖项添加到你的项目中。使用第11章"测试"中的TIL应用程序,或本章的启动项目,打开Package.swift。将其内容替换为以下内容:
// swift-tools-version:5.2
import PackageDescription
let package = Package(
name: "TILApp",
platforms: [
.macOS(.v10_15)
],
dependencies: [
// 💧 A server-side Swift web framework.
.package(
url: "https://github.com/vapor/vapor.git",
from: "4.0.0"),
.package(
url: "https://github.com/vapor/fluent.git",
from: "4.0.0"),
.package(
url:
"https://github.com/vapor/fluent-postgres-driver.git",
from: "2.0.0"),
.package(
url: "https://github.com/vapor/leaf.git",
from: "4.0.0")
],
targets: [
.target(
name: "App",
dependencies: [
.product(name: "Fluent", package: "fluent"),
.product(
name: "FluentPostgresDriver",
package: "fluent-postgres-driver"),
.product(name: "Vapor", package: "vapor"),
.product(name: "Leaf", package: "leaf")
],
swiftSettings: [
.unsafeFlags(
["-cross-module-optimization"],
.when(configuration: .release))
]
),
.target(name: "Run", dependencies: [.target(name: "App")]),
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
])
]
)
所做的修改是:
- 使
TILApp包依赖于Leaf包。 - 使
App目标依赖Leaf目标,以确保其正确链接。
默认情况下,Leaf希望模板在Resources/Views目录下。在终端,输入以下内容来创建这些目录:
mkdir -p Resources/Views
最后,你必须为网站创建新的路由。创建一个新的控制器来包含这些路由。在Xcode中,在Sources/App/Controllers创建一个名为WebsiteController.swift的新Swift文件。
渲染一个页面¶
打开WebsiteController.swift并将其内容替换为以下内容,以创建一个新的类型来容纳所有的网站路由和一个返回索引模板的路由:
import Vapor
import Leaf
// 1
struct WebsiteController: RouteCollection {
// 2
func boot(routes: RoutesBuilder) throws {
// 3
routes.get(use: indexHandler)
}
// 4
func indexHandler(_ req: Request)
-> EventLoopFuture<View> {
// 5
return req.view.render("index")
}
}
下面是这个的作用:
- 声明一个新的
WebsiteController类型,符合RouteCollection。 - 按照
RouteCollection的要求实现boot(routes:)。 - 注册
indexHandler(_:)来处理对路由器根路径的GET请求,也就是对/的请求。 - 实现
indexHandler(_:),返回EventLoopFuture<View>。 - 渲染
index模板并返回结果。稍后你会了解到req.view。
Leaf从Resources/Views目录下的一个名为index.leaf的模板中生成一个页面。
注意,文件扩展名不是render(_:)调用所需要的。创建Resources/Views/index.leaf并将其内容替换为以下内容:
<!DOCTYPE html>
<!-- 1 -->
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- 2 -->
<title>Hello World</title>
</head>
<body>
<!-- 3 -->
<h1>Hello World</h1>
</body>
</html>
以下是这个文件的作用:
- 声明一个基本的
HTML 5页面,有<head>和<body>。 - 将页面标题设置为
Hello World- 这是显示在浏览器标签中的标题。 - 将正文设置为一个单一的
<h1>标题,写着Hello World。
Note
你可以使用任何你选择的文本编辑器创建你的.leaf文件,包括Xcode。如果你使用Xcode,请选择Editor ▸ Syntax Coloring ▸ HTML,以便获得适当的元素高亮和缩进支持。
你必须注册你的新的WebsiteController。打开routes.swift,在routes(_:)的末尾添加以下内容:
let websiteController = WebsiteController()
try app.register(collection: websiteController)
最后,同样在routes.swift中,删除以下代码:
app.get { req in
return "It works!"
}
WebsiteController现在为/提供了一个路由,而不是。接下来,你必须告诉Vapor使用Leaf。打开configure.swift,在import Vapor下面的导入部分添加以下内容:
import Leaf
使用通用的req.view来获取渲染器,可以让你轻松切换到不同的模板引擎。虽然这在运行你的应用程序时可能没有什么用处,但对于测试却非常有用。
例如,它允许你使用测试渲染器产生纯文本来验证,而不是在你的测试案例中解析HTML输出。
req.view要求Vapor提供一个符合ViewRenderer的类型。Vapor提供了PlaintextRenderer,LeafKit--Leaf的基础模块--提供了LeafRenderer。在configure.swift中,在try app.autoMigrate().wait()之后添加以下内容:
app.views.use(.leaf)
这告诉Vapor在渲染视图时使用Leaf,当要求使用ViewRenderer类型时使用LeafRenderer。
最后,你必须告诉Vapor应用程序在哪里运行,因为你可能从一个独立的Xcode项目或在一个工作区内运行应用程序。要做到这一点,在Xcode中设置一个自定义工作目录。在Xcode中选项-点击Run按钮来打开方案编辑器。在Options标签上,点击启用Use custom working directory,并选择Package.swift文件所在的目录:

构建并运行该应用程序,记得选择Run方案,然后打开浏览器。输入URLhttp://localhost:8080,你将收到由模板生成的页面:

注入变量¶
这个模板目前只是一个静态的页面,一点也不令人印象深刻! 为了使页面更加动态,打开index.leaf,将<title>行改为以下内容:
<title>#(title) | Acronyms</title>
这是使用#() Leaf函数提取一个名为title的参数。像很多Vapor一样,Leaf使用Codable来处理数据。
在WebsiteController.swift的底部,添加以下内容,创建一个新的类型来包含标题:
struct IndexContext: Encodable {
let title: String
}
由于数据只流向Leaf,你只需要符合Encodable。IndexContext是你视图的数据,类似于MVVM设计模式中的视图模型。接下来,改变indexHandler(_:)以传递一个IndexContext给模板。用下面的内容替换实现:
func indexHandler(_ req: Request)
-> EventLoopFuture<View> {
// 1
let context = IndexContext(title: "Home page")
// 2
return req.view.render("index", context)
}
以下是新代码的作用:
- 创建一个包含所需标题的
IndexContext。 - 将
context作为render(_:_:)的第二个参数传递给Leaf。
建立并运行,然后在浏览器中刷新页面。你会看到更新的标题:

使用标签¶
TIL网站的主页应该显示所有缩略语的列表。还是在WebsiteController.swift中,在title下面为IndexContext添加一个新的属性:
let acronyms: [Acronym]?
这是一个可选的缩写数组;它可以是nil,因为数据库中可能没有缩写。接下来,改变indexHandler(_:)以获得所有的首字母缩写并将其插入IndexContext。
再一次用下面的内容替换实现:
func indexHandler(_ req: Request)
-> EventLoopFuture<View> {
// 1
Acronym.query(on: req.db).all().flatMap { acronyms in
// 2
let acronymsData = acronyms.isEmpty ? nil : acronyms
let context = IndexContext(
title: "Home page",
acronyms: acronymsData)
return req.view.render("index", context)
}
}
下面是这个的作用:
- 使用
Fluent查询,从数据库中获取所有的缩写词。 - 如果有的话,将缩写添加到
IndexContext中,否则将该属性设置为nil。Leaf可以检查模板中的nil。
最后打开index.leaf,将<body>标签之间的部分改为如下:
<!-- 1 -->
<h1>Acronyms</h1>
<!-- 2 -->
#if(acronyms):
<!-- 3 -->
<table>
<thead>
<tr>
<th>Short</th>
<th>Long</th>
</tr>
</thead>
<tbody>
<!-- 4 -->
#for(acronym in acronyms):
<tr>
<!-- 5 -->
<td>#(acronym.short)</td>
<td>#(acronym.long)</td>
</tr>
#endfor
</tbody>
</table>
<!-- 6 -->
#else:
<h2>There aren’t any acronyms yet!</h2>
#endif
以下是新代码的作用:
- 宣布一个新的标题,
Acronyms。 - 使用
Leaf的#if()标签来查看acronyms变量是否被设置。#if()可以验证变量是否为空,对布尔运算进行处理,甚至可以评估表达式。 - 如果
acronyms被设置,创建一个HTML表格。该表有一个标题行--<thead>,有两列,Short和Long。 - 使用
Leaf的#for()标签来循环浏览所有的缩写词。这与Swift的for循环的工作方式相似。 - 为每个首字母缩写创建一个行。使用
Leaf的#()函数来提取值。由于所有的东西都是Encodable的,你可以使用点符号来访问首字母缩写的属性,就像Swift一样! - 如果没有首字母缩写,打印一个合适的信息。
建立并运行,然后在浏览器中刷新页面。
如果你的数据库中没有缩略语,你会看到正确的信息:

如果数据库中有首字母缩写,你会在表中看到它们:

缩略语详情页¶
现在,你需要一个页面来显示每个缩略语的细节。在WebsiteController.swift的末尾,创建一个新的类型来保存这个页面的上下文:
struct AcronymContext: Encodable {
let title: String
let acronym: Acronym
let user: User
}
这个AcronymContext包含页面的标题,缩写本身和创建缩写的用户。在indexHandler(_:)下为缩写的详细页面创建以下路由处理程序:
// 1
func acronymHandler(_ req: Request)
-> EventLoopFuture<View> {
// 2
Acronym.find(req.parameters.get("acronymID"), on: req.db)
.unwrap(or: Abort(.notFound))
.flatMap { acronym in
// 3
acronym.$user.get(on: req.db).flatMap { user in
// 4
let context = AcronymContext(
title: acronym.short,
acronym: acronym,
user: user)
return req.view.render("acronym", context)
}
}
}
下面是这个路由处理程序的工作:
- 声明一个新的路由处理程序,
acronymHandler(_:),它返回EventLoopFuture<View>。 - 从请求的参数中提取首字母缩写,并将结果解包。如果没有首字母缩写,则返回一个
404 Not Found。 - 获取用户的首字母缩写,并将结果解包。
- 创建一个包含适当细节的
AcronymContext,并使用acronym.leaf模板渲染页面。
最后在boot(routes:)的底部注册路由:
routes.get("acronyms", ":acronymID", use: acronymHandler)
这为/acronyms/<ACRONYM ID>注册了acronymHandler路由,与API类似。在Resources/Views目录下创建acronym.leaf模板,打开新文件并添加以下内容:
<!DOCTYPE html>
<!-- 1 -->
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- 2 -->
<title>#(title) | Acronyms</title>
</head>
<body>
<!-- 3 -->
<h1>#(acronym.short)</h1>
<!-- 4 -->
<h2>#(acronym.long)</h2>
<!-- 5 -->
<p>Created by #(user.name)</p>
</body>
</html>
以下是这个模板的作用:
- 声明一个
HTML5页面,如index.leaf。 - 将标题设置为传入的值。
- 在一个
<h1>标题中打印缩写的short属性。 - 在
<h2>标题中打印首字母缩写的long属性。 - 在一个
<p>块中打印缩写的用户。
最后,改变index.leaf,这样你就可以导航到该页面。将表格中每个缩写的第一列(<td>#(acronym.short)</td>)改为。
<td><a href="/acronyms/#(acronym.id)">#(acronym.short)</a></td>
这将首字母缩写的short属性包裹在一个HTML <a>标签中,这是一个链接。该链接将每个首字母缩写的URL设置为上面注册的路线。建立并运行,然后在浏览器中刷新页面:

你会看到,每个首字母缩写的简称现在都是一个链接。点击该链接,浏览器就会导航到该首字母缩写的页面:

接下来去哪?¶
本章介绍了Leaf,并告诉你如何开始建立一个动态网站。本节的下一章将向你展示如何将模板嵌入其他模板,美化你的应用程序,并从网站上创建缩写。