CC 咖啡猫的工作空间 Coding Space

Vue 3 深度笔记

本文聚焦 Vue 3,以组合式 API 和编译优化为主线,覆盖响应式原理、虚拟 DOM、路由、状态管理等核心领域。


1. 响应式原理

1.1 Vue 2:Object.defineProperty 的局限

Vue 2 通过 Object.defineProperty 递归遍历 data 对象,为每个属性定义 getter/setter。

核心局限:

  • 无法检测属性的新增和删除 — 必须用 Vue.set() / Vue.delete()
  • 无法检测数组索引赋值和长度变更 — 重写了 7 个数组方法(push/pop/shift/unshift/splice/sort/reverse),但直接 arr[index] = val 无效
  • 递归深度大 — 初始化时一次性递归全部属性,对象嵌套深时性能开销大
  • 无法代理 Map/Set 等新数据结构

1.2 Vue 3:Proxy

const handler = {
  get(target, key, receiver) {
    track(target, key)          // 依赖收集
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    trigger(target, key)        // 触发更新
    return result
  }
}

Proxy 优势:

  • 拦截整个对象而非属性,新增/删除属性自动响应
  • 数组无需特殊 hack,下标赋值、length 变更均可拦截
  • 拦截操作丰富(has/deleteProperty/ownKeys 等 13 种)
  • 懒递归 — 只有 get 到嵌套对象时才将其包装为响应式

1.3 ref vs reactive

特性 ref reactive
数据类型 任意 仅对象
访问方式 .value(template 中自动 unwrap) 直接访问
原理 使用 reactive 包装 .value,本质是 { value: reactive(obj) } Proxy 直接代理
解构 解构后仍响应(自动 unwrap) 解构后丢失响应(需 toRefs)
追踪 通过 RefImpl 类管理依赖 通过 Proxy 捕获

核心原则:

  • 涉及基础类型或需要重新赋值时用 ref
  • 复杂对象结构用 reactive
  • 如果团队风格统一,全用 ref 也可以(Vue 官方推荐场景偏向 ref)

1.4 shallowRef / shallowReactive

只对顶层属性做响应式处理,深层次修改不触发更新。适用于大对象或第三方状态管理。

const state = shallowRef({ a: { b: 1 } })
state.value.a.b = 2  // 不触发响应
state.value = { a: { b: 2 } }  // 触发响应(顶层引用变化)

1.5 toRaw / markRaw

import { toRaw, markRaw } from 'vue'

const original = { foo: 1 }
const reactiveObj = reactive(original)
toRaw(reactiveObj) === original  // true

// markRaw 跳过代理,适合第三方实例或不可变数据
const rawObj = markRaw({ foo: 1 })
reactive(rawObj)  // 仍为原始对象

1.6 readonly

将响应式对象变为只读代理(递归),内部调用 reactive 包装后替换 setter。

const state = reactive({ count: 0 })
const readOnlyState = readonly(state)
readOnlyState.count++  // warn: Set operation on key "count" failed

1.7 effectScope

用于管理多个 effect(watch/computed/watchEffect)的生命周期,适用于组件库、插件或需批量停止响应的场景。

const scope = effectScope()
scope.run(() => {
  const doubled = computed(() => state.count * 2)
  watch(doubled, (val) => console.log(val))
})
// 批量停止
scope.stop()

替代 Vue 2 中 vm.$destroy 的碎片化清理。


2. 组合式 API

2.1 setup

setup() 在组件创建前执行,接收 propscontext,返回的值暴露给模板。

// <script setup> 语法(推荐)
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>

<script setup> 的优势:更少的样板代码、更好的类型推断、顶层绑定自动暴露给模板。

2.2 ref / reactive

见上节。

2.3 computed(缓存原理)

computed 本质是一个 ComputedRefImpl 类,内部维护一个 dirty 标志(懒求值):

