【vue3】defineModel的多种打开方式:多Model、ts、泛型、修饰符等

2,764 阅读8分钟

vue 3.4 终于把 defineModel 给转正了,我们再也不用各种折腾了,统一使用 defineModel 就好。那么 defineModel 有多少种使用方式呢?这里尽量介绍的全面一点。
包括:多Model、Typescript、泛型、修饰符、翻译后的代码、源码等内容。

后篇: 【vue3】手撸 defineModel 实现防抖、多字段转换等功能

基础用法和结构

defineModel 的基础使用方法非常简单,一行代码即可搞定:

  • 子组件
  const model = defineModel()
  console.log('model的结构:', model)
  
  function update() {
    model.value += '--'
  }

这样我们就定义了一个 model,父组件使用 v-model 即可与之呼应,不需要我们再去写 props、emit、computed 这些代码了。

  • 父组件
  <modelDefault v-model="person.name"></modelDefault>

  const person = reactive({
    name: 'jyk',
    age: 15
  })
  

我比较喜欢使用 reactive,如果你喜欢使用 ref,那也没有问题。

编译后的代码

defineModel 是一个语法糖,也是一个宏,那么编译(翻译)之后是什么样子呢?我们按F12看看代码:

const _sfc_main = /* @__PURE__ */ _defineComponent({
  __name: "base-default",
  props: {
    "modelValue": {},
    "modelModifiers": {}
  },
  emits: ["update:modelValue"],
  setup(__props, { expose: __expose }) {
    __expose();
    const model = _useModel(__props, "modelValue"); // 就是这一行
    console.log("model\u7684\u7ED3\u6784\uFF1A", model);
    function update() {
      model.value += "--";
    }
    const __returned__ = { model, update };
    Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
    return __returned__;
  }
});

简单的说,defineModel“翻译”成了 _useModel 函数。
那么这个函数又是什么样子呢?

function useModel(props, name, options = EMPTY_OBJ) {
  const i = getCurrentInstance();
  if (!!(process.env.NODE_ENV !== "production") && !i) {
    warn$1(`useModel() called without active instance.`);
    return ref();
  }
  if (!!(process.env.NODE_ENV !== "production") && !i.propsOptions[0][name]) {
    warn$1(`useModel() called with prop "${name}" which is not declared.`);
    return ref();
  }
  const camelizedName = camelize(name);
  const hyphenatedName = hyphenate(name);
  const modifiers = getModelModifiers(props, name);
  const res = customRef((track, trigger) => {
    let localValue;
    let prevSetValue = EMPTY_OBJ;
    let prevEmittedValue;
    watchSyncEffect(() => {
      const propValue = props[name];
      if (hasChanged(localValue, propValue)) {
        localValue = propValue;
        trigger();
      }
    });
    return {
      get() {
        track(); // 【修饰符时使用的 get】
        return options.get ? options.get(localValue) : localValue;
      },
      set(value) {
        if (!hasChanged(value, localValue) && !(prevSetValue !== EMPTY_OBJ && hasChanged(value, prevSetValue))) {
          return;
        }
        const rawProps = i.vnode.props;
        if (!(rawProps && // check if parent has passed v-model
        (name in rawProps || camelizedName in rawProps || hyphenatedName in rawProps) && (`onUpdate:${name}` in rawProps || `onUpdate:${camelizedName}` in rawProps || `onUpdate:${hyphenatedName}` in rawProps))) {
          localValue = value;
          trigger();
        }  //           【修饰符时使用的 set】
        const emittedValue = options.set ? options.set(value) : value;
        i.emit(`update:${name}`, emittedValue); // 使用 emit 提交变更
        if (hasChanged(value, emittedValue) && hasChanged(value, prevSetValue) && !hasChanged(emittedValue, prevEmittedValue)) {
          trigger();
        }
        prevSetValue = value;
        prevEmittedValue = emittedValue;
      }
    };
  });
  res[Symbol.iterator] = () => { // 【迭代,修饰符的时候需要】
    let i2 = 0;
    return {
      next() {
        if (i2 < 2) {  // 1: 返回修饰符 ;  0: 返回 customerRef (res)
          return { value: i2++ ? modifiers || EMPTY_OBJ : res, done: false };
        } else {
          return { done: true }; // 结束迭代
        }
      }
    };
  };
  return res;
}

如果对内部原理感兴趣可以到这里看看:juejin.cn/post/735496…

defineModel 返回对象的结构

官网说他是 ref,其实准确的说,应该是 customerRef 的返回值。
其区别是,后者可以设置 get、set,修饰符的时候就需要用到。

我们打印出来看看结构:

model 的结构:
 CustomRefImpl {dep: undefined, __v_isRef: true, _get: ƒ, _set: ƒ, Symbol(Symbol.iterator): ƒ}
   depMap(1) {ReactiveEffect => 1}
   Symbol(Symbol.iterator): () => {…}
   __v_isReftrue
   _get: ƒ get()
   _set: ƒ set(value)
   value: (...)
   __proto__Object

很明显了是 customerRef 的返回值,那么官网为啥说他是 ref 呢?

大概是为了减少心智负担吧,ref 和 reactive 争吵了好多年,最后官网来个一刀切:推荐 ref。
既然这样,那么就都是 ref 好了,再扯出来一个 customerRef,估计就又蒙了。

校验方式

既然 props 是有校验方式的,那么 defineModel 呢?当然也可以设置校验,我们可以通过参数来设置验证。

用参数的方式设置校验

目前发现有四种验证方式:

  • type:类型。这里不是 Typescript 的类型,而是 Vue 内部提供的形式,包含:Boolean | String | Number | Date | Object | Function | Array 等,以前 Vue2 的时候就在使用这种方式。
  • default:默认值,如果是引用类型的话,需要使用函数
  • required:必填
  • validator:自定义校验,函数的方式

我们来看看例子:

  const model = defineModel({
    type:  String, // Boolean | String | Number | Date | Object | Array | Function
    // type: [String, Number], // 多种类型
    default: '', // () => {}
    validator: (value: string, props) => {
      // The value must match one of these strings
      return true // ['success', 'warning', 'danger'].includes(value)
    },
    required: true // 必填
  })

校验失败的提示

虽然可以做各种校验,但是没有通过校验的警告有点太“温柔”,只是出一个警告(warn),而这个warn,一般都是被隐藏了,太容易被忽略。

[Vue warn]: Invalid prop: custom validator check failed for prop "modelValue". 

多个Model

一个组件只能有一个 Model 吗?当然不是,我们可以设置多个 Model

  • 子组件定义 model
const name = defineModel('name')
const name = defineModel('age')

<el-input v-model="name" placeholder=""></el-input>
<el-input v-model="age" placeholder=""></el-input>
  • 父组件的使用
  <modelMore
    v-model:name="person.name"
    v-model:age="person.age"
  ></modelMore>

使用校验

多个model如何设置校验值?第一个参数被占用了!既然第一个位置没了,那么就放在第二位好了。

  const age = defineModel('age', {
    type:  Number, 
    default: 0,
    required: true // 必填
  })

v-model 的修饰符

v-model有修饰符,而且可以自定义,v-on也有修饰符,但是v-bind似乎不支持修饰符。

自定义修饰符

  • 父组件设置修饰符
<modelCapitalize v-model.capitalize="person.name"></modelCapitalize>
  • 子组件获得修饰符
  const [model, modifiers] = defineModel()
  
  // 修饰符的结构
  console.log('modifiers', modifiers)

修饰符的结构

修饰符的结构很简单,就是一个对象,修饰符的名称是key,值固定是 true。如果是多个修饰符,那么对象就会有多个key与之对应。

modifiers {capitalize: true}

设置处理代码

那么如何处理修饰符呢?我们看看官网的例子:

  const [model, modifiers] = defineModel({
    set(value:string) {
      if (modifiers.capitalize) {
        return value.charAt(0).toUpperCase() + value.slice(1)
      }
      return value
    }
  })

这里的 set 其实就是 customerRef 提供的 set。所以,我觉得应该用 get。

  • get:获取的时候就会被触发,第一时间看到效果。
  • set:修改数据的时候,才会被触发,看到效果。

和 customer 的 get、set 的区别

  • customer 的 get、set 可以使用 track, trigger,但是这两个并没有返给 defineModel 的 get、set,这导致不容易实现防抖。
  • customer 的 set 不需要 return,但是 defineModel的 set 需要 return 一个值。

翻译后的代码

  props: {
    "modelValue": {},
    "modelModifiers": {}
  },
  emits: ["update:modelValue"],
  setup(__props, { expose: __expose }) {
    __expose();
    const [model, modifiers] = _useModel(__props, "modelValue", {
      get(value) {
        if (modifiers.capitalize) {
          return value.charAt(0).toUpperCase() + value.slice(1);
        }
        return value;
      }
    });
    console.log("modifiers", modifiers);
    const key1 = Object.keys(modifiers)[0];
    const __returned__ = { model, modifiers, key1 };
    Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
    return __returned__;
  }
});

这个中括号是什么意思?

细心的你可能会发现,不用修饰符的时候,直接返回 Model,但是使用修饰符的时候,怎么弄个中括号出来?解构不是应该用大括号吗?
其实这里不是解构,而是 Symbol.iterator 的迭代器。具体情况就先不说了,上面贴的源码里面做了简单的注释,其他的大家可以自行寻找相关资料。

