Vue 3 组合式 API 与响应式原理

2024-09-06 前端

一、Vue 3 概述

1.1 Vue 2 vs Vue 3 核心变化

Vue 3 是 Vue.js 框架的一个重大版本升级,于 2020 年 9 月正式发布。它不仅带来了全新的组合式 API,还在底层架构、性能优化和开发体验方面进行了深度重构。

对比维度 Vue 2 Vue 3
响应式系统 Object.defineProperty Proxy
性能 中等 提升 1.3~2 倍
TypeScript 支持 有限(需额外插件) 原生支持
包体积 较大 Tree-shaking 后更小
组合逻辑复用 Mixins Composables
多根节点 不支持 支持 Fragment
虚拟 DOM 静态模板 静态标记 + 块树
全局 API 挂在 Vue 对象上 createApp 实例化

1.2 性能提升的三大支柱

Proxy 响应式系统

Vue 2 使用 Object.defineProperty 遍历对象所有属性并逐一劫持 getter/setter,这种方式存在三个固有缺陷:

  1. 无法检测属性的新增和删除
  2. 无法直接检测数组索引变更和 length 修改
  3. 需要递归遍历整个对象,初始化性能开销大

Vue 3 改用 ES6 的 Proxy 代理整个对象,代理层拦截所有属性访问(get)、赋值(set)、删除(deleteProperty)等操作。这意味着:

  • 新增属性会自动获得响应式
  • 数组下标赋值会触发更新
  • 惰性代理——只有访问到嵌套对象时才递归代理,初始化更快

虚拟 DOM 重写

Vue 3 重写了虚拟 DOM 的 diff 算法,引入了静态标记(PatchFlag)和树结构打平(Tree Flattening)。编译器在编译阶段分析模板,对动态绑定标记具体类型(如 TEXT、CLASS、STYLE、PROPS),diff 时只需对比带标记的节点,跳过静态子树。

<template>
  <div>
    <span>静态文本</span>
    <span :class="dynamicClass">动态类名</span>
  </div>
</template>

编译后,第二个 <span> 会被标记为 PatchFlag.CLASS,diff 时跳过第一个 <span> 的对比。

Tree-shaking

Vue 3 的源码采用 ESM 模块化设计,未使用的 API(如 v-model、Transition、KeepAlive)不会打包到最终产物中。这让一个基础 Vue 3 应用的最小体积仅为 ~12 KB(gzip),远小于 Vue 2 的 ~22 KB。

1.3 Vue 3 的工程化生态

Vue 3 不仅重构了框架本身,还建立了一整套现代化的工程化生态体系。这套体系围绕 Vite 构建工具和 create-vue 脚手架展开,同时配合 Vue 官方的路由方案 Vue Router 4、状态管理方案 Pinia(替代 Vue 2 时代的 Vuex)、以及组件测试方案 Vitest。

Vite 是 Vue 3 生态中最具革命性的变化之一。Vue 2 时代使用 Vue CLI(基于 Webpack),随着项目规模增长,冷启动和热更新时间往往从几秒退化到几十秒甚至几分钟。Vite 利用浏览器原生 ESM 的能力,实现了按需编译——浏览器请求哪个模块,Vite 才编译哪个模块,从而让冷启动速度恒定在亚秒级,与项目规模无关。

Pinia 作为 Vuex 的替代品,在设计上更加简洁:移除了 mutations(直接通过 actions 修改状态)、完整的 TypeScript 类型推断、支持组合式 API 风格的 store 定义,以及自动的 devtools 集成。

Vue Router 4 针对 Vue 3 进行了重写,核心变化包括:使用 createRouter 工厂函数代替 new Router()、组合式 API 风格的导航守卫、动态路由参数的类型安全等。

1.4 使用 create-vue 创建项目

官方推荐使用 create-vue 脚手架创建 Vue 3 + Vite 项目:

# 使用 npm
npm create vue@latest

# 使用 yarn
yarn create vue

# 使用 pnpm
pnpm create vue@latest

交互式命令行会提示你是否需要 TypeScript、JSX、Router、Pinia、Vitest 等选装项:

✔ Project name: my-vue3-app
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? … No / Yes
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes

Scaffolding project in ./my-vue3-app...
Done.

项目创建后,进入目录并安装依赖即可启动:

cd my-vue3-app
npm install
npm run dev

1.4 Vite 开发体验

Vue 3 的官方推荐构建工具从 Vue CLI(基于 Webpack)转向了 Vite(基于 esbuild + Rollup)。Vite 的核心优势在于:

  • 极速冷启动:利用浏览器原生 ESM 按需编译,无需打包整个项目
  • 热更新 HMR 即时生效:修改组件后几乎瞬时刷新,不会随项目规模变大而变慢
  • 开箱即用:原生支持 TypeScript、JSX、CSS 预处理器、静态资源导入
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000,
    open: true,
  },
})

二、组合式 API(Composition API)

组合式 API 是 Vue 3 引入的一套全新的逻辑组织方式,它从根本上改变了 Vue 组件的代码组织范式。在 Vue 2 的选项式 API 中,一个功能的代码被分散在 data、methods、computed、watch 等不同选项中——例如,一个计数器功能,其数据定义在 data 中、方法定义在 methods 中、派生状态定义在 computed 中、副作用监听定义在 watch 中。当组件功能变多时,这种"碎片化组织"使得代码可读性急剧下降,开发者需要在文件中反复上下滚动来理解一个功能的完整逻辑。组合式 API 解决了这个核心问题:它让开发者能够按逻辑关注点组织代码,而不是按照选项(data、methods、computed)分散代码。

2.1 setup() 函数 vs \<script setup>

setup() 函数

