前端表单进阶之路:通过 Vue.js 实现表单可配置化

前端表单进阶之路:通过 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 这样的字段来实现原生的约束,而更多复杂的功能则必须交由 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来实现我们的表单构造。

附注

  1. http://vuejs.org/v2/examples
  2. caniuse.com/#
  3. laravel.com/docs/5.4/bl

构造表单控件

在 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) {