多Model的修饰符

多个Model的修饰符怎么弄?其实很简单,叠加在一起就行。

  const [age, ageModifiers] = defineModel('age', {
    get(value:string) {
      return value + 100 // 仅举例
    }
  })

多Model、修饰符、校验

修饰符和校验同时使用的时候怎么办?都放在第二个参数里就好。

  const [age, ageModifiers] = defineModel('age', {
    required: true, // 必填
    get(value:string) {
      return value + 100 // 仅举例
    }
  })

修饰符的骚操作

修饰符有啥用呢?想到了一个骚操作,就是说吧,我们封装 el-input 做一个自己的 my-input,不想传入字符串,而是想直接传入一个对象,那么我们可以这样操作:

子组件

<script setup lang="ts" generic="T extends {[key: string]: any}, K extends keyof T">
  import { reactive } from 'vue'
  const [model, modifiers] = defineModel<T>()
  
  const key1: K = Object.keys(modifiers)[0] as K

</script>

本来打算传入一个k的,但是好像不知道要怎么传。。。

父组件

<myInput v-model.name="person"></myInput>
<myInput v-model.age="person"></myInput>

这样是不是很简洁。

对比

这种做法和 v-model="person.name"有啥区别?

  • 前者传递的是 reactive,子组件可以直接操作,不用麻烦 emit(defineModel)了。
  • 在父组件控制子组件使用对象的哪个属性。
  • 后者是经典的传递 string,需要 emit 配合。

这么做是否违反了单向数据流?

简单的说,Proxy 的 set 是不是属于父组件的函数?

  • 如果是的话,那么就没有破坏;
  • 如果不是的话,那么属于谁?如果属于子组件,那么子组件的函数是如何修改父组件的数据的?

详细的话,可以看这里:juejin.cn/post/739835…

既然不需要 emit,那么为啥还用 defineModel?

  • v-model 支持修饰符,但是 v-bind 不支持修饰符,想传递修饰符,就需要 v-model,或者用自定义指令(但是自定义指令不负责传递 Props)。
  • 刚刚才发现,props 也可以获得修饰符,以前以为得不到呢。
  • 恕我脑洞不够,想不出来自定义修饰符还能实现什么功能,就用这个做演示了。

在Typescript中使用

既然 vue 自己提供了类型和校验,那么为什么还要使用 Typescript 呢?
那是因为 vue 的有点温柔,编写的时候如果类型不一致,并不会有提示,运行的时候也只是一个 warn 太容易被忽略了。

而 Typescript 有几个优点:

  • 在编写代码的时候会有提示;
  • 类型不匹配的时候,会划红色波浪线;
  • 打包的时候也可以强制进行校验;

这样更有安全感。

使用类型

使用方法也很简单,和 reactive 有点像:

const modelValue = defineModel<string>()

// 用带有选项的默认 model,设置 required 去掉了可能的 undefined 值
const modelValue = defineModel<string>({ required: true })
// 第一个限制值,第二个限制修饰符
const [modelValue, modifiers] = defineModel<string, "trim" | "uppercase">()

和校验可以配合,type 就不用设置了,容易冲突,必填和默认值可以使用一下。

从外部文件引入类型

那么是否可以从ts文件里面引入类型呢?当然是可以的,vue3.3就支持了。

  • 在单独文件里面定义类型:
export interface Person {
  name: string,
  age: number
}

export type Person1 = {
  name: string,
  age: number
}
  • 组件里面引入
  import type { Person, Person1 } from './ts-type'
  const model = defineModel<Person1>({  required: true  })

使用 interface 还是 type?这个我也说不好,type 的提示有具体属性,而 interface 的提示就没有具体属性了,不知道为啥。反正,在这里二者都可以用。

使用泛型

还记得在 vue3.3 里面推出来的组件泛型(generic) 吗?在这里也是可以使用的。

<script setup lang="ts"  generic="T extends {name: string} ">
  import { reactive } from 'vue'
  const model = defineModel<T>()
</script>

传入的对象需要用有name属性。

使用 多Model、泛型、校验、修饰符

这几项能不能同时使用?当然可以的。

<script setup lang="ts" generic="T">
  import { reactive } from 'vue'
  const [name, nameModifiers] = defineModel<T, 'capitalize'>('name')
  const [age, ageModifiers] = defineModel<number, 'add'>('age', {
    required: true, // 必填
    get (value:string) {
      return value + 100
    },
  })
</script>

小结

没想到排列组合会有这么多可能,有些组合方式没有写出来,但是也基本差不多了。

想想好像有两个小问题:

  • 想实现防抖,怎么做?defineModel 虽然提供了set、get,但是没有提供 trigger
  • 想做多字段转换,好像也不太容易。