setup() 是组合式 API 的入口函数,在组件创建时执行(beforeCreate 之前)。它接收 props 和 context 两个参数,返回的对象暴露给模板使用。

<script>
import { ref, onMounted } from 'vue'

export default {
  props: { count: Number },
  setup(props, context) {
    const message = ref('Hello')
    const { emit, slots, attrs, expose } = context

    onMounted(() => {
      console.log('Mounted!')
    })

    // 暴露给模板
    return { message }
  }
}
</script>

\<script setup>(推荐)

<script setup> 是语法糖,在单文件组件(SFC)中大幅简化了组合式 API 的写法。所有顶层变量、函数、导入都可以直接在模板中使用,无需 return。

<script setup>
import { ref, onMounted } from 'vue'

const message = ref('Hello')

onMounted(() => {
  console.log('Mounted!')
})
</script>

<template>
  <div>{{ message }}</div>
</template>

\<script setup> 的优势:

  • 更简洁的代码,消除重复的 return
  • 更好的 TypeScript 类型推断
  • 严格模式下无法在模板中访问组件实例属性,避免意外泄漏
  • 支持顶层 await(自动成为异步组件)
<script setup>
const data = await fetch('/api/data').then(r => r.json())
</script>

2.2 ref:基本类型响应式

ref() 用于创建任意值的响应式引用。它返回一个包含 .value 属性的对象——JavaScript 中的原始类型(string、number、boolean)无法被 Proxy 直接代理,因此 ref 相当于给原始值套了一层对象包装。

关键点:

  • 在 <script> 中操作 ref 值必须使用 .value
  • 在 <template> 中 Vue 会自动解包 ref,无需 .value
  • ref 可以接受任何类型的值,包括对象和数组(内部自动转为 reactive)

2.3 reactive:对象响应式

reactive() 用于创建对象类型的响应式代理。它基于 Proxy 实现,直接操作属性即可,无需 .value。

2.4 ref vs reactive 对比

特性 ref reactive
数据类型 任意类型(含原始类型) 仅对象/数组/Map/Set
访问方式 .value(脚本中) 直接访问属性
自动解包 模板中自动解包 模板中自动解包
重新赋值 支持 ref.value = newVal 不支持直接替换整个对象
类型安全 .value 让类型明确 泛型推断良好
解构后响应性 丢失(需 toRef) 丢失(需 toRefs)

推荐实践:

  • 始终使用 ref——Vue 官方文档在 Vue 3.3+ 之后更推荐 ref 作为首选,因为它语义简单、类型安全,且能处理任何类型
  • 仅在需要深层响应式对象且频繁访问嵌套属性时使用 reactive(省去 .value)
  • 避免在 reactive 中使用解构,否则会丢失响应性
<script setup>
import { ref, reactive } from 'vue'

// ✅ 推荐:大多数情况用 ref
const count = ref(0)
const user = ref({ name: 'Alice' })

// ✅ 可选:深层对象用 reactive
const form = reactive({
  username: '',
  password: '',
  profile: { bio: '' },
})

// ❌ 错误:解构会丢失响应性
const { username } = form
username = 'new' // 不会触发更新
</script>

2.5 computed:计算属性

computed() 创建基于其他响应式数据的派生值。它缓存计算结果,仅在依赖变化时重新计算。

只读计算属性:

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 只读计算属性(默认模式)
const fullName = computed(() => {
  return firstName.value + ' ' + lastName.value
})

console.log(fullName.value) // '张 三'
</script>

<template>
  <p>全名: {{ fullName }}</p>
</template>

可读写计算属性:

通过传入包含 get 和 set 的对象来创建可写计算属性。

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 可读写计算属性
const fullName = computed({
  get() {
    return firstName.value + ' ' + lastName.value
  },
  set(newValue) {
    ;[firstName.value, lastName.value] = newValue.split(' ')
  },
})

// 使用 setter
fullName.value = '李 四'
console.log(firstName.value) // '李'
console.log(lastName.value)  // '四'
</script>

<template>
  <input v-model="fullName" />
</template>

计算属性 vs 方法:

<script setup>
import { ref, computed } from 'vue'

const items = ref([1, 2, 3, 4, 5, 6])

// 计算属性——缓存,只在依赖变化时重新计算
const evenItems = computed(() => {
  console.log('computed 重新计算')
  return items.value.filter(n => n % 2 === 0)
})

// 方法——每次渲染都重新执行
function getEvenItems() {
  console.log('方法重新执行')
  return items.value.filter(n => n % 2 === 0)
}
</script>

<template>
  <p>计算属性: {{ evenItems }}</p>
  <p>方法: {{ getEvenItems() }}</p>
</template>

计算属性具有缓存优势——如果依赖未变,多次访问计算属性会直接返回缓存值,而不会重新执行函数体。

2.6 watch / watchEffect

watch:监听特定数据源

watch() 监听一个或多个响应式数据源,当数据变化时执行回调。

监听单个 ref:

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})
</script>

<template>
  <button @click="count++">增加</button>
</template>

监听 getter 函数(监听对象属性):

<script setup>
import { reactive, watch } from 'vue'

const state = reactive({ count: 0 })

watch(
  () => state.count,
  (newVal, oldVal) => {
    console.log(`count 变为 ${newVal}`)
  }
)
</script>

监听多个数据源:

<script setup>
import { ref, watch } from 'vue'

const x = ref(0)
const y = ref(0)

watch([x, y], ([newX, newY], [oldX, oldY]) => {
  console.log(`x: ${oldX} -> ${newX}, y: ${oldY} -> ${newY}`)
})
</script>

watch 选项:

watchEffect:自动追踪依赖

