紫色誓言 發表於 2022-6-23 09:11:00

用Typescript 的方式封装Vue3的表单绑定,支持防抖等功能。

<blockquote>
<p>Vue3 的父子组件传值、绑定表单数据、UI库的二次封装、防抖等,想来大家都很熟悉了,本篇介绍一种使用 Typescript 的方式进行统一的封装的方法。</p>
</blockquote>
<h1 id="基础使用方法">基础使用方法</h1>
<p>Vue3对于表单的绑定提供了一种简单的方式:v-model。对于使用者来说非常方便,<code>v-model="name"</code> 就可以了。</p>
<h2 id="自己做组件">自己做组件</h2>
<p>但是当我们要自己做一个组件的时候,就有一点麻烦:</p>
<p>https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-model</p>
<pre><code class="language-html">&lt;script setup&gt;
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
&lt;/script&gt;

&lt;template&gt;
&lt;input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
/&gt;
&lt;/template&gt;
</code></pre>
<p>需要我们定义 props、emit、input 事件等。</p>
<h2 id="对ui库的组件进行二次封装">对UI库的组件进行二次封装</h2>
<p>如果我们想对UI库进行封装的话,就又麻烦了一点点:</p>
<p>https://staging-cn.vuejs.org/guide/components/events.html#usage-with-v-model</p>
<pre><code class="language-js">// &lt;script setup&gt;
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)
}
})
// &lt;/script&gt;

&lt;template&gt;
&lt;el-input v-model="value" /&gt;
&lt;/template&gt;
</code></pre>
<p>由于 v-model 不可以直接用组件的 props,而 el-input 又把原生的 value 变成了 v-model 的形式,所以需要使用 computed 做中转,这样代码就显得有点繁琐。</p>
<p>如果考虑防抖功能的话,代码会更复杂一些。</p>
<blockquote>
<p>代码为啥会越写越乱?因为没有及时进行重构和必要的封装!</p>
</blockquote>
<h1 id="建立-vue3-项目">建立 vue3 项目</h1>
<p>情况讲述完毕,我们开始介绍解决方案。</p>
<p>首先采用 vue3 的最新工具链:create-vue, 建立一个支持 Typescript 的项目。<br>
https://staging-cn.vuejs.org/guide/typescript/overview.html</p>
<p>先用 Typescript 的方式封装一下 v-model,然后再采用一种更方便的方式实现需求,二者可以对照看看哪种更适合。</p>
<h1 id="v-model-的封装">v-model 的封装</h1>
<p>我们先对 v-model、emit 做一个简单的封装,然后再加上防抖的功能。</p>
<h2 id="基本封装方式">基本封装方式</h2>
<ul>
<li>ref-emit.ts</li>
</ul>
<pre><code class="language-js">import { customRef } from 'vue'

