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忧设。