Form development is one of the most common requirements in Web development, and the complexity of forms is increasing day by day. How can we use technology to better implement form structure and organize business code? This article provides some experience in constructing configurable forms using vue.js.

background

As one of the earliest logical parts of the modern web, forms still play an important role in blogs, classifieds, and forums where user-posted information is the core of the web. For these sites, forms represent the initial source of information, so they actually carry the primary logic for information processing. For different categories, the content of the form obviously needs to be differentiated in business, so how to realize the differentiation and configuration of the form content has become a major focus of this kind of Web applications.

Traditional Web applications use the server to directly output forms to output different form content for different page logic. Some relatively mature frameworks provide the ability for a server to output a form with some simple configuration. For example, the PHP framework Laravel provides ways to render a Form control in the template layer of a view via Form:: Textarea (‘ Content ‘, NULL, [‘class’ => ‘form-Control ‘]). However, in today’s increasingly complex interaction logic, many requirements, such as: field real-time verification, linkage between controls, in this mode is very difficult to achieve, simple server-side rendering has been far from meeting the needs of business development.

While Microsoft’s WPF first showed us the MVVM pattern for applications, Knockout brings it into the front-end world. So far, view-layer frameworks like React and Vue have put this pattern into production well. This article is about optimizing our form development capabilities and experience through the vue.js framework.

The target

Beyond technical exploration, what are the goals for forms?

Imagine having these needs:

  1. In the simplest form, you need three fields: content, location, and contact information
  2. The content field should be at least eight characters long and should not contain simple banned phrases
  3. The location field is a tree selection control that needs to provide the user with the ability to select from the province to the district
  4. Contact information is mandatory, and this field must be the mobile phone number
  5. If a mobile phone number is displayed in the content field and the user does not enter a mobile phone number, the mobile phone number must be automatically added to the contact information

You see, even with a form as simple as this, there’s a need for this. Some functions, such as mandatory and format verification, can be implemented with native constraints via fields such as required or pattern in HTML5, while more complex functions must be left to JavaScript. All that aside, on a pure page structure, we want something like this:

<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">
      <! -- Control implementation -->
    </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">submit</button>
</form>Copy the code

We would like to have a configuration that directly configures the above page structure and some of its logic:

[{"type": "textarea"."name": "content"."validators": [
      "minlength": 8] {},"type": "tree"."name": "address"."datasrc": "areaTree"."level": 3
  },
  {
    "type": "text"."name": "contact"."required": true."validators": [
      "regexp": "<mobile>",]}]Copy the code

A little bit of simple business logic code was all we needed to configure the form, and the form framework did the rest.

implementation

There are excellent descriptions of how to build a simple Web application using vue.js in many places. For example, vue.js website [1] provides many examples, so we won’t go into them again. I’ll just introduce some of the core implementations for your reference.

The basic implementation logic is shown below:

The whole process can be divided into two parts: back-end data transfer (magenta) and external extension (blue). The core process of each part will be introduced in detail next.

Back-end data transfer

The vue.js-oriented operating environment can be well supported by most mobile browsers [2]. Therefore, we can write the following code directly in the HTML or corresponding template file:

<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>Copy the code

(Note: The language used here is Blade [3])

The #my-form element is declared as the root container we hand over to Vue, and

is the control we create for the form. It is worth noting here that we use a script tag with ref to enable us to pass data from the back end to the Vue component.

Here, I use two data objects from the back end. The schema is similar to the configuration I mentioned in the previous section, which is passed to the corresponding form control through the root container of the Vue; Context is used to process other data that needs to be read by the back end. For example, some code may be processed according to different user roles, so we can pass this information to JS for easy control.

In the JS file, we can handle the above data in the following way:

new Vue({
    // ...
    mounted() {
        this.schema = JSON.parse(this.$refs.schema.innerText)
        this.context = JSON.parse(this.$refs.context.innerText)
    }
})Copy the code

Thus, we can implement our form construction by implementing form.vue.

note

  1. vuejs.org/v2/examples
  2. Caniuse.com/#search=ECM…
  3. Laravel.com/docs/5.4/bl…

Constructing a form control

In the my-Form component, we can use the Schema configuration passed from the back end to generate the corresponding controls

<template>
  <form :class="form" method="post">
    <my-line v-for="(item, index) in schema" :schema="item"></my-line>
  </form>
</template>Copy the code

The my-line element is used here to construct a uniform form template. For example, if all controls are wrapped in a

container, we can use this as a template declaration for the my-line element. Using this method we can construct the same Label element, error message, and so on.

In the my-line component, we can declare the actual form control like this:

<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>Copy the code

This approach seems simple and straightforward, but it can make my-line components very complicated. To solve this problem, we can introduce a virtual component, my-Control, which itself renders different form elements based on different schema.type.

Using functional components in vue.js allows you to declare a component that does not render itself, but can call its children. All we need to do is declare:

<div class="form-ctrl">
  <my-control :schema="schema"></my-control>
</div>Copy the code
// my-control.js
function getControl(context) {
  const type = context.props.schema.type
  // Distribute components here
}
export default {
  functional: true.props: {
    schema: Object
  },
  render(h, context) {
    return h(getControl(context), context)
  }
}Copy the code

In this way, the complexity of the control can be removed from the my-line component, which is more conducive to the independent maintenance of each component.

Control inherits