/**
* 控件的直接输入,不需要防抖。负责父子组件交互表单值
* @param props 组件的 props
* @param emit 组件的 emit
* @param key v-model 的名称,用于 emit
*/
export default function emitRef&lt;T, K extends keyof T &amp; string&gt;
(
props: T,
emit: (event: any, ...args: any[]) =&gt; void,
key: K
) {
return customRef&lt;T&gt;((track: () =&gt; void, trigger: () =&gt; void) =&gt; {
    return {
      get(): T {
      track()
      return props // 返回 modelValue 的值
      },
      set(val: T) {
      trigger()
      // 通过 emit 设置 modelValue 的值
      emit(`update:${key.toString()}`, val)
      }
    }
})
}
</code></pre>
<ul>
<li>
<p>K keyof T<br>
因为属性名称应该在 props 里面,所以使用 keyof T 的方式进行约束。</p>
</li>
<li>
<p>T<br>
可以使用 T 作为返回类型。</p>
</li>
<li>
<p>key 的默认值<br>
尝试了各种方式,虽然可以运行,但是TS会报错。可能是我打开的方式不对吧。</p>
</li>
<li>
<p>customRef<br>
为啥没有用 computed?因为后续要增加防抖功能。<br>
在 set 里面使用 emit 进行提交,在 get 里面获取 props 里的属性值。</p>
</li>
<li>
<p>emit 的 type<br>
<code>emit: (event: any, ...args: any[]) =&gt; void</code>,各种尝试,最后还是用了any。</p>
</li>
</ul>
<p>这样简单的封装就完成了。</p>
<h2 id="支持防抖的方式">支持防抖的方式</h2>
<p>官网提供的防抖代码,对应原生 input 是好用的,但是用在 el-input 上面就出了一点小问题,所以只好修改一下:</p>
<ul>
<li>ref-emit-debounce.ts</li>
</ul>
<pre><code class="language-js">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&lt;T, K extends keyof T&gt;
(
props: T,
emit: (name: any, ...args: any[]) =&gt; void,
key: K,
delay = 500
) {
// 计时器
let timeout: NodeJS.Timeout
// 初始化设置属性值
let _value = props

return customRef&lt;T&gt;((track: () =&gt; void, trigger: () =&gt; void) =&gt; {
    // 监听父组件的属性变化,然后赋值,确保响应父组件设置属性
    watch(() =&gt; props, (v1) =&gt; {
      _value = v1
      trigger()
    })

    return {
      get(): T {
      track()
      return _value
      },
      set(val: T) {
      _value = val // 绑定值
      trigger() // 输入内容绑定到控件,但是不提交
      clearTimeout(timeout) // 清掉上一次的计时
      // 设置新的计时
      timeout = setTimeout(() =&gt; {
          emit(`update:${key.toString()}`, val) // 提交
      }, delay)
      }
    }
})
}
</code></pre>
<ul>
<li>
<p>timeout = setTimeout(() =&gt; {})<br>
实现防抖功能,延迟提交数据。</p>
</li>
<li>
<p>let _value = props<br>
定义一个内部变量,在用户输入字符的时候保存数据,用于绑定组件,等延迟后再提交给父组件。</p>
</li>
<li>
<p>watch(() =&gt; props, (v1) =&gt; {})<br>
监听属性值的变化,在父组件修改值的时候,可以更新子组件的显示内容。<br>
因为子组件的值对应的是内部变量 _value,并没有直接对应props的属性值。</p>
</li>
</ul>
<p>这样就实现了防抖的功能。</p>
<h1 id="直接传递-model-的方法">直接传递 model 的方法。</h1>
<p>一个表单里面往往涉及多个字段,如果每个字段都使用 v-model 的方式传递的话,就会出现“中转”的情况,这里的“中转”指的是 emit,其内部代码比较复杂。</p>
<p>如果组件嵌套比较深的话,就会多次“中转”,这样不够直接,也比较繁琐。<br>
另外如果需要 v-for 遍历表单子控件的话,也不方便处理多 v-model 的情况。</p>
<p>所以为什么不把一个表单的 model 对象直接传入子组件呢?这样不管嵌套多少层组件,都是直接对地址进行操作,另外也方便处理一个组件对应多个字段的情况。</p>
<p>当然,也有一点麻烦的地方,需要多传入一个属性,记录组件要操作的字段名称。</p>
<blockquote>
<p>组件的 props 的类型是 shallowReadonly,即根级只读,所以我们可以修改传入的对象的属性。</p>
</blockquote>
<h2 id="基础封装方式">基础封装方式</h2>
<ul>
<li>ref-model.ts</li>
</ul>
<pre><code class="language-js">import { computed } from 'vue'

/**
* 控件的直接输入,不需要防抖。负责父子组件交互表单值。
* @param model 组件的 props 的 model
* @param colName 需要使用的属性名称
*/
export default function modelRef&lt;T, K extends keyof T&gt; (model: T, colName: K) {

return computed&lt;T&gt;({
    get(): T {
      // 返回 model 里面指定属性的值
      return model
    },
    set(val: T) {
      // 给 model 里面指定属性赋值
      model = val
    }
})
}