第一次读取 computed → dirty === true → 执行 getter → 缓存结果 → dirty = false
依赖变化 → trigger → dirty = true
再次读取 → dirty === true → 重新求值
  • 只有依赖变化且被读取时才重新计算(惰性求值
  • 无依赖变化的重复读取直接返回缓存
  • 可写 computed:传入 { get, set } 对象
const count = ref(1)
const doubled = computed(() => count.value * 2)
// 可写 computed
const plusOne = computed({
  get: () => count.value + 1,
  set: (val) => { count.value = val - 1 }
})

2.4 watch / watchEffect

特性 watch watchEffect
执行时机 懒执行(监听变化后执行) 立即执行一次
访问依赖 显式指定监听源 自动追踪函数内响应式依赖
旧值 提供 不提供
场景 需对比新旧值、复杂监听 只需执行副作用,无需旧值
// watch
watch(source, (newVal, oldVal) => {}, {
  deep: true,       // 深度监听(开销大)
  immediate: true,  // 立即执行
  flush: 'post'     // post(默认,DOM更新后) | pre(DOM更新前) | sync(同步)
})

// watchEffect
watchEffect(() => console.log(state.count), {
  flush: 'post'     // 推荐使用 'post' 避免 DOM 未更新问题
})

flush 选项

  • pre(默认):在组件更新前执行,此时 DOM 尚未更新
  • post:在组件更新后执行,此时 DOM 已更新
  • sync:响应式依赖变化时同步执行,性能最差

2.5 生命周期钩子

onMounted(() => {})      // 挂载完成
onUpdated(() => {})      // 更新后
onUnmounted(() => {})    // 卸载前清理
onBeforeMount(() => {})
onBeforeUpdate(() => {})
onBeforeUnmount(() => {})
onErrorCaptured(() => {})
onActivated(() => {})    // 配合 KeepAlive
onDeactivated(() => {})  // 配合 KeepAlive

Vue 3 中 beforeDestroyonBeforeUnmountdestroyedonUnmounted

2.6 provide / inject

// 祖先组件
provide('theme', ref('dark'))
provide('userService', new UserService())

// 后代组件
const theme = inject('theme', 'light')  // 第二个参数为默认值

配合 readonly 防止子组件修改注入值:

provide('theme', readonly(ref('dark')))

3. 虚拟 DOM 与 Diff

3.1 VNode 结构

interface VNode {
  __v_isVNode: true
  type: string | Component | Fragment | Text | Static
  props: Record<string, any> | null
  children: VNode[] | string | null
  patchFlag?: number        // Vue 3 编译优化标志
  dynamicProps?: string[]   // 动态属性列表
  staticCount?: number
  shapeFlag: number
  key: any
  el: HostElement | null     // 真实 DOM 引用
}

3.2 patch 过程

function patch(n1, n2, container) {
  if (n1 === n2) return
  if (n1 && !isSameVNode(n1, n2)) {
    unmount(n1)
    n1 = null
  }
  switch (n2.type) {
    case Text:     processText(n1, n2, container); break
    case Fragment: processFragment(n1, n2, container); break
    case Static:   processStatic(n1, n2, container); break
    default:
      if (shapeFlag & ShapeFlags.COMPONENT) processComponent(n1, n2, container)
      else processElement(n1, n2, container)
  }
}

3.3 双端 Diff 算法(Vue 2)

Vue 2 使用双端比较策略,四个指针(oldStart、oldEnd、newStart、newEnd)同时向中间靠拢:

  1. 新旧头头比较
  2. 新旧尾尾比较
  3. 旧头 vs 新尾
  4. 旧尾 vs 新头
  5. 以上都不匹配时,用 key 建立映射表

优势:比普通 diff 更优地处理了 DOM 移动场景。 局限:对静态节点仍需遍历比较。

3.4 快速 Diff(Vue 3)

Vue 3 基于预处理 + 最长递增子序列(LIS)的优化策略:

1. 从头开始同步(sync from start)— 相同 key 的节点逐个比较直至不同
2. 从尾开始同步(sync from end)— 尾部相同 key 节点逐个比较直至不同
3. 剩余节点处理:
   a. 新节点多 → 挂载新节点
   b. 旧节点多 → 卸载旧节点
   c. 位置不确定 → 构建新下标映射表 + 最长递增子序列(最小化移动)

最长递增子序列的应用:找到不需要移动的节点,只移动其他节点。例如:

旧序列: [A, B, C, D, E]
新序列: [A, C, B, E, D]
新索引数组: [0, 2, 1, 4, 3]  // 元素在新列表中的位置
最长递增子序列: [0, 2, 4]     // A、C、E 不需要移动

4. 编译优化

4.1 静态提升(Static Hoisting)

将静态节点提升到 render 函数之外,避免每次渲染都重新创建。

<!-- 模板 -->
<div><span>静态文本</span><span>{{ dynamic }}</span></div>

<!-- 编译后 -->
const _hoisted_1 = _createElementVNode("span", null, "静态文本", -1 /* HOISTED */)

function render() {
  return _openBlock(), _createBlock("div", null, [
    _hoisted_1,
    _createElementVNode("span", null, _toDisplayString(dynamic), 1 /* TEXT */)
  ])
}

4.2 预字符串化(Static Stringify)

连续多个静态节点(阈值默认 20 个)合并为字符串,减少 VNode 创建。

<div>
  <span>1</span><span>2</span><span>3</span>...  <!-- 20+ 个静态 span -->
</div>

<!-- 编译为 -->
_createStaticVNode("<span>1</span><span>2</span><span>3</span>...")

createTextVNode 粒度更大、性能更好。

4.3 PatchFlags

编译时标记动态内容的类型,patch 阶段跳过静态部分。

enum PatchFlags {
  TEXT = 1,           // 动态文本
  CLASS = 2,          // 动态 class
  STYLE = 4,          // 动态 style
  PROPS = 8,          // 动态属性(非 class/style)
  FULL_PROPS = 16,    // 动态 key 的属性
  HYDRATE_EVENTS = 32,// 需要水合事件
  STABLE_FRAGMENT = 64,
  KEYED_FRAGMENT = 128,
  UNKEYED_FRAGMENT = 256,
  NEED_PATCH = 512,
  DYNAMIC_SLOTS = 1024,
  // ...
  HOISTED = -1,       // 静态提升节点
  BAIL = -2           // 退出优化,回退完整 diff
}

4.4 Block Tree

Vue 3 将模板结构化分为 Block(带动态子节点的节点),形成树状结构:

Block 树结构:
  div (block) [dynamicChildren = [span, p]]
    ├── span 动态文本  ← 加入 dynamicChildren
    ├── p :title 动态属性  ← 加入 dynamicChildren
    └── div (block)
         └── input v-model  ← 加入子 block 的 dynamicChildren
  • 只有 dynamicChildren 变化时才需要 diff
  • 静态结构变化(v-if/v-for)会创建新的 Block
  • patch 时只遍历 dynamicChildren,跳过静态部分

4.5 靶向更新(Targeted Update)

结合 PatchFlags 和 Block Tree,Vue 3 在 patch 时只更新标记的动态内容,不再逐层遍历整个 VNode 树,实现 O(动态节点数量) 的更新复杂度。


5. 组件

5.1 Props / Emits / Expose

defineProps<{
  title: string
  count?: number
}>()

const emit = defineEmits<{
  (e: 'update', id: number): void
  (e: 'delete'): void
}>()

// expose 暴露给父组件通过 ref 访问
defineExpose({
  reset() { count.value = 0 }
})

5.2 Slots

<!-- 默认插槽 -->
<slot />

<!-- 具名插槽 -->
<slot name="header" />

<!-- 作用域插槽 -->
<slot name="item" :data="item" :index="i" />

<!-- 使用 -->
<template #item="{ data, index }">
  <span>{{ index }}: {{ data.name }}</span>
</template>

5.3 动态组件 & 异步组件 & Suspense

<!-- 动态组件 -->
<component :is="currentComponent" />

<!-- 异步组件 -->
<script setup>
const Heavy = defineAsyncComponent(() => import('./HeavyComp.vue'), {
  loadingComponent: Loading,
  delay: 200,        // 延迟显示 loading
  timeout: 5000,     // 超时错误
  errorComponent: ErrorComp
})
</script>

<!-- Suspense -->
<Suspense>
  <template #default>
    <AsyncComponent />   <!-- 异步依赖 resolved 前显示 fallback -->
  </template>
  <template #fallback>
    <Loading />
  </template>
</Suspense>

defineAsyncComponent 返回一个高阶组件,内部处理加载状态、超时、错误等。

5.4 Teleport

将子节点渲染到 DOM 的指定位置(如 body、modal 根节点),避免 CSS 层级问题。

<Teleport to="body">
  <Modal :show="showModal" />
</Teleport>

6. Vue Router

6.1 Hash vs History 模式

模式 URL 原理 后端配置
hash /#/path hashchange 事件,不会发送到服务端 不需要
history /path pushState + popstate,需服务端 fallback 需要(Nginx try_files /index.html)

6.2 动态路由

const routes = [
  { path: '/users/:id', component: UserDetail },
  // 正则匹配
  { path: '/articles/:slug(\\d+)', component: ArticleDetail },
]
// 通过 useRoute().params.id 获取

6.3 导航守卫

// 全局前置守卫
router.beforeEach((to, from) => {
  if (!isAuthenticated && to.name !== 'login') return '/login'
})

// 全局解析守卫
router.beforeResolve((to) => {})

// 全局后置钩子
router.afterEach((to, from) => { sendAnalytics() })

// 路由级守卫
{
  path: '/admin',
  component: Admin,
  beforeEnter: (to) => { /* ... */ }
}

// 组件内守卫
onBeforeRouteLeave((to, from) => {
  if (hasUnsaved) return '确认离开?'
})
onBeforeRouteUpdate(async (to) => {
  await loadData(to.params.id)
})

6.4 路由懒加载

const UserDetail = () => import('@/views/UserDetail.vue')
// 配合 webpack 注释或 Vite 的 rollupOptions 做代码分割
const UserDetail = () => import(/* webpackChunkName: "user" */ '@/views/UserDetail.vue')

7. Pinia(状态管理)

7.1 Option Store vs Setup Store

// Option Store(类似 Vuex)
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2,
  },
  actions: {
    increment() { this.count++ }
  }
})

