Leaf 模板引擎 封面
Vapor 大型教程

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)
                }
        }
    }
小提示:Vapor 3 里大量使用 FutureflatMap(to:) 处理异步。模板渲染 req.view().render(...) 返回的是 Future<View>,所以控制器方法也要返回 Future<View>。这是 Vapor 3 与 Vapor 4 最明显的区别之一。

本系列其他文章