</code></pre>
<p>我们也可以使用 computed 来做中转,还是用 <code>K extends keyof T</code>做一下约束。</p>
<h2 id="防抖的实现方式">防抖的实现方式</h2>
<ul>
<li>ref-model-debounce.ts</li>
</ul>
<pre><code class="language-js">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&lt;T, K extends keyof T&gt; (
model: T,
colName: K,
events: IEventDebounce,
delay = 500
) {

// 计时器
let timeout: NodeJS.Timeout
// 初始化设置属性值
let _value: T = model
   
return customRef&lt;T&gt;((track: () =&gt; void, trigger: () =&gt; void) =&gt; {
    // 监听父组件的属性变化,然后赋值,确保响应父组件设置属性
    watch(() =&gt; model, (v1) =&gt; {
      _value = v1
      trigger()
    })

    return {
      get(): T {
      track()
      return _value
      },
      set(val: T) {
      _value = val // 绑定值
      trigger() // 输入内容绑定到控件,但是不提交
      clearTimeout(timeout) // 清掉上一次的计时
      // 设置新的计时
      timeout = setTimeout(() =&gt; {
          model = _value // 提交
      }, delay)
      }
    }
})
}
</code></pre>
<p>对比一下就会发现,代码基本一样,只是取值、赋值的地方不同,一个使用 emit,一个直接给model的属性赋值。</p>
<p>那么能不能合并为一个函数呢?当然可以,只是参数不好起名,另外需要做判断,这样看起来就有点不易读,所以还是做两个函数直接一点。</p>
<blockquote>
<p>我比较喜欢直接传入 model 对象,非常简洁。</p>
</blockquote>
<h2 id="范围取值多字段的封装方式">范围取值(多字段)的封装方式</h2>
<p>开始日期、结束日期,可以分为两个控件,也可以用一个控件,如果使用一个控件的话,就涉及到类型转换,字段对应的问题。</p>
<p>所以我们可以再封装一个函数。</p>
<ul>
<li>ref-model-range.ts</li>
</ul>
<pre><code class="language-js">import { customRef } from 'vue'

interface IModel {
: any
}

/**
* 一个控件对应多个字段的情况,不支持 emit
* @param model 表单的 model
* @param arrColName 使用多个属性,数组
*/
export default function range2Ref&lt;T extends IModel, K extends keyof T&gt;
(
model: T,
...arrColName: K[]
) {

return customRef&lt;Array&lt;any&gt;&gt;((track: () =&gt; void, trigger: () =&gt; void) =&gt; {
    return {
      get(): Array&lt;any&gt; {
      track()
      // 多个字段,需要拼接属性值
      const tmp: Array&lt;any&gt; = []
      arrColName.forEach((col: K) =&gt; {
          // 获取 model 里面指定的属性值,组成数组的形式
          tmp.push(model)
      })
      return tmp
      },
      set(arrVal: Array&lt;any&gt;) {
      trigger()
      if (arrVal) {
          arrColName.forEach((col: K, i: number) =&gt; {
            // 拆分属性赋值,值的数量可能少于字段数量
            if (i &lt; arrVal.length) {
            model = arrVal
            } else {
            model = ''
            }
          })
      } else {
          // 清空选择
          arrColName.forEach((col: K) =&gt; {
            model = '' // undefined
          })
      }
      }
    }
})
}

</code></pre>
<ul>
<li>IModel<br>
定义一个接口,用于约束泛型 T,这样 <code>model</code> 就不会报错了。</li>
</ul>
<p>这里就不考虑防抖的问题了,因为大部分情况都不需要防抖。</p>
<h1 id="使用方法">使用方法</h1>
<p>封装完毕,在组件里面使用就非常方便了,只需要一行即可。</p>
<p>先做一个父组件,加载各种子组件做一下演示。</p>
<ul>
<li>js</li>
</ul>
<pre><code>// v-model 、 emit 的封装
const emitVal = ref('')
// 传递 对象
const person = reactive({name: '测试', age: 111})
// 范围,分为两个属性
const date = reactive({d1: '2012-10-11', d2: '2012-11-11'})
</code></pre>
<ul>
<li>template</li>
</ul>
<pre><code>emit 的封装
&lt;input-emit v-model="emitVal"/&gt;
&lt;input-emit v-model="person.name"/&gt;
model的封装
&lt;input-model :model="person" colName="name"/&gt;
&lt;input-model :model="person" colName="age"/&gt;
model 的范围取值
&lt;input-range :model="date" colName="d1_d2"/&gt;
</code></pre>
<h2 id="emit">emit</h2>
<p>我们做一个子组件:</p>
<ul>
<li>10-emit.vue</li>
</ul>
<pre><code class="language-js">// &lt;template&gt;
&lt;!--测试 emitRef--&gt;
&lt;el-input v-model="val"&gt;&lt;/el-input&gt;
// /template&gt;

// &lt;script lang="ts"&gt;
import { defineComponent } from 'vue'

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

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

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

      return {
      val
      }
    }
})
// &lt;/script&gt;
</code></pre>
<p>定义一下 props 和 emit,然后调用函数即可。<br>
也支持 script setup 的方式:</p>
<ul>
<li>12-emit-ss.vue</li>
</ul>
<pre><code>&lt;template&gt;
&lt;el-input v-model="val" &gt;&lt;/el-input&gt;
&lt;/template&gt;