// Setup Store(组合式风格)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubled = computed(() => count.value * 2)
  function increment() { count.value++ }
  return { count, doubled, increment }
})
  • Setup Store 更灵活,可混合 composables
  • Option Store 更清晰,DevTools 支持更好
  • 推荐团队统一风格,优先 Setup Store

7.2 state / getters / actions

// 直接修改
store.count++
store.$patch({ count: store.count + 1 })
store.$patch((state) => state.count++)

// 替换整个 state
store.$state = { count: 10 }

// 重置
store.$reset()

// 订阅
store.$subscribe((mutation, state) => {})

7.3 $patch

$patch 将多次修改合并为一次通知,内部遍历 key 或执行回调,之后触发一次订阅。

7.4 持久化插件

// pinia-plugin-persistedstate
export const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  persist: {
    key: 'my-store',
    storage: sessionStorage,
    paths: ['count'],  // 只持久化特定字段
  }
})

7.5 与 Vuex 的区别

特性 Pinia Vuex
架构 去中心化(多个独立 store) 单一全局 Store
TypeScript 原生完美支持 需要大量类型声明
Mutations 无(合并到 actions) 有(mutation + action 分离)
DevTools 支持 支持
体积 ~1KB 更重
模块/命名空间 自动隔离 需 namespaced: true

