airi 项目 Vue 组件 v-model 双向绑定实战:useVModel 与 defineModel 的取舍与用法详解
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
useVModel是 VueUse 提供的 v-model 快捷封装,把「props + emit」这一对松散概念收敛为一个可直接读写的 ref,是组件库与复杂交互组件中双向绑定的经典实现。本文以 .agents/skills/vueuse-functions/references/useVModel.md 为骨架,结合 airi 仓库(packages/stage-ui、packages/stage-pages、packages/ui等)的真实组件源码,讲解 useVModel 的完整用法、六大选项的底层行为,以及它与defineModel在什么场景下应该二选一,帮助你写出更健壮的受控组件。
一、useVModel 是什么:props + emit → ref
在 Vue 中实现v-model需要同时处理两件事:通过props.modelValue读取父级传入的值,通过emit('update:modelValue', newValue)把变更回传给父级。这套「读 + 写」的配对逻辑在每个组件里重复出现,而useVModel正是它的简写形式——把props、emit和属性名绑定在一起,返回一个可读写的 ref:读取ref.value等价于读props,赋值ref.value = newValue等价于触发对应的事件。
它的类型签名可以用一句话概括:
props + emit -> ref默认约定:属性名为modelValue,事件名为update:modelValue,这与 Vue 内置的v-model约定完全一致,因此useVModel的返回值可以直接接在模板的v-model上,形成组件间的无缝衔接。
二、基础用法:Composition API 与 Options API
Composition API(<script setup>)
import { useVModel } from '@vueuse/core' const props = defineProps<{ modelValue: string }>() const emit = defineEmits(['update:modelValue']) const data = useVModel(props, 'modelValue', emit)之后在模板中可直接使用:
<input v-model="data" />data.value的读取对应props.modelValue,写入data.value = 'foo'则触发emit('update:modelValue', 'foo')。
Options API(setup()函数)
useVModel 同样适用于不使用<script setup>的写法,第二个参数可以指定任意属性名(不局限于modelValue):
import { useVModel } from '@vueuse/core' export default { setup(props, { emit }) { const data = useVModel(props, 'data', emit) console.log(data.value) // props.data data.value = 'foo' // emit('update:data', 'foo') }, }这里体现了 useVModel 的通用性:它并不关心属性名是否叫modelValue,只要传入的key与emit的能力匹配,就能为任意 prop 建立双向通道。
三、六大选项详解:从被动同步到发射前校验
useVModel的第四参数options提供了六个可配置项,理解它们各自的触发条件与默认值,是正确使用的关键。完整类型声明可查阅文档中的UseVModelOptions<T, Passive>接口,下面逐一展开。
1. passive(被动模式)
- 类型:
boolean,默认值false - 作用:决定返回值是 computed ref 还是本地 ref
默认情况下,useVModel返回的是一个computed ref(WritableComputedRef):读操作直接代理到 prop,写操作直接 emit。这是一种「直通」语义,无额外中间层,性能最优。
而在passive: true模式下,它会创建一个本地 ref,并通过watch与 prop 保持同步:
const data = useVModel(props, 'modelValue', emit, { passive: true })被动模式的核心收益是允许深层响应式追踪。computed 模式对嵌套对象内部属性的变更(如obj.a.b = 1)不会产生预期的同步效果,而本地 ref + watch 的组合可以做到。
2. deep(深层监听)
- 类型:
boolean,默认值false - 适用前提:仅在
passive: true时生效
当绑定的是嵌套对象或数组,且需要在内部属性变更时也能同步,需要同时开启passive与deep:
const data = useVModel(props, 'modelValue', emit, { passive: true, deep: true, })这是defineModel尚不支持的 edge case 之一——文档明确提示,类似deep: true的场景需要回退到 useVModel。
3. clone(克隆 prop 值)
- 类型:
boolean | CloneFn<T>,默认值false - 作用:避免直接修改父级传入的原始对象
当值为对象/数组时,子组件内部的修改可能意外污染父组件的状态。设置clone: true会用JSON.parse(JSON.stringify(value))做深拷贝;也可以传入自定义克隆函数以支持Date、Map等 JSON 无法表达的类型:
const data = useVModel(props, 'modelValue', emit, { clone: true, // or provide custom clone function // clone: (val) => structuredClone(val), })注意structuredClone是浏览器原生 API,相比 JSON 序列化能正确处理更多数据类型,代价是对运行环境有要求。
4. defaultValue(默认值)
- 类型:
T,默认值undefined - 作用:当 prop 为
undefined时,为返回的 ref 提供兜底值
const data = useVModel(props, 'modelValue', emit, { defaultValue: 'default', })这在「父组件未传值、但子组件内部需要有一个可用初值」的场景非常实用,例如表单输入框的默认占位值。
5. eventName(自定义事件名)
- 类型:
string,默认值undefined(使用update:propName) - 作用:覆盖默认的
update:propName事件名
const data = useVModel(props, 'value', emit, { eventName: 'change', })当组件需要兼容旧的事件协议(例如历史上使用change而非update:value)时,无需改动父组件,即可用该选项适配。
6. shouldEmit(发射前校验)
- 类型:
(v: T) => boolean,默认值undefined - 作用:在触发 emit 之前做校验,返回
false则阻止发射
const data = useVModel(props, 'modelValue', emit, { shouldEmit: (value) => { // Only emit if value is valid return value.length > 0 }, })这是文档中标注为「可用于表单校验」的钩子:它拦截的是「向外发射」这个动作,适合实现「非法输入不打扰父组件」的防御式设计——例如空字符串、超出范围的值等都不再向上冒泡。
选项速查表
| 选项 | 类型 | 默认值 | 生效条件 | 核心作用 |
|---|---|---|---|---|
passive | boolean | false | 始终 | 返回 computed ref 或 watch 同步的本地 ref |
eventName | string | undefined | 始终 | 覆盖 emit 事件名 |
deep | boolean | false | passive: true | 深层监听嵌套对象/数组变化 |
defaultValue | T | undefined | 始终 | prop 为undefined时提供兜底值 |
clone | boolean \| CloneFn<T> | false | 始终 | 克隆 prop,避免污染父级原始对象 |
shouldEmit | (v: T) => boolean | undefined | 始终 | emit 前校验,返回false阻止发射 |
四、useVModel 与 defineModel:什么时候该用哪个
文档给出的官方态度非常明确:推荐优先使用 Vue 的defineModel,但仍存在defineModel覆盖不到的 edge case,需要回退到 useVModel:
- TSX / 非 SFC 环境:
defineModel是<script setup>的编译宏,只能在 SFC 中工作;而在 TSX 渲染函数或纯 Options API 组件中,defineModel不可用,useVModel 是唯一选择。 deep: true深层监听:当绑定对象需要监听嵌套属性变化时,defineModel原生不支持该能力,需借助 useVModel 的passive + deep组合。clone、shouldEmit等高级选项:useVModel 提供的这组细粒度选项,让它在需要防御性拷贝、发射前校验的复杂组件中更具表达力。
在 airi 仓库中可以观察到这种分工的实际落地。packages/stage-ui、packages/stage-pages、packages/ui等包中,共有59 个组件文件采用defineModel作为默认双向绑定方案,与文档「鼓励优先使用 defineModel」的指引一致;同时@vueuse/core作为依赖出现在 20 余个包的 package.json 中,VueUse 生态(含 useVModel)始终是可用后备方案。
五、仓库实战:airi 中的 v-model 组件设计模式
模式一:布尔开关——最简 defineModel
check-bar.vue 是设置面板中的勾选项,只用一行完成双向绑定声明:
const model = defineModel<boolean>()模板中直接把 ref 接回原生控件:
<input v-model="model" :aria-checked="model" type="checkbox" hidden>模式二:带默认值的受控输入
provider-api-key-input.vue 是 AI 服务商 API Key 输入框,展示了defineModel对「默认值」的处理:
const modelValue = defineModel<string>({ required: false, default: '' })随后将modelValue透传给更底层的FieldInput:
<FieldInput v-model="modelValue" :label="label || t('settings.pages.providers.common.fields.field.api-key.label')" :placeholder="placeholder" :required="required" type="password" />这是典型的「受控组件组合」:上层组件把 v-model 向下透传,值的变化会沿FieldInput -> provider-api-key-input -> 父级配置表单逐层冒泡,最终写入配置 store。
模式三:多命名模型
context-flow-filters.vue 展示了defineModel的命名模型能力——上下文流调试面板同时暴露多个筛选状态:
const directionFilter = defineModel<'all' | FlowDirection>('directionFilter', { required: true }) const showIncoming = defineModel<boolean>('showIncoming', { required: true }) const showOutgoing = defineModel<boolean>('showOutgoing', { required: true }) const showServer = defineModel<boolean>('showServer', { required: true }) const showBroadcast = defineModel<boolean>('showBroadcast', { required: true }) const showChat = defineModel<boolean>('showChat', { required: true }) const showDevtools = defineModel<boolean>('showDevtools', { required: true }) const maxEntries = defineModel<string>('maxEntries', { required: true })父组件通过<ContextFlowFilters v-model:direction-filter="..." v-model:show-incoming="..." />分别绑定各筛选项,避免了把一堆布尔值塞进单个modelValue对象的脏做法。对应到 useVModel 体系,这套能力由姊妹函数useVModels(批量处理多个 prop)提供,两条路线在仓库中均可按需选用。
模式四:泛型受控组件
input.vue 是packages/ui基础组件库中的通用输入框,用泛型约束模型类型:
const modelValue = defineModel<T>({ required: false })这种写法让基础组件在string、number等类型间保持类型安全,是组件库层面 v-model 设计的推荐范式。
六、使用建议与最佳实践
综合文档与 airi 仓库的落地经验,可以总结出如下选型与使用建议:
- 默认走
defineModel:SFC 内、无特殊需求的组件,优先使用编译宏,代码最简洁(仓库中 59 个组件的实践已证明这一点)。 - 以下场景切回
useVModel:TSX 渲染、Options API 组件、需要deep: true深层监听、需要clone隔离父级对象、需要shouldEmit做发射前表单校验。 - 组合选项要满足前提:
deep必须搭配passive: true才生效;clone为true时注意 JSON 序列化对Date、Map、函数等类型的丢失,需要时改用structuredClone或自定义函数。 - 把校验放在边界:
shouldEmit适合拦截非法值上抛,但「值本身是否合法」的最终裁决仍应交给父组件,保持单向数据流的清晰。 - 命名模型保持克制:当组件需要暴露 3 个以上模型时,优先评估是拆分子组件还是使用命名模型(
v-model:xxx/useVModels),避免modelValue携带过大的聚合对象。
七、进一步探索
- 本文主题的完整选项定义与类型声明,见 useVModel.md;
- 批量处理多个 v-model 的姊妹方案:同目录下的 useVModels.md;
- 仓库中的真实应用:
packages/stage-ui/src/components/scenarios/(设置项与弹窗)、packages/stage-pages/src/pages/devtools/context-flow/(多命名模型)、packages/ui/src/components/form/(基础受控组件); - 依赖声明:
@vueuse/core见 packages/stage-ui/package.json 与 packages/ui/package.json。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考