&lt;script setup lang="ts"&gt;
import emitRef from '../../../../lib/base/ref-emit'

const props = defineProps&lt;{
    modelValue: string
}&gt;()

const emit = defineEmits&lt;{
    (e: 'update:modelValue', value: string): void
}&gt;()

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

&lt;/script&gt;
</code></pre>
<p>定义props,定义emit,然后调用 emitRef。</p>
<h2 id="model">model</h2>
<p>我们做一个子组件</p>
<ul>
<li>20-model.vue</li>
</ul>
<pre><code>&lt;template&gt;
&lt;el-input v-model="val2"&gt;&lt;/el-input&gt;
&lt;/template&gt;

&lt;script lang="ts"&gt;
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&lt;Person&gt;
      },
      colName: {
      type: String
    },
    setup(props, context) {
      const val2 = modelRef(props.model, 'name')
      return {
      val2
      }
    }
})
&lt;/script&gt;
</code></pre>
<p>定义 props,然后调用即可。<br>
虽然多了一个描述字段名称的参数,但是不用定义和传递 emit 了。</p>
<h2 id="范围取值">范围取值</h2>
<pre><code>&lt;template&gt;
&lt;el-date-picker
    v-model="val2"
    type="daterange"
    value-format="YYYY-MM-DD"
    range-separator="-"
    start-placeholder="开始日期"
    end-placeholder="结束日期"
/&gt;
&lt;/template&gt;

&lt;script lang="ts"&gt;
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&lt;DateRange&gt;
      },
      colName: {
      type:
      }
    },
    setup(props, context) {
      const val2 = rangeRef&lt;DateRange&gt;(props.model, 'd1', 'd2')
      return {
      val2
      }
    }
})
&lt;/script&gt;
</code></pre>
<p>el-date-picker 组件在 type="daterange" 的时候,v-model 是一个数组,而后端数据库的设置,一般是两个字段,比如 startDate、endDate,需要提交的也是对象形式,这样就需要在数组和对象之间做转换。</p>
<p>而我们封装的 rangeRef 就可以做这样的转换。</p>
<h1 id="ts-的尴尬">TS 的尴尬</h1>
<p>可能你会注意到,上面的例子没有使用 colName 属性,而是直接传递字符层的参数。</p>
<p>因为 TS 只能做静态检查,不能做动态检查,直接写字符串是静态的方式,TS可以检查。</p>
<p>但是使用 colName 属性的话,是动态的方式,TS的检查不支持动态,然后直接给出错误提示。</p>
<p>虽然可以正常运行,但是看着红线,还是很烦的,所以最后封装了个寂寞。</p>
<h1 id="对比一下">对比一下</h1>
<table>
<thead>
<tr>
<th>对比项目</th>
<th>emit</th>
<th>model</th>
</tr>
</thead>
<tbody>
<tr>
<td>类型明确</td>
<td>困难</td>
<td>很明确</td>
</tr>
<tr>
<td>参数(使用)</td>
<td>一个</td>
<td>两个</td>
</tr>
<tr>
<td>效率</td>
<td>emit内部需要中转</td>
<td>直接使用对象地址修改</td>
</tr>
<tr>
<td>封装难度</td>
<td>有点麻烦</td>
<td>轻松</td>
</tr>
<tr>
<td>组件里使用</td>
<td>需要定义emit</td>
<td>不需要定义emit</td>
</tr>
<tr>
<td>多字段(封装)</td>
<td>无需单独封装</td>
<td>需要单独封装</td>
</tr>
<tr>
<td>多字段(使用)</td>
<td>需要写多个v-model</td>
<td>不需要增加参数的数量</td>
</tr>
<tr>
<td>多字段(表单v-for)</td>
<td>不好处理</td>
<td>容易</td>
</tr>
</tbody>
</table>
<p>如果表单里的子组件,想采用 v-for 的方式遍历出来的话,显然 model 的方式更容易实现,因为不用考虑一个组件需要写几个 v-model。</p>
<h1 id="源码">源码</h1>
<p>https://gitee.com/naturefw-code/nf-rollup-ui-controller</p><br><br>
来源:https://www.cnblogs.com/jyk/p/16403985.html
頁: [1]
查看完整版本: 用Typescript 的方式封装Vue3的表单绑定,支持防抖等功能。