组合式API深入
当一个音乐播放器的代码膨胀到数千行,播放控制逻辑散落在
data、methods、computed、watch四处角落时,维护它就像在一座没有索引的唱片仓库里找一张特定的黑胶——你知道它在某个架子上,但每次都要把整间仓库翻个底朝天。组合式 API 的出现,正是为了给这座仓库建立清晰的分类索引系统。
📋 开篇自测
在正式学习之前,请先检验自己的当前认知水平。如果以下三个问题你都能准确回答,可以快速浏览本章;如果有任何迟疑,请认真逐节研读。
- Options API 在大型组件中最核心的维护性痛点是什么? 它导致了怎样的代码组织问题?
ref和reactive的底层代理机制有何不同? 为什么reactive解构后会丢失响应式?- 自定义 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() { /* 混合了多种初始化逻辑 */ }
}
注意观察:同一个功能关注点(比如”歌词同步”)的代码被强制拆分到了 data、computed、watch、methods 四个不同的选项块中。这就好比你把一张专辑的 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 在应用级别生效:只要整个应用中有任何一个组件 import 了 watch,watch 的代码就会被保留在最终产物中。但如果全应用没有任何地方导入某个 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:包含emit、attrs、slots、expose的普通对象。
需要注意的是,不要对 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> 时,它会在背后完成以下转换:
- 自动 return:所有顶层变量、函数、
import的组件都被自动暴露给<template>。 - 组件自动注册:
import进来的组件无需在components选项中声明。 - 宏函数展开:
defineProps、defineEmits、defineExpose等编译器宏在编译阶段被处理,无需从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:安全解构的桥梁
toRef 和 toRefs 就是给解构操作加装的”延长线”,让拆出的每个属性仍然与原始对象保持电路连接:
<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:性能优化利器
对于包含大量数据的列表或嵌套较深的对象,深层响应式代理会带来不可忽视的性能开销。shallowRef 和 shallowReactive 只对第一层属性进行响应式追踪:
<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 选型指南
| 维度 | ref | reactive |
|---|---|---|
| 适用类型 | 任意类型 | 仅对象/数组/Map/Set |
| 访问方式 | .value(模板自动解包) | 直接访问属性 |
| 赋值/替换 | 可直接替换整个值 | 不能替换整个对象(会断开代理) |
| 解构安全 | 本身是独立 ref,安全 | 解构丢失响应式,需 toRefs |
| 类型推导 | 优秀(Ref<T>) | 良好 |
| 推荐场景 | 基本类型、需要替换整体的场景 | 表单数据、状态对象等固定结构 |
团队实践建议:统一使用
ref是目前社区的主流选择。它的心智模型更简单——始终通过.value访问,不需要担心”这个变量是 ref 还是 reactive”的问题。当你确实需要管理一个结构稳定的状态对象时,reactive也完全合适。
四、生命周期钩子——在 setup 中掌控组件生命
4.1 生命周期映射表
Composition API 为每个生命周期阶段提供了对应的钩子函数,它们都以 on 为前缀,且只能在 setup 执行期间调用:
| Options API | Composition API | 执行时机 |
|---|---|---|
beforeCreate | 不需要(setup 在其之前执行) | 组件实例初始化前 |
created | 不需要(setup 在其之前执行) | 组件实例初始化后 |
beforeMount | onBeforeMount | DOM 挂载前 |
mounted | onMounted | DOM 挂载完成 |
beforeUpdate | onBeforeUpdate | DOM 更新前 |
updated | onUpdated | DOM 更新完成 |
beforeUnmount | onBeforeUnmount | 组件卸载前 |
unmounted | onUnmounted | 组件卸载完成 |
activated | onActivated | keep-alive 组件激活 |
deactivated | onDeactivated | keep-alive 组件停用 |
errorCaptured | onErrorCaptured | 捕获后代组件错误 |
核心要点:setup 在 beforeCreate 之前执行,其作用覆盖了 beforeCreate 和 created 两个阶段的职责,因此 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 为前缀命名,如 usePlaylist、useVolume、useLyricSync。
与普通工具函数的区别在于:Composable 内部使用了响应式 API(ref、reactive、computed、watch)和/或生命周期钩子,它管理着自己的响应式状态。
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 有两个好处:
- 全局唯一,杜绝不同模块之间的 key 冲突。
- 配合泛型,
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 包裹注入的响应式数据,可以在开发模式下对后代的直接修改行为发出警告,从而维护数据流的可预测性。
🤔 思考题
请在学习完本章后,认真思考以下问题:
-
Composable 的嵌套调用:一个 Composable A 内部调用了 Composable B,B 中注册了
onMounted钩子。当使用 A 的组件挂载时,B 的onMounted会被触发吗?为什么?请设计一个音乐播放器场景来验证你的结论。 -
响应式选型的边界:假设你需要管理一个实时音频频谱数据数组(每秒更新 60 次,包含 1024 个频率值)。你会选择
ref、reactive、shallowRef还是shallowReactive?请从性能角度论证你的选择。 -
provide/inject vs 全局状态管理:在什么规模和复杂度的应用中,
provide/inject足以替代 Pinia 等全局状态管理方案?它们的边界在哪里?请列举至少两个provide/inject力不能及、必须使用 Pinia 的场景。 -
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. shallowRef 和 ref 在处理对象类型时的核心区别是什么?什么场景下应该优先考虑 shallowRef?
提示:从 Proxy 代理的深度和性能开销两个角度回答。
4. 在 Composable 函数内部注册 onMounted 钩子,它的执行时机是怎样的?如果同一个 Composable 被两个不同组件使用,它们的 onMounted 会互相影响吗?
答案方向:生命周期钩子与当前组件实例的绑定关系。
5. 使用 provide/inject 时,如果忘记在祖先组件中调用 provide,后代组件的 inject 会发生什么?如何设计一个健壮的降级策略?
提示:
inject的第二个参数(默认值)、TypeScript 的InjectionKey、以及开发环境的警告提示。
本章我们从维护性和性能优化的视角,系统梳理了 Composition API 的核心知识体系。从 Options API 的关注点碎片化问题出发,理解了组合式 API 存在的根本原因;通过
setup()与<script setup>的对比,掌握了两种入口的适用场景;深入ref、reactive、toRefs、shallowRef等 API 的底层差异,建立了正确的响应式选型心智模型;在生命周期钩子部分,理解了钩子可以被封装进 Composable 的设计哲学;通过构建音乐播放器的 Composable 体系,掌握了逻辑复用的最佳实践;最后在依赖注入部分,理解了原型链驱动的provide/inject机制及其类型安全方案。下一章我们将进入「路由与状态管理」,学习如何用 Vue Router 构建单页应用的导航体系,并用 Pinia 搭建应用级的状态管理方案。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90