defineModel
🐱🐱🐱随着 Vue 3.4 的更新,defineModel
API 也正式加入了。它可以简化组件间双向绑定的操作,在自定义表单类组件中非常有用。
以前的自定义双向绑定
defineModel
可以看成是通过修改props
、emits
、事件监听或者watch
实现自定义v-model
双向绑定的语法糖。以前没有defineModel
的时候,我们需要这样子:
// child
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps({
modelValue: {
default: 0
}
})
const emits = defineEmits(['update:modelValue'])
const modelValue = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
modelValue.value = val
})
watch(modelValue, (val) => {
emits('update:modelValue', val)
})
</script>
<template>
<div>
<button type="button" @click="modelValue++">count is {{ modelValue }}</button>
</div>
</template>
引用子组件,使用v-model
进行双向绑定。
// parent
<script setup lang="ts">
import { ref } from 'vue'
import Child from './child.vue';
const count = ref(0)
</script>
<template>
<button @click="count++">count</button>
<Child v-model="count"></Child>
</template>
defineModel 自定义双向绑定
在defineModel
下,我们在子组件自定义双向绑定只需要这样子:
<script setup lang="ts">
const modelValue = defineModel({
default: 0
})
</script>
<template>
<div>
<button type="button" @click="modelValue++">count is {{ modelValue }}</button>
</div>
</template>
而且defineModel
还支持v-model
添加修饰符:
// child
<script setup lang="ts">
const [modelValue, modifiers] = defineModel({
default: 0,
set (value) {
// 如果有 v-model.notLessThan0 则...
if (modifiers.notLessThan0) {
return Math.max(value, 0)
}
// 返回原来的值
return value
}
})
</script>
<template>
<div>
<button type="button" @click="modelValue++">count is {{ modelValue }}</button>
</div>
</template>
modifiers
是v-model
接受的修饰符,它是这样子的数据结构:{ 修饰符名: true }
,配合set
选项,可以根据修饰符来对来自亲组件的赋值进行调整。
// parent
<script setup lang="ts">
import { ref } from 'vue'
import Child from './child.vue';
const count = ref(0)
</script>
<template>
<button @click="count++">count</button>
<Child v-model.notLessThan0="count"></Child>
</template>
这里给子组件的v-model
设置了notLessThan0
修饰符,进入上面子组件defineModel
的set
选项逻辑。
defineModel 原理
defineXxx
系列的函数,本质上是在<script setup>
中,Vue 的宏,要看原理,那先看它被编译成了什么。举个栗子🌰:
<script setup lang="ts">
const modelValue = defineModel({
default: 0
})
</script>
<template>
<div>
<button type="button" @click="modelValue++">count is {{ modelValue }}</button>
</div>
</template>
编译的结果:
const _sfc_main$2 = /* @__PURE__ */ defineComponent({
__name: "child",
props: {
"modelValue": {
default: 0
},
"modelModifiers": {}
},
emits: ["update:modelValue"],
setup(__props) {
const modelValue = useModel(__props, "modelValue");
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", null, [
createBaseVNode("button", {
type: "button",
onClick: _cache[0] || (_cache[0] = ($event) => modelValue.value++)
}, "count is " + toDisplayString(modelValue.value), 1)
]);
};
}
});
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
const Child = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-bb686a29"]]);
_sfc_main$2
中,自动添加了双向绑定的props
、emits
,以及调用了useModel
函数。modelModifiers
,其实就是往v-model
命令中添加的修饰符,例如v-model.trim
,此外,如果双向绑定的变量叫其他名字,例如v-model:test
,对应地,修饰符的props
属性名变成testModifiers
。
useModel
defineModel
被编译成useModel
,下面看一下useModel
的逻辑。
export function useModel(
props: Record<string, any>,
name: string,
options: DefineModelOptions = EMPTY_OBJ,
): Ref {
const i = getCurrentInstance()!
const camelizedName = camelize(name)
const hyphenatedName = hyphenate(name)
const res = customRef((track, trigger) => {
let localValue: any
watchSyncEffect(() => {
const propValue = props[name]
if (hasChanged(localValue, propValue)) {
localValue = propValue
trigger()
}
})
return {
get() {
track()
return options.get ? options.get(localValue) : localValue
},
set(value) {
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)
) &&
hasChanged(value, localValue)
) {
localValue = value
trigger()
}
i.emit(`update:${name}`, options.set ? options.set(value) : value)
},
}
})
const modifierKey =
name === 'modelValue' ? 'modelModifiers' : `${name}Modifiers`
// @ts-expect-error
res[Symbol.iterator] = () => {
let i = 0
return {
next() {
if (i < 2) {
return { value: i++ ? props[modifierKey] || {} : res, done: false }
} else {
return { done: true }
}
},
}
}
return res
}
先来看customRef
,这个是强化版的ref
允许用户增强get
、set
方法,以及自定义value
的处理。
export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
return new CustomRefImpl(factory) as any
}
class CustomRefImpl<T> {
public dep?: Dep = undefined
private readonly _get: ReturnType<CustomRefFactory<T>>['get']
private readonly _set: ReturnType<CustomRefFactory<T>>['set']
public readonly __v_isRef = true
constructor(factory: CustomRefFactory<T>) {
const { get, set } = factory(
() => trackRefValue(this),
() => triggerRefValue(this),
)
this._get = get
this._set = set
}
get value() {
return this._get()
}
set value(newVal) {
this._set(newVal)
}
}
trackRefValue
和triggerRefValue
是基本上就是ref
那一套收集、触发依赖的方法,这里就不展开了(Vue 3.4 也对它的响应式进行了迭代,大家感兴趣的话后面再说)。这个CustomRefImpl
给useModel
中的入参传入了trackRefValue
和triggerRefValue
,这就意味着useModel
也实现了 Vue 的响应式。在get
的时候收集依赖,在set
的时候触发依赖。
useModel
定义的customRef
res
中使用localValue
作为组件自身的状态。使用watchSyncEffect
监听props
中绑定的变量的改变,去同步修改组件的状态,并且触发响应式依赖。watchSyncEffect
是一个同步的watchEffect
,它可以自动监听回调函数用到的所有响应式变量,随后触发回调函数。
res
的set
方法可以触发onUpdate:xxx
事件实现了子组件状态同步到亲组件的过程。
最后useModel
赋值了一个res[Symbol.iterator]
,在解构赋值的时候类似于一个[res, props[modifierKey]]
的数组,实现了返回单个变量和返回变量和修饰符两种形式的返回格式。见文档,可以const model = defineModel()
,也可以const [modelValue, modelModifiers] = defineModel()
。
setup 函数编译
代码转换、为代码块加上emits
和props
是在模板编译中实现的。
转换为 useModel
在 packages/compiler-sfc/src/compileScript.ts,compileScript
函数中有:
if (node.type === 'ExpressionStatement') {
const expr = unwrapTSNode(node.expression)
// process `defineProps` and `defineEmit(s)` calls
if (
processDefineProps(ctx, expr) ||
processDefineEmits(ctx, expr) ||
processDefineOptions(ctx, expr) ||
processDefineSlots(ctx, expr)
) {
ctx.s.remove(node.start! + startOffset, node.end! + startOffset)
} else if (processDefineExpose(ctx, expr)) {
// defineExpose({}) -> expose({})
const callee = (expr as CallExpression).callee
ctx.s.overwrite(
callee.start! + startOffset,
callee.end! + startOffset,
'__expose',
)
} else {
processDefineModel(ctx, expr)
}
}
这里的node
是<script setup>
模板中的 JS/TS 代码 AST 节点,ctx
是转换代码的上下文,这里就不展开了。processDefineModel
实现了defineModel
到useModel
的替换:
export function processDefineModel(
ctx: ScriptCompileContext,
node: Node,
declId?: LVal,
): boolean {
// ...
ctx.hasDefineModelCall = true
// ...
ctx.modelDecls[modelName] = {
type,
options: optionsString,
runtimeOptionNodes,
identifier:
declId && declId.type === 'Identifier' ? declId.name : undefined,
}
// ...
}
这里的modelDecls
记录了defineModel
涉及的props
,后面处理props
的时候会用到。
function processDefineModel(
ctx: ScriptCompileContext,
node: Node,
declId?: LVal,
) {
// ...
// defineModel -> useModel
ctx.s.overwrite(
ctx.startOffset! + node.callee.start!,
ctx.startOffset! + node.callee.end!,
ctx.helper('useModel'),
)
// inject arguments
ctx.s.appendLeft(
ctx.startOffset! +
(node.arguments.length ? node.arguments[0].start! : node.end! - 1),
`__props, ` +
(hasName
? ``
: `${JSON.stringify(modelName)}${optionsRemoved ? `` : `, `}`),
)
return true
}
ctx.helper('useModel')
就是插入_useModel
(这里可以和 Vite 的编译有关系,上面的编译结果是插入了useModel
)。ctx.s.appendLeft
这一段代码自然是插入useModel
的参数了。从而实现了从
const modelValue = defineModel({
default: 0
})
到
const modelValue = useModel(__props, "modelValue")
的转换。
添加 props
complieScript
调用genRuntimeProps
:
const propsDecl = genRuntimeProps(ctx)
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`
genRuntimeProps
中合并defineModel
产生的props
:
genRuntimeProps(
// ...
) {
// ...
const modelsDecls = genModelProps(ctx)
if (propsDecls && modelsDecls) {
return `/*#__PURE__*/${ctx.helper(
'mergeModels',
)}(${propsDecls}, ${modelsDecls})`
} else {
return modelsDecls || propsDecls
}
}
export function genModelProps(ctx: ScriptCompileContext) {
if (!ctx.hasDefineModelCall) return
const isProd = !!ctx.options.isProd
let modelPropsDecl = ''
for (const [name, { type, options }] of Object.entries(ctx.modelDecls)) {
// ...
// codegenOptions 和 runtimeType 是 vue 编译时产生的 TS 类型映射到 Vue Props 类型的相关内容,不用管它
// options 是给 defineModel 传入的 props 属性
let decl: string
if (runtimeType && options) {
decl = ctx.isTS
? `{ ${codegenOptions}, ...${options} }`
: `Object.assign({ ${codegenOptions} }, ${options})`
} else {
decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
}
modelPropsDecl += `\n ${JSON.stringify(name)}: ${decl},`
// also generate modifiers prop
const modifierPropName = JSON.stringify(
name === 'modelValue' ? `modelModifiers` : `${name}Modifiers`,
)
modelPropsDecl += `\n ${modifierPropName}: {},`
}
return `{${modelPropsDecl}\n }`
}
processDefineModel
标记了ctx.hasDefineModelCall = true
,在这里记录的ctx.modelDecls
,在genModelProps
被合并到props
中去。
添加 emits
complieScript
调用genRuntimeProps
:
const emitsDecl = genRuntimeEmits(ctx)
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`
genRuntimeEmits
:
export function genRuntimeEmits(ctx: ScriptCompileContext): string | undefined {
let emitsDecl = ''
//...
if (ctx.hasDefineModelCall) {
let modelEmitsDecl = `[${Object.keys(ctx.modelDecls)
.map(n => JSON.stringify(`update:${n}`))
.join(', ')}]`
emitsDecl = emitsDecl
? `/*#__PURE__*/${ctx.helper(
'mergeModels',
)}(${emitsDecl}, ${modelEmitsDecl})`
: modelEmitsDecl
}
return emitsDecl
}
processDefineModel
标记了ctx.hasDefineModelCall = true
,genRuntimeEmits
中合并emits
选项。
结语
本文介绍了 Vue 3.3 的特性defineModel
,并且对其编译过程与结果进行简介。
defineModel
是 Vue 3.4 转正的 API,极大简化了自定义双向绑定的处理。它使用useModel
定义的customRef
,利用 Vue 的响应式,完成来自上层组件的数据同步以及发起update:Xxx
事件。
setup
的代码编译不太熟,这里没有进行深入介绍。
菜狗狗第一次发帖,有什么不足请批评指正...... 大家的阅读是我发帖的动力。
另外,这是我的个人博客:deerblog.gu-nami.com/