Leaf 模板引擎
接口返回 JSON 是给前端 App 用的;但要做一个人人能直接打开的网站,得返回 HTML。Leaf 就是 Vapor 自带的模板语言:把变量塞进页面,动态生成最终 HTML。
一、什么是 Leaf
Leaf 是 Vapor 框架自带的模板语言。模板语言的作用是:把数据传给页面,页面再根据这些数据动态生成最终的 HTML。它还能帮我们省代码——写页面展示逻辑时可以塞进各种参数变量,不同页面之间也能复用写好的代码块。想统一改样式时,往往只改一处就能影响所有页面。Leaf 还支持在一个模板里嵌套另一个模板。
我们会基于之前写好的后端 API 项目,直接在其上开发 Web 页面。先在 Package.swift 里加上 Leaf 依赖:
...
.package(url: "https://github.com/vapor/leaf.git", from: "3.0.1")
...
.target(name: "App", dependencies: ["FluentPostgreSQL", "Vapor", "Leaf"]),
Leaf 默认使用 Resources/Views 目录来存放模板,所以我们新建一个同名目录:
mkdir -p Resources/Views
接着新建一个专门返回 Web 页面的控制器 WebsiteController.swift:
import Vapor
import Leaf
struct WebsiteController: RouteCollection {
func boot(router: Router) throws {
router.get(use: indexHandler)
}
func indexHandler(_ req: Request) throws -> Future<View> {
return try req.view().render("index")
}
}
在 routes.swift 里注册这个控制器让它生效:
...
let websiteController = WebsiteController()
try router.register(collection: websiteController)
...
还要把 Leaf 作为一种「视图渲染服务」配置好,在 configure.swift 中:
try services.register(LeafProvider())
config.prefer(LeafRenderer.self, for: ViewRenderer.self)
二、注入参数
Leaf 用 #(arg) 的方式把参数注入页面模板。Vapor 里大量使用了 Codable 协议,Leaf 也不例外。
在 index.leaf 里用 #(title) 占位:
...
<title>#(title) | Acronyms</title>
...
在控制器里定义一个遵循 Encodable 的上下文结构体 IndexContext,把数据带进去:
import Vapor
import Leaf
struct IndexContext: Encodable {
let title: String
}
struct WebsiteController: RouteCollection {
func boot(router: Router) throws {
router.get(use: indexHandler)
}
func indexHandler(_ req: Request) throws -> Future<View> {
let context = IndexContext(title: "Homepage")
return try req.view().render("index", context)
}
}
三、展示缩略语列表
光有一个标题不够,接下来让首页把数据库里的缩略词(Acronym)列出来。模板用 #if / #for 做条件与循环:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>#(title) | Acronyms</title>
</head>
<body>
<h1>Acronyms</h1>
#if(acronyms) {
<table>
<thead>
<tr>
<th>Short</th>
<th>Long</th>
</tr>
</thead>
<tbody>
#for(acronym in acronyms) {
<tr>
<td>#(acronym.short)</td>
<td>#(acronym.long)</td>
}
</tbody>
</table>
} else {
<h2>There aren't any acronyms yet!</h2>
}
</body>
</html>
控制器里先用 Acronym.query(on:) 查出全部,再用 flatMap(to:) 在拿到结果后渲染页面。注意空数组时把 acronyms 置为 nil,模板里 #if(acronyms) 就会走「还没有数据」的分支:
import Vapor
import Leaf
struct IndexContext: Encodable {
let title: String
let acronyms: [Acronym]?
}
struct WebsiteController: RouteCollection {
func boot(router: Router) throws {
router.get(use: indexHandler)
}
func indexHandler(_ req: Request) throws -> Future<View> {
return Acronym.query(on: req).all()
.flatMap(to: View.self) { acronyms in
let acronymsData = acronyms.isEmpty ? nil : acronyms
let context = IndexContext(title: "Homepage", acronyms: acronymsData)
return try req.view().render("index", context)
}
}
}
四、跳转到 Acronym 详情
列表里的每一项都该能点进详情页。在列表模板里给标题包上链接:
...
<td><a href="/acronyms/#(acronym.id)">#(acronym.short)</a></td>
...
详情页模板 acronyms.leaf 显示缩略词及其创建者:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>#(title) | Acronyms</title>
</head>
<body>
<h1>#(acronym.short)</h1>
<h2>#(acronym.long)</h2>
<p>Created by #(user.name)</p>
</body>
</html>
详情控制器用 req.parameters.next(Acronym.self) 取出路由里的参数,再链式 flatMap 查出关联的用户,最后渲染:
...
struct AcronymContext: Encodable {
let title: String
let acronym: Acronym
let user: User
}
...
router.get("acronyms", Acronym.parameter, use: acronymHandler)
...
func acronymHandler(_ req: Request) throws -> Future<View> {
return try req.parameters.next(Acronym.self)
.flatMap(to: View.self) { acronym in
return acronym.user.get(on: req)
.flatMap(to: View.self) { user in
let context = AcronymContext(title: acronym.short, acronym: acronym, user: user)
return try req.view().render("acronym", context)
}
}
}
Future 和 flatMap(to:) 处理异步。模板渲染 req.view().render(...) 返回的是 Future<View>,所以控制器方法也要返回 Future<View>。这是 Vapor 3 与 Vapor 4 最明显的区别之一。