当 defineModel、useTemplateRef、effectScope 已经成为稳定特性,你的项目还在用 2020 年的写法吗?
一、引言
Vue 3 发布至今已逾四年,但很多项目的代码风格还停留在「Vue 3 早期」------大量的 props + emit 模板代码、字符串模板 ref、分散在各处的副作用清理。这些写法并非错误,但在 Vue 3.4+ 提供了更优雅的替代方案后,它们正在成为技术债务。
今天要聊的三个 API 都不是新面孔,它们在 Vue 3.4+ 已经稳定,却鲜少被正确使用:
- defineModel :
v-model的语法糖,能减少 70% 的表单组件样板代码 - useTemplateRef:类型安全的模板引用,告别字符串 ref 的隐患
- effectScope:副作用的「收纳盒」,让状态管理更可控,Pinia 内部就在用它
这三个 API 代表了 Vue 渐进式增强的设计哲学------不是推翻重来,而是在保留原有能力的基础上提供更优解。
二、defineModel ------ 告别 v-model 的样板代码地狱
2.1 旧写法的痛点
在 defineModel 出现之前,封装一个支持 v-model 的表单组件是这样的:
vue
<!-- MyInput.vue -->
<template>
<input :value="modelValue" @input="handleInput" />
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
使用方:
vue
<template>
<MyInput v-model="username" />
</template>
这段代码有几个问题:
- 冗长 :每个组件都需要定义
modelValueprop 和update:modelValueemit,当项目有几十个表单组件时,这就是纯粹的体力劳动 - 类型割裂:prop 和 emit 的类型需要手动保持一致
- 修饰符处理繁琐 :如果要支持
.trim、.lazy等修饰符,还需要额外的modelModifiersprop
2.2 defineModel 的用法与原理
defineModel 是 Vue 3.4 引入的宏,它将 modelValue prop 和 update:modelValue emit 合并为一个双向绑定:
vue
<!-- MyInput.vue -->
<template>
<input v-model="model" />
</template>
<script setup lang="ts">
import { defineModel } from 'vue'
const model = defineModel<string>()
</script>
这就是全部代码。defineModel 返回一个响应式引用,直接就可以用 v-model 绑定它。
原理剖析 :defineModel 是一个编译时宏,Vue 编译器会将其展开为:
typescript
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const model = computed({
get() { return props.modelValue },
set(value) { emit('update:modelValue', value) }
})
它利用了 v-model 的底层机制------modelValue prop + update:modelValue emit 的组合,只是帮你省去了手动编写的步骤。
2.3 进阶用法
2.3.1 带默认值的 defineModel
typescript
const model = defineModel<string>({ default: 'Hello' })
2.3.2 修饰符支持
vue
<!-- 父组件 -->
<template>
<!-- 使用 .trim 修饰符 -->
<MyInput v-model.trim="username" />
</template>
<!-- 子组件 -->
<script setup>
import { defineModel } from 'vue'
// 获取修饰符
const model = defineModel<string, { trim: boolean }>()
function onInput(e: Event) {
let value = (e.target as HTMLInputElement).value
if (model.modifiers?.trim) {
value = value.trim()
}
model.value = value
}
</script>
2.3.3 多 v-model
Vue 3.4 还支持在一个组件上绑定多个 v-model:
vue
<!-- DatePicker.vue -->
<script setup>
import { defineModel } from 'vue'
const startDate = defineModel<Date>('startDate')
const endDate = defineModel<Date>('endDate')
</script>
<template>
<div class="date-picker">
<input type="date" v-model="startDate" />
<span>至</span>
<input type="date" v-model="endDate" />
</div>
</template>
使用方:
vue
<template>
<DatePicker
v-model:startDate="rangeStart"
v-model:endDate="rangeEnd"
/>
</template>
2.4 实战:封装表单组件
让我们对比一个完整表单组件的旧写法和新写法:
旧写法(Vue 3.4 之前):
vue
<!-- FormField.vue -->
<template>
<div class="form-field">
<label v-if="label">{{ label }}</label>
<input
v-if="type === 'text'"
:type="inputType"
:value="modelValue"
@input="onInput"
/>
<textarea
v-else-if="type === 'textarea'"
:value="modelValue"
@input="onInput"
/>
<select
v-else-if="type === 'select'"
:value="modelValue"
@change="onSelect"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue'
interface Option {
label: string
value: string
}
const props = defineProps<{
modelValue: string
label?: string
type?: 'text' | 'textarea' | 'select'
inputType?: string
options?: Option[]
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const type = computed(() => props.type ?? 'text')
function onInput(e: Event) {
const target = e.target as HTMLInputElement
emit('update:modelValue', target.value)
}
function onSelect(e: Event) {
const target = e.target as HTMLSelectElement
emit('update:modelValue', target.value)
}
</script>
新写法(defineModel):
vue
<!-- FormField.vue -->
<template>
<div class="form-field">
<label v-if="label">{{ label }}</label>
<input
v-if="type === 'text'"
:type="inputType"
v-model="model"
/>
<textarea
v-else-if="type === 'textarea'"
v-model="model"
/>
<select
v-else-if="type === 'select'"
v-model="model"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineModel } from 'vue'
interface Option {
label: string
value: string
}
const props = defineProps<{
label?: string
type?: 'text' | 'textarea' | 'select'
inputType?: string
options?: Option[]
error?: string
}>()
// 核心:defineModel 替代 props.modelValue + emit
const model = defineModel<string>({ default: '' })
</script>
代码量对比:
表格
| 指标 | 旧写法 | 新写法 | 减少 |
|---|---|---|---|
| emit 定义 | 4 行 | 0 行 | 100% |
| handler 函数 | 10 行 | 0 行 | 100% |
| 事件绑定 | :value + @input |
v-model |
语义更清晰 |
三、useTemplateRef ------ 模板引用的「正名」
3.1 ref 命名耦合问题
Vue 2 时代就有了模板引用(ref="myRef"),Vue 3 继承了这个能力。但字符串 ref 有一个严重的问题:
vue
<script setup>
import { ref, onMounted } from 'vue'
// 隐患:字符串 "chart" 必须与模板中的 ref="chart" 完全匹配
const chart = ref<any>(null)
onMounted(() => {
// 如果模板中写成了 ref="myChart",这里会是 null,不会报错
chart.value.init()
})
</script>
<template>
<!-- 拼写错误或重构时改名,这里不会提示 -->
<div ref="chart">...</div>
</template>
类型安全缺失是一个问题,但更严重的是重构隐患 :当你在 IDE 中重命名 ref="chart" 时,const chart = ref(...) 可能不会同步更新。
3.2 useTemplateRef 的类型安全引用
Vue 3.3 引入了 useTemplateRef,3.4 中得到了完善:
vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'
// 泛型参数指定类型,变量名即模板 ref 名
const chart = useTemplateRef<any>('chart')
onMounted(() => {
// 现在有类型提示
chart.value?.init()
})
</script>
<template>
<div ref="chart">...</div>
</template>
核心优势:
- 类型推断 :
chart的类型会被正确推断为Ref<HTMLElement | null> - 编译时检查 :编译器会确保模板中存在对应的
ref属性 - IDE 重构支持:重命名变量时,IDE 可以同步更新模板中的 ref 名
3.3 与 ref() 绑定的区别
很多初学者会混淆 ref() 和 useTemplateRef() 的使用场景:
vue
<script setup>
import { ref, useTemplateRef } from 'vue'
// ref():声明响应式变量,不自动关联模板
const count = ref(0)
// useTemplateRef():获取模板中实际 DOM/组件的引用
const container = useTemplateRef<HTMLElement>('container')
function scrollToTop() {
container.value?.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
<template>
<div class="counter">
<p>Count: {{ count }}</p>
<button @click="count++">+1</button>
</div>
<!-- 这个 ref 是给 useTemplateRef 用的 -->
<div ref="container" class="scroll-container">
<LongContent />
</div>
</template>
表格
| 特性 | ref() |
useTemplateRef() |
|---|---|---|
| 用途 | 响应式状态 | DOM/组件引用 |
| 模板绑定 | 不需要 | 需要 ref="xxx" |
| 初始值 | null 或指定值 |
始终 null(等待挂载) |
| 类型推断 | 手动或自动 | 通过泛型指定 |
3.4 实战:动态组件引用 + 方法调用
一个典型的场景是封装可折叠面板,需要在父组件中控制子组件的展开状态:
vue
<!-- CollapsiblePanel.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const isExpanded = ref(false)
const contentRef = ref<HTMLElement>(null)
function toggle() {
isExpanded.value = !isExpanded.value
}
function expand() {
isExpanded.value = true
}
function collapse() {
isExpanded.value = false
}
// 暴露方法给父组件调用
defineExpose({ expand, collapse })
</script>
<template>
<div class="panel">
<button @click="toggle">
{{ isExpanded ? '收起' : '展开' }}
</button>
<div
ref="contentRef"
class="content"
:class="{ expanded: isExpanded }"
>
<slot />
</div>
</div>
</template>
父组件使用 useTemplateRef 调用子组件方法:
vue
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
import CollapsiblePanel from './CollapsiblePanel.vue'
// 类型安全地获取子组件实例
const panels = useTemplateRef<InstanceType<typeof CollapsiblePanel>[]>('panel')
function expandAll() {
panels.value?.forEach(p => p.expand())
}
function collapseAll() {
panels.value?.forEach(p => p.collapse())
}
</script>
<template>
<div class="accordion">
<div class="controls">
<button @click="expandAll">全部展开</button>
<button @click="collapseAll">全部收起</button>
</div>
<CollapsiblePanel ref="panel" title="第一项">
内容 1
</CollapsiblePanel>
<CollapsiblePanel ref="panel" title="第二项">
内容 2
</CollapsiblePanel>
<CollapsiblePanel ref="panel" title="第三项">
内容 3
</CollapsiblePanel>
</div>
</template>
注意这里使用了同名 ref (ref="panel"),useTemplateRef 会自动收集所有同名 ref 为数组,这在 Vue 3.4+ 中得到了官方支持。
四、effectScope ------ 状态管理的「隐形基石」
4.1 为什么需要 effectScope
Vue 的响应式系统会自动追踪依赖,但副作用的清理一直是个问题:
typescript
// 问题:组件卸载后,定时器可能仍在运行
onMounted(() => {
const timer = setInterval(() => {
fetchData()
}, 5000)
// 如果忘记清理,组件销毁后 timer 仍会执行
})
onUnmounted(() => {
clearInterval(timer) // 必须记住写这个
})
当你的组件有多个副作用(定时器、事件监听、WebSocket 连接等),忘记清理任何一个都会导致内存泄漏或奇怪的行为。
effectScope 的核心思想:把相关的副作用「收进」一个作用域里,销毁时一次性全部清理。
4.2 基础用法:收集与批量清理
typescript
import { effectScope, ref, watch, computed } from 'vue'
// 创建作用域
const scope = effectScope()
// 在作用域内创建响应式数据
scope.run(() => {
const counter = ref(0)
const doubled = computed(() => counter.value * 2)
watch(counter, (newVal) => {
console.log(`counter changed to ${newVal}`)
})
// ... 其他响应式代码
})
// 组件卸载时,调用一次stop即可清理所有副作用
onUnmounted(() => {
scope.stop() // 定时器、watcher、computed 全部清理
})
对比传统写法:
typescript
// 传统写法:每个副作用都要单独清理
let timer: number
let unwatch: WatchHandle
onMounted(() => {
timer = setInterval(() => fetchData(), 5000)
unwatch = watch(data, handler)
})
onUnmounted(() => {
clearInterval(timer)
unwatch()
})
typescript
// effectScope 写法:一次清理所有
const scope = effectScope()
onMounted(() => {
scope.run(() => {
const timer = setInterval(() => fetchData(), 5000)
watch(data, handler)
})
})
onUnmounted(() => {
scope.stop() // 一行代码搞定
})
4.3 Pinia 内部的 effectScope
你可能不知道,Pinia 状态管理内部就使用了 effectScope:
typescript
// Pinia store 简化实现
function defineStore(id, setup) {
const scope = effectScope()
return {
$id: id,
// 在 scope 内执行 setup,返回响应式 state
$setup() {
return scope.run(() => setup())
},
// 销毁时清理
$dispose() {
scope.stop()
}
}
}
这就是为什么 Pinia store 能在组件卸载后自动清理相关的响应式依赖。当你调用 store.$dispose() 时,scope 内的所有 effect、watcher、computed 都会被清理。
4.4 实战:创建可销毁的 Composable
effectScope 最强大的用法是创建可复用的、有完整生命周期的 composable:
typescript
// useWebSocket.ts
import { ref, onUnmounted, effectScope } from 'vue'
interface UseWebSocketOptions {
url: string
autoReconnect?: boolean
maxRetries?: number
}
export function useWebSocket<T>(options: UseWebSocketOptions) {
const scope = effectScope()
const data = ref<T | null>(null)
const status = ref<'connecting' | 'connected' | 'disconnected'>('disconnected')
let ws: WebSocket | null = null
let retryCount = 0
function connect() {
status.value = 'connecting'
ws = new WebSocket(options.url)
ws.onopen = () => {
status.value = 'connected'
retryCount = 0
}
ws.onmessage = (event) => {
data.value = JSON.parse(event.data)
}
ws.onclose = () => {
status.value = 'disconnected'
if (options.autoReconnect && retryCount < (options.maxRetries ?? 5)) {
retryCount++
setTimeout(connect, 1000 * retryCount) // 指数退避
}
}
}
function disconnect() {
ws?.close()
}
function send(message: unknown) {
ws?.send(JSON.stringify(message))
}
// 在 scope 内启动连接
scope.run(() => {
connect()
})
// 清理函数
const dispose = () => {
scope.stop() // 清理所有响应式依赖
disconnect()
}
return {
data: readonly(data),
status: readonly(status),
send,
disconnect,
dispose // 暴露清理方法
}
}
使用方:
vue
<script setup>
import { useWebSocket } from './useWebSocket'
import { onUnmounted } from 'vue'
const ws = useWebSocket<Notification>({
url: 'wss://api.example.com/ws',
autoReconnect: true,
maxRetries: 3
})
// 组件卸载时自动清理
onUnmounted(() => {
ws.dispose() // 定时器、断线重连全部清理
})
</script>
这样封装的好处:
- 作用域隔离:composable 内部的响应式依赖不会泄漏到组件
- 一次性清理 :调用
dispose()就清除所有资源 - 可复用:多个组件可以使用同一个 composable,互不干扰
五、三个 API 的协作场景
5.1 defineModel + effectScope:创建可回收的表单状态
typescript
// useFormField.ts
import { effectScope, ref, watch } from 'vue'
import { defineModel } from 'vue'
export function useFormField<T>(initialValue: T) {
const scope = effectScope()
// 使用 defineModel 建立双向绑定
const value = scope.run(() => defineModel<T>({ default: initialValue }))
// 表单级别的验证逻辑
const errors = ref<string[]>([])
const touched = ref(false)
scope.run(() => {
watch(value, () => {
if (touched.value) {
errors.value = validate(value.value)
}
})
})
function touch() {
touched.value = true
errors.value = validate(value.value)
}
function reset() {
value.value = initialValue
errors.value = []
touched.value = false
}
// 清理
const dispose = () => scope.stop()
return {
value,
errors,
touched,
touch,
reset,
dispose
}
}
5.2 useTemplateRef + effectScope:管理 DOM 观察器
typescript
// useIntersectionObserver.ts
import { effectScope, ref, useTemplateRef, onMounted } from 'vue'
export function useIntersectionObserver(
targetRef: ReturnType<typeof useTemplateRef<HTMLElement>>,
callback: IntersectionObserverCallback
) {
const scope = effectScope()
let observer: IntersectionObserver | null = null
scope.run(() => {
onMounted(() => {
if (targetRef.value) {
observer = new IntersectionObserver(callback)
observer.observe(targetRef.value)
}
})
})
const dispose = () => {
observer?.disconnect()
scope.stop()
}
return { dispose }
}
六、常见陷阱与注意事项
6.1 defineModel 的注意事项
- 不能与
defineProps的同名属性混用
typescript
// ❌ 错误:defineModel 会自动声明 modelValue prop
const props = defineProps<{ modelValue: string }>()
const model = defineModel() // 会冲突
// ✅ 正确
const model = defineModel<string>()
- 在异步组件中使用需谨慎
defineModel 依赖编译时展开,异步组件的编译产物可能有差异。如遇问题,考虑回退到传统写法。
- 类型必须是可赋值的
typescript
// ❌ modelValue 是 string,但 defineModel 声明 number
const model = defineModel<number>()
// ✅ 类型必须与父组件 v-model 传递的值兼容
const model = defineModel<string>()
6.2 useTemplateRef 的注意事项
- 必须在 setup 阶段调用
typescript
// ❌ 错误:在异步函数中调用
async function setup() {
const ref = useTemplateRef('el') // 可能为 null
}
// ✅ 正确:在 setup 同步执行
const ref = useTemplateRef('el')
onMounted(() => {
// 此时 ref.value 才会有值
})
- 模板 ref 必须在 DOM 中存在
vue
<template>
<!-- v-if="false" 时,ref 不会被设置 -->
<div v-if="isVisible" ref="el"></div>
<!-- 解决方案:使用 v-show -->
<div v-show="isVisible" ref="el"></div>
</template>
6.3 effectScope 的注意事项
- stop() 是不可逆的
typescript
const scope = effectScope()
scope.run(() => { /* ... */ })
scope.stop() // 清理
scope.run(() => { /* ... */ }) // ❌ 不会再运行
- 不要在 scope.run() 内调用
getCurrentScope()
getCurrentScope() 会返回组件的 scope,而不是你创建的 scope。如需在内部访问 scope,使用闭包。
- 性能考虑
每个 effectScope 都有轻微的内存开销。对于简单场景(如只有一两个 watch),不必过度封装。
七、总结
回顾这三个 API,它们有一个共同的设计哲学:减少样板代码,让开发者专注于业务逻辑。
表格
| API | 解决的问题 | 核心价值 |
|---|---|---|
defineModel |
props + emit 的冗长模式 |
双向绑定,一行搞定 |
useTemplateRef |
字符串 ref 的类型不安全 | 类型推导,重构友好 |
effectScope |
副作用散落难以清理 | 作用域隔离,一次清理 |
这三个 API 在 Vue 3.4+ 都已稳定,配套的 TypeScript 支持也非常完善。如果你的项目还在用旧写法,不妨花半小时迁移------代码会更简洁,bug 会更少,团队会更幸福。
更进一步 :这三个 API 不是孤立的。将它们组合使用------用 defineModel 处理表单绑定,用 effectScope 封装有生命周期的高级 composable,用 useTemplateRef 安全地操作 DOM------你的 Vue 3 代码质量会提升一个台阶。
Vue 的演进方向是「渐进增强」:不强求你使用新 API,但当你需要时,它准备好了。
本文由AI辅助整理