8. 原理进阶

8.1 nextTick 原理

function nextTick(fn?) {
  return fn ? Promise.resolve().then(fn) : Promise.resolve()
}

内部用 Promise.then(fallback: MutationObserversetImmediatesetTimeout)实现微任务调度。

Vue 响应式更新是同步修改、异步批处理的:trigger 时将一个 flush job 加入微任务队列,所有同步代码执行完后统一执行 DOM 更新。

8.2 keep-alive(LRU 缓存)

<KeepAlive> 内部实现 LRU(Least Recently Used) 缓存策略:

  • 缓存组件实例到 cache 对象(Map)
  • keys 数组维护访问顺序
  • 超出 max 时淘汰最久未使用的组件实例
  • 被缓存的组件会触发 activated / deactivated 生命周期
// 伪代码
if (cache.size >= max) {
  const oldestKey = keys[0]
  pruneCacheEntry(oldestKey) // 移除最久未使用的
}
keys.push(key) // 最新使用

8.3 v-model 原理

Vue 3 中 v-model:modelValue + @update:modelValue 的语法糖。

<!-- 等价 -->
<input v-model="text" />
<input :value="text" @input="text = $event.target.value" />

<!-- 自定义组件 -->
<Child v-model="val" />
<!-- 等价于 -->
<Child :modelValue="val" @update:modelValue="val = $event" />