As mentioned above, we can already implement various controls such as my-input and my-Textarea independently. However, there may be some general logic in these components. For example, to display the name of the form field corresponding to the control, we actually need an attribute like this:

export default {
  // ...
  computed: {
    displayName() {
      // The name of the configuration is used if there is a separate configuration. By default, the name attribute of the table element is used as the name
      return this.schema.displayName || this.schema.name
    }
  }
}Copy the code

For example, for all controls, we will have the corresponding data data attribute; Or for individual components, we need to perform the corresponding operations of the lifecycle methods uniformly. In this case, we can abstract the unified implementation into a separate class:

// contract.js
export default {
  // Some common methods
}
// input.vue
import Contract from './contract'
export default {
  mixins: [Contract]
  // ...
}Copy the code

Also, because of the mixin mechanism of Vue, we can declare a uniform lifecycle function in contract.js, whereas in the corresponding component of the control, a redeclaration of the lifecycle function will not override the uniform processing, but will be executed after the uniform function. This ensures that we can safely declare separate life cycles without having to add uniform logic again.

The external elements

There are some special elements, such as the submit button and the protocol check that may appear on some web publishing forms, which obviously cannot be injected as form controls. But there are other ways we can do this simply:

<! -- 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>Copy the code

The Slot mechanism allows us to inject an element into a Form from outside that is not a Form control. Similarly, if we need to add hidden form items such as CSRF elements, we can do it this way.

extension

After completing the basic components, we have some basic interaction capabilities, as well as capabilities that the business logic might consider. For example, mandatory fields mentioned above. At this point, we need to extend our form from a JavaScript perspective.

To prevent the business logic from spilling into the control logic, we need to provide a mechanism for the business logic to execute at the appropriate time. For example, the true meaning of a mandatory field is to see if it is empty when the control data changes. If any required data is empty, disable the submit button. Obviously, control data changes as part of the lifecycle (updated, or custom @change event), so we can implement a framework for business logic processing through event-passing mechanisms.

The core of a Form is a Form element and a Control, so we need to use an independent Event Emitter to delegate the events of these core controls.

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) {// ...}}Copy the code

In this way, we can get an Emitter that persists on the current page using core.form () or other methods such as core.Control (‘content’). Then we just need to delegate life cycle events in the corresponding Vue file:

import Core from './core.js'
export default {
  // ...
  beforeUpdate() {
    // Avoid events before initialization
    if (!this.schema.length) return
    Core.form().mount(this).emit('create'.this)}},Copy the code

To avoid introducing CoreProxy globally, you can expose this class to Vue.Prototype. With the Vue Plugin, the following effects can be achieved:

// contract.js
export default {
  // ...
  updated() {
    this.$core.control(this.schema.name).emit('update'.this)
    // propagation
    this.$core.form().emit('update'.this)}}Copy the code

In this way, we can pass the corresponding Vue object to Core to proxy without exposing it directly to the outside world. For example, our code might look like this:

// This file is used to implement the "required" function
Core.form().on('update'.function(control) {
  if(! control.schema.required)return
  if (control.model) {
    // Events corresponding to error are handled by other files
    Core.form().emit('resolve-error', control, 'required')}else {
    Core.form().emit('reject-error', control, 'required'.'This is required')}})Copy the code

Similarly, we can pass events between different components. For example, we need to verify the Contact information field when Type is set to Mobile number:

Core.control('contact-type').on('change'.function(control) {
  // We can not read the "contact information" directly, should be handled by other ways
  const proxy = Core.control('contact')
  const contact = proxy.read()
  // ...
})Copy the code

Because we can get the corresponding Vue object inside Core, we can expose some read-only methods like read for external calls. For data modification, for example, the external control may also need to modify the data, we can also provide some built-in events, such as core.control (‘contact’).emit(‘write’, newValue) to enable the external to modify the data and obtain unified control.

conclusion

Starting with the end, let’s finish by talking about why our forms are better represented in a framework like Vue:

  1. Bidirectional binding mechanism. Bidirectional binding means that we don’t need to care about data changes to redraw the view, and we don’t need to care about how user operations are synchronized to JS data changes, which makes our data processing can be highly simplified; At the same time, Vue gives a lot of options for data synchronization, for example.lazy,.trim“, allows us to concentrate on processing the logic itself
  2. Componentization. The form itself is a good place to use componentization because each form control has similarities and differences, and the form itself is made up of a variety of controls. Abstracting controls into components is a necessary and optimal way to deal with controls
  3. Template description. Vue uses templates to describe how to render a component, and since HTML’s own form controls encapsulate a lot of logic, using templates to describe a form control is more straightforward and natural than rendering functions. As explained in Vue’s official language, the form controls themselves are presentational rather than logical components, so the template will obviously perform better

Vue.js is an excellent framework by itself. On the one hand, it allows us to describe our controls in the form of Vue components in the simplest way possible. At the same time, we can use a range of other functions provided by Vue to implement more complex functions such as control abstraction, control distribution, sharing of event delivery modules, and external content injection. If you have similar form development needs in your daily life, you can try using Vue to build.


Front end engineer, Web development engineer, dedicated to business architecture design and development based on different frameworks and languages.

This article is the author’s personal view only, does not represent the people’s net position.


This article is published on the wechat public account of “People’s Net Technical Team”. Scan the code to subscribe immediately: