Vue 3.4+ 被低估的 3 个 API,让你的代码更优雅

当 defineModel、useTemplateRef、effectScope 已经成为稳定特性,你的项目还在用 2020 年的写法吗?

一、引言

Vue 3 发布至今已逾四年,但很多项目的代码风格还停留在「Vue 3 早期」------大量的 props + emit 模板代码、字符串模板 ref、分散在各处的副作用清理。这些写法并非错误,但在 Vue 3.4+ 提供了更优雅的替代方案后,它们正在成为技术债务。

今天要聊的三个 API 都不是新面孔,它们在 Vue 3.4+ 已经稳定,却鲜少被正确使用:

  • defineModelv-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>

这段代码有几个问题:

  1. 冗长 :每个组件都需要定义 modelValue prop 和 update:modelValue emit,当项目有几十个表单组件时,这就是纯粹的体力劳动
  2. 类型割裂:prop 和 emit 的类型需要手动保持一致
  3. 修饰符处理繁琐 :如果要支持 .trim.lazy 等修饰符,还需要额外的 modelModifiers prop

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>

核心优势

  1. 类型推断chart 的类型会被正确推断为 Ref<HTMLElement | null>
  2. 编译时检查 :编译器会确保模板中存在对应的 ref 属性
  3. 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>

注意这里使用了同名 refref="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>

这样封装的好处:

  1. 作用域隔离:composable 内部的响应式依赖不会泄漏到组件
  2. 一次性清理 :调用 dispose() 就清除所有资源
  3. 可复用:多个组件可以使用同一个 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 的注意事项

  1. 不能与 defineProps 的同名属性混用
typescript 复制代码
// ❌ 错误:defineModel 会自动声明 modelValue prop
const props = defineProps<{ modelValue: string }>()
const model = defineModel() // 会冲突

// ✅ 正确
const model = defineModel<string>()
  1. 在异步组件中使用需谨慎

defineModel 依赖编译时展开,异步组件的编译产物可能有差异。如遇问题,考虑回退到传统写法。

  1. 类型必须是可赋值的
typescript 复制代码
// ❌ modelValue 是 string,但 defineModel 声明 number
const model = defineModel<number>()

// ✅ 类型必须与父组件 v-model 传递的值兼容
const model = defineModel<string>()

6.2 useTemplateRef 的注意事项

  1. 必须在 setup 阶段调用
typescript 复制代码
// ❌ 错误:在异步函数中调用
async function setup() {
  const ref = useTemplateRef('el') // 可能为 null
}

// ✅ 正确:在 setup 同步执行
const ref = useTemplateRef('el')
onMounted(() => {
  // 此时 ref.value 才会有值
})
  1. 模板 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 的注意事项

  1. stop() 是不可逆的
typescript 复制代码
const scope = effectScope()
scope.run(() => { /* ... */ })
scope.stop() // 清理
scope.run(() => { /* ... */ }) // ❌ 不会再运行
  1. 不要在 scope.run() 内调用 getCurrentScope()

getCurrentScope() 会返回组件的 scope,而不是你创建的 scope。如需在内部访问 scope,使用闭包。

  1. 性能考虑

每个 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辅助整理

相关推荐
dishugj1 小时前
HANA数据库常用命令总结
java·前端·数据库
clove1 小时前
JavaScript 提升(Hoisting)与声明优先级:一篇文章说透
前端
七牛开发者1 小时前
不写框架、不用 npm,我用 AI Coding 做了一个家庭记忆站
前端·人工智能·npm
@PHARAOH1 小时前
WHAT - npm和corepack
前端·npm·node.js
不爱学英文的码字机器1 小时前
被 AE 的关键帧折磨过的人,应该试试这个用 React 写视频的路子
前端·react.js·音视频
Csvn1 小时前
组合式函数
前端·vue.js
CodeSheep1 小时前
中国编程第一人,一人抵一城!
前端·后端·程序员
GISer_Jing1 小时前
Claude Code项目配置终极指南
前端·ai·ai编程