跳到主要内容
预计阅读 63 分钟

组合式API深入

当一个音乐播放器的代码膨胀到数千行,播放控制逻辑散落在 datamethodscomputedwatch 四处角落时,维护它就像在一座没有索引的唱片仓库里找一张特定的黑胶——你知道它在某个架子上,但每次都要把整间仓库翻个底朝天。组合式 API 的出现,正是为了给这座仓库建立清晰的分类索引系统。


📋 开篇自测

在正式学习之前,请先检验自己的当前认知水平。如果以下三个问题你都能准确回答,可以快速浏览本章;如果有任何迟疑,请认真逐节研读。

  1. Options API 在大型组件中最核心的维护性痛点是什么? 它导致了怎样的代码组织问题?
  2. refreactive 的底层代理机制有何不同? 为什么 reactive 解构后会丢失响应式?
  3. 自定义 Composable 和 Vue 2 时代的 Mixin 相比,在命名冲突和类型推导方面有哪些本质区别?

一、从 Options API 到 Composition API——维护性困境的破局

1.1 选项式 API 的”抽屉式”困局

想象你正在开发一个音乐播放器组件。它需要处理播放列表管理、播放状态控制、音量调节、歌词同步四大功能。用 Options API 来编写,代码结构类似于:

export default {
  data() {
    return {
      // 播放列表相关
      trackList: [],
      currentTrackIndex: 0,
      // 播放状态相关
      isPlaying: false,
      playbackProgress: 0,
      // 音量相关
      volumeLevel: 80,
      isMuted: false,
      // 歌词相关
      currentLyric: '',
      lyricLines: []
    }
  },
  computed: {
    currentTrack() { /* 播放列表逻辑 */ },
    formattedVolume() { /* 音量逻辑 */ },
    activeLyricIndex() { /* 歌词逻辑 */ }
  },
  watch: {
    currentTrackIndex() { /* 播放列表逻辑 */ },
    playbackProgress() { /* 歌词逻辑 */ },
    volumeLevel() { /* 音量逻辑 */ }
  },
  methods: {
    playTrack() { /* 播放状态逻辑 */ },
    pauseTrack() { /* 播放状态逻辑 */ },
    addToPlaylist() { /* 播放列表逻辑 */ },
    adjustVolume() { /* 音量逻辑 */ },
    syncLyric() { /* 歌词逻辑 */ }
  },
  mounted() { /* 混合了多种初始化逻辑 */ }
}

注意观察:同一个功能关注点(比如”歌词同步”)的代码被强制拆分到了 datacomputedwatchmethods 四个不同的选项块中。这就好比你把一张专辑的 A 面放在一楼、B 面放在三楼、封面放在地下室、歌词本放在阁楼——每次想完整地审视这张专辑,你都得跑遍整栋楼。

当组件体量增长到三五百行以上时,这种”关注点碎片化”问题会导致:

  • 阅读困难:定位一个功能需要在多个选项块之间反复跳转。
  • 修改风险:改动歌词逻辑时,你可能忘记更新散落在 watch 中的相关代码。
  • 复用困难:想把”音量控制”逻辑抽取到另一个组件,需要从四个选项块中手动剥离。

1.2 组合式 API 的”按功能归档”思维

Composition API 允许我们按功能关注点而非选项类型来组织代码。同样的音乐播放器,可以重构为:

<script setup>
import { usePlaylist } from './composables/usePlaylist'
import { usePlayback } from './composables/usePlayback'
import { useVolume } from './composables/useVolume'
import { useLyricSync } from './composables/useLyricSync'

// 每个功能内聚为一个独立的逻辑单元
const { trackList, currentTrack, addToPlaylist } = usePlaylist()
const { isPlaying, playbackProgress, playTrack, pauseTrack } = usePlayback()
const { volumeLevel, isMuted, adjustVolume } = useVolume()
const { currentLyric, activeLyricIndex } = useLyricSync(playbackProgress)
</script>

现在,每个功能模块的数据、计算属性、侦听器、方法都集中在一个 Composable 函数里。这就像给唱片仓库装上了按艺术家、按流派、按年代分类的索引柜——任何人走进来都能迅速定位目标。

1.3 从性能角度看:Tree-shaking 的优势