watchEffect() 立即执行一个函数,并自动追踪该函数中访问的所有响应式数据。无需手动声明依赖。

<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('Vue')

// 自动追踪 count 和 name
watchEffect(() => {
  console.log(`count: ${count.value}, name: ${name.value}`)
})

// 修改变量时会自动触发
// count 变化 -> watchEffect 自动运行
// name 变化 -> watchEffect 自动运行

// 清除副作用
watchEffect((onCleanup) => {
  const timer = setTimeout(() => {
    console.log('延迟执行')
  }, 1000)

  // 当 watchEffect 重新运行或组件卸载时,清除之前的副作用
  onCleanup(() => {
    clearTimeout(timer)
  })
})
</script>

watch vs watchEffect 对比:

特性 watch watchEffect
依赖声明 显式指定 自动追踪
立即执行 需要 immediate: true 默认立即执行
获取旧值 ✅ 支持 ❌ 不支持
执行时机 默认懒执行(数据变化后) 默认立即执行
适用场景 需要旧值 / 精确控制监听源 日志、调试、需要自动追踪

2.7 toRef / toRefs:解构响应式对象

toRef 和 toRefs 用于从响应式对象中提取属性,使其在解构时仍然保持响应性。

toRef:创建单个 ref

<script setup>
import { reactive, toRef } from 'vue'

const state = reactive({
  count: 0,
  name: 'Vue',
})

// 创建与 state.count 保持同步的 ref
const countRef = toRef(state, 'count')

// 修改 ref 会同步到原对象
countRef.value++
console.log(state.count) // 1

// 修改原对象也会同步到 ref
state.count++
console.log(countRef.value) // 2
</script>

toRefs:解构整个对象

<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({
  count: 0,
  name: 'Vue',
})

// 解构后的每个属性都是 ref,仍然保持响应性
const { count, name } = toRefs(state)

// 现在可以放心解构使用了
count.value++
name.value = 'Vue 3'
</script>

<template>
  <p>{{ count }} - {{ name }}</p>
</template>

使用场景: 在组合式函数中返回响应式对象时,用 toRefs 让解构的用户仍然保持响应性。

<script setup>
import { reactive, toRefs } from 'vue'

function useMouse() {
  const state = reactive({ x: 0, y: 0 })
  // ... 监听鼠标事件

  return toRefs(state) // 返回 { x: ref, y: ref }
}

// 用户可以解构使用
const { x, y } = useMouse()
</script>

2.8 shallowRef / shallowReactive

shallowRef:浅层响应式

只跟踪 .value 的变化,不递归代理内部属性。适合大型数据结构的性能优化。

<script setup>
import { shallowRef } from 'vue'

const state = shallowRef({
  list: Array.from({ length: 10000 }, (_, i) => i),
  meta: { version: 1 },
})

// ✅ 触发更新——重新赋值 .value
state.value = { list: [], meta: { version: 2 } }

// ❌ 不会触发更新——修改内部属性
state.value.list.push(9999)
state.value.meta.version = 3 // 不会触发视图更新
</script>

shallowReactive:浅层响应式对象

只代理对象的第一层属性,嵌套对象不会被代理。

<script setup>
import { shallowReactive } from 'vue'

const state = shallowReactive({
  nested: { count: 0 },
  arr: [1, 2, 3],
})

// ✅ 触发更新——修改第一层属性
state.nested = { count: 10 }

// ❌ 不会触发更新——修改嵌套属性
state.nested.count++

// ❌ 不会触发更新——数组原地修改
state.arr.push(4)
</script>

性能优化提示: 当不需要深层响应式(比如只关心顶层引用变化)时,优先使用 shallowRef 代替 ref,可以减少 Proxy 代理开销。

2.9 readonly / shallowReadonly

readonly:深层只读代理

接受一个响应式对象(ref 或 reactive),返回其只读代理。任何修改尝试都会在开发模式下抛出警告。

<script setup>
import { reactive, readonly, ref } from 'vue'

const original = reactive({ count: 0, nested: { value: 1 } })
const readOnlyCopy = readonly(original)

// 尝试修改只读代理
readOnlyCopy.count++ // 警告: Set operation on key "count" failed: target is readonly
readOnlyCopy.nested.value = 2 // 同样警告(深层只读)
</script>

shallowReadonly:浅层只读代理

只限制第一层属性为只读,嵌套对象仍可修改。

<script setup>
import { shallowReadonly, reactive, shallowRef } from 'vue'

const state = shallowReadonly({
  count: 0,
  nested: { value: 1 },
})

state.count++ // 警告:不可修改
state.nested.value = 2 // ✅ 可以修改(深层的没有被限制)

// 常见用法:在组合式函数中暴露只读状态
function useCounter() {
  const count = ref(0)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return {
    count: readonly(count), // 对外只读
    double: readonly(double),
    increment, // 方法可调用
  }
}
</script>

典型场景: 组合式函数返回给外部使用的状态应该包装为 readonly,防止外部意外修改内部状态,保持单向数据流。


三、响应式原理深入

3.1 Vue 2 响应式缺陷(Object.defineProperty 的局限性)

Vue 2 使用 Object.defineProperty 实现响应式,本质是遍历对象的所有属性,将每个属性转换为 getter/setter。这种设计在 2014 年 Vue 发布时是合理的选择,因为当时 Proxy 尚未被主流浏览器广泛支持。然而,随着 Web 应用的复杂化,Object.defineProperty 的固有缺陷日益突出,成为 Vue 2 开发者最常见的问题来源。

Object.defineProperty 的工作原理:

