先记住这个答案
defineModel 是 Vue 3.4+ 的 <script setup> 编译期宏。无参数调用会声明 modelValue prop 和 update:modelValue 事件,并返回一个可读写 ref;读取 ref 得到父级值,写入 ref 会发出更新事件。传入字符串可创建 v-model:title 等命名通道,类型参数、required、default、get/set 和修饰符也能一起声明。它不需要导入,也不能在普通运行时函数中动态调用。若子组件给模型设置 default 而父级绑定值起初是 undefined,双方可能暂时不同步,应优先由父级初始化或明确处理缺省值。
- 宏生成 prop 与 update 事件
- 返回 ref 可直接读写
- Vue 3.4+ 编译器与默认值边界要明确
编译器生成重复的桥接层
手写组件通常要声明 modelValue、update:modelValue,再用 computed get/set 或事件处理器把两者连起来。defineModel 把这三部分压缩成一个 ref,模板可以直接对该 ref 使用原生输入 v-model。
因为它是编译期宏,源码必须位于 <script setup> 顶层,构建工具需要兼容版本。不能 import defineModel 后在条件分支里调用,也不能仅升级运行时而保留旧的 SFC 编译器。组件库应在发布配置中声明版本要求。
默认通道与命名通道可以并列
下例创建默认字符串模型和名为 count 的数字模型。title.value 赋值会通知父级默认通道,count.value 赋值会发出 update:count。required 让类型排除 undefined,但父级运行时仍需真的传值。
修饰符可通过解构模型返回值取得,并用 get/set 选项规范化。转换应保持同步、确定,涉及网络和失败的流程仍用显式 action 或事件。
// <script setup lang="ts">
const title = defineModel<string>({ required: true })
const count = defineModel<number>('count', { default: 0 })
function rename() {
title.value = title.value.trim()
}
function increment() {
count.value += 1
}父级使用 v-model 和 v-model:count 连接两个通道。该代码需要 Vue 3.4+ 的 SFC 编译器;它不是可直接交给浏览器执行的普通 TypeScript 模块。
默认值可能制造两个初始真相
父级 ref 为 undefined、子模型配置 default: 1 时,子组件先看到 1,而父级仍是 undefined,直到子级发出更新才可能同步。这与普通 prop 默认值一致,却更容易因模型 ref 可写而被误认为已经双向同步。
最稳妥的方式是让父级初始化受控状态,或在接口设计中允许 undefined 并显式归一化。SSR 页面还要确保服务端与客户端使用同一初值,否则首次文本和 hydration 可能不一致。
容易答错的地方
- 在普通 setup 文件里导入并调用 defineModel
- 它只在 <script setup> 编译上下文中识别,不是运行时组合式函数。可复用逻辑应接收 ref 或回调,模型宏留在组件边界。
- 把可写 ref 当成直接修改父变量
- 写入会发出更新事件,父级仍可能拒绝、转换或延迟写回。需要乐观草稿时要明确暂存状态,不能假设赋值已经被父级接受。
面试官还会怎么问?
怎样拿到自定义修饰符?
可以写 const [model, modifiers] = defineModel(),再通过 set 选项读取 modifiers 并返回转换后的值。支持哪些键应写进组件文档与类型。
defineModel 能完全替代 defineProps 和 defineEmits 吗?
只能替代模型相关的 prop 与更新事件。其他输入和业务事件仍应分别声明,避免把所有交互都伪装成双向状态。
组件库为什么还要理解底层事件名?
调试、类型发布、旧版本兼容和测试都需要知道实际公开契约。宏生成的仍是对应 prop 与 update 事件,不是新的运行时通信通道。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。