Options API 的 this 上下文挂载了所有选项,打包工具很难判断哪些方法从未被调用。而 Composition API 基于 ES Module 的 import 机制,天然支持 Tree-shaking。需要注意,Tree-shaking 在应用级别生效:只要整个应用中有任何一个组件 importwatchwatch 的代码就会被保留在最终产物中。但如果全应用没有任何地方导入某个 API(比如 <Transition>v-model 的运行时辅助函数),它就会被完全移除。对于追求极致包体积的应用,这是实实在在的性能收益。

关键结论:Composition API 不是对 Options API 的否定,而是对大型组件维护性和代码复用能力的系统性升级。Vue 3 依然完整支持 Options API,小型组件用选项式写法完全没有问题。


二、setup() 与 <script setup>——两种入口的取舍

2.1 setup() 函数:显式的入口

setup() 是组件中 Composition API 的执行入口,它在 beforeCreate 钩子之前执行(即组件实例初始化的最早阶段)。此时组件实例尚未完全初始化,因此 setup 内部无法访问 this

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

export default {
  props: {
    initialTrack: Object
  },
  setup(props, { emit, attrs, slots, expose }) {
    const isPlaying = ref(false)
    const statusText = computed(() =>
      isPlaying.value ? '正在播放' : '已暂停'
    )

    function togglePlay() {
      isPlaying.value = !isPlaying.value
      emit('playback-change', isPlaying.value)
    }

    // 必须显式返回,模板才能使用
    return {
      isPlaying,
      statusText,
      togglePlay
    }
  }
}
</script>

setup 函数接收两个参数:

  • props:响应式的只读对象,包含父组件传入的属性。
  • context:包含 emitattrsslotsexpose 的普通对象。

需要注意的是,不要对 props 进行解构赋值,否则解构出的变量会失去响应式:

// 错误:解构后 initialTrack 不再是响应式的
setup({ initialTrack }) {
  // initialTrack 是一个普通值,后续父组件更新它时这里不会感知
}

// 正确:通过 props.xxx 访问,或使用 toRefs
setup(props) {
  const { initialTrack } = toRefs(props)
  // initialTrack 是一个 ref,保持响应式
}

2.2 <script setup>:编译时的语法糖

Vue 3.2 引入的 <script setup>setup() 函数的编译时语法糖。它消除了显式 return 的样板代码,让开发体验更加流畅:

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

const isPlaying = ref(false)
const statusText = computed(() =>
  isPlaying.value ? '正在播放' : '已暂停'
)

function togglePlay() {
  isPlaying.value = !isPlaying.value
}
// 无需 return,所有顶层绑定自动暴露给模板
</script>

<template>
  <div>{{ statusText }}</div>
  <button @click="togglePlay">切换播放</button>
</template>

编译器做了什么?

当 Vue 编译器遇到 <script setup> 时,它会在背后完成以下转换:

  1. 自动 return:所有顶层变量、函数、import 的组件都被自动暴露给 <template>
  2. 组件自动注册import 进来的组件无需在 components 选项中声明。
  3. 宏函数展开definePropsdefineEmitsdefineExpose 等编译器宏在编译阶段被处理,无需从 vue 中导入。
<script setup>
import PlayerControls from './PlayerControls.vue'
// PlayerControls 自动注册,模板可以直接使用 <PlayerControls />

// defineProps 是编译器宏,不需要 import
const props = defineProps({
  trackList: Array,
  initialVolume: {
    type: Number,
    default: 80
  }
})

const emit = defineEmits(['track-change', 'volume-change'])

function selectTrack(index) {
  emit('track-change', index)
}

// 仅暴露特定内容给父组件的 ref 引用
defineExpose({ selectTrack })
</script>

2.3 Vue 3.5+ 新特性:Reactive Props Destructure

从 Vue 3.5 开始,在 <script setup> 中解构 defineProps 的返回值会保持响应式(此前解构后会丢失响应式)。这意味着你可以更简洁地使用 props:

<script setup>
// Vue 3.5+ 中,解构出的变量仍然是响应式的
const { trackList, initialVolume = 80 } = defineProps<{
  trackList: Track[]
  initialVolume?: number
}>()

// initialVolume 在模板和 watch 中都保持响应式
// 解构时的默认值等价于 withDefaults 的效果
</script>

这一特性(Reactive Props Destructure)在 Vue 3.5 之前是实验性的,3.5 起正式稳定。它让 props 的使用更加自然,特别是配合 TypeScript 类型声明时更加简洁。