<!-- 多个 v-model -->
<Child v-model:title="title" v-model:content="content" />

8.4 v-if vs v-show

特性 v-if v-show
渲染 条件性创建/销毁 DOM 始终渲染,切换 display
初始化开销 条件假时不渲染 始终渲染
切换开销 销毁/重建组件 + DOM 仅修改 CSS
适用场景 运行时切换少的场景 频繁切换的场景

8.5 v-for 中 key 的作用

Vue 的 Diff 算法以 key 作为节点身份标识:

  • 有 key → 尽量复用/移动节点,减少 DOM 操作
  • 同 key 节点比较后 patch,不同 key 视为不同节点
  • 无 key → 默认 inline 比较(索引对比),可能导致不必要的 DOM 复用和状态错乱

最佳实践:用稳定且唯一的 key(如数据 id),避免用 index 或随机值。

8.6 computed 的惰性求值

详见 2.3 节。核心:ComputedRefImpl 通过 _dirty 标志实现懒缓存,依赖变化仅标记 dirty,不做实际计算,直到被读取。


9. 性能优化

9.1 组件懒加载

const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))

仅在需要时加载组件代码。结合 Vue Router 的路由懒加载效果更佳。

9.2 合理使用 shallowRef

处理大型数据(图表数据、长列表、不可变数据)时使用 shallowRef 避免深度响应式的开销:

const largeDataset = shallowRef([])
// 修改时替换整个引用
largeDataset.value = newData

9.3 大列表虚拟滚动

使用虚拟滚动库(如 vue-virtual-scrollervueucLazyVirtualList)只渲染可视区域内的节点:

  • 固定容器高度
  • 计算总滚动高度(撑开滚动条)
  • 根据 scrollTop 计算可视范围,只渲染可见项

9.4 v-memo

Vue 3.2+,跳过指定依赖未变化时的子节点渲染:

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.updated]">
  <!-- 仅当 item.id 或 item.updated 变化时重新渲染 -->
  <ExpensiveComponent :data="item" />
</div>

注意:v-memo 与 v-for 共用时需配合有 key 的 v-for。

9.5 KeepAlive

缓存频繁切换的动态组件:

<KeepAlive :max="10">
  <component :is="tabComponent" />
</KeepAlive>

避免组件重复销毁/重建,配合 max 限制缓存数量防止内存泄漏。