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): ƒ}
dep: Map(1) {ReactiveEffect => 1}
Symbol(Symbol.iterator): () => {…}
__v_isRef: true
_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
- 想做多字段转换,好像也不太容易。