需要注意的是,解构出的 prop 变量在传递给 watch 或 composable 函数时,需要用 getter 包裹才能保持响应式追踪:

const { initialVolume } = defineProps<{ initialVolume: number }>()

// ❌ 直接传递会丢失响应式(传递的是当前值的快照)
watch(initialVolume, (val) => { /* ... */ })

// ✅ 用 getter 包裹,保持响应式追踪
watch(() => initialVolume, (val) => { /* ... */ })

2.4 何时使用 setup() 函数形式?

在绝大多数场景下推荐 <script setup>,但以下情况需要回退到 setup() 函数:

场景原因
需要 inheritAttrs: false 等组件选项<script setup> 不支持直接声明组件选项
需要命名导出<script setup> 只能有默认导出
需要同时使用 Options API可将 <script setup> 与普通 <script> 并存

当需要同时设置组件选项时,可以采用”双 script”写法:

<script>
export default {
  inheritAttrs: false,
  customOptions: {}
}
</script>

<script setup>
// 组合式 API 逻辑写在这里
</script>

三、响应式深入——ref 与 reactive 的底层差异

3.1 ref:值的响应式容器

ref 创建一个包含 .value 属性的响应式引用对象。它可以持有任何类型的值——基本类型、对象、数组,甚至 DOM 元素引用。

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

const volumeLevel = ref(80)        // 基本类型
const currentTrack = ref(null)      // 可以后续赋值为对象
const playHistory = ref([])         // 数组

// 修改时通过 .value
function increaseVolume() {
  if (volumeLevel.value < 100) {
    volumeLevel.value += 5
  }
}

// 在模板中,Vue 会自动解包 ref,无需 .value
</script>

<template>
  <span>音量:{{ volumeLevel }}</span>
  <button @click="increaseVolume">增大音量</button>
</template>

底层机制:当 ref 接收基本类型时,响应式通过一个类(RefImpl)的 getter/setter 实现依赖追踪;当 ref 接收对象类型时,其内部的 .value 会被自动调用 reactive() 进行深层代理。

ref(80)       → RefImpl { _value: 80 }                  // getter/setter 追踪
ref({a: 1})   → RefImpl { _value: reactive({a: 1}) }    // 内部自动 reactive

3.2 reactive:对象的深层代理

reactive 基于 Proxy 对整个对象进行深层响应式代理,直接返回原始对象的代理版本:

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

const playbackState = reactive({
  isPlaying: false,
  progress: 0,
  duration: 0,
  mode: 'sequential'  // sequential | shuffle | loop
})

// 直接修改属性,无需 .value
function startPlayback(duration) {
  playbackState.isPlaying = true
  playbackState.duration = duration
  playbackState.progress = 0
}
</script>

关键限制——解构会断开响应式连接:

const playbackState = reactive({
  isPlaying: false,
  progress: 0
})

// 错误!解构出的是纯值,失去响应式
const { isPlaying, progress } = playbackState
// 此时 isPlaying 是 false(一个普通布尔值),后续变更 playbackState.isPlaying 不会影响它

这就像从收音机上拆下旋钮——旋钮脱离了电路,转动它不再能调节音量。

3.3 toRef 与 toRefs:安全解构的桥梁

toReftoRefs 就是给解构操作加装的”延长线”,让拆出的每个属性仍然与原始对象保持电路连接:

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

const playbackState = reactive({
  isPlaying: false,
  progress: 0,
  duration: 240
})

// toRef:为单个属性创建 ref 引用
const isPlaying = toRef(playbackState, 'isPlaying')
// isPlaying.value 和 playbackState.isPlaying 双向同步

// toRefs:一次性为所有属性创建 ref 引用
const { progress, duration } = toRefs(playbackState)
// progress.value 和 playbackState.progress 双向同步
// duration.value 和 playbackState.duration 双向同步

function updateProgress(newVal) {
  progress.value = newVal
  // 等价于 playbackState.progress = newVal
}
</script>

实战建议:当你需要将 reactive 对象的属性传入 Composable 函数或作为 props 解构时,务必使用 toRefs,这是保持响应式链路完整的关键手段。

3.4 shallowRef 与 shallowReactive:性能优化利器

对于包含大量数据的列表或嵌套较深的对象,深层响应式代理会带来不可忽视的性能开销。shallowRefshallowReactive 只对第一层属性进行响应式追踪:

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