Vue 2 在初始化时递归遍历 data 对象的每一个属性,使用 Object.defineProperty 为每个属性添加 getter 和 setter。getter 负责收集依赖(记录哪些 watcher 依赖了这个属性),setter 负责通知变化(当属性值变化时通知所有依赖的 watcher 重新渲染)。

// Vue 2 响应式简化实现
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      dep.depend()
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal
        // 触发更新
        dep.notify()
      }
    },
  })
}

Vue 2 响应式的三大缺陷:

  1. 无法检测属性的新增和删除
const vm = new Vue({
  data: { user: { name: 'Alice' } },
})

// ❌ 不是响应式的
vm.user.age = 25

// ❌ 也不是响应式的
delete vm.user.name

// 👇 必须使用 Vue.set / Vue.delete
Vue.set(vm.user, 'age', 25)
Vue.delete(vm.user, 'name')
  1. 无法检测数组索引变更和长度修改
const vm = new Vue({
  data: { items: ['a', 'b', 'c'] },
})

// ❌ 不是响应式的
vm.items[0] = 'x'
vm.items.length = 0

// 👇 必须使用 Vue.set 或数组变异方法
vm.items.splice(0, 1, 'x')
Vue.set(vm.items, 0, 'x')
  1. 初始化性能开销大

Object.defineProperty 需要递归遍历整个 data 对象,所有属性都要在组件初始化时就完成 getter/setter 转换。对于深层嵌套的大型对象,初始化的时间成本不可忽视。

3.2 Proxy vs defineProperty 对比

特性 Proxy Object.defineProperty
拦截粒度 代理整个对象 逐个拦截属性
属性新增/删除 ✅ 自动检测 ❌ 无法检测
数组下标赋值 ✅ 自动检测 ❌ 无法检测
数组长度变化 ✅ 自动检测 ❌ 无法检测
初始化开销 惰性代理(仅代理顶层) 递归遍历所有属性
浏览器兼容性 IE11 不支持 全兼容
可拦截操作 13 种(get/set/has/deleteProperty 等) 仅 get/set

3.3 reactive 实现原理(Proxy + Reflect)

Vue 3 的 reactive() 核心是基于 Proxy 和 Reflect 实现的。

为什么使用 Reflect?

Reflect 的 get/set 方法作为 Proxy 拦截器的默认转发行为,有以下优势:

  1. 正确的 this 指向:Reflect.get/set 会将 receiver 作为 this 传入,确保在继承场景下正确访问原型链上的属性
  2. 返回值语义:Reflect.set 返回布尔值指示操作是否成功,而 target[key] = value 不返回值
  3. 标准化行为:Reflect 上的方法与 Proxy 的拦截器一一对应,使代理行为更可预测

3.4 ref 实现原理

ref() 本质上是对值的封装。对于原始类型(字符串、数字、布尔值),将其包装为 { value: ... } 对象,然后通过 reactive 代理这个包装对象。

关键设计点:

  1. ref 返回的是一个普通对象,不是 Proxy,但有 getter/setter
  2. ref 的内部 _value 如果存储的是对象,会通过 reactive() 转换为响应式代理
  3. 这就是为什么 ref({ count: 0 }).value.count 也是响应式的
  4. 模板中自动解包 ref 是通过编译器的语法糖实现的,编译时在渲染函数中自动插入 .value

3.5 依赖收集与触发更新机制

Vue 3 的响应式系统核心是一个发布-订阅模式,包含三个关键角色:

  • targetMap:WeakMap,以目标对象为键,存储其所有属性的依赖映射。使用 WeakMap 而非普通 Map 的原因是为了避免内存泄漏——当目标对象不再被任何地方引用时,WeakMap 中的键值对会被垃圾回收器自动清理。
  • depsMap:Map,以属性 key 为键,存储该属性对应的所有副作用
  • dep:Set,存储该属性的所有副作用(effect)。使用 Set 的原因是确保同一个 effect 不会重复收集,避免多次执行相同的副作用。

完整的数据流链路如下:

当组件渲染时,render 函数会读取响应式对象的属性,触发 Proxy 的 get 拦截器。get 拦截器调用 track() 函数,将当前正在运行的 effect(即组件的渲染 effect)记录到对应属性的 dep 集合中。当用户修改响应式对象的属性时,触发 Proxy 的 set 拦截器,set 拦截器调用 trigger() 函数,遍历该属性 dep 集合中的所有 effect 并依次执行它们,最终触发组件的重新渲染。

用户操作 → 修改属性 → Proxy set 拦截器 → trigger() → 执行所有 effect → 组件重新渲染
                                                                           ↓
组件渲染 → 读取属性 → Proxy get 拦截器 → track() → 收集当前 effect ←───┘

这种设计确保了精确的更新粒度——只有真正依赖某个属性的组件或计算属性才会在属性变化时重新执行,不会有多余的重新计算。

targetMap (WeakMap)
  ├── target1 → depsMap (Map)
  │   ├── 'count' → dep (Set) [effect1, effect2, ...]
  │   ├── 'name'  → dep (Set) [effect1, effect3, ...]
  │   └── Symbol('iterate') → dep (Set) [effect4, ...]
  └── target2 → depsMap (Map)
      └── ...

依赖收集(track):

// 当前正在执行的 effect
let activeEffect = null

function track(target, key) {
  if (!activeEffect) return

  // 获取 target 的 depsMap
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }

  // 获取 key 的 dep
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }

  // 将当前 effect 加入 dep
  dep.add(activeEffect)
}

触发更新(trigger):

function trigger(target, key, type, newValue) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  // 收集所有需要执行的 effect
  const effectsToRun = new Set()

  // 属性相关的 effect
  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => effectsToRun.add(effect))
  }

  // 如果是数组且 key 是 'length',需要触发所有依赖
  if (Array.isArray(target) && key === 'length') {
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= newValue) {
        dep.forEach(effect => effectsToRun.add(effect))
      }
    })
  }

  // 执行所有 effect
  effectsToRun.forEach(effect => effect())
}

