defineModel:是便利的语法糖,还是隐形的状态“地雷”?

123 阅读4分钟

“假设你写了一个弹窗组件,父组件只传了:visible="visible",没传@update:visible。请问:子组件里点关闭按钮,弹窗会关闭吗?

在 Vue 3.4 之前,答案是不会。但现在,用了 defineModel, 答案是:会变化

如果你在项目里这样用了,而且没意识到这件事,恭喜你,你已经踩上了 defineModel 悄悄埋下的“地雷”

defineModel 是 Vue 3.4 引入的语法糖。

它看起来只是让 v-model 更优雅:

const visible = defineModel<boolean>('visible')

但它背后做的事情,远不止简单的语法糖,甚至 改变了组件的数据源

“雷区”之前:传统 v-model 的清晰世界

在大部分 v-model 语义里,存在一个隐含规则:

只传 prop,不监听 update 事件 = 组件不可更新。

比如对弹窗组件,如果父组件只传递了 visibleprop

<MyDialog :visible="visible" />

我们会认为 MyDialog 的显示和隐藏完全由父组件控制

父组件的 visible 变量是控制 MyDialog 显示/隐藏的唯一数据源

这是一种非常清晰的“受控组件”边界

“地雷”缘由:defineModel 的本地数据源

但当 MyDialog.vuevisibledefineModel 实现时,情况会有些不一样:

<script setup>
  const visible = defineModel('visible')
</script>

<template>
  <div>
    <div>MyDialog 内的 visible:{{ visible }}</div>
    <button @click="visible = !visible">MyDialog 内切换 visible</button>
  </div>
</template>

如果父组件中还是

<MyDialog :visible="visible" />

那么子组件点击切换按钮时,结果如图所示:

switch.gif

代码链接

是的,你或许注意到了,const visible = defineModel('visible') 不是外部 visible 变量的映射,而是一个新的本地变量!

拆解“地雷”:defineModel 的源码秘密

直接看 playground 生成的代码:

image.png

defineModel 除了生成对应的 propsemits,还通过 useModel 产生了 MyDialog 内的 visible 变量。

而在 useModel 里,会使用 customRef 创建一个本地变量。

在这个本地变量的设值逻辑里,是这样的(简化):

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)
  )
) {
  // no v-model, local update
  localValue = value
  trigger()
}

看到这个 if 判断了吗?它就是这颗“地雷”的引信。只要父组件没同时递上 prop 和 @update,引信就被点燃,子组件立刻切换成本地数据源。

翻译一下:

只有父组件同时提供 “prop + @update”,子组件才会始终使用父组件的值

否则 —— 子组件会使用本地变量的数据

“地雷”的引信:动态切换的数据源

这意味着:

父组件传入的数据,并不一定是子组件使用的数据源。

真正的数据源变成:

  • 有监听 → 父组件
  • 无监听 → 子组件本地

这是一种动态切换的数据源模型。

“地雷”炸了会怎样?

从功能角度看,defineModel 很强大。

  • 支持“受控 / 非受控”自动切换
  • 多 model 场景写法更优雅

对于“有内部状态”的组件,非常舒服。比如手风琴组件,使用方不需要提供变量保存手风琴的开关状态。

但对于大部分输入组件,它带来了新的权衡。

后果一:模糊的组件边界

传统设计下:

只传 prop = 组件不可修改

现在:

只传 prop ≠ 不可修改

这意味着,组件的接口不再诚实了。只看 <MyDialog :visible="visible" />,你永远猜不到它背后是一颗“地雷”还是一个普通的 prop。

如果你想让组件真正受控,你必须写:

<MyDialog
  :visible="visible"
  @update:visible="() => {}"
/>

用一个空监听器,强制关闭本地数据源。

后果二:变味的接口语义

<MyDialog :visible="visible" />

由原本的只读受控语义,隐式拓展出了类似 init-visible 的初始值赋值语义。

这种“一词多义”的模糊性,正是这颗“地雷”最危险的地方——它让调用者和维护者对代码的理解产生了无法弥合的“认知断层”。

如何“排雷”?我的使用建议

defineModel 不是恶魔,它是一把锋利的双刃剑。它让 Vue 组件具备了“双数据源”的动态能力,但也把数据源从“静态归属”变成了“动态判断”。

正因为它改变了组件的状态哲学,我们在使用时更需要清醒的认知:

  1. 对于普通的输入组件(输入框、单选框等) :请尽量避免使用 defineModel。保持传统的 prop + emit,让数据源清晰单一,别让调用者猜。
  2. 对于有内部状态的组件(手风琴、折叠面板等) :可以大胆用,但必须在文档里明确标注“本组件使用 defineModel,支持受控/非受控自动切换”。
  3. 当你遇到诡异的状态不同步问题时:请把“父组件是否忘了传 @update 监听”加入你的 Bug 排查清单。

最后,留一个问题给看到这里的你:

如果你的同事在代码 review 时,用 <MyDialog :visible="visible" /> 但没传 @update,你会直接通过,还是会提醒他看看组件内部是不是用了 defineModel?

欢迎在评论区分享你的看法。毕竟,只有意识到“地雷”的存在,我们才能安全地绕过它,或者——学会正确地使用它。