Vue 3 响应式系统与 Composition API / Vue 3 Reactivity System and Composition API
📅 创建时间:2026-07-28 🏷️ 标签:#Vue #Reactivity #CompositionAPI #Composables 📚 前置知识:[[01-vue-components-and-templates]]
📋 本章目标
- 理解 Vue 3 Proxy-based 响应式系统的底层原理(track / trigger / effect)
- 掌握 ref、reactive、shallowRef、shallowReactive 的适用场景与决策方法
- 理解 computed 的惰性求值、缓存机制及与 watch 的本质区别
- 掌握 watch、watchEffect、watchPostEffect、watchSyncEffect 的执行时序与清理机制
- 能够设计类型安全、生命周期受控的可组合函数(Composables)
- 了解 Vue 3.3 / 3.4 / 3.5 引入的关键新特性及其使用方式
- 能够完成从 Vue 2 Options API 到 Vue 3 Composition API 的迁移
第1部分:Proxy 响应式原理图解
1.1 从 Object.defineProperty 到 Proxy
Vue 2 使用 Object.defineProperty 劫持对象属性的 getter/setter,这带来三个先天缺陷:无法检测属性新增/删除、无法直接监听数组索引变更、必须在初始化时深度遍历所有嵌套属性。Vue 3 改用 Proxy,从根本上解决了这些问题。
┌─────────────────────────────────────────────────────────────┐
│ Vue 2 vs Vue 3 响应式实现对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Vue 2 (Object.defineProperty) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ❌ 新增属性检测 → 需 Vue.set(obj, key, val) │ │
│ │ ❌ 删除属性检测 → 需 Vue.delete(obj, key) │ │
│ │ ❌ 数组索引赋值 → arr[3] = x 不触发更新 │ │
│ │ ❌ 初始化递归 → 大对象首次渲染慢 │ │
│ │ ✅ IE 兼容 → 仅此一个优势 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Vue 3 (Proxy) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ 新增属性检测 → proxy.newProp = val 自动追踪 │ │
│ │ ✅ 删除属性检测 → delete proxy.prop 自动触发 │ │
│ │ ✅ 数组索引/长度 → 原生数组操作全部响应 │ │
│ │ ✅ 惰性响应 → 访问时才递归,不访问不追踪 │ │
│ │ ✅ 支持 Map/Set/WeakMap/WeakSet │ │
│ │ ❌ 不支持 IE → 无法 polyfill Proxy │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘1.2 核心三要素:targetMap / track / trigger
Vue 3 响应式系统的核心是一个发布-订阅模型,由三个关键部分构成:
- targetMap(WeakMap):全局的依赖映射表,结构为
WeakMap<target, Map<key, Set<effect>>> - track(依赖收集):在 Proxy 的
gettrap 中调用,将当前正在运行的 effect 注册到对应属性的依赖集合中 - trigger(派发更新):在 Proxy 的
set/deletePropertytrap 中调用,查找所有依赖该属性的 effect 并重新执行
┌─────────────────────────────────────────────────────────────┐
│ 响应式系统核心数据流 │
├─────────────────────────────────────────────────────────────┤
│ │
│ component.render() 执行 │
│ │ │
│ ▼ │
│ ┌──────────┐ 读取 state.x ┌──────────┐ │
│ │ effect │ ──────────────────→ │ Proxy │ │
│ │(渲染函数) │ ←── 注册依赖 ────── │ get() │ │
│ └──────────┘ └──────────┘ │
│ │ │ │
│ │ track(target, key) │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ targetMap │ │
│ │ │ │ │
│ │ │ WeakMap { │ │
│ │ │ state → Map { │ │
│ │ │ "x" → Set { │ │
│ │ │ renderEffect │ │
│ │ │ } │ │
│ │ │ } │ │
│ │ │ } │ │
│ │ └──────────────────┘ │
│ │ │
│ │ state.x = 10 │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────┐ 写入 state.x ┌──────────┐ │
│ │ │ Proxy │ ──────────────────→ │ 新值 │ │
│ │ │ set() │ │ 10 │ │
│ │ └──────────┘ └──────────┘ │
│ │ │ │
│ │ trigger(target, key) │
│ │ │ │
│ │ ▼ │
│ │ 查找 targetMap 中所有依赖 "x" 的 effect │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────┐ │
│ └──→│ effect │ 重新执行(组件重新渲染) │
│ │(渲染函数) │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘1.3 Effect 作为"订阅者"
effect 是响应式系统中的一等公民 —— 任何需要响应式地重新执行的函数都被包裹为 effect。组件的渲染函数、computed 的 getter、watch 的回调、watchEffect 的回调,本质上都是 effect。
// Vue 3 内部 effect 的简化实现概念
let activeEffect: ReactiveEffect | null = null
class ReactiveEffect {
deps: Set<Dep>[] = [] // 记录该 effect 被哪些属性依赖集合引用
private _fn: () => unknown // 用户传入的函数
constructor(fn: () => unknown) {
this._fn = fn
}
run() {
// 将当前 effect 设置为全局 activeEffect
// track() 在 Proxy get 中读取 activeEffect 并注册依赖
activeEffect = this
const result = this._fn()
activeEffect = null
return result
}
}
// track: 在 get 中调用,建立 属性 → effect 的映射
function track(target: object, key: string | symbol) {
if (!activeEffect) return // 没有 effect 在运行,无需收集
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect)
}
// trigger: 在 set/deleteProperty 中调用,通知所有依赖更新
function trigger(target: object, key: string | symbol) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
dep.forEach(effect => effect.run())
}
}1.4 批量更新与 nextTick / Scheduler
连续多次修改响应式数据时,每次 set 都会触发 trigger,但 Vue 不会立即同步执行 effect —— 这样会导致同一个组件在一次同步操作中被渲染多次。Vue 使用 scheduler(调度器) 将 effect 的执行推迟到微任务队列,实现批量更新。
┌─────────────────────────────────────────────────────────────┐
│ Scheduler 批量更新机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 同步代码块: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ count.value = 1 → trigger(effect) │ │
│ │ count.value = 2 → trigger(effect) (重复) │ │
│ │ count.value = 3 → trigger(effect) (重复) │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ │ effect 不会立即执行,而是进入调度队列 │
│ │ scheduler 发现同一个 effect 已在队列中 → 去重 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 微任务队列 (Promise.then / queueMicrotask) │ │
│ │ │ │
│ │ 等待当前同步代码全部执行完毕... │ │
│ │ │ │
│ │ → 执行 effect(组件仅重新渲染一次!) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ nextTick(() => { │
│ // 在这里可以拿到更新后的 DOM │
│ console.log(document.querySelector('#app').textContent) │
│ }) │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:ref vs reactive 决策树
2.1 内部实现差异
ref 和 reactive 在底层共用同一套 Proxy 机制,但包装方式不同:
// 概念等价:ref(0) 约等于 reactive({ value: 0 })
// ref 内部维护一个 .value 属性,该属性本身被 reactive 包装
// Vue 3 内部 ref 简化实现
class RefImpl<T> {
private _value: T
private _rawValue: T
public readonly __v_isRef = true
constructor(value: T) {
this._rawValue = value
// 如果是对象,用 reactive 包裹;基本值直接存储
this._value = isObject(value) ? reactive(value) : value
}
get value(): T {
track(this, 'value') // 依赖收集
return this._value
}
set value(newVal: T) {
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = isObject(newVal) ? reactive(newVal) : newVal
trigger(this, 'value') // 派发更新
}
}
}2.2 .value 解包规则
Vue 在特定上下文中会自动解包 Ref,减少模板和代码中的 .value 冗余:
┌─────────────────────────────────────────────────────────────┐
│ .value 自动解包规则 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 模板 (template) 中: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <div>{{ count }}</div> ← 自动解包,无需 .value │ │
│ │ <div>{{ obj.count }}</div> ← 仅顶层 ref 自动解包 │ │
│ │ <div>{{ obj.count + 1 }}</div> ← 解包后参与运算 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ reactive 对象内部: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const state = reactive({ │ │
│ │ count: ref(0), ← 自动解包! │ │
│ │ name: 'hello' │ │
│ │ }) │ │
│ │ state.count // 0 (不是 RefImpl 对象) │ │
│ │ state.count += 1 // ✅ 直接修改,无需 .value │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 数组 / Map 等集合类型中:❌ 不会自动解包 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const arr = reactive([ref(1), ref(2)]) │ │
│ │ arr[0] // RefImpl { value: 1 } 需要 .value │ │
│ │ const map = reactive(new Map([['k', ref(10)]])) │ │
│ │ map.get('k') // RefImpl { value: 10 } 需要 .value │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.3 决策树
┌─────────────────────────────────────────────────────────────┐
│ ref vs reactive vs shallowRef 决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的数据是什么? │
│ │ │
│ ┌────┴────────────────────────────┐ │
│ ▼ ▼ │
│ 基本类型 对象/数组 │
│ (string/number/boolean) /Map/Set │
│ │ │ │
│ ▼ ▼ │
│ 使用 ref 是否需要整体替换? │
│ const n = ref(0) │ │
│ ┌──┴──┐ │
│ ▼ ▼ │
│ 是 否 │
│ │ │ │
│ ▼ ▼ │
│ ref reactive │
│ .value 直接替换 const state = │
│ = 新对象 reactive({...}) │
│ │
│ 特殊情况: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ shallowRef → 仅 .value 本身是响应式的 │ │
│ │ 适用:大型不可变数据(Markdown 内容)、第三方实例 │ │
│ │ const md = shallowRef('# Title') │ │
│ │ md.value = newContent // ✅ 触发更新 │ │
│ │ md.value += '...' // ❌ 不触发(未修改 .value)│ │
│ │ │ │
│ │ shallowReactive → 仅对象第一层属性是响应式的 │ │
│ │ 适用:性能敏感的大对象,只关心顶层属性变化 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘// 各 API 适用场景代码示例
// ref —— 基本值、需要整体替换的值
const count = ref(0)
const user = ref<User | null>(null)
user.value = await fetchUser() // 整体替换,触发更新
// reactive —— 表单、配置对象等聚合数据
const form = reactive({
username: '',
password: '',
rememberMe: false,
})
// 不可解构:const { username } = form ❌ 失去响应式
// shallowRef —— 大体积不可变数据
import type { MarkdownContent } from '@/types'
const markdownContent = shallowRef<MarkdownContent>({
raw: '# Hello',
html: '<h1>Hello</h1>',
toc: [{ level: 1, text: 'Hello' }],
})
// 通过整体替换 .value 触发更新,避免深层响应式转换的开销
// shallowReactive —— 仅顶层属性需要响应的场景
const chartOptions = shallowReactive({
width: 800,
height: 600,
theme: { // 深层对象不会被代理
colors: ['#ff0000', '#00ff00'],
fonts: { size: 14 },
},
})
// chartOptions.width = 900 ✅ 触发更新
// chartOptions.theme.colors.push('#0000ff') ❌ 不触发更新第3部分:computed 的惰性求值
3.1 依赖追踪与缓存机制
computed 与普通函数的核心区别在于:它只在依赖变化时才重新计算,否则返回上次缓存的结果。这是一个"惰性求值"(lazy evaluation)的过程。
┌─────────────────────────────────────────────────────────────┐
│ computed 惰性求值与缓存流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ const fullName = computed(() => { │
│ console.log('computed 执行了') │
│ return `${firstName.value} ${lastName.value}` │
│ }) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 初始状态:_dirty = true (缓存失效) │ │
│ │ │ │ │
│ │ 首次读取 fullName.value │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 执行 getter → 计算结果 → 缓存 → _dirty = false │ │
│ │ │ │ │
│ │ 再次读取 fullName.value │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ _dirty === false → 直接返回缓存,不执行 getter │ │
│ │ │ │ │
│ │ firstName.value 或 lastName.value 变化 │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ trigger → _dirty = true (标记缓存失效) │ │
│ │ │ │ │
│ │ 下次读取 → 重新执行 getter → 更新缓存 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘// computed 内部简化实现
class ComputedRefImpl<T> {
private _value!: T
private _dirty = true // 缓存是否失效
private _effect: ReactiveEffect
constructor(getter: () => T) {
// effect 的调度器:依赖变化时不立即执行,只标记 dirty
this._effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
trigger(this, 'value') // 通知读取了此 computed 的 effect
}
})
}
get value(): T {
if (this._dirty) {
this._value = this._effect.run()
this._dirty = false
}
track(this, 'value') // 收集依赖此 computed 的 effect
return this._value
}
}3.2 computed vs watch + 手动赋值
很多初学者会用 watch 手动将派生值写入 ref,这是反模式:
// ❌ 反模式:用 watch 手动同步派生状态
const firstName = ref('Alice')
const lastName = ref('Smith')
const fullName = ref('')
watch(
[firstName, lastName],
([first, last]) => {
fullName.value = `${first} ${last}` // 手动赋值
},
{ immediate: true }
)
// ✅ 正确做法:用 computed 表达派生关系
const fullName = computed(() =>
`${firstName.value} ${lastName.value}`
)
// 为什么更好?
// 1. 声明式:直接表达"fullName 由 firstName + lastName 派生"
// 2. 缓存:依赖未变时不重新计算
// 3. 类型推断:自动推断为 ComputedRef<string>
// 4. 无多余 effect:不产生额外的订阅开销3.3 可写 computed(getter + setter)
computed 可以同时提供 getter 和 setter,在需要双向绑定的场景下非常有用:
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
firstName: string
lastName: string
}>()
const emit = defineEmits<{
'update:firstName': [value: string]
'update:lastName': [value: string]
}>()
// 可写 computed:将两个 prop 合并为一个逻辑字段
const fullName = computed({
get: () => `${props.firstName} ${props.lastName}`,
set: (val: string) => {
const [first, ...rest] = val.trim().split(' ')
emit('update:firstName', first)
emit('update:lastName', rest.join(' '))
},
})
</script>
<template>
<input v-model="fullName" placeholder="输入全名" />
<p>First: {{ firstName }}</p>
<p>Last: {{ lastName }}</p>
</template>第4部分:watch 与 watchEffect 的完整对比
4.1 执行时序与 flush 选项
┌─────────────────────────────────────────────────────────────┐
│ watch / watchEffect / watchPostEffect / │
│ watchSyncEffect 执行时序对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 时间轴: │
│ │
│ ── DOM 更新前(pre flush,默认)── │
│ │ watchEffect(() => { ... }) │
│ │ watch(source, callback) ← 默认 flush: 'pre' │
│ │ │
│ ── 组件 DOM 更新 ── │
│ │ │
│ ── DOM 更新后(post flush)── │
│ │ watchPostEffect(() => { │
│ │ // 可以安全访问更新后的 DOM │
│ │ }) │
│ │ watch(source, cb, { flush: 'post' }) │
│ │ │
│ ── 同步(sync flush)── │
│ │ watchSyncEffect(() => { ... }) │
│ │ watch(source, cb, { flush: 'sync' }) │
│ │ ⚠️ 状态变化时立即同步执行,可能多次触发 │
│ │ │
│ ── nextTick ── │
│ │ nextTick(() => { /* DOM 已保证是最新 */ }) │
│ │
└─────────────────────────────────────────────────────────────┘4.2 watch vs watchEffect 选择指南
// watchEffect —— 自动收集依赖,适合简单逻辑
const counter = ref(0)
const doubled = ref(0)
// ❌ 不适合 watchEffect:你需要在回调中访问什么?
// 它会在首次立即执行,依赖自动追踪
watchEffect(() => {
// 首次执行时 counter 为 0,自动建立依赖关系
doubled.value = counter.value * 2
})
// watch —— 显式指定来源,适合需要旧值/精确控制的场景
// ✅ 适合 watch:
// 1. 需要新旧值对比
watch(counter, (newVal, oldVal) => {
console.log(`counter: ${oldVal} → ${newVal}`)
})
// 2. 异步操作(网络请求)
watch(
() => route.params.id,
async (id, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
data.value = await fetchData(String(id), controller.signal)
},
{ immediate: true }
)
// 3. 精确控制触发条件
watch(
() => form.value.email,
(email) => {
// 只有 email 变化时才执行
}
)
// 4. immediate + deep 选项
watch(
() => settings.value,
(newSettings) => {
applySettings(newSettings)
},
{ immediate: true, deep: true }
)4.3 onCleanup 清理与 stop handle
┌─────────────────────────────────────────────────────────────┐
│ watch 副作用清理机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景:用户快速切换路由 / 输入搜索关键词 │
│ │
│ 第1次 watch 触发 → 发起请求 A │
│ 第2次 watch 触发 → onCleanup 先执行 → 取消请求 A │
│ → 发起请求 B │
│ 第3次 watch 触发 → onCleanup 先执行 → 取消请求 B │
│ → 发起请求 C │
│ │
│ 最终只有请求 C 的结果会显示,避免竞态条件 │
│ │
└─────────────────────────────────────────────────────────────┘// onCleanup 的两种使用方式
// 方式一:watch 回调的第三个参数
watch(keyword, async (newVal, _oldVal, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
results.value = await search(newVal, controller.signal)
})
// 方式二:watchEffect 内部调用 onCleanup
import { watchEffect, onCleanup } from 'vue'
// 注意:onCleanup 在 watchSyncEffect 中不可用
let timer: ReturnType<typeof setInterval>
// stop handle —— 手动停止监听
const stop = watchEffect((onCleanup) => {
timer = setInterval(() => {
refreshData()
}, 5000)
onCleanup(() => {
clearInterval(timer)
console.log('定时器已清理')
})
})
// 需要时手动停止
onUnmounted(() => {
stop() // 停止 watchEffect,触发 onCleanup
})4.4 watch 的 deep 选项陷阱
const hugeObject = reactive({
// ... 数千个嵌套属性
})
// ❌ deep: true 会递归遍历所有嵌套属性建立依赖
// 对大对象来说性能代价极高
watch(hugeObject, () => {
// ...
}, { deep: true })
// ✅ 替代方案:精确监听需要的属性
watch(
() => hugeObject.specificField.subField,
(newVal) => { /* ... */ }
)
// ✅ 或用 computed 提取需要的片段
const relevantData = computed(() => ({
a: hugeObject.deeply.nested.a,
b: hugeObject.deeply.nested.b,
}))
watch(relevantData, (newVal) => { /* ... */ })第5部分:可组合函数(Composables)设计模式
5.1 命名约定与返回值设计
┌─────────────────────────────────────────────────────────────┐
│ Composable 设计规范 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 命名: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ useXxx useMouse, useFetch, useLocalStorage │ │
│ │ ✅ useXxxYyy useEventListener, useMediaQuery │ │
│ │ ❌ getXxx 不是 getter │ │
│ │ ❌ handleXxx 这是事件处理函数的命名 │ │
│ │ ❌ initXxx 这是初始化函数的命名 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 返回值:返回 ref / reactive / computed / 方法 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ 返回 ref —— 调用方保持响应式连接 │ │
│ │ ✅ 返回 computed —— 只读派生状态 │ │
│ │ ✅ 返回普通函数 —— 提供修改入口 │ │
│ │ ❌ 返回解构后的普通值 —— 丢失响应式 │ │
│ │ ❌ 返回 reactive 对象 —— 调用方解构时丢失响应式 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘5.2 参数标准化:MaybeRef 与 toValue
Composable 的输入应该足够灵活,允许调用方传入 ref、getter 或普通值。Vue 3.3+ 提供了 MaybeRef、MaybeRefOrGetter 类型和 toValue 工具函数。
import { ref, computed, toValue, type MaybeRefOrGetter } from 'vue'
// 参数类型:MaybeRefOrGetter<T> = T | Ref<T> | (() => T)
export function useTitle(newTitle: MaybeRefOrGetter<string>) {
// toValue 统一归一化:Ref 取 .value,getter 调用求值,普通值直接返回
const title = computed(() => toValue(newTitle))
watchEffect(() => {
document.title = title.value
})
return title
}
// 使用方式灵活
useTitle('Home') // 普通字符串
useTitle(ref('Dashboard')) // Ref
useTitle(() => `Page ${page.value}`) // Getter(响应式派生)5.3 异步 Composable 模式
// 标准的异步数据获取 Composable
export function useAsyncData<T>(
fetcher: () => Promise<T>,
) {
const data = shallowRef<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function execute() {
isLoading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
}
// 返回统一的 { data, error, isLoading, execute } 接口
return { data, error, isLoading, execute }
}
// 使用
const { data: users, isLoading, execute } = useAsyncData(
() => fetch('/api/users').then(r => r.json())
)
onMounted(() => execute())5.4 effectScope 管理生命周期
当 Composable 内部创建了多个 watch/watcher 时,手动管理每个 stop handle 很繁琐。effectScope 允许批量管理一组 effect 的生命周期。
import { effectScope, onScopeDispose, ref, watchEffect } from 'vue'
export function useFeatureFlag(key: string) {
const enabled = ref(false)
const scope = effectScope() // 创建独立的作用域
scope.run(() => {
// 此 scope 内的所有 effect 都会被统一管理
const stored = localStorage.getItem(`flag:${key}`)
enabled.value = stored === 'true'
watchEffect(() => {
localStorage.setItem(`flag:${key}`, String(enabled.value))
})
// onScopeDispose 注册清理回调
onScopeDispose(() => {
console.log(`Feature flag "${key}" scope disposed`)
})
})
function dispose() {
scope.stop() // 一次性停止所有 effect
}
return { enabled, dispose }
}
// 在组件中使用
const { enabled: showNewUI, dispose } = useFeatureFlag('new-dashboard')
onUnmounted(dispose)5.5 provide/inject 与 Composable 配合
┌─────────────────────────────────────────────────────────────┐
│ provide/inject + Composable 模式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 父组件 │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ useTheme() ← 内部 provide() │ │ │
│ │ │ const theme = ref('light') │ │ │
│ │ │ provide(THEME_KEY, readonly(theme)) │ │ │
│ │ │ return { theme, toggle } │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ │ inject(THEME_KEY) │ │
│ │ ┌───────────┴────────────────────────────────┐ │ │
│ │ │ 子组件 │ │ │
│ │ │ useTheme() ← 内部 inject() │ │ │
│ │ │ const theme = inject(THEME_KEY) │ │ │
│ │ │ return { theme } │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ 关键原则: │
│ • 用 Symbol 作为注入键,避免命名冲突 │
│ • 修改状态的函数由 Provider 暴露,消费者只读 │
│ • 提供默认值防止缺失 Provider 时崩溃 │
│ │
└─────────────────────────────────────────────────────────────┘// 完整示例:主题管理的 provide/inject + Composable
// theme.ts —— 键定义 + Composable 函数
import { ref, readonly, inject, provide, type InjectionKey, type Ref } from 'vue'
type Theme = 'light' | 'dark'
export const THEME_KEY: InjectionKey<{
theme: Ref<Theme>
toggle: () => void
}> = Symbol('theme')
export function useThemeProvider() {
const theme = ref<Theme>('light')
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide(THEME_KEY, { theme: readonly(theme), toggle })
return { theme, toggle }
}
export function useTheme() {
const ctx = inject(THEME_KEY)
if (!ctx) {
throw new Error('useTheme() must be used within a <ThemeProvider>')
}
return ctx
}第6部分:Vue 3.3 / 3.4 / 3.5 新特性
6.1 Vue 3.3:defineModel(v-model 简化)
在 Vue 3.3 之前,实现组件的 v-model 需要手动声明 props 和 emits。defineModel 将两者合并,大幅减少样板代码。
<!-- Vue 3.3 之前 -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const localValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<!-- Vue 3.3+ defineModel -->
<script setup lang="ts">
// 一个宏替代了 props + emits + computed 三件套
const modelValue = defineModel<string>({ required: true })
// 命名 v-model (v-model:title)
const title = defineModel<string>('title')
// 带默认值
const count = defineModel<number>({ default: 0 })
</script>
<template>
<input v-model="modelValue" />
<input v-model="title" />
</template>6.2 Vue 3.5:useTemplateRef(类型安全模板引用)
Vue 3.5 引入 useTemplateRef,替代传统的 ref(null) 方式获取模板引用,提供更好的类型推断。
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
// Vue 3.4 及之前
// const inputRef = ref<HTMLInputElement | null>(null)
// Vue 3.5+ 类型安全模板引用
const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
onMounted(() => {
// 类型自动推断为 HTMLInputElement | null
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputEl" placeholder="类型安全引用" />
</template>6.3 Vue 3.3:defineSlots(类型安全插槽)
<script setup lang="ts">
// 声明组件暴露的插槽及其类型
defineSlots<{
default?: (props: { item: { id: number; name: string } }) => unknown
header?: () => unknown
footer?: (props: { count: number }) => unknown
}>()
</script>
<template>
<header><slot name="header" /></header>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" />
</li>
</ul>
<footer><slot name="footer" :count="items.length" /></footer>
</template>6.4 toValue / toRef / toRefs 工具函数
┌─────────────────────────────────────────────────────────────┐
│ toValue / toRef / toRefs 功能对照 │
├─────────────────────────────────────────────────────────────┤
│ │
│ toValue(value) —— Vue 3.3+ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 归一化:ref → 取 .value │ │
│ │ getter → 调用求值 │ │
│ │ 普通值 → 直接返回 │ │
│ │ 非响应式:返回的是快照值 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ toRef(reactiveObj, 'key') —— 保持响应式连接 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 从 reactive 对象中提取单个属性为 ref │ │
│ │ 修改 ref 的值会同步回源对象 │ │
│ │ const state = reactive({ x: 1 }) │ │
│ │ const xRef = toRef(state, 'x') │ │
│ │ xRef.value = 10 // state.x 也变为 10 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ toRefs(reactiveObj) —— 解构保持响应式 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 将 reactive 对象的每个属性转为 ref │ │
│ │ const { x, y } = toRefs(state) │ │
│ │ // x 和 y 都是 Ref,解构后不丢失响应式 │ │
│ │ // 常用于 Composable 返回值中解构 reactive 对象 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第7部分:Vue 2 Options API 到 Vue 3 Composition API 迁移
7.1 概念对照表
┌─────────────────────────────────────────────────────────────┐
│ Vue 2 Options API → Vue 3 Composition API 映射表 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Vue 2 (Options API) │ Vue 3 (Composition API) │
│ ───────────────────────────┼────────────────────────────── │
│ data() { return { x } } │ const x = ref(initial) │
│ computed: { fullName() } │ const fullName = computed() │
│ methods: { doSmth() } │ function doSmth() { } │
│ watch: { x(val, old) } │ watch(x, (val, old) => {}) │
│ mounted() │ onMounted(() => {}) │
│ updated() │ onUpdated(() => {}) │
│ unmounted() │ onUnmounted(() => {}) │
│ beforeDestroy() │ onBeforeUnmount(() => {}) │
│ props: ['title'] │ defineProps<{ title }>() │
│ this.$emit('event', val) │ emit('event', val) │
│ mixins: [myMixin] │ useMyMixin() ← Composable │
│ provide: { key: val } │ provide(KEY, val) │
│ inject: ['key'] │ inject(KEY) │
│ template refs: this.$refs │ useTemplateRef('el') │
│ │
└─────────────────────────────────────────────────────────────┘7.2 setup() 函数 vs <script setup>
<!-- Vue 3 早期:setup() 函数 -->
<script lang="ts">
import { ref, computed, onMounted } from 'vue'
export default {
props: { initialCount: Number },
emits: ['update'],
setup(props, { emit }) {
const count = ref(props.initialCount)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
emit('update', count.value)
}
onMounted(() => {
console.log('mounted')
})
// 必须 return 才能在模板中使用
return { count, doubled, increment }
},
}
</script>
<!-- Vue 3.2+:<script setup>(推荐) -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ initialCount: number }>()
const emit = defineEmits<{ 'update': [value: number] }>()
const count = ref(props.initialCount)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
emit('update', count.value)
}
onMounted(() => {
console.log('mounted')
})
// 顶层变量和函数自动暴露给模板,无需 return
</script>7.3 mixins → composables 的思维转换
┌─────────────────────────────────────────────────────────────┐
│ mixins → composables 思维转换 │
├─────────────────────────────────────────────────────────────┤
│ │
│ mixins 的问题: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ❌ 来源不明:this.method() 来自哪个 mixin? │ │
│ │ ❌ 命名冲突:多个 mixin 的同名属性静默覆盖 │ │
│ │ ❌ 隐式耦合:mixin 依赖 this 上由别的 mixin 提 │ │
│ │ 供的属性,运行时才发现缺失 │ │
│ │ ❌ 不可组合:mixin 之间不能互相调用 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ composables 的优势: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ 来源明确:const mouse = useMouse() 一眼看出 │ │
│ │ ✅ 显式命名:解构时可重命名 const { x: mx } = ... │ │
│ │ ✅ 类型安全:完整 TypeScript 支持 │ │
│ │ ✅ 可组合:composable 内部可以调用其他 composable │ │
│ │ ✅ 作用域隔离:每次调用创建独立的状态副本 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘// Vue 2 mixin 示例
const paginationMixin = {
data() {
return { page: 1, pageSize: 20 }
},
methods: {
nextPage() { this.page++ },
prevPage() { this.page-- },
},
}
// ------------------- 迁移为 Composable ------------------- //
// Vue 3 Composable 等价实现
export function usePagination(initialPage = 1, initialPageSize = 20) {
const page = ref(initialPage)
const pageSize = ref(initialPageSize)
const offset = computed(() => (page.value - 1) * pageSize.value)
function nextPage() { page.value++ }
function prevPage() { if (page.value > 1) page.value-- }
function resetPage() { page.value = 1 }
// 可选:监听 page 变化自动重新获取数据
// watch(page, () => fetchPageData())
return { page, pageSize, offset, nextPage, prevPage, resetPage }
}
// 组件中使用
const { page, pageSize, offset, nextPage, prevPage } = usePagination()7.4 常见迁移陷阱
// 陷阱一:this 的消失
// Vue 2
// this.$router.push('/home')
// this.$store.dispatch('login')
// Vue 3
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/home')
// store 同理:import { useStore } from 'vuex' 或 Pinia
// 陷阱二:$refs 替换
// Vue 2: this.$refs.myInput.focus()
// Vue 3.4-: const myInput = ref<HTMLInputElement | null>(null)
// Vue 3.5+: const myInput = useTemplateRef<HTMLInputElement>('myInput')
// 陷阱三:filters 已移除
// Vue 2: {{ amount | currency }}
// Vue 3: const formatted = computed(() => formatCurrency(amount.value))
// 或注册全局属性 app.config.globalProperties.$filters
// 陷阱四:事件总线 ($on/$off/$once) 已移除
// 替代方案:mitt 库、provide/inject、Pinia store
// 陷阱五:Vue 2 的 Vue.set / Vue.delete
// Vue 2: Vue.set(obj, 'newProp', value)
// Vue 3: obj.newProp = value // Proxy 自动检测核心总结
总结1:响应式系统核心架构
Proxy (拦截 get/set/deleteProperty)
├── get() → track(target, key) → 收集依赖(effect → 属性映射)
├── set() → trigger(target, key) → 派发更新(查找依赖 → 调度执行)
└── Scheduler → 批量去重 → 微任务队列 → 组件只渲染一次总结2:API 选择速查
| 需求 | API | 说明 |
|---|---|---|
| 基本类型 / 需要整体替换 | ref | 首选,模板自动解包 |
| 表单对象 / 聚合状态 | reactive | 不可解构,深层响应 |
| 大型不可变数据 | shallowRef | 仅 .value 触发更新 |
| 顶层属性响应即可 | shallowReactive | 避免深层代理开销 |
| 派生值 | computed | 惰性求值,自动缓存 |
| 需要旧值 / 异步操作 | watch | 显式来源,支持清理 |
| 简单同步协议 | watchEffect | 自动收集依赖 |
总结3:Composable 设计 Checklist
- 函数名以
use开头 - 参数支持
MaybeRefOrGetter<T>+toValue()归一化 - 返回值用
ref/computed(不用解构后的普通值) - 副作用在
onMounted/onUnmounted中启动和清理 - 复杂的 effect 集合用
effectScope统一管理 - SSR 安全:不在模块顶层保存用户级可变状态
- 用
Symbol作为provide/inject的键
总结4:迁移要点
data/computed/methods→ref/computed/ 普通函数mounted/destroyed→onMounted/onUnmounted- mixins → composables(显式、类型安全、无冲突)
this.$xxx→useXxx()组合式函数filters→ 手动计算属性或函数调用<script setup>替代setup()函数(减少样板代码)
章节测试
测试1:响应式原理
请描述 Vue 3 中 track 和 trigger 分别做什么,以及它们是在哪些 Proxy trap 中被调用的。
测试2:ref vs reactive
以下代码中,哪些修改会触发视图更新?为什么?
const state = reactive({ items: [1, 2, 3] })
state.items.push(4) // ①
state.items = [4, 5, 6] // ②
const { items } = state
items.push(7) // ③
const itemsRef = toRef(state, 'items')
itemsRef.value.push(8) // ④测试3:computed 缓存
const a = ref(1)
const b = ref(2)
const sum = computed(() => {
console.log('computed')
return a.value + b.value
})
console.log(sum.value) // 输出 "computed" + 3
console.log(sum.value) // 输出 3(无 "computed")
a.value++
console.log(sum.value) // 输出什么?解释原因。测试4:watch 清理
在以下代码中,用户连续快速输入 "a" → "ab" → "abc",最终 results 的值会有竞态问题吗?为什么?
watch(keyword, async (val, _old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const data = await fetch(`/api/search?q=${val}`, {
signal: controller.signal,
}).then(r => r.json())
results.value = data
})测试5:Composable 设计
以下 Composable 有什么问题?请指出并修复。
export function useCounter() {
const state = reactive({ count: 0 })
const doubled = computed(() => state.count * 2)
return { ...state, doubled }
}测试6:迁移
将以下 Vue 2 mixin 改写为 Vue 3 Composable:
const formMixin = {
data() {
return { loading: false, error: null }
},
methods: {
async submit(data) {
this.loading = true
try {
await this.$axios.post('/api/form', data)
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
},
}参考答案
测试1答案
答案:
track(依赖收集):在 Proxy 的
gettrap 中被调用。当读取响应式对象的属性时,track 将当前正在执行的 effect(如组件的渲染函数)注册为该属性的订阅者。数据结构为targetMap: WeakMap<target, Map<key, Set<effect>>>。trigger(派发更新):在 Proxy 的
set、deleteProperty等 trap 中被调用。当修改/删除属性时,trigger 查找所有订阅了该属性的 effect 并通知它们重新执行(或通过 scheduler 调度)。
测试2答案
答案:
- ① ✅ 触发更新 ——
items是 Proxy 代理的数组,push被 Proxy 拦截并触发 trigger - ② ✅ 触发更新 —— 整体替换
items属性,set trap 触发 trigger - ③ ❌ 不触发更新 ——
const { items } = state解构得到的是 Proxy 代理的数组引用(快照),修改这个数组虽然内部元素会变,但如果后续组件不访问state.items就不会重新渲染。实际上items.push(7)操作的是同一个 Proxy 数组,所以 会触发更新 — 因为 Proxy 数组是同一个引用。但如果是解构基础属性(如const { count } = reactive({ count: 0 })),则 count 是普通值,修改不会触发。 - ④ ✅ 触发更新 ——
toRef保持了与源对象的响应式连接,修改value等价于修改state.items
测试3答案
答案:
输出 "computed" + undefined(实际输出 "computed" + 3)。
解析:a.value++ 将 a 从 1 改为 2,这会触发 sum 的依赖更新通知——将 _dirty 标记为 true。但 computed 是惰性求值的,getter 不会在 a.value++ 时立即执行,而是等到下次读取 sum.value 时才执行。所以第三次 console.log 会输出 "computed" + 4(1+2=3 是旧的;此时 a=2, b=2, 重新计算得 4)。
更正:第一次输出 computed + 3;第二次输出 3(无 computed);a = 2, b = 2;第三次输出 computed + 4。
测试4答案
答案:不会有竞态问题。
解析:每次 watch 回调执行前,Vue 会先调用上一次的 onCleanup 回调。用户输入 "a"→"ab"→"abc" 的过程中:
- 输入 "a" → 创建 AbortController A → 发起请求 A
- 输入 "ab" → 先执行 onCleanup →
controller.abort()取消请求 A → 创建 AbortController B → 发起请求 B - 输入 "abc" → 先执行 onCleanup →
controller.abort()取消请求 B → 创建 AbortController C → 发起请求 C
只有请求 C 的结果会写入 results.value。已被 abort 的请求会抛出 AbortError,但该错误被浏览器静默处理,不会污染 results。
测试5答案
答案:return { ...state, doubled } 中展开 state 会丢失响应式。
...state 将 reactive 对象的属性复制为普通值(count: 0 而非 Ref<number>)。调用方拿到的是一个不会触发更新的普通数值。
修复:
export function useCounter() {
const count = ref(0) // 使用 ref 而非 reactive
const doubled = computed(() => count.value * 2)
return { count, doubled }
}
// 调用方:const { count, doubled } = useCounter()
// count 是 Ref<number>,template 中自动解包,script 中用 .value或使用 toRefs:
export function useCounter() {
const state = reactive({ count: 0 })
const doubled = computed(() => state.count * 2)
return { ...toRefs(state), doubled }
}测试6答案
答案:
export function useFormSubmit(url: string) {
const loading = ref(false)
const error = ref<string | null>(null)
async function submit(data: unknown) {
loading.value = true
error.value = null
try {
return await $fetch(url, { method: 'POST', body: data })
} catch (e) {
error.value = (e as Error).message
throw e // 让调用方也能捕获
} finally {
loading.value = false
}
}
return { loading, error, submit }
}
// 使用:
const { loading, error, submit } = useFormSubmit('/api/form')
await submit({ name: 'Alice' })相关笔记
- [[01-vue-components-and-templates]] - 组件基础与模板语法
- [[04-pinia-state-management]] - Pinia 状态管理
- [[../02-react-and-nextjs/00-overview]] - React 生态对比
- [[04-vue-router]] - Vue Router 路由管理
下一步学习
- [ ] 阅读 04 - 状态管理与 Pinia
- [ ] Vue 官方文档:Reactivity in Depth
- [ ] Vue 官方文档:Composables
学习状态:🟡 开始学习