3.6 effect 系统简介

effect 是 Vue 3 响应式系统的底层运行单元。watchEffect、computed、watch、render(组件的渲染函数)等 API 都是基于 effect 实现的。

// 简化版 effect
function effect(fn) {
  const _effect = () => {
    try {
      activeEffect = _effect // 设置当前 effect
      cleanup(_effect)       // 清除旧的依赖
      return fn()            // 执行函数,触发 track 收集新依赖
    } finally {
      activeEffect = null
    }
  }

  _effect.deps = [] // 存储依赖
  _effect()         // 立即执行
  return _effect
}

// 清除旧的依赖(如果某属性不再被访问,需要取消对该属性的订阅)
function cleanup(effect) {
  effect.deps.forEach(dep => dep.delete(effect))
  effect.deps.length = 0
}

为什么 Vue 3 的 effect 系统性能更好?

Vue 2 的依赖收集是粗粒度的——每个组件只有一个 watcher,组件内任何响应式数据变化都会触发整个组件重新渲染。Vue 3 的 effect 系统是细粒度的——每个响应式属性都有自己的依赖集合,只有真正依赖该属性的 effect 才会被触发。配合编译器的静态标记,Vue 3 避免了不必要的组件重渲染。


四、组合式函数(Composables)

4.1 什么是组合式函数

组合式函数(Composable)是一个利用 Vue 组合式 API 来封装和复用有状态逻辑的函数。这是 Vue 3 中最具价值的设计模式之一,也是选项式 API 和组合式 API 之间最大的区别所在。

与普通的工具函数不同,组合式函数内部可以使用 Vue 的响应式 API(ref、reactive、computed、watch、生命周期钩子等),因此可以封装与组件生命周期相关的有状态逻辑。这意味着组合式函数能够持有内部状态、响应式地更新这些状态、并在组件卸载时自动清理副作用。

一个简单的类比: 如果把组件看作一座房子,普通工具函数就像是一把螺丝刀——它没有状态,用完就完了。而组合式函数就像是一个智能家居系统——它有温度传感器(状态)、有定时器(生命周期)、有联动规则(响应式),并且可以独立安装和拆卸。

组合式函数的命名约定使用 use 前缀,例如 useMouse、useCounter、useLocalStorage。这个约定源于 React Hooks 的命名习惯,并在 Vue 社区中广泛采用。

<script setup>
// 一个简单的组合式函数
import { ref, onMounted, onUnmounted } from 'vue'

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.clientX
    y.value = event.clientY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

// 在组件中使用
const { x, y } = useMouse()
</script>

<template>
  <p>鼠标位置: {{ x }}, {{ y }}</p>
</template>

4.2 与 Mixins 对比

Vue 2 通过 Mixins 复用逻辑,但 Mixins 存在多个严重问题,这些问题是 Vue 3 中引入组合式 API 的重要动因之一。Mixins 在小型项目中似乎很便利,但随着项目规模的扩大,它们带来的问题会逐渐显现:

命名冲突问题: 当多个 mixin 定义了同名的 data 属性或 methods 方法时,Vue 会按照 mixin 数组的顺序进行合并,后定义的会覆盖先定义的。这种覆盖是隐式的——开发者很难意识到自己的组件中哪些变量来自哪个 mixin,更不用说追踪变量被覆盖的情况了。这种"幽灵覆盖"在大型团队协作中尤其危险,因为不同开发者可能在不了解全局的情况下引入相同名称的变量。

来源不明确问题: 在 Vue 2 的模板中,{{ count }} 这个变量可能来自组件自身的 data,也可能来自 mixinA 的 data,还可能来自 mixinB 的 data。开发者需要手动翻阅多个 mixin 文件才能确定变量的来源。当项目中有十几个 mixin 交织在一起时,这种"变量溯源"几乎是不可能的任务。

逻辑交叉问题: Mixins 之间无法直接通信。如果 mixinA 需要依赖 mixinB 的数据,只能通过共享的组件实例来间接实现,这导致 mixin 之间产生了隐式耦合。更糟糕的是,这种耦合完全依赖于开发者的文档约定,框架层面没有任何机制来保证 mixin 之间的依赖关系正确。

类型推断问题: 由于 Mixins 的工作原理本质上是在运行时将选项合并到组件实例上,TypeScript 无法在静态分析阶段推断出 this 上挂载了哪些属性。这导致 Vue 2 + TypeScript 的组合使用体验极差,经常出现 this.xxx 报类型错误的情况。

对比维度 Mixins Composables
命名冲突 ❌ 属性合并时存在隐式覆盖 ✅ 解构时显式命名,无冲突
来源不明 ❌ 模板中的变量不知来自哪个 mixin ✅ 变量通过解构明确来源
逻辑交叉 ❌ mixin 之间无法互相通信 ✅ composable 可以调用其他 composable
类型推断 ❌ TypeScript 支持差 ✅ 天然类型安全
树摇优化 ❌ 无法 tree-shaking ✅ 支持 tree-shaking

Mixins 的问题示例:

// mixin-a.js
export default {
  data() { return { count: 0 } },
  methods: { increment() { this.count++ } },
}

// mixin-b.js
export default {
  data() { return { count: 10 } }, // ❌ 与 mixin-a 冲突
}

// 组件中
export default {
  mixins: [mixinA, mixinB],
  mounted() {
    console.log(this.count) // 10(mixin-b 覆盖了 mixin-a)
  },
}

使用 Composables 完全避免了这类问题:

