Use Typescript to encapsulate Vue3’s form binding, and support functions such as anti-shake.

Everyone is familiar with Vue3’s parent-child component value transfer, binding form data, secondary packaging of UI library, anti-shake, etc. This article introduces a method of unified packaging using Typescript.

Basic usage

Vue3 provides a simple way for form binding: v-model. It is very convenient for users, v-model="name" is enough.

Make your own components

But when we want to make a component by ourselves, there is a little trouble:

https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-modelicon-default.png?t=N4N7https://links.jianshu .com/go?to=https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-model

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

We need to define props, emit, input events, etc.

Secondarily encapsulate the components of the UI library

If we want to encapsulate the UI library, it will be a little more troublesome:

https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-modelicon-default.png?t=N4N7https://links.jianshu .com/go?to=https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-model

// <script setup>
import { computed } from 'vue'

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get() {
    return props. modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})
// </script>

<template>
  <el-input v-model="value" />
</template>

Since v-model cannot directly use component props, and el-input converts the original value into the form of v-model, it needs to use computed for transfer, so the code is a bit cumbersome.

If the anti-shake function is considered, the code will be more complicated.

Why does the code become more and more messy? Because there was no timely refactoring and necessary encapsulation!

Create a vue3 project

Now that the situation has been told, let’s move on to the solution.

First, use the latest toolchain of vue3: create-vue to create a project that supports Typescript.
https://staging-cn.vuejs.org/guide/typescript/overview.htmlicon-default.png?t=N4N7https://links.jianshu.com/go?to=https ://staging-cn.vuejs.org/guide/typescript/overview.html

First use Typescript to encapsulate v-model, and then use a more convenient way to realize the requirements. You can compare the two to see which one is more suitable.

V-model package

We first make a simple package for v-model and emit, and then add the anti-shake function.

Basic packaging method

  • ref-emit.ts
import { customRef } from 'vue'

/**
 * Direct input of controls, no anti-shake required. Responsible for parent-child component interaction form values
 * @param props props of the component
 * @param emit The emit of the component
 * @param key v-model name, used for emit
 */
export default function emitRef<T, K extends keyof T & amp; string>
(
  props: T,
  emit: (event: any, ...args: any[]) => void,
  key: K
) {
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    return {
      get(): T[K] {
        track()
        return props[key] // return the value of modelValue
      },
      set(val: T[K]) {
        trigger()
        // Set the value of modelValue through emit
        emit(`update:${key.toString()}`, val)
      }
    }
  })
}
  • K key of T
    Because the attribute name should be in props, use keyof T to constrain it.

  • T[K]
    You can use T[K] as the return type.

  • Default value for key
    Tried various methods, although it can run, but TS will report an error. Maybe it’s the wrong way for me to open it.

  • customRef
    Why not use computed? Because the anti-shake function will be added in the future.
    Use emit to submit in set, and get the attribute value in props in get.

  • type of emit
    emit: (event: any, ...args: any[]) => void, various attempts, and finally used any.

This simple encapsulation is complete.

The way to support anti-shake

The anti-shake code provided by the official website is easy to use for native input, but there is a small problem when it is used on el-input, so I have to modify it:

  • ref-emit-debounce.ts
import { customRef, watch } from 'vue'

/**
 * The anti-shake input of the control, the way of emit
 * @param props props of the component
 * @param emit The emit of the component
 * @param key v-model name, default modelValue, used for emit
 * @param delay delay time, default 500 milliseconds
 */
export default function debounceRef<T, K extends keyof T>
(
  props: T,
  emit: (name: any, ...args: any[]) => void,
  key: K,
  delay = 500
) {
  // timer
  let timeout: NodeJS.Timeout
  // Initialize property values
  let_value = props[key]
  
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    // Listen to the property changes of the parent component, and then assign values to ensure that the property is set in response to the parent component
    watch(() => props[key], (v1) => {
      _value = v1
      trigger()
    })

    return {
      get(): T[K] {
        track()
        return_value
      },
      set(val: T[K]) {
        _value = val // binding value
        trigger() // Input content is bound to the control, but not submitted
        clearTimeout(timeout) // Clear the last timer
        // set new timing
        timeout = setTimeout(() => {
          emit(`update:${key.toString()}`, val) // submit
        }, delay)
      }
    }
  })
}
  • timeout = setTimeout(() => {})
    Implement the anti-shake function and delay submitting data.

  • let_value = props[key]
    Define an internal variable, save data when the user enters characters, use it to bind components, and submit it to the parent component after a delay.

  • watch(() => props[key], (v1) => {})
    Monitor the change of the attribute value, and update the display content of the child component when the parent component modifies the value.
    Because the value of the subcomponent corresponds to the internal variable _value, it does not directly correspond to the attribute value of props.

