用Typescript 的方式封裝Vue3的表單繫結,支援防抖等功能。

金色海洋(jyk) 發表於 2022-06-23
Vue

Vue3 的父子元件傳值、繫結表單資料、UI庫的二次封裝、防抖等,想來大家都很熟悉了,本篇介紹一種使用 Typescript 的方式進行統一的封裝的方法。

基礎使用方法

Vue3對於表單的繫結提供了一種簡單的方式:v-model。對於使用者來說非常方便,v-model="name" 就可以了。

自己做元件

但是當我們要自己做一個元件的時候,就有一點麻煩:

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>

需要我們定義 props、emit、input 事件等。

對UI庫的元件進行二次封裝

如果我們想對UI庫進行封裝的話,就又麻煩了一點點:

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>

由於 v-model 不可以直接用元件的 props,而 el-input 又把原生的 value 變成了 v-model 的形式,所以需要使用 computed 做中轉,這樣程式碼就顯得有點繁瑣。

如果考慮防抖功能的話,程式碼會更復雜一些。

程式碼為啥會越寫越亂?因為沒有及時進行重構和必要的封裝!

建立 vue3 專案

情況講述完畢,我們開始介紹解決方案。

首先採用 vue3 的最新工具鏈:create-vue, 建立一個支援 Typescript 的專案。
https://staging-cn.vuejs.org/guide/typescript/overview.html

先用 Typescript 的方式封裝一下 v-model,然後再採用一種更方便的方式實現需求,二者可以對照看看哪種更適合。

v-model 的封裝

我們先對 v-model、emit 做一個簡單的封裝,然後再加上防抖的功能。

基本封裝方式

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

/**
 * 控制元件的直接輸入,不需要防抖。負責父子元件互動表單值
 * @param props 元件的 props
 * @param emit 元件的 emit
 * @param key v-model 的名稱,用於 emit
 */
export default function emitRef<T, K extends keyof T & 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] // 返回 modelValue 的值
      },
      set(val: T[K]) {
        trigger()
        // 通過 emit 設定 modelValue 的值
        emit(`update:${key.toString()}`, val) 
      }
    }
  })
}
  • K keyof T
    因為屬性名稱應該在 props 裡面,所以使用 keyof T 的方式進行約束。

  • T[K]
    可以使用 T[K] 作為返回型別。

  • key 的預設值
    嘗試了各種方式,雖然可以執行,但是TS會報錯。可能是我開啟的方式不對吧。

  • customRef
    為啥沒有用 computed?因為後續要增加防抖功能。
    在 set 裡面使用 emit 進行提交,在 get 裡面獲取 props 裡的屬性值。

  • emit 的 type
    emit: (event: any, ...args: any[]) => void,各種嘗試,最後還是用了any。

這樣簡單的封裝就完成了。

支援防抖的方式

官網提供的防抖程式碼,對應原生 input 是好用的,但是用在 el-input 上面就出了一點小問題,所以只好修改一下:

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

/**
 * 控制元件的防抖輸入,emit的方式
 * @param props 元件的 props
 * @param emit 元件的 emit
 * @param key v-model的名稱,預設 modelValue,用於emit
 * @param delay 延遲時間,預設500毫秒
 */
export default function debounceRef<T, K extends keyof T> 
(
  props: T,
  emit: (name: any, ...args: any[]) => void,
  key: K,
  delay = 500
) {
  // 計時器
  let timeout: NodeJS.Timeout
  // 初始化設定屬性值
  let _value = props[key]
  
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    // 監聽父元件的屬性變化,然後賦值,確保響應父元件設定屬性
    watch(() => props[key], (v1) => {
      _value = v1
      trigger()
    })

    return {
      get(): T[K] {
        track()
        return _value
      },
      set(val: T[K]) {
        _value = val // 繫結值
        trigger() // 輸入內容繫結到控制元件,但是不提交
        clearTimeout(timeout) // 清掉上一次的計時
        // 設定新的計時
        timeout = setTimeout(() => {
          emit(`update:${key.toString()}`, val) // 提交
        }, delay)
      }
    }
  })
}
  • timeout = setTimeout(() => {})
    實現防抖功能,延遲提交資料。

  • let _value = props[key]
    定義一個內部變數,在使用者輸入字元的時候儲存資料,用於繫結元件,等延遲後再提交給父元件。

  • watch(() => props[key], (v1) => {})
    監聽屬性值的變化,在父元件修改值的時候,可以更新子元件的顯示內容。
    因為子元件的值對應的是內部變數 _value,並沒有直接對應props的屬性值。