<script setup>
// 每个 composable 的返回值通过解构显式命名
const { count: countA, increment } = useCounterA()
const { count: countB } = useCounterB()
</script>

4.3 常用组合式函数示例

useCounter:计数器

<script setup>
import { ref, computed } from 'vue'

function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initialValue }

  return { count, double, increment, decrement, reset }
}

// 使用
const { count, double, increment, decrement, reset } = useCounter(10)
</script>

<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍: {{ double }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">重置</button>
  </div>
</template>

useLocalStorage:持久化响应式数据

<script setup>
import { ref, watch } from 'vue'

function useLocalStorage(key, defaultValue) {
  // 尝试从 localStorage 读取初始值
  const stored = localStorage.getItem(key)
  const data = ref(stored ? JSON.parse(stored) : defaultValue)

  // 监听数据变化并写入 localStorage
  watch(data, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true })

  return data
}

// 使用
const theme = useLocalStorage('theme', 'light')
const user = useLocalStorage('user', { name: '', age: 0 })
</script>

<template>
  <select v-model="theme">
    <option value="light">浅色</option>
    <option value="dark">深色</option>
  </select>
</template>

useDebounce:防抖

<script setup>
import { ref, watch } from 'vue'

function useDebounce(source, delay = 300) {
  const debouncedValue = ref(source.value)

  watch(source, () => {
    const timer = setTimeout(() => {
      debouncedValue.value = source.value
    }, delay)
    // 自动清理
  })

  return debouncedValue
}

// 使用:搜索框防抖
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)

watch(debouncedQuery, (query) => {
  console.log('发送搜索请求:', query)
})
</script>

4.4 组合式函数的可组合性

组合式函数的真正威力在于它们可以互相调用,形成逻辑的组合。这比 Mixins 的线性合并强大得多。在 Mixins 的世界里,逻辑复用是扁平的——你只能把 mixin 合并到组件中,mixin 之间无法互相组合。而在 Composables 的世界里,逻辑复用是有层级的——一个组合式函数可以内部调用另一个组合式函数,形成"函数调用链",就像普通的函数调用一样自然。

这种可组合性的价值在于:你可以从最基础的功能单元开始构建,然后逐步组合出更复杂的功能模块。每个组合式函数只负责一个逻辑关注点,通过"组合"而非"继承"的方式构建出完整的功能。这种设计模式被称为"组合优于继承"(Composition over Inheritance),在函数式编程和响应式编程中都有广泛的应用。

一个实际的可组合性例子: 假设你要构建一个搜索功能,需要防抖、本地持久化和自动重新获取数据。你可以将这三个功能分别封装为 useDebounce、useLocalStorage 和 useFetch 三个独立的组合式函数,然后在 useSearch 中组合使用它们。每个子函数都可以独立测试、独立复用,而且替换实现时不会影响其他部分。

组合式函数的设计原则:

  1. 单一职责:每个组合式函数只封装一个逻辑关注点
  2. 可组合:组合式函数可以任意嵌套调用
  3. 返回只读数据:对外暴露的状态尽量用 readonly 包装
  4. 参数灵活:支持接收 ref 或原始值,内部用 unref 或 toValue 处理
  5. 清理副作用:使用生命周期钩子或 watch 的 onCleanup 清理副作用

五、生命周期钩子

5.1 Vue 3 生命周期对应表

Vue 3 的生命周期钩子命名统一改为 onXxx 格式,与组合式 API 风格一致。同时移除了 beforeCreate 和 created(因为 setup() 本身就在这两个阶段之前执行)。这个变化不仅仅是命名的简化,更重要的是它将生命周期函数从组件选项(如 mounted() {})变成了可导入的函数(如 onMounted(() => {})),这意味着生命周期钩子现在可以在组件外部、组合式函数的内部使用——这是 Mixins 无法做到的。

生命周期执行顺序: 理解生命周期钩子的执行顺序对于调试和性能优化至关重要。在组件挂载阶段,执行顺序为:setup → onBeforeMount → 挂载 DOM → onMounted。在组件更新阶段:onBeforeUpdate → 重新渲染 DOM → onUpdated。在组件卸载阶段:onBeforeUnmount → 卸载 DOM → onUnmounted。

Vue 2 选项式 Vue 3 组合式 执行时机
beforeCreate —(setup 替代) 组件实例初始化前
created —(setup 替代) 组件实例创建后
beforeMount onBeforeMount DOM 挂载前
mounted onMounted DOM 挂载完成
beforeUpdate onBeforeUpdate 响应式数据更新,DOM 重新渲染前
updated onUpdated DOM 重新渲染后
beforeDestroy onBeforeUnmount 组件卸载前
destroyed onUnmounted 组件卸载后
activated onActivated keep-alive 组件激活
deactivated onDeactivated keep-alive 组件失活
errorCaptured onErrorCaptured 捕获子组件错误
— onRenderTracked 响应式依赖被追踪时(仅开发模式)
— onRenderTriggered 响应式依赖触发重新渲染时(仅开发模式)

5.2 常用生命周期钩子示例

5.3 onActivated / onDeactivated(keep-alive)

当组件被 <KeepAlive> 包裹时,切换显示不会触发 onMounted 和 onUnmounted,而是触发 onActivated 和 onDeactivated。

<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'

const activeTab = ref('A')
</script>

<template>
  <div>
    <button @click="activeTab = 'A'">标签 A</button>
    <button @click="activeTab = 'B'">标签 B</button>

    <KeepAlive>
      <TabA v-if="activeTab === 'A'" />
      <TabB v-else />
    </KeepAlive>
  </div>