In this way, the anti-shake function is realized.

The method of directly passing the model.

A form often involves multiple fields. If each field is passed by v-model, there will be a “transfer” situation. The “transfer” here refers to emit, and its internal code is more complicated.

If the component nesting is deep, it will be “transited” multiple times, which is not direct enough and cumbersome.
In addition, if v-for is required to traverse form sub-controls, it is not convenient to deal with multiple v-models.

So why not pass a form’s model object directly into the child component? In this way, no matter how many layers of components are nested, the address is directly operated, and it is also convenient to handle the situation where one component corresponds to multiple fields.

Of course, there is also a bit of trouble, you need to pass in an additional attribute to record the name of the field to be operated by the component.

The type of props of the component is shallowReadonly, that is, the root level is read-only, so we can modify the properties of the passed-in object.

Basic packaging method

  • ref-model.ts
import { computed } from 'vue'

/**
 * Direct input of controls, no anti-shake required. Responsible for parent-child component interaction form values.
 * @param model The props model of the component
 * @param colName attribute name to be used
 */
export default function modelRef<T, K extends keyof T> (model: T, colName: K) {
  
  return computed<T[K]>({
    get(): T[K] {
      // Return the value of the specified attribute in the model
      return model[colName]
    },
    set(val: T[K]) {
      // Assign values to the specified attributes in the model
      model[colName] = val
    }
  })
}

We can also use computed for transit, or use K extends keyof T as a constraint.

How to achieve anti-shake

  • ref-model-debounce.ts
import { customRef, watch } from 'vue'

import type { IEventDebounce } from '../types/20-form-item'

/**
 * Directly modify the anti-shake of the model
 * @param model The props model of the component
 * @param colName attribute name to be used
 * @param events collection of events, run: submit immediately; clear: clear timing, for Chinese character input
 * @param delay delay time, default 500 milliseconds
 */
export default function debounceRef<T, K extends keyof T> (
  model: T,
  colName: K,
  events: IEventDebounce,
  delay = 500
) {

  // timer
  let timeout: NodeJS.Timeout
  // Initialize property values
  let _value: T[K] = model[colName]
    
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    // Listen to the property changes of the parent component, and then assign values to ensure that the property is set in response to the parent component
    watch(() => model[colName], (v1) => {
      _value = v1
      trigger()
    })

    return {
      get(): T[K] {
        track()
        return_value
      },
      set(val: T[K]) {
        _value = val // binding value
        trigger() // Input content is bound to the control, but not submitted
        clearTimeout(timeout) // Clear the last timer
        // set new timing
        timeout = setTimeout(() => {
          model[colName] = _value // Submit
        }, delay)
      }
    }
  })
}

By comparison, you will find that the codes are basically the same, except that the place to get and assign values is different, one uses emit, and the other directly assigns values to the attributes of the model.

So can it be combined into one function? Of course, it’s just that the parameters are not easy to name, and you need to make judgments, which seems a bit difficult to read, so it’s better to make two functions directly.

I prefer to pass in the model object directly, which is very concise.

Encapsulation method of range value (multi-field)

The start date and end date can be divided into two controls, or one control can be used. If one control is used, it involves type conversion and field correspondence.

So we can encapsulate another function.

  • ref-model-range.ts
import { customRef } from 'vue'

interface IModel {
  [key: string]: any
}

/**
 * When a control corresponds to multiple fields, emit is not supported
 * @param model the model of the form
 * @param arrColName use multiple attributes, array
 */
export default function range2Ref<T extends IModel, K extends keyof T>
(
  model: T,
  ...arrColName: K[]
) {

  return customRef<Array<any>>((track: () => void, trigger: () => void) => {
    return {
      get(): Array<any> {
        track()
        // multiple fields, need to concatenate attribute values
        const tmp: Array<any> = []
        arrColName. forEach((col: K) => {
          // Get the attribute value specified in the model and form an array
          tmp.push(model[col])
        })
        return tmp
      },
      set(arrVal: Array<any>) {
        trigger()
        if (arrVal) {
          arrColName.forEach((col: K, i: number) => {
            // Split attribute assignment, the number of values may be less than the number of fields
            if (i < arrVal. length) {
              model[col] = arrVal[i]
            } else {
              model[col] = ''
            }
          })
        } else {
          // clear selection
          arrColName. forEach((col: K) => {
            model[col] = '' // undefined
          })
        }
      }
    }
  })
}

  • IModel
    Define an interface to constrain the generic type T, so that model[col] will not report an error.

The issue of anti-shake is not considered here, because most of the cases do not need anti-shake.

How to use

After the encapsulation is complete, it is very convenient to use it in the component, and only one line is required.

First make a parent component, load various sub-components for a demonstration.

  • js
 // encapsulation of v-model and emit
  const emitVal = ref('')
  // pass the object
  const person = reactive({name: 'test', age: 111})
  // range, divided into two properties
  const date = reactive({d1: '2012-10-11', d2: '2012-11-11'})
  • template
 Encapsulation of emit
  <input-emit v-model="emitVal"/>
  <input-emit v-model="person.name"/>
  The package of the model
  <input-model :model="person" colName="name"/>
  <input-model :model="person" colName="age"/>
  The range of model values
  <input-range :model="date" colName="d1_d2"/>

emit

We make a subcomponent:

  • 10-emit.vue
// <template>
  <!--test emitRef-->
  <el-input v-model="val"></el-input>
// /template>

// <script lang="ts">
  import { defineComponent } from 'vue'

  import emitRef from '../../../../lib/base/ref-emit'

  export default defineComponent({
    name: 'nf-demo-base-emit',
    props: {
      modelValue: {
        type: [String, Number, Boolean, Date]
      }
    },
    emits: ['update:modelValue'],
    setup(props, context) {

      const val = emitRef(props, context. emit, 'modelValue')

      return {
        val
      }
    }
  })
// </script>

Define props and emit, and then call the function.
The script setup method is also supported:

  • 12-emit-ss.vue
<template>
  <el-input v-model="val" ></el-input>
</template>

<script setup lang="ts">
  import emitRef from '../../../../lib/base/ref-emit'

  const props = defineProps<{
    modelValue: string
  }>()

  const emit = defineEmits<{
    (e: 'update:modelValue', value: string): void
  }>()
 
  const val = emitRef(props, emit, 'modelValue')

</script>

Define props, define emit, then call emitRef.

model

we make a subcomponent

  • 20-model.vue
<template>
  <el-input v-model="val2"></el-input>
</template>

<script lang="ts">
  import { defineComponent } from 'vue'
  import type { PropType } from 'vue'
  import modelRef from '../../../../lib/base/ref-model'

  interface Person {
    name: string,
    age: 12
  }

  export default defineComponent({
    name: 'nf-base-model',
    props: {
      model: {
        type: Object as PropType<Person>
      },
      colName: {
        type: String
    },
    setup(props, context) {
      const val2 = modelRef(props. model, 'name')
      return {
        val2
      }
    }
  })
</script>

Define props, and then call it.
Although there is an additional parameter describing the field name, there is no need to define and pass emit.

Range value

<template>
  <el-date-picker
    v-model="val2"
    type="daterange"
    value-format="YYYY-MM-DD"
    range-separator="-"
    start-placeholder="start date"
    end-placeholder="end date"
  />
</template>

<script lang="ts">
  import { defineComponent } from 'vue'
  import type { PropType } from 'vue'

  import rangeRef from '../../../../lib/base/ref-model-range2'
 
  interface DateRange {
    d1: string,
    d2: string
  }

  export default defineComponent({
    name: 'nf-base-range',
    props: {
      model: {
        type: Object as PropType<DateRange>
      },
      colName: {
        type: [String]
      }
    },
    setup(props, context) {
      const val2 = rangeRef<DateRange>(props. model, 'd1', 'd2')
      return {
        val2
      }
    }
  })
</script>

When the el-date-picker component is in type=”daterange”, the v-model is an array, and the setting of the back-end database is generally two fields, such as startDate and endDate, which need to be submitted in the form of objects, so you need to Convert between arrays and objects.

And the rangeRef we encapsulate can do such a conversion.

The embarrassment of TS

You may notice that the above example does not use the colName attribute, but directly passes the parameters of the character layer.

Because TS can only do static checks, not dynamic checks, directly writing strings is a static way, and TS can check them.

However, if you use the colName attribute, it is a dynamic method. The TS check does not support dynamics, and then directly gives an error message.

Although it can run normally, it is still very annoying to look at the red line, so I finally encapsulated a loneliness.

Compare

Compare project emit model
type clear difficult very clear
parameter (use) one Two
Efficiency Emit needs to be transferred internally Directly use the object address to modify
Encapsulation Difficulty It’s a bit troublesome Easy
Used in components Need to define emit No need to define emit
Multi-field (encapsulation) No separate encapsulation Need to encapsulate separately
Multiple fields (use) Need to write multiple v-model No need to increase the number of parameters
Multiple fields (form v-for) Not easy to handle Easy

If you want to use v-for to traverse the sub-components in the form, obviously the model method is easier to implement, because you don’t need to write several v-models for one component.