// 播放列表可能包含上千首歌,每首歌有嵌套的元数据
const trackList = shallowRef([
  { id: 1, title: '夜曲', artist: '周杰伦', metadata: { album: '...', year: 2005 } },
  { id: 2, title: '晴天', artist: '周杰伦', metadata: { album: '...', year: 2003 } },
  // ... 可能有上千条
])

// 修改深层属性不会触发更新
trackList.value[0].title = '新名称'  // 视图不会更新

// 需要整体替换 .value 才能触发
function refreshTrackList(newList) {
  trackList.value = newList  // 这会触发更新
}

// 或者手动触发
function updateTrackTitle(index, newTitle) {
  trackList.value[index].title = newTitle
  triggerRef(trackList)  // 强制触发依赖更新
}
</script>

shallowReactive 同理,只追踪对象自身属性的变化:

import { shallowReactive } from 'vue'

const playerConfig = shallowReactive({
  theme: 'dark',
  equalizer: { bass: 5, treble: 3, mid: 4 }  // 不会被深层代理
})

playerConfig.theme = 'light'         // 触发更新
playerConfig.equalizer.bass = 10     // 不会触发更新
playerConfig.equalizer = { bass: 10, treble: 3, mid: 4 }  // 触发更新

3.5 ref vs reactive 选型指南