</template>
<!-- TabA.vue -->
<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('TabA 被激活')
  // 可以在这里刷新数据
})

onDeactivated(() => {
  console.log('TabA 失活')
  // 暂停不必要的操作
})
</script>

5.4 onErrorCaptured

捕获后代组件抛出的错误,防止错误冒泡到控制台导致应用崩溃。

<script setup>
import { ref, onErrorCaptured } from 'vue'

const error = ref(null)

onErrorCaptured((err, instance, info) => {
  console.error('捕获到错误:', err)

  // 保存错误信息供模板显示
  error.value = {
    message: err.message,
    component: instance?.type?.__name,
    info, // 错误类型信息
  }

  // 返回 false 阻止错误继续传播
  return false
})
</script>

<template>
  <div v-if="error" class="error-boundary">
    <h3>组件出错了</h3>
    <p>{{ error.message }}</p>
  </div>
  <div v-else>
    <slot />
  </div>
</template>

六、模板语法

6.1 插值与指令

文本插值

使用双花括号 {{ }} 将数据渲染为纯文本:

<template>
  <div>
    <p>{{ message }}</p>
    <p>{{ greeting + ' ' + name }}</p>
    <p>{{ count > 10 ? '超过10' : '小于等于10' }}</p>
    <p>{{ items.length > 0 ? items.join(', ') : '暂无数据' }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const message = ref('Hello Vue 3!')
const greeting = ref('你好')
const name = ref('小明')
const count = ref(15)
const items = ref(['苹果', '香蕉', '橙子'])
</script>

v-bind:绑定属性

v-on:绑定事件

v-model:双向绑定

6.2 v-if vs v-show

在 Vue 3 的模板渲染中,v-if 和 v-show 是两种控制元素可见性的指令,但它们的工作机制和适用场景截然不同。理解两者的区别对于性能优化至关重要。

v-if 是"真条件渲染": 当条件为 false 时,对应的元素或组件根本不会被渲染到 DOM 中。这意味着不存在对应的虚拟 DOM 节点,也不存在真实的 DOM 元素。如果是组件,它的生命周期钩子(onMounted、onUnmounted)会在条件切换时完整执行。因此,v-if 的切换开销较大——每次切换都涉及 DOM 节点的创建和销毁。

v-show 是"样式切换": 无论条件如何,元素始终会被渲染到 DOM 中,只是通过 CSS 的 display 属性来控制可见性。当条件为 false 时,Vue 会给元素添加 display: none 样式。因此,v-show 的初始渲染开销比 v-if 大(所有元素都会渲染),但切换开销极小(只修改一个 CSS 属性)。

选择原则: 如果元素的显隐切换频率很高(每秒多次,如标签页切换、下拉菜单展开收起),优先使用 v-show。如果切换频率很低(如用户权限控制、首次加载时的状态分支),优先使用 v-if。一个常见的误解是认为 v-show 总是比 v-if 好——实际上,如果条件在绝大部分时间都是 false(例如权限不足的页面元素),v-if 避免了无谓的渲染,反而更优。

特性 v-if v-show
渲染方式 条件为 false 时不渲染DOM 始终渲染 DOM,仅切换 display
初始渲染成本 低(条件假时不渲染) 高(始终渲染)
切换成本 高(销毁和重建组件/元素) 低(仅修改 CSS)
适用场景 运行时切换频率低 运行时切换频率高
与 v-for 配合 优先级高于 v-for 不推荐同时使用
组件生命周期 每次切换都会触发 mount/unmount 仅触发一次 mount
<template>
  <!-- v-if:适合低频切换 -->
  <div v-if="isLoggedIn">
    欢迎回来,{{ username }}
  </div>
  <div v-else>
    请先登录
  </div>

  <!-- v-else-if:多条件分支 -->
  <div v-if="status === 'loading'">加载中...</div>
  <div v-else-if="status === 'error'">加载失败</div>
  <div v-else>内容已加载</div>

  <!-- v-show:适合高频切换 -->
  <div v-show="isVisible" class="modal">
    弹窗内容
  </div>
</template>

<script setup>
import { ref } from 'vue'

const isLoggedIn = ref(false)
const username = ref('')
const status = ref('loading')
const isVisible = ref(false)
</script>

6.3 v-for 的 key 重要性

Vue 使用就地复用策略:当列表数据变化时,会尽可能地复用已有 DOM 节点,而不是销毁整个列表再重新创建。key 属性为 Vue 提供了节点的"身份标识",帮助 Vue 精确判断哪些元素被新增、删除或移动。

虚拟 DOM 的 diff 算法是如何工作的?

当列表数据变化时,Vue 的虚拟 DOM diff 算法会逐层对比新旧虚拟节点。在没有 key 的情况下,Vue 采用"就地复用"策略——它会尽可能复用同一层级的节点,只更新内容有变化的部分。这种策略在列表末尾添加或删除元素时没有问题,但在列表中间插入、删除或元素顺序发生变化时,就会产生严重的渲染错误。

正确的 key 使用方式:

在编写 v-for 列表时,始终提供一个唯一的、稳定的 key 值。最佳选择是使用数据项的唯一标识符(如数据库 id、UUID 等),而不是数组索引。

<template>
  <!-- ✅ 推荐:使用唯一标识 -->
  <li v-for="item in items" :key="item.id">{{ item.text }}</li>

  <!-- ❌ 不推荐:使用 index -->
  <li v-for="(item, index) in items" :key="index">{{ item.text }}</li>

  <!-- ❌ 不推荐:无 key -->
  <li v-for="item in items">{{ item.text }}</li>
</template>

为什么避免用 index 作为 key?

很多开发者在没有唯一标识时习惯用 v-for 的索引 index 作为 key,这在大部分静态展示场景下可以工作,但在涉及表单输入、组件状态或动画过渡时会产生难以排查的 bug。

<template>
  <div>
    <p v-for="(item, index) in items" :key="index">
      <input v-model="item.value" />
    </p>
    <button @click="addItem">在最前面插入</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([
  { id: 1, value: 'A' },
  { id: 2, value: 'B' },
])

function addItem() {
  // 在最前面插入
  items.value.unshift({ id: 3, value: 'C' })
  // 问题:原来 key=0 的节点对应 id=1 的数据
  // 插入后 key=0 的节点对应 id=3 的数据(新数据)
  // 但 Vue 复用了原有节点,input 中显示的内容是 'C' 吗?
  // 实际上 input 内容不会被更新——因为 v-model 绑定的是 .value,而 key 复用时 DOM 节点复用导致 input 状态混乱
}
</script>

使用 id 作为 key 后,Vue 能精确识别每个数据项对应的 DOM 节点,插入操作只需要新增一个节点,而不需要重新匹配已有节点。

6.4 模板 ref(模板引用)

通过 ref 属性获取 DOM 元素或子组件实例的引用。

在 v-for 中使用模板 ref(Vue 3.2+):

<script setup>
import { ref, onMounted } from 'vue'

const items = ref(['A', 'B', 'C'])
// 使用数组收集 v-for 中的 ref
const itemRefs = ref([])

onMounted(() => {
  console.log('所有列表项:', itemRefs.value)
  itemRefs.value.forEach(el => {
    console.log('高度:', el.offsetHeight)
  })
})
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item"
      :ref="(el) => { if (el) itemRefs.push(el) }"
    >
      {{ item }}
    </li>
  </ul>
</template>

子组件暴露内部方法(defineExpose):

<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'

const internalCount = ref(0)

function sayHello() {
  return 'Hello from child'
}

function increment() {
  internalCount.value++
}

// 只有显式暴露的才能在父组件通过模板 ref 访问
defineExpose({
  sayHello,
  increment,
})
</script>

<template>
  <div>子组件: {{ internalCount }}</div>
</template>

总结

Vue 3 的组合式 API 和响应式系统代表了 Vue 框架的一次重大进化。本文从六个维度系统性地探讨了其核心内容,每个维度都对应 Vue 3 开发中不可或缺的知识模块。

核心收获回顾

  1. Vue 3 概述:我们了解了从 Vue 2 到 Vue 3 的核心变化,包括 Proxy 响应式系统、虚拟 DOM 重写、Tree-shaking 带来的性能提升,以及 Vite 带来的极致开发体验。Vue 3 不是 Vue 2 的简单升级,而是从底层架构开始的全面重构,带来了约 1.3~2 倍的整体性能提升和更小的包体积。

  2. 组合式 API:深入掌握了 ref、reactive、computed、watch、watchEffect、toRef/toRefs 等核心 API,理解了它们的设计意图和最佳实践。我们看到了 <script setup> 如何大幅简化组件的编写体验,也明白了 ref 和 reactive 各自的使用场景。

  3. 响应式原理:从 Object.defineProperty 到 Proxy 的演进,深入分析了依赖收集(track)和触发更新(trigger)的完整机制,理解了 Vue 3 响应式系统高效运行的底层原理。这个理解不仅有助于日常开发中的调试,还能够在遇到性能瓶颈时做出有针对性的优化。

  4. 组合式函数:学习了如何利用组合式 API 封装可复用的有状态逻辑。我们理解了 Composables 相比 Mixins 的质变优势——没有命名冲突、来源明确、支持互相组合、天然类型安全。通过 useMouse、useCounter、useLocalStorage 等实际例子,我们看到了组合式函数在真实项目中的巨大价值。

  5. 生命周期钩子:掌握了 Vue 3 组合式 API 中全部生命周期钩子的使用方法和适用场景。生命周期的变化不仅仅是命名的调整,更重要的是它从组件选项转变为可导入的函数,这为组合式函数的实现提供了基础保障。

  6. 模板语法:回顾了 Vue 3 模板中的各种指令和语法特性。从插值、v-bind、v-on、v-model 到 v-if/v-show、v-for、模板 ref,每个知识点都有对应的最佳实践建议。

学习建议

对于刚刚从 Vue 2 迁移到 Vue 3 的开发者,建议按照以下路径循序渐进地学习和使用组合式 API:

  1. 先掌握 ref 和 reactive:这是组合式 API 的基础,理解它们的工作原理和使用场景是学习其他 API 的前提
  2. 然后学习 computed 和 watch:派生状态和副作用管理是组件逻辑的核心
  3. 再学习生命周期钩子:掌握如何在组合式 API 中管理组件的生命周期
  4. 最后学习组合式函数:当你对基础 API 感到熟悉后,开始尝试将可复用的逻辑抽取为组合式函数

推荐与不推荐

Vue 3 的组合式 API 不是对选项式 API 的完全替代,而是为特定的复杂场景提供了更强大、更灵活的工具。对于简单的组件(如展示型组件、表单组件等),选项式 API 仍然是完全有效的选择。建议根据以下准则来选择:

  • 简单组件(少于 10 行逻辑):选项式 API 更直观
  • 中等复杂度组件:两种方式都可以,根据团队偏好选择
  • 复杂组件(多种交互、多种数据源):强烈推荐组合式 API
  • 可复用的有状态逻辑:使用组合式函数
  • 大型项目:统一使用组合式 API,保持代码风格一致

最终,框架只是工具,选择最适合你和你的团队的方案才是最重要的。Vue 3 的设计哲学就是如此——它为你提供了选择的自由,而不是强加一种唯一正确的方式。