這樣就實現了防抖的功能。

直接傳遞 model 的方法。

一個表單裡面往往涉及多個欄位,如果每個欄位都使用 v-model 的方式傳遞的話,就會出現“中轉”的情況,這裡的“中轉”指的是 emit,其內部程式碼比較複雜。

如果元件巢狀比較深的話,就會多次“中轉”,這樣不夠直接,也比較繁瑣。
另外如果需要 v-for 遍歷表單子控制元件的話,也不方便處理多 v-model 的情況。

所以為什麼不把一個表單的 model 物件直接傳入子元件呢?這樣不管巢狀多少層元件,都是直接對地址進行操作,另外也方便處理一個元件對應多個欄位的情況。

當然,也有一點麻煩的地方,需要多傳入一個屬性,記錄元件要操作的欄位名稱。

元件的 props 的型別是 shallowReadonly,即根級只讀,所以我們可以修改傳入的物件的屬性。

基礎封裝方式

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

/**
 * 控制元件的直接輸入,不需要防抖。負責父子元件互動表單值。
 * @param model 元件的 props 的 model
 * @param colName 需要使用的屬性名稱
 */
export default function modelRef<T, K extends keyof T> (model: T, colName: K) {
  
  return computed<T[K]>({
    get(): T[K] {
      // 返回 model 裡面指定屬性的值
      return model[colName]
    },
    set(val: T[K]) {
      // 給 model 裡面指定屬性賦值
      model[colName] = val
    }
  })
}

我們也可以使用 computed 來做中轉,還是用 K extends keyof T做一下約束。

防抖的實現方式

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

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

/**
 * 直接修改 model 的防抖
 * @param model 元件的 props 的 model
 * @param colName 需要使用的屬性名稱
 * @param events 事件集合,run:立即提交;clear:清空計時,用於漢字輸入
 * @param delay 延遲時間,預設 500 毫秒
 */
export default function debounceRef<T, K extends keyof T> (
  model: T,
  colName: K,
  events: IEventDebounce,
  delay = 500
) {

  // 計時器
  let timeout: NodeJS.Timeout
  // 初始化設定屬性值
  let _value: T[K] = model[colName]
    
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    // 監聽父元件的屬性變化,然後賦值,確保響應父元件設定屬性
    watch(() => model[colName], (v1) => {
      _value = v1
      trigger()
    })

    return {
      get(): T[K] {
        track()
        return _value
      },
      set(val: T[K]) {
        _value = val // 繫結值
        trigger() // 輸入內容繫結到控制元件,但是不提交
        clearTimeout(timeout) // 清掉上一次的計時
        // 設定新的計時
        timeout = setTimeout(() => {
          model[colName] = _value // 提交
        }, delay)
      }
    }
  })
}

對比一下就會發現,程式碼基本一樣,只是取值、賦值的地方不同,一個使用 emit,一個直接給model的屬性賦值。

那麼能不能合併為一個函式呢?當然可以,只是引數不好起名,另外需要做判斷,這樣看起來就有點不易讀,所以還是做兩個函式直接一點。

我比較喜歡直接傳入 model 物件,非常簡潔。

範圍取值(多欄位)的封裝方式

開始日期、結束日期,可以分為兩個控制元件,也可以用一個控制元件,如果使用一個控制元件的話,就涉及到型別轉換,欄位對應的問題。

所以我們可以再封裝一個函式。

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

interface IModel {
  [key: string]: any
}

/**
 * 一個控制元件對應多個欄位的情況,不支援 emit
 * @param model 表單的 model
 * @param arrColName 使用多個屬性,陣列
 */
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()
        // 多個欄位,需要拼接屬性值
        const tmp: Array<any> = []
        arrColName.forEach((col: K) => {
          // 獲取 model 裡面指定的屬性值,組成陣列的形式
          tmp.push(model[col])
        })
        return tmp
      },
      set(arrVal: Array<any>) {
        trigger()
        if (arrVal) {
          arrColName.forEach((col: K, i: number) => {
            // 拆分屬性賦值,值的數量可能少於欄位數量
            if (i < arrVal.length) {
              model[col] = arrVal[i]
            } else {
              model[col] = ''
            }
          })
        } else {
          // 清空選擇
          arrColName.forEach((col: K) => {
            model[col] = '' // undefined
          })
        }
      }
    }
  })
}

  • IModel
    定義一個介面,用於約束泛型 T,這樣 model[col] 就不會報錯了。

這裡就不考慮防抖的問題了,因為大部分情況都不需要防抖。

使用方法

封裝完畢,在元件裡面使用就非常方便了,只需要一行即可。

先做一個父元件,載入各種子元件做一下演示。

  • js
  // v-model 、 emit 的封裝
  const emitVal = ref('')
  // 傳遞 物件
  const person = reactive({name: '測試', age: 111})
  // 範圍,分為兩個屬性
  const date = reactive({d1: '2012-10-11', d2: '2012-11-11'})
  • template
  emit 的封裝
  <input-emit v-model="emitVal"/>
  <input-emit v-model="person.name"/>
  model的封裝
  <input-model :model="person" colName="name"/>
  <input-model :model="person" colName="age"/>
  model 的範圍取值
  <input-range :model="date" colName="d1_d2"/>

emit

我們做一個子元件:

  • 10-emit.vue
// <template>
  <!--測試 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>

定義一下 props 和 emit,然後呼叫函式即可。
也支援 script setup 的方式:

  • 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>

定義props,定義emit,然後呼叫 emitRef。

model

我們做一個子元件

  • 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>

定義 props,然後呼叫即可。
雖然多了一個描述欄位名稱的引數,但是不用定義和傳遞 emit 了。

範圍取值

<template>
  <el-date-picker
    v-model="val2"
    type="daterange"
    value-format="YYYY-MM-DD"
    range-separator="-"
    start-placeholder="開始日期"
    end-placeholder="結束日期"
  />
</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>

el-date-picker 元件在 type="daterange" 的時候,v-model 是一個陣列,而後端資料庫的設定,一般是兩個欄位,比如 startDate、endDate,需要提交的也是物件形式,這樣就需要在陣列和物件之間做轉換。

而我們封裝的 rangeRef 就可以做這樣的轉換。

TS 的尷尬

可能你會注意到,上面的例子沒有使用 colName 屬性,而是直接傳遞字元層的引數。

因為 TS 只能做靜態檢查,不能做動態檢查,直接寫字串是靜態的方式,TS可以檢查。

但是使用 colName 屬性的話,是動態的方式,TS的檢查不支援動態,然後直接給出錯誤提示。

雖然可以正常執行,但是看著紅線,還是很煩的,所以最後封裝了個寂寞。

對比一下

對比專案 emit model
型別明確 困難 很明確
引數(使用) 一個 兩個
效率 emit內部需要中轉 直接使用物件地址修改
封裝難度 有點麻煩 輕鬆
元件裡使用 需要定義emit 不需要定義emit
多欄位(封裝) 無需單獨封裝 需要單獨封裝
多欄位(使用) 需要寫多個v-model 不需要增加引數的數量
多欄位(表單v-for) 不好處理 容易

如果表單裡的子元件,想採用 v-for 的方式遍歷出來的話,顯然 model 的方式更容易實現,因為不用考慮一個元件需要寫幾個 v-model。

原始碼

https://gitee.com/naturefw-code/nf-rollup-ui-controller