前端表单进阶之路:通过Vue.js实现表单可配置化
表单开发是 Web 开发中最常见的需求之⼀,表单本⾝的复杂度也在⽇益增加。我们如何借助技术⼿段,更好地实现表单结构、组织业务代码?本⽂介绍了使⽤ Vue.js 构造可配置化表单的⼀些经验。
背景
作为现代⽹页中最早具有逻辑的部分,表单⾄今仍在博客类、分类信息以及论坛等以⽤户发布的信息为核⼼的⽹站中,扮演着重要的⾓⾊。对这些⽹站来说,表单意味着信息的初始来源,因此它实际上承载了对于信息处理的第⼀⼿逻辑。对于不同的类⽬,表单的内容显然在业务上需要进⾏区分,所以,如何实现表单内容的区别化和可配置化就成为了这⼀类 Web 应⽤的⼀⼤重点。
传统的 Web 应⽤使⽤服务端直接输出表单的⽅式,来针对不同的页⾯逻辑输出不同的表单内容。⼀些相对完备的框架会提供服务端通过⼀些简单的配置输出表单的功能。例如,PHP 框架 Laravel 提供了通过 Form::textarea('content', null, ['class' => 'form-control']) 这样的⽅式来允许在视图的模板层渲染⼀个表单控件。然⽽,在交互逻辑⽇益复杂的今天,许多需求,例如:字段的实时校验、控件之间的联动,在这种模式下的实现是⾮常困难的,简单的服务端渲染已经远远不能满⾜业务的发展需求。
微软的 WPF 最早向我们展⽰了应⽤的 MVVM 模式,⽽ Knockout 则将它带⼊了前端的世界。到⽬前,
以 React 和 Vue 为代表的视图层框架已经很好地将这种模式投⼊了⽣产中。⽽本⽂将要介绍的,则正是通过 Vue.js 框架来优化我们的表单开发能⼒和体验。
⽬标
抛开技术领域的探索,对于表单,我们要达成的⽬标是什么呢?
试想,有这样的⼀些需求:
1. ⼀个最简单的表单中,需要有内容、地点、联系⽅式三个字段
2. 内容字段⾄少需要填写8个字,且不能包含⼀些简单的违禁词组
3. 地点字段是⼀个树形的选择控件,需要提供给⽤户从省级选到区县级的能⼒
4. 联系⽅式是必填的,并且这个字段必须是⼿机号码
5. 如果内容字段中出现了⼿机号码,且⽤户没有填写号码,需要将这个号码⾃动补充到联系⽅式中
⼤家看,即使是内容如此简单的表单,也会有这样的需求。有⼀些功能,例如:必填、格式校验,我们可以通过 HTML5 中的 required 或者 pattern 这样的字段来实现原⽣的约束,⽽更多复杂的功能则
原生js和js的区别
必须交由 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)
}
})复制代码
这样,我们就可以通过实现 form.vue 来实现我们的表单构造。
附注
1.
2.
3.
构造表单控件
在 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="pe === 'input'"></my-input>
<my-textarea :schema="schema" v-else-if="pe === 'textarea'"></my-textarea>
</div>复制代码
这种⽅式看起来简单直接,但它会使 my-line 组件变得异常复杂。为了解决这个问题,我们可以引⼊⼀个虚拟组件 my-control,由它⾃⼰根据不同的 pe 渲染出不同的表单元素。
Vue.js 中使⽤函数式组件可以声明⼀个本⾝不渲染,但可以调⽤⼦组件的组件。我们只需要这样声明:
<div class="form-ctrl">
<my-control :schema="schema"></my-control>
</div>复制代码
function getControl(context) {
const type = context.pe
// 在这⾥分发组件
}
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 中声明统⼀的⽣命周期函数,⽽在控件对应的组件中,再次声明⽣命周期函数不会覆盖统⼀的处理,⽽是会在统⼀函数之后执⾏。这保证了我们可以安全声明独⽴的⽣命周期⽽⽆需再次添加统⼀逻辑。
外部元素
有⼀些⽐较特别的元素,例如:提交按钮、及有些⽹站发布表单可能会出现的协议勾选,这些东西显然不能作为表单控件注⼊。但我们可以使⽤其他⽅式来简单实现:
<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 l[name] ||
(l[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) {
// ...
}
}复制代码

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。