表单开发是 Web 开发中最多见的需求之一,表单自己的复杂度也在日益增长。咱们如何借助技术手段,更好地实现表单结构、组织业务代码?本文介绍了使用 Vue.js 构造可配置化表单的一些经验。html
做为现代网页中最先具备逻辑的部分,表单至今仍在博客类、分类信息以及论坛等以用户发布的信息为核心的网站中,扮演着重要的角色。对这些网站来讲,表单意味着信息的初始来源,所以它实际上承载了对于信息处理的第一手逻辑。对于不一样的类目,表单的内容显然在业务上须要进行区分,因此,如何实现表单内容的区别化和可配置化就成为了这一类 Web 应用的一大重点。前端
传统的 Web 应用使用服务端直接输出表单的方式,来针对不一样的页面逻辑输出不一样的表单内容。一些相对完备的框架会提供服务端经过一些简单的配置输出表单的功能。例如,PHP 框架 Laravel 提供了经过 Form::textarea('content', null, ['class' => 'form-control'])
这样的方式来容许在视图的模板层渲染一个表单控件。然而,在交互逻辑日益复杂的今天,许多需求,例如:字段的实时校验、控件之间的联动,在这种模式下的实现是很是困难的,简单的服务端渲染已经远远不能知足业务的发展需求。vue
微软的 WPF 最先向咱们展现了应用的 MVVM 模式,而 Knockout 则将它带入了前端的世界。到目前,以 React 和 Vue 为表明的视图层框架已经很好地将这种模式投入了生产中。而本文将要介绍的,则正是经过 Vue.js 框架来优化咱们的表单开发能力和体验。laravel
抛开技术领域的探索,对于表单,咱们要达成的目标是什么呢?json
试想,有这样的一些需求:后端
你们看,即便是内容如此简单的表单,也会有这样的需求。有一些功能,例如:必填、格式校验,咱们能够经过 HTML5 中的 required
或者 pattern
这样的字段来实现原生的约束,而更多复杂的功能则必须交由 JavaScript。抛开这一部分不谈,在纯页面结构上,咱们想要的大概是这样:浏览器
<form class="form">
<div class="form-line">
<div class="form-control">
<textarea name="content"></textarea>
</div>
</div>
<div class="form-line">
<div class="form-control">
<input type="hidden" name="address">
<!-- 具体的控件实现 -->
</div>
</div>
<div class="form-line">
<div class="form-control">
<input type="text" name="contact">
</div>
</div>
<input type="hidden" name="_token" value="1wev5wreb8hi1mn=">
<button type="submit">提交</button>
</form>复制代码
而咱们指望能有这样的配置直接配置上述的页面结构,以及其部分的逻辑:安全
[
{
"type": "textarea",
"name": "content",
"validators": [
"minlength": 8
]
},
{
"type": "tree",
"name": "address",
"datasrc": "areaTree",
"level": 3
},
{
"type": "text",
"name": "contact",
"required": true,
"validators": [
"regexp": "<mobile>",
]
}
]复制代码
再加上一点简单的业务逻辑代码,就构成了咱们对于表单的所有配置,而剩下的工做都由表单框架来生成。微信
关于如何使用 Vue.js 搭建一个简单的 Web 应用,在不少地方已经有很是优秀的介绍,例如 Vue.js 的官网 [1] 就提供了不少实例,所以咱们也再也不赘述。在这里我将只介绍一些核心的实现,以供你们参考。前端工程师
基本的实现逻辑以下图所示:
整个流程能够分为:后端数据传递(品红)和外部扩展(蓝色)两部分,接下来会对各个部分的核心流程详细介绍。
Vue.js 面向的运行环境在绝大多数的手机浏览器上是能够良好支持的 [2] 。所以咱们能够直接在 HTML 或者对应的模板文件中写以下的代码:
<div id="my-form">
<my-form :schema="schema" :context="context"></my-form>
<script type="text/json" ref="schema">{!! json_encode($schema) !!}</script>
<script type="text/json" ref="context">{!! json_encode($context) !!}</script>
</div>复制代码
(注:这里使用的语言是 Blade [3])
#my-form
这个元素做为咱们交由 Vue 控制的根容器声明,而 <my-form>
则是咱们为表单建立的控件。这里值得注意的是,咱们经过一个带有 ref
的 script
标签来使得咱们能够从后端传递数据给 Vue 组件。
在这里,我使用了两个来自于后端的数据对象。schema
是相似于上一节中我提到的配置内容,它将经过 Vue 的根容器传递给对应的表单控件;而 context
则用于处理其余须要后端读取的数据,例如一些代码中可能会根据不一样的用户角色进行处理,则咱们能够把这部分信息也传递给 JS 便于控制。
在 JS 文件中,咱们可使用以下的方式来处理上述的数据:
new Vue({
// ...
mounted() {
this.schema = JSON.parse(this.$refs.schema.innerText)
this.context = JSON.parse(this.$refs.context.innerText)
}
})复制代码
这样,咱们就能够经过实现 form.vue
来实现咱们的表单构造。
附注
在 my-form
组件中,咱们能够经过后端传递的 Schema 配置,来生成对应的控件
<template>
<form :class="form" method="post">
<my-line v-for="(item, index) in schema" :schema="item"></my-line>
</form>
</template>复制代码
my-line
这个元素,在这里被咱们用于构造统一的表单模板,例如,全部的控件都会被 <div class="form-line"></div>
这样的容器包裹,那么咱们能够将这部份内容做为 my-line
元素的模板声明。使用这种方法咱们能够构造相同的 Label 元素、错误提示等。
在 my-line
组件中,咱们能够经过这样的方式来声明实际的表单控件:
<div class="form-ctrl">
<my-input :schema="schema" v-if="schema.type === 'input'"></my-input>
<my-textarea :schema="schema" v-else-if="schema.type === 'textarea'"></my-textarea>
</div>复制代码
这种方式看起来简单直接,但它会使 my-line
组件变得异常复杂。为了解决这个问题,咱们能够引入一个虚拟组件 my-control
,由它本身根据不一样的 schema.type
渲染出不一样的表单元素。
Vue.js 中使用函数式组件能够声明一个自己不渲染,但能够调用子组件的组件。咱们只须要这样声明:
<div class="form-ctrl">
<my-control :schema="schema"></my-control>
</div>复制代码
// my-control.js
function getControl(context) {
const type = context.props.schema.type
// 在这里分发组件
}
export default {
functional: true,
props: {
schema: Object
},
render(h, context) {
return h(getControl(context), context)
}
}复制代码
这样,能够将控件的复杂度从 my-line
这个组件中抽离出来,更有利于各组件的独立维护。
如上所述,咱们已经能够将各类控件,例如 my-input
、my-textarea
独立进行实现。可是,这些组件中可能会有一些通用的逻辑。好比,控件对应的表单字段显示的名称,咱们实际上须要这样的属性:
export default {
// ...
computed: {
displayName() {
// 若是有独立配置就使用配置的名称,而默认使用表单项的 name 属性做为名称
return this.schema.displayName || this.schema.name
}
}
}复制代码
再好比,咱们对于全部的控件,都会有对应数据的 data
属性;或者对于各个组件,咱们须要统一执行生命周期方法对应的操做。这种状况下,咱们能够将统一的实现抽象为一个独立的类:
// contract.js
export default {
// 一些公用的方法
}
// input.vue
import Contract from './contract'
export default {
mixins: [Contract]
// ...
}复制代码
而且,因为 Vue 的 mixin 机制,咱们能够在 contract.js
中声明统一的生命周期函数,而在控件对应的组件中,再次声明生命周期函数不会覆盖统一的处理,而是会在统一函数以后执行。这保证了咱们能够安全声明独立的生命周期而无需再次添加统一逻辑。
有一些比较特别的元素,例如:提交按钮、及有些网站发布表单可能会出现的协议勾选,这些东西显然不能做为表单控件注入。但咱们可使用其余方式来简单实现:
<!-- template -->
<div id="my-form">
<my-form :schema="schema" :context="context"></my-form>
<div class="action" slot="action">
<button class="form-submit" type="submit">{{ $btnText }}</button>
</div>
</div>
<!-- my-form -->
<template>
<form :class="form" method="post">
<my-line v-for="(item, index) in schema" :schema="item"></my-line>
<slot name="action"></slot>
</form>
</template>复制代码
经过 Slot 机制,咱们能够从外部向 Form 内注入一个不属于表单控件的元素。同理,若是咱们须要加入一些 CSRF 元素等隐藏的表单项,也能够经过这种方式进行。
在完成了基础组件以后,咱们还有一些基本的交互功能,以及业务逻辑可能会考虑的功能。例如上文中提到的必填等。这时候,咱们须要从 JavaScript 角度对咱们的表单进行扩展。
为了防止业务逻辑扩散到控件逻辑中,咱们须要提供一套机制来使得业务逻辑能够在对应的时刻执行。例如,必填的真实含义实际上是当控件数据改变时,观察是否为空。若是存在必填项数据为空,禁用提交按钮。显然,控件数据改变时是生命周期的一个过程(updated,或者是自定义的 @change 事件),因此咱们能够经过事件传递的机制来实现一套业务逻辑处理的框架。
表单的核心是 Form(表单元素)和 Control(控件),因此,咱们须要经过一个独立的 Event Emitter 将对应的核心控件的事件代理出来。
const storage = {
proxy: {
form: null,
control: {}
}
}
class Core {
constructor(target) {
this.target = target
}
static control(name) {
return storage.proxy.control[name] ||
(storage.proxy.control[name] = new CoreProxy(`control.${name}`))
}
static form() {
return storage.proxy.form ||
(storage.proxy.form = new CoreProxy('form'))
}
mount(target) {
// ...
}
on(events, handler) {
// ...
}
emit(events, ...args) {
// ...
}
}复制代码
经过这种方式,咱们能够经过 Core.form()
或者诸如 Core.control('content')
的方式来得到一个在当前页面持久有效的 Emitter。而后咱们只须要在对应的 Vue 文件中代理生命周期事件:
import Core from './core.js'
export default {
// ...
beforeUpdate() {
// 避免初始化以前产生事件
if (!this.schema.length) return
Core.form().mount(this).emit('create', this)
},
}复制代码
为了不全局引入 CoreProxy
,能够把这个类暴露在 Vue.prototype
上。经过 Vue Plugin,能够实现下面的效果:
// contract.js
export default {
// ...
updated() {
this.$core.control(this.schema.name).emit('update', this)
// propagation
this.$core.form().emit('update', this)
}
}复制代码
经过这种方式,咱们能够将对应的 Vue 对象传递给 Core 来代理,但同时不把它直接暴露给外部。好比咱们的代码多是这样:
// 这个文件用来实现“必填”功能
Core.form().on('update', function(control) {
if (!control.schema.required) return
if (control.model) {
// error对应的事件由其余文件来处理
Core.form().emit('resolve-error', control, 'required')
} else {
Core.form().emit('reject-error', control, 'required', '此项必填')
}
})复制代码
同理,咱们也能够将事件在不一样的组件中传递。例如,咱们须要在“类型”选择为“手机号码”的状况下校验“联系方式”字段:
Core.control('contact-type').on('change', function(control) {
// 这里咱们不能直接读取到“联系方式”,应该经过其余的方式来处理
const proxy = Core.control('contact')
const contact = proxy.read()
// ...
})复制代码
由于在 Core 的内部,咱们能够获取到对应的 Vue 对象,因此咱们彻底能够暴露出一些相似于 read
这样的只读方法供外部调用;对于数据修改,例如外部也可能须要修改其余控件的数据,咱们一样能够提供一些内置的事件,例如 Core.control('contact').emit('write', newValue)
来使得外部有能力修改这些数据,同时能够获得统一的控制。
以终为始,咱们最后来聊一聊,为何咱们的表单在 Vue 这样的框架中能够被更好地表达:
.lazy
、.trim
等修饰符,可让咱们将精力集中在对于逻辑自己的处理上Vue.js 自己是一个很是优秀的框架,一方面,它能够经过最精简的方式让咱们以 Vue 组件的形式描述出咱们的控件;同时,咱们可使用 Vue 提供的一系列其余功能,来实现诸如控件抽象、控件分发、事件传递模块的共用、外部内容注入等更加复杂的功能。若是你们在平时也有相似的表单开发的需求,不妨尝试使用 Vue 来构建。
做者:孙翛然
简介:前端工程师、Web 开发工程师,致力于基于不一样框架和语言的业务架构设计和开发。本文仅为做者我的观点,不表明百姓网立场。
本文在 “百姓网技术团队” 微信公众号首发,扫码当即订阅: