用Typescript 的方式封裝Vue3的表單綁定托启,支持防抖等功能宅倒。

Vue3 的父子組件傳值、綁定表單數(shù)據(jù)屯耸、UI庫的二次封裝拐迁、防抖等,想來大家都很熟悉了疗绣,本篇介紹一種使用 Typescript 的方式進(jìn)行統(tǒng)一的封裝的方法线召。

基礎(chǔ)使用方法

Vue3對于表單的綁定提供了一種簡單的方式:v-model。對于使用者來說非常方便持痰,v-model="name" 就可以了灶搜。

自己做組件

但是當(dāng)我們要自己做一個組件的時候,就有一點麻煩:

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庫的組件進(jìn)行二次封裝

如果我們想對UI庫進(jìn)行封裝的話患雏,就又麻煩了一點點:

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 做中轉(zhuǎn)淹仑,這樣代碼就顯得有點繁瑣丙挽。

如果考慮防抖功能的話,代碼會更復(fù)雜一些匀借。

代碼為啥會越寫越亂颜阐?因為沒有及時進(jìn)行重構(gòu)和必要的封裝!

建立 vue3 項目

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

首先采用 vue3 的最新工具鏈:create-vue, 建立一個支持 Typescript 的項目是鬼。
https://staging-cn.vuejs.org/guide/typescript/overview.html

先用 Typescript 的方式封裝一下 v-model肤舞,然后再采用一種更方便的方式實現(xiàn)需求,二者可以對照看看哪種更適合均蜜。

v-model 的封裝

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

基本封裝方式

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

/**
 * 控件的直接輸入篙顺,不需要防抖偶芍。負(fù)責(zé)父子組件交互表單值
 * @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 設(shè)置 modelValue 的值
        emit(`update:${key.toString()}`, val) 
      }
    }
  })
}
  • K keyof T
    因為屬性名稱應(yīng)該在 props 里面慰安,所以使用 keyof T 的方式進(jìn)行約束腋寨。

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

  • key 的默認(rèn)值
    嘗試了各種方式化焕,雖然可以運行萄窜,但是TS會報錯∪鼋埃可能是我打開的方式不對吧查刻。

  • customRef
    為啥沒有用 computed?因為后續(xù)要增加防抖功能凤类。
    在 set 里面使用 emit 進(jìn)行提交穗泵,在 get 里面獲取 props 里的屬性值。

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

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

支持防抖的方式

官網(wǎng)提供的防抖代碼履肃,對應(yīng)原生 input 是好用的,但是用在 el-input 上面就出了一點小問題坐桩,所以只好修改一下:

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

/**
 * 控件的防抖輸入尺棋,emit的方式
 * @param props 組件的 props
 * @param emit 組件的 emit
 * @param key v-model的名稱,默認(rèn) modelValue绵跷,用于emit
 * @param delay 延遲時間膘螟,默認(rèn)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
  // 初始化設(shè)置屬性值
  let _value = props[key]
  
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    // 監(jiān)聽父組件的屬性變化,然后賦值碾局,確保響應(yīng)父組件設(shè)置屬性
    watch(() => props[key], (v1) => {
      _value = v1
      trigger()
    })

    return {
      get(): T[K] {
        track()
        return _value
      },
      set(val: T[K]) {
        _value = val // 綁定值
        trigger() // 輸入內(nèi)容綁定到控件荆残,但是不提交
        clearTimeout(timeout) // 清掉上一次的計時
        // 設(shè)置新的計時
        timeout = setTimeout(() => {
          emit(`update:${key.toString()}`, val) // 提交
        }, delay)
      }
    }
  })
}
  • timeout = setTimeout(() => {})
    實現(xiàn)防抖功能,延遲提交數(shù)據(jù)净当。

  • let _value = props[key]
    定義一個內(nèi)部變量脊阴,在用戶輸入字符的時候保存數(shù)據(jù),用于綁定組件蚯瞧,等延遲后再提交給父組件。

  • watch(() => props[key], (v1) => {})
    監(jiān)聽屬性值的變化品擎,在父組件修改值的時候埋合,可以更新子組件的顯示內(nèi)容。
    因為子組件的值對應(yīng)的是內(nèi)部變量 _value萄传,并沒有直接對應(yīng)props的屬性值甚颂。

這樣就實現(xiàn)了防抖的功能蜜猾。

直接傳遞 model 的方法。

一個表單里面往往涉及多個字段振诬,如果每個字段都使用 v-model 的方式傳遞的話蹭睡,就會出現(xiàn)“中轉(zhuǎn)”的情況,這里的“中轉(zhuǎn)”指的是 emit赶么,其內(nèi)部代碼比較復(fù)雜肩豁。

如果組件嵌套比較深的話,就會多次“中轉(zhuǎn)”企垦,這樣不夠直接缚甩,也比較繁瑣驹止。
另外如果需要 v-for 遍歷表單子控件的話,也不方便處理多 v-model 的情況祟昭。

所以為什么不把一個表單的 model 對象直接傳入子組件呢?這樣不管嵌套多少層組件怖侦,都是直接對地址進(jìn)行操作篡悟,另外也方便處理一個組件對應(yīng)多個字段的情況。

當(dāng)然匾寝,也有一點麻煩的地方搬葬,需要多傳入一個屬性,記錄組件要操作的字段名稱旗吁。

組件的 props 的類型是 shallowReadonly踩萎,即根級只讀,所以我們可以修改傳入的對象的屬性很钓。

基礎(chǔ)封裝方式

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

/**
 * 控件的直接輸入香府,不需要防抖。負(fù)責(zé)父子組件交互表單值码倦。
 * @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 來做中轉(zhuǎn)企孩,還是用 K extends keyof T做一下約束。

防抖的實現(xiàn)方式

  • 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 延遲時間推汽,默認(rèn) 500 毫秒
 */
export default function debounceRef<T, K extends keyof T> (
  model: T,
  colName: K,
  events: IEventDebounce,
  delay = 500
) {

  // 計時器
  let timeout: NodeJS.Timeout
  // 初始化設(shè)置屬性值
  let _value: T[K] = model[colName]
    
  return customRef<T[K]>((track: () => void, trigger: () => void) => {
    // 監(jiān)聽父組件的屬性變化补疑,然后賦值,確保響應(yīng)父組件設(shè)置屬性
    watch(() => model[colName], (v1) => {
      _value = v1
      trigger()
    })

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

對比一下就會發(fā)現(xiàn)莲组,代碼基本一樣,只是取值暖夭、賦值的地方不同锹杈,一個使用 emit撵孤,一個直接給model的屬性賦值。

那么能不能合并為一個函數(shù)呢竭望?當(dāng)然可以邪码,只是參數(shù)不好起名,另外需要做判斷咬清,這樣看起來就有點不易讀闭专,所以還是做兩個函數(shù)直接一點。

我比較喜歡直接傳入 model 對象枫振,非常簡潔喻圃。

范圍取值(多字段)的封裝方式

開始日期、結(jié)束日期粪滤,可以分為兩個控件斧拍,也可以用一個控件,如果使用一個控件的話杖小,就涉及到類型轉(zhuǎn)換肆汹,字段對應(yīng)的問題。

所以我們可以再封裝一個函數(shù)予权。

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

interface IModel {
  [key: string]: any
}

/**
 * 一個控件對應(yīng)多個字段的情況昂勉,不支持 emit
 * @param model 表單的 model
 * @param arrColName 使用多個屬性,數(shù)組
 */
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 里面指定的屬性值岗照,組成數(shù)組的形式
          tmp.push(model[col])
        })
        return tmp
      },
      set(arrVal: Array<any>) {
        trigger()
        if (arrVal) {
          arrColName.forEach((col: K, i: number) => {
            // 拆分屬性賦值,值的數(shù)量可能少于字段數(shù)量
            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蒋荚,然后調(diào)用函數(shù)即可放航。
也支持 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圆裕,然后調(diào)用 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,然后調(diào)用即可吓妆。
雖然多了一個描述字段名稱的參數(shù)赊时,但是不用定義和傳遞 emit 了。

范圍取值

<template>
  <el-date-picker
    v-model="val2"
    type="daterange"
    value-format="YYYY-MM-DD"
    range-separator="-"
    start-placeholder="開始日期"
    end-placeholder="結(jié)束日期"
  />
</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 是一個數(shù)組祖秒,而后端數(shù)據(jù)庫的設(shè)置,一般是兩個字段舟奠,比如 startDate竭缝、endDate,需要提交的也是對象形式沼瘫,這樣就需要在數(shù)組和對象之間做轉(zhuǎn)換抬纸。

而我們封裝的 rangeRef 就可以做這樣的轉(zhuǎn)換。

TS 的尷尬

可能你會注意到耿戚,上面的例子沒有使用 colName 屬性湿故,而是直接傳遞字符層的參數(shù)。

因為 TS 只能做靜態(tài)檢查膜蛔,不能做動態(tài)檢查坛猪,直接寫字符串是靜態(tài)的方式,TS可以檢查皂股。

但是使用 colName 屬性的話墅茉,是動態(tài)的方式,TS的檢查不支持動態(tài)呜呐,然后直接給出錯誤提示就斤。

雖然可以正常運行,但是看著紅線卵史,還是很煩的战转,所以最后封裝了個寂寞。

對比一下

對比項目 emit model
類型明確 困難 很明確
參數(shù)(使用) 一個 兩個
效率 emit內(nèi)部需要中轉(zhuǎn) 直接使用對象地址修改
封裝難度 有點麻煩 輕松
組件里使用 需要定義emit 不需要定義emit
多字段(封裝) 無需單獨封裝 需要單獨封裝
多字段(使用) 需要寫多個v-model 不需要增加參數(shù)的數(shù)量
多字段(表單v-for) 不好處理 容易

如果表單里的子組件以躯,想采用 v-for 的方式遍歷出來的話槐秧,顯然 model 的方式更容易實現(xiàn),因為不用考慮一個組件需要寫幾個 v-model忧设。

源碼

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末刁标,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子址晕,更是在濱河造成了極大的恐慌膀懈,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件谨垃,死亡現(xiàn)場離奇詭異启搂,居然都是意外死亡硼控,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進(jìn)店門胳赌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來牢撼,“玉大人,你說我怎么就攤上這事疑苫⊙妫” “怎么了?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵捍掺,是天一觀的道長撼短。 經(jīng)常有香客問我,道長挺勿,這世上最難降的妖魔是什么曲横? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮满钟,結(jié)果婚禮上胜榔,老公的妹妹穿的比我還像新娘。我一直安慰自己湃番,他們只是感情好夭织,可當(dāng)我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著吠撮,像睡著了一般尊惰。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上泥兰,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天弄屡,我揣著相機與錄音,去河邊找鬼鞋诗。 笑死膀捷,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的削彬。 我是一名探鬼主播全庸,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼融痛!你這毒婦竟也來了壶笼?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤雁刷,失蹤者是張志新(化名)和其女友劉穎覆劈,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡责语,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年炮障,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片鹦筹。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡铝阐,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出铐拐,到底是詐尸還是另有隱情,我是刑警寧澤练对,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布遍蟋,位于F島的核電站,受9級特大地震影響螟凭,放射性物質(zhì)發(fā)生泄漏虚青。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一螺男、第九天 我趴在偏房一處隱蔽的房頂上張望棒厘。 院中可真熱鬧,春花似錦下隧、人聲如沸奢人。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽何乎。三九已至,卻和暖如春土辩,著一層夾襖步出監(jiān)牢的瞬間支救,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工拷淘, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留各墨,地道東北人。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓启涯,卻偏偏與公主長得像贬堵,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子逝嚎,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內(nèi)容