本篇都是关于创建一个抽象的表单构建器,我们可以使用它来生成 HTML 表单。 这能让我们复用通用字段来组成所有类型的输入表单。 在本篇的后半部分,将讨论使用面向协议的解决方案处理用户输入、加载和持久化数据。 最后,我们将使用组件重建我们已经存在的用户登录表单模块
可复用表单
作为第一步,我们应该在Framework文件夹中创建一个新的 Form 目录,我们可以在其中放置所有共享的表单组件。 我们先从一个 LabelContext 对象开始,该对象将代表给定表单字段的标签。 将它放在 Templates/Contexts 子目录中
/// FILE: Sources/App/Framework/Form/Templates/Contexts/LabelContext.swift
public struct LabelContext {
public var key: String
public var title: String?
public var required: Bool
public var more: String?
public init(key: String,
title: String? = nil,
required: Bool = false,
more: String? = nil) {
self.key = key
self.title = title
self.required = required
self.more = more
}
}
正如我们从前面文章中学到的,我们将需要一个模板文件来渲染上下文对象。
/// FILE: Sources/App/Framework/Form/Templates/Html/LabelTemplate.swift
import Vapor
import SwiftHtml
public struct LabelTemplate: TemplateRepresentable {
var context: LabelContext
public init(_ context: LabelContext) {
self.context = context
}
@TagBuilder
public func render(_ req: Request) -> Tag {
Label {
Text(context.title ?? context.key.capitalized)
if let more = context.more {
Span(more)
.class("more")
}
if context.required {
Span("*")
.class("required")
}
}.for(context.key )
}
}
现在我们有一个可重用的标签,我们可以使用它来呈现表单字段对象。 如果我们想重构用户登录表单,我们将需要一个可以呈现电子邮件和密码样式的 HTML 输入的通用输入框。 我们将使用 InputFieldContext 结构来设置输入模板。 我们将把所有与字段相关的文件放入 Form/Fields 目录中。
/// FILE: Sources/App/Framework/Form/Fields/InputFieldContext.swift
import SwiftHtml
public struct InputFieldContext {
public let key: String
public var label: LabelContext
public var type: SwiftHtml.Input.`Type`
public var placeholder: String?
public var value: String?
public var error: String?
public init(key: String,
label: LabelContext? = nil,
type: Input.`Type` = .text,
placeholder: String? = nil,
value: String? = nil,
error: String? = nil) {
self.key = key
self.label = label ?? .init(key: key)
self.type = type
self.placeholder = placeholder
self.value = value
self.error = error
}
}
类型枚举将用于设置输入字段的类型,key将是每个字段的唯一值,稍后我们将能够使用此key检索服务器端的字段值 . value 将是一个简单的字符串值表示,如果没有设置value,则输入元素将显示占位符值。 label 属性是用于呈现标签模板的上下文。 error 属性是一个可选值,如果有错误我们将显示它。
import Vapor
import SwiftHtml
public struct InputFieldTemplate: TemplateRepresentable {
public var context: InputFieldContext
public init(_ context: InputFieldContext) {
self.context = context
}
@TagBuilder
public func render(_ req: Request) -> Tag {
LabelTemplate(context.label).render(req)
Input()
.type(context.type)
.key(context.key)
.placeholder(context.placeholder)
.value(context.value)
.class("field")
if let error = context.error {
Span(error)
.class("error")
}
}
}
现在我们需要一些东西来连接输入和输出模板。 理想情况下,我们希望能够通过解码通用输入值来处理表单,然后当我们必须呈现表单时,最好使用通用模板来显示实际的表单字段。 我们将创建一个抽象表单字段类来实现这一点。
/// FILE: Sources/App/Framework/Form/AbstractFormField.swift
import Vapor
open class AbstractFormField {
public var key: String
public var input: Input
public var output: Output
public var error: String?
public init(key: String,
input: Input,
output: Output,
error: String? = nil) {
self.key = key
self.input = input
self.output = output
self.error = error
}
open func config(_ block: (AbstractFormField) -> Void) -> Self {
block(self)
return self
}
}
key将用于解码输入并设置输出模板的密钥。 输入和输出值是通用值,它们将在子类中指定。 我们还将向 AbstractFormField 类添加一个错误属性,以便我们可以验证输入并在以后呈现问题(如果有)
这就是我们如何根据现有组件定义实际的 InputField。
/// FILE: Sources/App/Framework/Form/Fields/InputField.swift
public final class InputField: AbstractFormField {
public convenience init(_ key: String) {
self.init(key: key, input: "", output: .init(.init(key: key)))
}
}
我们仍然需要以某种方式将Field存储在表单类中,但在此之前,我们可以用 FormAction
定义这个操作
/// FILE: Sources/App/Framework/Form/FormAction.swift
import SwiftHtml
public struct FormAction {
public var method: SwiftHtml.Method
public var url: String?
public var enctype: SwiftHtml.Enctype?
public init(method: SwiftHtml.Method = .post,
url: String? = nil,
enctype: SwiftHtml.Enctype? = nil){
self.method = method
self.url = url
self.enctype = enctype
}
}
就是这样,我们现在可以定义一个 AbstractForm 类,它可以存储所有可用的表单字段以及表单操作。 一个表单也可以有一个通用的错误信息,所以我们为此目的存储一个错误属性。 例如,如果登录尝试失败,我们只想打印“无效的电子邮件或密码消息”而不是特定的表单字段错误,但如果是电子邮件输入错误,我们可能希望在其旁边显示“无效的电子邮件地址”
/// FILE: Sources/App/Framework/Form/AbstractForm.swift
import Vapor
open class AbstractForm {
open var action: FormAction
open var fields: [Any]
open var error: String?
open var submit: String?
public init(action: FormAction = .init(),
fields: [Any] = [],
error: String? = nil,
submit: String? = nil) {
self.action = action
self.fields = fields
self.error = error
self.submit = submit
}
}
因为Swift 不允许我们存储不确定类型的 ** [AbstractFormField] **数组,因为它使用到泛型。所以我们先临时在 fields 数组中放置一个 Any 表示
最终我们还是需要一种渲染表单的方法,所以在 FormContext。 fields将被表示为 TemplateRepresentable 值
/// FILE: Sources/App/Framework/Form/Templates/Contexts/FormContext.swift
public struct FormContext {
public var action: FormAction
public var fields: [TemplateRepresentable]
public var error: String?
public var submit: String?
}
Inside the FormTemplate we can use the context to render the form with the fields.
/// FILE: Sources/App/Framework/Form/Templates/Html/FormTemplate.swift
import Vapor
import SwiftHtml
public struct FormTemplate: TemplateRepresentable {
var context: FormContext
public init(_ context: FormContext) {
self.context = context
}
@TagBuilder
public func render(_ req: Request) -> Tag {
Form {
if let error = context.error {
Section {
P(error)
.class("error")
}
}
context.fields.map { field in
Section {
field.render(req)
}
}
Section {
Input()
.type(.submit)
.value(context.submit ?? "Save")
}
}
.method(context.action.method)
.action(context.action.url)
.enctype(context.action.enctype)
}
}
逻辑很简单,我们只需要遍历表单字段并调用 TemplateRepresentable 协议上的 render
方法。 如果context携带异常值,我们将显示错误,并且我们还设置了正确的 action、method和 enctype values
这样通用表单的基础框架就搭建完毕了,后面我们可以做更多的扩展和处理 AbstractForm 类中的 Any 类型问题
表单组件
我们将要通过表单组件去响应处理表单发生的多个事件
load事件在当初始加载表单时会调用,这是从数据库加载渲染表单所需的相关模型的好位置。 在渲染方法之前,应该调用 read 方法,我们可以使用该方法读回字段的实际值。 当后端尝试使用模板引擎显示表单时,将调用 render 方法
process 方法负责处理用户通过表单提交的输入数据。 将数据存储在表单字段的输入值中后,应调用 validate 方法,进行验证并设置错误。 在write 方法能使用模型写入有效的输入值。 在我们写回经过验证的数据值之后,可以使用 save 方法执行保存操作
根据上述,下面是 FormComponent 协议的一些方法。
/// FILE: Sources/App/Framework/Form/FormComponent.swift
import Vapor
public protocol FormComponent {
func load(req: Request) async throws
func process(req: Request) async throws
func validate(req: Request) async throws -> Bool
func write(req: Request) async throws
func save(req: Request) async throws
func read(req: Request) async throws
func render(req: Request) -> TemplateRepresentable
}
一个表单组件将在我们的 AbstractForm 类中表示表单字段数组。 首先,我们必须在 AbstractFormField 类中实现 FormComponent 协议
/// FILE: Sources/App/Framework/Form/AbstractFormField.swift
import Vapor
open class AbstractFormField: FormComponent {
public var key: String
public var input: Input
public var output: Output
public var error: String?
public init(key: String,
input: Input,
output: Output,
error: String? = nil) {
self.key = key
self.input = input
self.output = output
self.error = error
}
open func config(_ block: (AbstractFormField) -> Void) -> Self {
block(self)
return self
}
// MARK: - FormComponent
public func load(req: Request) async throws {
}
public func process(req: Request) async throws {
if let value = try? req.content.get(Input.self, at: key) {
input = value
}
}
public func validate(req: Request) async throws -> Bool {
return true
}
public func write(req: Request) async throws {
}
public func save(req: Request) async throws {
}
public func read(req: Request) async throws {
}
public func render(req: Request) -> TemplateRepresentable {
return output
}
}
我们现在没有在扩展中定义这些方法,因为想要允许子类在需要时覆盖它们。 到现在,当前版本可以正常工作,下面我们将填补缺失的空白。 让我们继续处理AbstractForm类,并使用 FormComponent 协议对其进行扩展
/// FILE: Sources/App/Framework/Form/AbstractForm.swift
import Vapor
open class AbstractForm: FormComponent {
open var action: FormAction
open var fields: [FormComponent]
open var error: String?
open var submit: String?
public init(action: FormAction = .init(),
fields: [FormComponent] = [],
error: String? = nil,
submit: String? = nil) {
self.action = action
self.fields = fields
self.error = error
self.submit = submit
}
// MARK: - FromComponent
public func load(req: Request) async throws {
for field in fields {
try await field.load(req: req)
}
}
public func process(req: Request) async throws {
for field in fields {
try await field.process(req: req)
}
}
public func validate(req: Request) async throws -> Bool {
var result: [Bool] = []
for field in fields {
result.append(try await field.validate(req: req))
}
return result.filter { $0 == false }.isEmpty
}
public func write(req: Request) async throws {
for field in fields {
try await field.write(req: req)
}
}
public func save(req: Request) async throws {
for field in fields {
try await field.save(req: req)
}
}
public func read(req: Request) async throws {
for field in fields {
try await field.read(req: req)
}
}
public func render(req: Request) -> TemplateRepresentable {
FormTemplate(.init(action: action,
fields: fields.map { $0.render(req: req)},
error: error,
submit: submit))
}
}
现在我们已经准备好 AbstractForm 的实现了,我们可以开始重构一下我们的用户模块。 让我们在用户模块内创建一个新的 Forms 目录,接着创建一个** UserLoginForm**
重构用户登录
第一步是调整UserLoginContext
对象。 我们将使用表单作为 TemplateRepresentable 变量,因此我们可以将其作为模板内的标签呈现
/// FILE: Sources/App/Modules/User/Templates/Contexts/UserLoginContext.swift
struct UserLoginContext {
let icon: String
let title: String
let message: String
let form: TemplateRepresentable
init(icon: String,
title: String,
message: String,
form: TemplateRepresentable) {
self.icon = icon
self.title = title
self.message = message
self.form = form
}
}
因为我们改变了UserLoginContext
,UserLoginTemplate
也要跟着改变。
/// FILE: Sources/App/Modules/User/Templates/Html/UserLoginTemplate.swift
import Vapor
import SwiftHtml
import SwiftSgml
struct UserLoginTemplate: TemplateRepresentable {
var context: UserLoginContext
@TagBuilder
func render(_ req: Request) -> Tag {
WebIndexTemplate.init(.init(title: context.title)) {
Div {
Section {
P (context.icon)
H1(context.title)
P(context.message)
}
.class("lead")
context.form.render(req)
}
.id("user-login")
.class("container")
}
.render(req)
}
}
我们还需要创建一个带有两个输入框的 UserLoginForm
/// FILE: Sources/App/Modules/User/Forms/UserLoginForm.swift
import Vapor
final class UserLoginForm: AbstractForm {
public convenience init() {
self.init(action: .init(method: .post, url: "/sign-in/"),
submit: "Sign in")
self.fields = createFields()
}
func createFields() -> [FormComponent] {
return [
InputField("email")
.config {
$0.output.context.label.required = true
$0.output.context.type = .email
},
InputField("password")
.config {
$0.output.context.label.required = true
$0.output.context.type = .password
}
]
}
}
接下来,我们回到 UserLoginController类中,我们可以使用 UserLoginForm去渲染页面。
/// FILE: Sources/App/Modules/User/Controllers/UserFrontendController.swift
import Vapor
struct UserFrontendController {
func renderSignInView(_ req: Request, _ form: UserLoginForm) -> Response {
let template = UserLoginTemplate(context: .init(icon: "⬇️",
title: "Sign in",
message: "Please log in with your existing account",
form: form.render(req: req)))
return req.templates.renderHtml(template)
}
func signInView(_ req: Request) async throws -> Response {
return renderSignInView(req, .init())
}
func signInAction(_ req: Request) async throws -> Response {
/// the user is authenticated, we can store the user data inside the session too
if let user = req.auth.get(AuthenticatedUser.self) {
req.session.authenticate(user)
return req.redirect(to: "/")
}
/// if the user credentials were wrong we render the form again with an error message
let form = UserLoginForm()
try await form.process(req: req)
form.error = "Invalid email or password."
return renderSignInView(req, form)
}
func signOut(req: Request) throws -> Response {
req.auth.logout(AuthenticatedUser.self)
req.session.unauthenticate(AuthenticatedUser.self)
return req.redirect(to: "/")
}
}
我们做完了上面的修改去适配我们新表单框架。 如你所见,我们能够从 UserFrontendController
中抽离了相关代码,并将与表单相关的逻辑移到单独的文件中。
表单组件构建器
在上面 UserLoginForm 中的 createFields
方法中,我们返回了一个表单字段数组,但我不喜欢我们总是必须在元素之间写方括号和冒号, 所以接下来我想优化这一块的代码
Swift 有一个很好的特性,叫做 result builders,我们可以使用它来让我们的代码更漂亮一点。 我们将创建一个可用于构建表单字段数组的 FormComponentBuilder
/// FILE: Sources/App/Framework/Form/FormComponentBuilder.swift
@resultBuilder
public enum FormComponentBuilder {
public static func buildBlock(_ components: FormComponent...) -> [FormComponent] {
components
}
}
现在可以使用 @FormComponentBuilder
结果构建器标记 createFields
方法,我们可以删除多余的符号
/// FILE: Sources/App/Modules/User/Forms/UserLoginForm.swift
import Vapor
final class UserLoginForm: AbstractForm {
public convenience init() {
self.init(action: .init(method: .post, url: "/sign-in/"),
submit: "Sign in")
self.fields = createFields()
}
@FormComponentBuilder
func createFields() -> [FormComponent] {
InputField("email")
.config {
$0.output.context.label.required = true
$0.output.context.type = .email
}
InputField("password")
.config {
$0.output.context.label.required = true
$0.output.context.type = .password
}
}
}
Result builders 是 Swift 中一个强大的功能。 可以在 Swift 中创建全新的领域特定语言 (DSL)。 SwiftHtml 模板引擎也利用了结果构建器,这样能让我们写出更加直观的 HTML页面。
总结
在本篇中,我们学习了如何构建可重用的表单组件系统并且利用新机制重构了用户模块。在下一篇,我们回继续完善表单组件事件和抽象表单的异步验证机制。