Vue 3.4 组件开发新范式:defineModel的底层逻辑与实践
defineModel
是 Vue 3.4中推出的 的 Composition API 中一个重要的新特性。用于定义响应式模型的新函数。意在简化组件中响应式状态的管理,尤其是在处理复杂组件状态时。
组件v-model
defineModel
使用之前先来回顾一下以前组件上的v-model
是如何使用的。
v-model
其实就是value
属性和input
事件的语法糖
<input type="text" :value="iptVal" @input="$event => iptVal = $event.target.value" />
<!-- v-model -->
<input type="text" v-model="iptVal" />
我们在表单开发时通常会使用v-model
指令来完成数据绑定,组件 components 上同样可以使用该指令以实现双向绑定。上面的示例是v-mdoel
在表单元素上的语法糖使用,而当在一个组件上使用时,它其实是modelValue
属性和update:modelValue
事件的语法糖
<IptCpn :modelValue="searchText" @update:modelValue="newValue => searchText = newValue" />
<!-- v-model -->
<IptCpn v-model="searchText" />
在组件内部需要做两件事来实现功能,参见官方文档的两句话:
- 将内部原生
<input>
元素的value
attribute 绑定到modelValue
prop - 当原生的
input
事件触发时,触发一个携带了新值的update:modelValue
自定义事件
所以子组件代码为:
<!-- IptCpn.vue -->
<script setup>
defineProps(["modelValue"])
defineEmits(["update:modelValue"])
</script>
<template>
<input
type="text"
:value="modelValue"
@input="$event => $emit('update:modelValue', $event.target.value)"
/>
</template>
这时组件v-model
就可以工作了,但是子组件中的元素还是必须绑定value
属性和监听input
事件,如果子组件中的表单元素也想使用语法糖写法v-model
来绑定状态,需要使用拥有 getter 和 setter 的computed
来编写:
<!-- IptCpn.vue -->
<script setup>
import { computed } from "vue"
const props = defineProps(["modelValue"])
const emit = defineEmits(["update:modelValue"])
const modelValueComputed = computed({
set(val) {
emit("update:modelValue", val)
},
get() {
return props.modelValue
},
})
</script>
<template>
<input type="text" v-model="modelValueComputed" />
</template>
defineModel使用
defineModel
是一个编译器宏
,无需导入直接使用。可以用来声明一个双向绑定 prop,通过父组件的 v-model
来使用。
- 如果第一个参数是一个字符串字面量,它将被用作 prop 名称;
- 否则,不传参,prop 名称将默认为
"modelValue"
。
// 声明 "modelValue" prop,由父组件通过 v-model 使用
const model = defineModel()
// 声明 "count" prop,由父组件通过 v-model:count 使用
const count = defineModel("count")
- 在以上两种情况下,传递一个额外的对象,它可以包含 prop 的选项和 model ref 的值转换选项。
// 声明带选项的 "modelValue" prop,表明该modelValue为 字符串 类型,更改为别的类型会报警告⚠
const model = defineModel({ type: String })
// 声明带选项的 "count" prop
const count = defineModel("count", { type: Number, default: 0 })
- 更新数据时,触发
update:modelValue
事件,如果是命名props,如count,则触发update:count
事件
function inc() {
//在被修改时,触发 "update:modelValue" 事件
model.value = '小新学研社'
// 在被修改时,触发 "update:count" 事件
count.value++
}
- 修饰符:解构
defineModel()
的返回值以获取修饰符
const [modelValue, modelModifiers] = defineModel()
// 对应 v-model.trim
console.log(modelModifiers.trim)
- 修饰符转换器:使用修饰符时,可能需要在读取或将其同步回父组件时对其值进行转换。我们可以通过使用
get
和set
转换器选项来实现这一点:
const [modelValue, modelModifiers] = defineModel({
// get() 省略了,因为这里不需要它
set(value) {
// 如果使用了 .trim 修饰符,则返回裁剪过后的值
if (modelModifiers.trim) {
return value.trim()
}
// 否则,原样返回
return value
}
})
TypeScript
集成:使用泛型
接收类型参数来指定 model 值和修饰符的类型:
const modelValue = defineModel<string>()
// ^? Ref<string | undefined>
// 用带有选项的默认 model,设置 required 去掉了可能的 undefined 值
const modelValue = defineModel<string>({ required: true })
// ^? Ref<string>
const [modelValue, modifiers] = defineModel<string, "trim" | "uppercase">()
// ^? Record<'trim' | 'uppercase', true | undefined>
defineModel底层逻辑
defineModel
是在编译时进行处理的,而不是在运行时。底层实现依赖于 Vue 的响应式系统和编译器的转换能力。
它在编译阶段将特定的代码结构转换或扩展为更详细的实现代码。processDefineModel
函数用于在编译阶段处理 defineModel 调用,解析并提取相关属性和选项
export function processDefineModel(
ctx: ScriptCompileContext,
node: Node,
declId?: LVal,
): boolean {
//先检查你调用的是否是 defineModel,如果不是则直接返回 false
if (!isCallOf(node, DEFINE_MODEL)) {
return false
}
//是, 它会设置 hasDefineModelCall 标志为 true,表示已经调用defineModel
ctx.hasDefineModelCall = true
//解析类型参数、模型名称(modelName)和选项(options)
const type =
(node.typeParameters && node.typeParameters.params[0]) || undefined
let modelName: string
let options: Node | undefined
const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
const hasName = arg0 && arg0.type === 'StringLiteral'
//如果传入名称,就用传入的,没有传入就用 modelValue
if (hasName) {
modelName = arg0.value
options = node.arguments[1]
} else {
modelName = 'modelValue'
options = arg0
}
//重复的模型名称,报错
if (ctx.modelDecls[modelName]) {
ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node)
}
let optionsString = options && ctx.getString(options)
let optionsRemoved = !options
const runtimeOptionNodes: Node[] = []
//根据选项options的类型和内容,处理并生成选项节点数组runtimeOptionNodes,将选项加入到runtimeOptionNodes数组中保存
if (
options &&
options.type === 'ObjectExpression' &&
!options.properties.some(p => p.type === 'SpreadElement' || p.computed)
) {
let removed = 0
for (let i = options.properties.length - 1; i >= 0; i--) {
const p = options.properties[i]
const next = options.properties[i + 1]
const start = p.start!
const end = next ? next.start! : options.end! - 1
if (
(p.type === 'ObjectProperty' || p.type === 'ObjectMethod') &&
((p.key.type === 'Identifier' &&
(p.key.name === 'get' || p.key.name === 'set')) ||
(p.key.type === 'StringLiteral' &&
(p.key.value === 'get' || p.key.value === 'set')))
) {
// remove runtime-only options from prop options to avoid duplicates
optionsString =
optionsString.slice(0, start - options.start!) +
optionsString.slice(end - options.start!)
} else {
// remove prop options from runtime options
removed++
ctx.s.remove(ctx.startOffset! + start, ctx.startOffset! + end)
// record prop options for invalid scope var reference check
runtimeOptionNodes.push(p)
}
}
if (removed === options.properties.length) {
optionsRemoved = true
ctx.s.remove(
ctx.startOffset! + (hasName ? arg0.end! : options.start!),
ctx.startOffset! + options.end!,
)
}
}
//函数更新编译上下文,包括模型声明、绑定类型和转换 defineModel 调用为 useModel。
ctx.modelDecls[modelName] = {
type,
options: optionsString,
runtimeOptionNodes,
identifier:
declId && declId.type === 'Identifier' ? declId.name : undefined,
}
// register binding type
ctx.bindingMetadata[modelName] = BindingTypes.PROPS
// 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
}
由于 defineModel
是编译器层面的特性,它没有以传统函数的形式出现。编译器将其展开为以下内容:
- 一个名为
modelValue
的 prop,本地 ref 的值与其同步; - 一个名为
update:modelValue
的事件,当本地 ref 的值发生变更时触发。
而在 defineModel
出现之前的组件v-model使用方式有助于理解其底层机制:
- 展开宏
编译过程中,defineModel
宏被调用,处理为具体的响应式状态声明和事件处理。
- 定义响应式状态
在子组件中使用ref API
定义了一个响应式状态,这个变量与父组件传递的 props
中的 modelValue
属性关联。
- 监听
props
变化
通过 watch API
或 watchEffect API
监听 props.modelValue
的变化,当其值变化时,同步更新响应式状态的值。即父组件更新状态。
- 生成事件监听器
当在子组件中修改响应式状态的值时,调用emit
触发 update:modelValue
事件,并将新的值传递给父组件。即子组件更新状态。
- v-model 模板编译
编译器编译模板时会识别 v-model
指令,并将其转换为对响应式状态的读写操作。编译器会根据子组件生成的响应式状态,替换模板中的 v-model
绑定,父组件更改响应式状态也会更新prop即子组件中的数据。
defineModel
就是使用此种方式实现父子组件状态的双向绑定,背后的核心是 Vue 3 的响应式系统,将组件的状态封装为一个响应式的模型对象。这样,当状态发生变化时,Vue 能够自动追踪变化并更新 DOM。
优势分析
- 简化代码:
defineModel
提供了一种更简洁的语法来处理双向绑定,避免了手动定义props
和emits
的繁琐代码。 - 提高代码可读性:使组件的逻辑更加清晰,易于理解和维护。
- 更好的类型推断:与 TypeScript 结合使用时,
defineModel
能够提供更好的类型推断,减少类型错误。 - 响应式状态管理:
defineModel
声明的属性会自动转换为响应式状态,便于在组件中进行状态管理。
今天的分享就到这里,感谢大家的阅读,有问题可以评论区一起交流!
转载自:https://juejin.cn/post/7398046883644702755