维度refreactive
适用类型任意类型仅对象/数组/Map/Set
访问方式.value(模板自动解包)直接访问属性
赋值/替换可直接替换整个值不能替换整个对象(会断开代理)
解构安全本身是独立 ref,安全解构丢失响应式,需 toRefs
类型推导优秀(Ref<T>良好
推荐场景基本类型、需要替换整体的场景表单数据、状态对象等固定结构

团队实践建议:统一使用 ref 是目前社区的主流选择。它的心智模型更简单——始终通过 .value 访问,不需要担心”这个变量是 ref 还是 reactive”的问题。当你确实需要管理一个结构稳定的状态对象时,reactive 也完全合适。


四、生命周期钩子——在 setup 中掌控组件生命

4.1 生命周期映射表

Composition API 为每个生命周期阶段提供了对应的钩子函数,它们都以 on 为前缀,且只能在 setup 执行期间调用:

Options APIComposition API执行时机
beforeCreate不需要(setup 在其之前执行)组件实例初始化前
created不需要(setup 在其之前执行)组件实例初始化后
beforeMountonBeforeMountDOM 挂载前
mountedonMountedDOM 挂载完成
beforeUpdateonBeforeUpdateDOM 更新前
updatedonUpdatedDOM 更新完成
beforeUnmountonBeforeUnmount组件卸载前
unmountedonUnmounted组件卸载完成
activatedonActivatedkeep-alive 组件激活
deactivatedonDeactivatedkeep-alive 组件停用
errorCapturedonErrorCaptured捕获后代组件错误

核心要点:setupbeforeCreate 之前执行,其作用覆盖了 beforeCreatecreated 两个阶段的职责,因此 Composition API 没有提供对应的钩子。

4.2 音乐播放器中的生命周期实战

<script setup>
import { ref, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'

const audioElement = ref(null)
const playbackProgress = ref(0)
let progressTimer = null

// setup 阶段(等价于 created):初始化数据、非 DOM 操作
console.log('setup 阶段:初始化播放器状态')

onMounted(() => {
  // DOM 已就绪,可以安全操作 DOM 和启动副作用
  console.log('onMounted:DOM 就绪,绑定 audio 事件')

  if (audioElement.value) {
    audioElement.value.addEventListener('timeupdate', handleTimeUpdate)
  }

  // 启动进度轮询(某些场景下 timeupdate 不够精确)
  progressTimer = setInterval(() => {
    if (audioElement.value && !audioElement.value.paused) {
      playbackProgress.value = audioElement.value.currentTime
    }
  }, 200)
})

onBeforeUpdate(() => {
  console.log('onBeforeUpdate:DOM 即将因响应式数据变化而更新')
})

onUpdated(() => {
  console.log('onUpdated:DOM 已完成更新')
})

onBeforeUnmount(() => {
  // 组件即将卸载,清理副作用
  console.log('onBeforeUnmount:清理事件监听')
  if (audioElement.value) {
    audioElement.value.removeEventListener('timeupdate', handleTimeUpdate)
  }
})

onUnmounted(() => {
  // 组件已卸载,清理定时器
  console.log('onUnmounted:清理定时器')
  if (progressTimer) {
    clearInterval(progressTimer)
    progressTimer = null
  }
})

function handleTimeUpdate(event) {
  playbackProgress.value = event.target.currentTime
}
</script>

<template>
  <audio ref="audioElement" />
  <div>播放进度:{{ playbackProgress.toFixed(1) }}s</div>
</template>

4.3 生命周期钩子的注册规则

规则一:必须在 setup 同步执行期间调用。

// 正确
onMounted(() => { /* ... */ })

// 错误!异步回调中注册,此时 setup 已执行完毕
setTimeout(() => {
  onMounted(() => { /* 不会生效 */ })
}, 0)

规则二:同一钩子可以注册多次。

onMounted(() => {
  console.log('初始化音频上下文')
})

onMounted(() => {
  console.log('初始化可视化动画')
})
// 两个回调都会按注册顺序执行

规则三:在 Composable 内部注册钩子是合法且推荐的。

// composables/useAudioVisualizer.js
import { onMounted, onUnmounted } from 'vue'

export function useAudioVisualizer(canvasRef) {
  let animationId = null

  onMounted(() => {
    // 在 Composable 内部注册生命周期——完全合法
    startAnimation(canvasRef.value)
  })

  onUnmounted(() => {
    if (animationId) cancelAnimationFrame(animationId)
  })

  function startAnimation(canvas) {
    // 绘制音频可视化波形...
    animationId = requestAnimationFrame(() => startAnimation(canvas))
  }
}

这正是 Composition API 的核心优势:生命周期钩子不再绑定于组件选项,而是可以被封装进独立的逻辑单元中,随 Composable 一起复用。


五、自定义 Composables——逻辑复用的最佳实践

5.1 什么是 Composable?

Composable 是一个利用 Vue Composition API 来封装和复用有状态逻辑的函数。按照社区约定,Composable 函数以 use 为前缀命名,如 usePlaylistuseVolumeuseLyricSync

与普通工具函数的区别在于:Composable 内部使用了响应式 API(refreactivecomputedwatch)和/或生命周期钩子,它管理着自己的响应式状态。

5.2 实战:构建音乐播放器的 Composable 体系

useVolume——音量控制

// composables/useVolume.js
import { ref, computed, watch } from 'vue'

export function useVolume(initialLevel = 80) {
  const volumeLevel = ref(initialLevel)
  const isMuted = ref(false)
  const previousLevel = ref(initialLevel)

  const effectiveVolume = computed(() =>
    isMuted.value ? 0 : volumeLevel.value
  )

  const volumeIcon = computed(() => {
    if (isMuted.value || volumeLevel.value === 0) return 'muted'
    if (volumeLevel.value < 30) return 'low'
    if (volumeLevel.value < 70) return 'medium'
    return 'high'
  })

  function adjustVolume(delta) {
    const newLevel = Math.min(100, Math.max(0, volumeLevel.value + delta))
    volumeLevel.value = newLevel
    if (newLevel > 0 && isMuted.value) {
      isMuted.value = false
    }
  }

  function toggleMute() {
    if (isMuted.value) {
      isMuted.value = false
      volumeLevel.value = previousLevel.value || 50
    } else {
      previousLevel.value = volumeLevel.value
      isMuted.value = true
    }
  }

  // 持久化到 localStorage
  watch(volumeLevel, (newVal) => {
    localStorage.setItem('player_volume', String(newVal))
  })

  return {
    volumeLevel,
    isMuted,
    effectiveVolume,
    volumeIcon,
    adjustVolume,
    toggleMute
  }
}

useLyricSync——歌词同步

// composables/useLyricSync.js
import { ref, computed, watch } from 'vue'

export function useLyricSync(playbackProgress, lyricData) {
  const lyricLines = ref([])
  const activeLyricIndex = ref(-1)

  const currentLyric = computed(() => {
    if (activeLyricIndex.value < 0 || !lyricLines.value.length) return ''
    return lyricLines.value[activeLyricIndex.value]?.text || ''
  })

  // 解析 LRC 格式歌词
  function parseLrc(lrcString) {
    const lines = lrcString.split('\n')
    const parsed = []
    const timeRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/

    for (const line of lines) {
      const match = line.match(timeRegex)
      if (match) {
        const minutes = parseInt(match[1])
        const seconds = parseInt(match[2])
        const millis = parseInt(match[3])
        const time = minutes * 60 + seconds + millis / (match[3].length === 3 ? 1000 : 100)
        const text = line.replace(timeRegex, '').trim()
        if (text) parsed.push({ time, text })
      }
    }
    lyricLines.value = parsed.sort((a, b) => a.time - b.time)
  }

  // 根据播放进度定位当前歌词行
  watch(playbackProgress, (currentTime) => {
    const lines = lyricLines.value
    if (!lines.length) {
      activeLyricIndex.value = -1
      return
    }
    let index = -1
    for (let i = lines.length - 1; i >= 0; i--) {
      if (currentTime >= lines[i].time) {
        index = i
        break
      }
    }
    activeLyricIndex.value = index
  })

  // 监听歌词数据变化,重新解析
  watch(lyricData, (newLrc) => {
    if (newLrc) parseLrc(newLrc)
  }, { immediate: true })

  return {
    lyricLines,
    currentLyric,
    activeLyricIndex,
    parseLrc
  }
}

在组件中组装

<script setup>
import { ref } from 'vue'
import { useVolume } from './composables/useVolume'
import { useLyricSync } from './composables/useLyricSync'

const playbackProgress = ref(0)
const lyricData = ref('[00:00.00]音乐开始\n[00:05.20]第一句歌词\n[00:10.80]第二句歌词')

const { volumeLevel, isMuted, effectiveVolume, volumeIcon, adjustVolume, toggleMute } = useVolume()
const { currentLyric, activeLyricIndex } = useLyricSync(playbackProgress, lyricData)
</script>

<template>
  <div class="player">
    <div class="lyric-display">{{ currentLyric }}</div>
    <div class="volume-control">
      <button @click="toggleMute">{{ volumeIcon }}</button>
      <input
        type="range"
        :value="volumeLevel"
        min="0"
        max="100"
        @input="volumeLevel = Number($event.target.value)"
      />
      <span>{{ effectiveVolume }}%</span>
    </div>
  </div>
</template>

5.3 Composable 设计原则

原则一:接收 ref,返回 ref。 Composable 的参数应当接受 ref(或 getter 函数),返回值也应当是 ref。这确保整条响应式链路不断裂。

// 推荐:参数是 ref
export function useLyricSync(playbackProgress, lyricData) { ... }

// 不推荐:参数是原始值,调用方传入的响应式会被解包为普通值
export function useLyricSync(currentTime, lrcString) { ... }

原则二:Composable 内部负责自己的清理。 如果注册了事件监听或定时器,务必在 onUnmounted 中清理。

原则三:返回值使用对象解构而非数组。 与 React Hooks 不同,Vue Composable 通常返回多个有名字的值,使用对象解构可读性更高。

5.4 Composable vs Mixin:全面对比

维度Mixin (Vue 2)Composable (Vue 3)
数据来源不透明,难以追溯显式 import,一目了然
命名冲突隐式合并,同名覆盖解构时自主命名,不存在冲突
类型推导几乎不可能完美支持 TypeScript
复用粒度整个选项块精确到单个函数
嵌套组合mixin 套 mixin,依赖关系混沌函数调用函数,依赖关系清晰
逻辑耦合data/methods/computed 混入同一命名空间每个 Composable 拥有独立闭包

一个典型的 Mixin 困境:

// Vue 2 Mixin
const volumeMixin = {
  data() {
    return { level: 80 }  // level 这个名称太通用了!
  },
  methods: {
    adjust() { /* ... */ }  // adjust 也很容易冲突
  }
}

const brightnessMixin = {
  data() {
    return { level: 50 }  // 命名冲突!谁的 level?
  },
  methods: {
    adjust() { /* ... */ }  // 命名冲突!谁的 adjust?
  }
}

// 在组件中混入两个 mixin,后者覆盖前者,悄无声息地制造了 Bug

Composable 写法下,这个问题根本不会出现:

const { volumeLevel, adjustVolume } = useVolume()
const { brightnessLevel, adjustBrightness } = useBrightness()
// 命名权在调用方手中,永不冲突

六、依赖注入进阶——provide/inject 的响应式传递

6.1 解决 Props 逐级透传的痛点

在多层嵌套的组件树中,顶层的播放器容器需要将当前播放状态传递给深层的歌词展示组件。如果通过 props 逐级传递,中间每一层都要充当”快递中转站”,既增加了代码量,也提高了维护成本。

provide/inject 机制允许祖先组件直接向任意深度的后代组件提供数据,无需经过中间组件。

6.2 基本用法

<!-- PlayerContainer.vue(祖先组件) -->
<script setup>
import { ref, provide, readonly } from 'vue'
import PlayerLayout from './PlayerLayout.vue'

const currentTrack = ref({ id: 1, title: '夜曲', artist: '周杰伦' })
const isPlaying = ref(false)

function switchTrack(track) {
  currentTrack.value = track
  isPlaying.value = true
}

// 提供响应式数据给后代组件
provide('currentTrack', readonly(currentTrack))  // readonly 防止后代意外修改
provide('isPlaying', readonly(isPlaying))
provide('switchTrack', switchTrack)  // 提供修改方法,由祖先控制数据流
</script>

<template>
  <PlayerLayout />
</template>
<!-- LyricPanel.vue(深层后代组件) -->
<script setup>
import { inject } from 'vue'

const currentTrack = inject('currentTrack')
const isPlaying = inject('isPlaying')
const switchTrack = inject('switchTrack')
// currentTrack 和 isPlaying 是响应式的,祖先组件修改时这里自动更新
</script>

<template>
  <div v-if="isPlaying" class="lyric-panel">
    <h3>{{ currentTrack.title }} - {{ currentTrack.artist }}</h3>
    <!-- 歌词内容 -->
  </div>
</template>

6.3 provide/inject 的响应式原理

provide 的底层实现利用了 JavaScript 的原型链机制。每个组件实例内部都有一个 provides 对象:

  • 根组件的 provides 是一个空对象。
  • 子组件创建时,provides 默认指向父组件的 provides
  • 当组件调用 provide() 时,会以父组件的 provides 为原型创建一个新对象,并在新对象上设置键值对。
RootComponent.provides = {}
  ├── PlayerContainer.provides = Object.create(Root.provides)
  │     → { currentTrack: ref(...), isPlaying: ref(...) }
  │   ├── PlayerLayout.provides → 指向 PlayerContainer.provides
  │   │   ├── LyricPanel 调用 inject('currentTrack')
  │   │   │   → 在 PlayerLayout.provides 上查找
  │   │   │   → 未找到,沿原型链到 PlayerContainer.provides
  │   │   │   → 找到!返回 ref 对象(保持响应式)

inject 的实现则是从当前组件的父组件 provides 上查找指定的 key。由于原型链的特性,查找会自动沿着组件树向上传递,直到找到匹配的 provide 或到达根组件。

正因为传递的是 ref 对象本身(而非 ref 的值),所以响应式连接在整个注入链路中天然保持完整。

6.4 类型安全的注入:使用 InjectionKey

字符串 key 容易产生拼写错误,也无法获得类型提示。Vue 3 提供了 InjectionKey<T> 类型来实现类型安全的注入:

// injection-keys.ts
import type { InjectionKey, Ref } from 'vue'

interface Track {
  id: number
  title: string
  artist: string
  duration: number
}

export const CurrentTrackKey: InjectionKey<Readonly<Ref<Track>>> = Symbol('currentTrack')
export const IsPlayingKey: InjectionKey<Readonly<Ref<boolean>>> = Symbol('isPlaying')
export const SwitchTrackKey: InjectionKey<(track: Track) => void> = Symbol('switchTrack')
<!-- 祖先组件 -->
<script setup lang="ts">
import { ref, provide, readonly } from 'vue'
import { CurrentTrackKey, IsPlayingKey, SwitchTrackKey } from './injection-keys'

const currentTrack = ref({ id: 1, title: '夜曲', artist: '周杰伦', duration: 240 })
const isPlaying = ref(false)

provide(CurrentTrackKey, readonly(currentTrack))
provide(IsPlayingKey, readonly(isPlaying))
provide(SwitchTrackKey, (track) => {
  currentTrack.value = track
  isPlaying.value = true
})
</script>
<!-- 后代组件 -->
<script setup lang="ts">
import { inject } from 'vue'
import { CurrentTrackKey, IsPlayingKey, SwitchTrackKey } from './injection-keys'

// 自动推导类型为 Readonly<Ref<Track>> | undefined
const currentTrack = inject(CurrentTrackKey)

// 提供默认值,类型不再包含 undefined
const isPlaying = inject(IsPlayingKey, ref(false))

const switchTrack = inject(SwitchTrackKey, () => {
  console.warn('SwitchTrack provider not found')
})
</script>

使用 Symbol 作为 key 有两个好处:

  1. 全局唯一,杜绝不同模块之间的 key 冲突。
  2. 配合泛型inject 的返回值自动获得正确的类型推导。

6.5 最佳实践:单向数据流

与 props 类似,provide/inject 也应当遵循单向数据流原则:

<!-- 推荐:祖先 provide 数据 + 修改方法,后代通过方法请求变更 -->
<script setup>
// 祖先
provide('volumeLevel', readonly(volumeLevel))
provide('setVolume', (val) => { volumeLevel.value = val })
</script>

<script setup>
// 后代
const volumeLevel = inject('volumeLevel')
const setVolume = inject('setVolume')
// 通过 setVolume 请求祖先修改,而非直接改 volumeLevel
</script>

使用 readonly 包裹注入的响应式数据,可以在开发模式下对后代的直接修改行为发出警告,从而维护数据流的可预测性。


🤔 思考题

请在学习完本章后,认真思考以下问题:

  1. Composable 的嵌套调用:一个 Composable A 内部调用了 Composable B,B 中注册了 onMounted 钩子。当使用 A 的组件挂载时,B 的 onMounted 会被触发吗?为什么?请设计一个音乐播放器场景来验证你的结论。

  2. 响应式选型的边界:假设你需要管理一个实时音频频谱数据数组(每秒更新 60 次,包含 1024 个频率值)。你会选择 refreactiveshallowRef 还是 shallowReactive?请从性能角度论证你的选择。

  3. provide/inject vs 全局状态管理:在什么规模和复杂度的应用中,provide/inject 足以替代 Pinia 等全局状态管理方案?它们的边界在哪里?请列举至少两个 provide/inject 力不能及、必须使用 Pinia 的场景。

  4. TypeScript 与 Composable 的协同:如果你设计一个 usePlaylist<T extends Track> 泛型 Composable,如何确保 trackList 的类型推导、addTrack 方法的参数校验、以及返回值的类型安全?


📝 结尾自测

完成本章学习后,请独立回答以下五个问题,验证你的掌握程度。

1. <script setup> 中为什么不需要 return

提示:从编译器的角度思考,<script setup> 的代码在编译阶段发生了什么转换。

2. 以下代码存在什么问题?如何修复?

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

const playbackState = reactive({ isPlaying: false, progress: 0 })
const { isPlaying } = playbackState

function togglePlay() {
  isPlaying = !isPlaying
}
</script>
<template>
  <span>{{ isPlaying ? '播放中' : '已暂停' }}</span>
  <button @click="togglePlay">切换</button>
</template>

答案方向:reactive 的解构问题 + toRefs 的使用。

3. shallowRefref 在处理对象类型时的核心区别是什么?什么场景下应该优先考虑 shallowRef

提示:从 Proxy 代理的深度和性能开销两个角度回答。

4. 在 Composable 函数内部注册 onMounted 钩子,它的执行时机是怎样的?如果同一个 Composable 被两个不同组件使用,它们的 onMounted 会互相影响吗?

答案方向:生命周期钩子与当前组件实例的绑定关系。

5. 使用 provide/inject 时,如果忘记在祖先组件中调用 provide,后代组件的 inject 会发生什么?如何设计一个健壮的降级策略?

提示:inject 的第二个参数(默认值)、TypeScript 的 InjectionKey、以及开发环境的警告提示。


本章我们从维护性和性能优化的视角,系统梳理了 Composition API 的核心知识体系。从 Options API 的关注点碎片化问题出发,理解了组合式 API 存在的根本原因;通过 setup()<script setup> 的对比,掌握了两种入口的适用场景;深入 refreactivetoRefsshallowRef 等 API 的底层差异,建立了正确的响应式选型心智模型;在生命周期钩子部分,理解了钩子可以被封装进 Composable 的设计哲学;通过构建音乐播放器的 Composable 体系,掌握了逻辑复用的最佳实践;最后在依赖注入部分,理解了原型链驱动的 provide/inject 机制及其类型安全方案。下一章我们将进入「路由与状态管理」,学习如何用 Vue Router 构建单页应用的导航体系,并用 Pinia 搭建应用级的状态管理方案。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90