Skip to content

前端核心知识点 03

响应式原理

Vue 2 和 Vue 3 的响应式实现有什么区别?

Vue 2 响应式原理

核心机制

  • 使用 Object.defineProperty 对 data 对象的每个属性进行递归遍历,定义 getter/setter。
  • 在 getter 中收集依赖(Dep + Watcher),在 setter 中触发更新。

局限性

无法监听新增/删除属性

因为 defineProperty 只能作用于已存在的属性。新增属性不会被自动劫持,需手动调用 Vue.set / this.$set。

数组问题

直接通过索引赋值(如 arr[0] = val)或修改 length 不会触发更新。 Vue 2 通过重写数组原型方法(push/pop/splice 等)来间接触发更新,但仍有盲区。

初始化开销大

需要深度遍历整个 data 对象,即使某些属性从未被访问过。

不支持 Map/Set/WeakMap 等结构

因为它们不是普通对象,无法用 defineProperty 劫持。

Vue 3 响应式原理(基于 Proxy + Reflect)

1. reactive(obj) —— 基于 Proxy

  • 对传入的对象(包括数组、Map、Set 等)创建一个 Proxy 代理。
  • 拦截操作包括:get、set、deleteProperty、has、ownKeys 等。

优势

  • 自动支持动态增删属性(无需 Vue.set)。
  • 完整支持数组索引和 length 修改。
  • 支持原生集合类型(Map/Set/WeakMap/WeakSet)。
  • 懒代理(Lazy Proxy):只有当属性被访问时,才会对其嵌套对象进行代理(避免无谓的递归)。

注意

reactive 不能用于原始值(string/number/boolean),因为 Proxy 只能代理对象。

2. ref(value) —— 基于 封装对象 + getter/setter

  • ref 内部创建一个 { value: xxx } 的对象,并对该对象的 .value 属性使用类似 Vue 2 的 defineProperty(或内部使用 class 的 getter/setter)来实现响应式。
  • 为什么不用 Proxy?因为原始值无法被 Proxy 代理。
  • 在模板中使用时,Vue 会自动 .value 解包(仅限模板和 setup() 返回的对象)。

注意

ref 就是一个带有 getter/setter 的 class 实例,它的 .value 是一个访问器属性,读写时自动触发依赖收集和派发更新

3. shallowReactive / shallowRef 等变体

reactive:递归深层代理

无论数据嵌套多深,修改任何层级的属性都能触发依赖更新。但代价是:每次访问嵌套对象时,都可能创建新的 Proxy 实例,带来额外的内存和性能开销

shallowReactive 的实现则完全不同。它创建的 Proxy 只拦截第一层属性的读写,对嵌套对象不做任何处理

ref 与 shallowRef:值的包裹深度

ref 会对 .value 中的对象自动进行深层响应式处理

shallowRef 仅将 .value 本身作为响应式引用,不对内部对象进行转换

shallowReactive / shallowRef真实应用场景分析

  • 当你的组件需要渲染一个大型的、从外部 API 获取的 JSON 数据(如树形目录、地理信息等),且这些数据在组件内部不会被修改时,使用 shallowReactive 或 shallowRef 是理想选择
  • 第三方库的实例(如 echarts 实例、地图引擎实例)或 DOM 元素通常包含复杂的内部结构,且不应被 Vue 的响应式系统侵入
  • 在渲染大型列表时,如果每个列表项都是深层响应式对象,滚动或交互时可能会有明显卡顿
  • 当多个自定义 Hook 共享一个大型状态对象时,若每次都传递深层响应式对象,可能会导致不必要的依赖追踪

4. triggerRef:打破被动,主动触发更新

shallowRef 的响应式机制完全依赖于 .value 的读写。当你修改 state.value.count 时,实际上是在操作一个普通对象, 完全绕过了 shallowRef 的 getter/setter,因此既不会触发 track,也不会触发 trigger

triggerRef 的作用正是解决这一问题。它允许开发者在修改 shallowRef 内部值后, 手动通知 Vue 系统:“.value 虽然没变,但它的内容已更新,请重新触发依赖”。

triggerRef真实应用场景

  • 在使用不可变数据结构时,状态更新通常通过纯函数产生新对象,而非直接修改。shallowRef 配合 triggerRef 可以高效地集成此类模式。
  • 当 shallowRef 存储的对象由外部系统(如 WebSocket、定时器、Web Worker)修改时,Vue 无法自动检测变化
  • 在需要频繁修改 shallowRef 内部状态的场景中,直接替换 .value 可能导致不必要的对象创建和垃圾回收。

customRef 的 API 设计

customRef 接收一个工厂函数作为参数,该函数返回一个包含 get 和 set 方法的对象

js
function customRef(factory) {
  const ref = factory(
    () => track(ref, 'value'),   // track 函数
    () => trigger(ref, 'value')  // trigger 函数
  );
  ref.__v_isRef = true;
  return ref;
}

factory 函数接收两个参数:track 和 trigger。 开发者可以在 get 中调用 track 来声明依赖,在 set 中调用 trigger 来通知依赖更新。 customRef 不提供自动的响应式追踪,一切行为由你定义

customRef 的高级应用

  • 深度防抖对象
  • 带脏检查的 ref
  • 与外部存储同步

解构响应式的隐形陷阱

const { count } = state 丢失响应性的根本原因在于: 解构操作将响应式属性的值提取为普通变量,切断了与源对象的响应式连接。get 拦截器虽被触发,但无法建立持续的依赖关系。

toRef 的作用是为响应式对象的某个属性创建一个 ref,这个 ref 与源对象的属性保持同步。

js
const state = reactive({ count: 0 });
const countRef = toRef(state, 'count');

countRef.value++; // 等价于 state.count++
state.count++;    // countRef.value 也会更新

toRef 创建的 ref 与源对象的属性是双向绑定的。它既保留了响应性,又提供了 ref 的统一接口

toRefs 的实现本质上是对对象的每个属性调用 toRef

为什么不能自动解包嵌套 ref?

你可能会问:既然模板能自动解包根级 ref,为什么不能自动解包 toRefs 解构后的 ref?

答案是:JavaScript 的解构赋值无法被拦截。

模板中的自动解包是由 Vue 的编译器在编译时实现的。编译器知道 setup 返回的对象结构,可以静态分析并生成解包代码。

而 const { count } = toRefs(state) 是纯粹的 JavaScript 运行时行为。JavaScript 引擎在执行解构时,只是简单地将 toRefs(state).count 的值(一个 ref 对象)赋给 count 变量。这个过程无法被 Vue 拦截或修改。

因此,开发者必须显式地通过 .value 来访问 ref 的值,这是语言层面的限制所决定的。

解包规则

模板中的解包规则

  • 根级属性解包: 当 ref 作为 setup 返回对象的根级属性时,在模板中会被自动解包
  • v-model 绑定: v-model 会自动处理 ref,无需 .value
  • v-for 中的解包: 在 v-for 循环中,如果迭代的是 ref 数组,数组元素会被解包

不解包的边界情况

  • 嵌套对象中的 ref: 如果 ref 是嵌套在普通对象中的属性,不会被解包
  • 数组中的 ref 元素: 如果数组本身不是 ref,但元素是 ref,则不会自动解包
  • 作为函数参数传递: 当 ref 作为方法参数传递时,接收函数需要手动解包

unref 与 toRaw

unref 是一个通用工具函数,用于解包一个值。它的行为取决于输入值的类型

实现原理

ts
function unref(value) {
  return isRef(value) ? value.value : value;
}

unref 的本质是一个条件解包器,它对 ref 进行解包,对普通值透明传递。

使用场景

  • unref 常用于编写通用函数,这些函数需要同时处理 ref 和普通值
  • 在 computed 或 watch 中也常见

toRaw:直达原始对象的通道

toRaw 的实现依赖于在创建 Proxy 时建立的反向引用

ts
function reactive(target) {
  // ... 其他逻辑
  
  const observed = new Proxy(target, mutableHandlers);

  // 关键:在原始对象上设置 __v_raw 属性,指向代理
  def(target, '__v_raw', observed);
  // 在代理对象上设置 __v_raw 属性,指向原始对象
  def(observed, '__v_raw', target);

  return observed;
}

toRaw 函数的实现

ts
function toRaw(observed) {
  // 如果传入的是代理对象,返回其 __v_raw(即原始对象)
  // 如果传入的是原始对象,__v_raw 可能不存在或指向自身
  const raw = observed && observed.__v_raw;
  return raw ? toRaw(raw) : observed;
}

详细解析

  • observed.__v_raw 直接指向创建 Proxy 时的原始 target。
  • 函数使用递归确保即使传入的是嵌套的代理,也能最终拿到最原始的对象。
  • 如果传入的是普通对象(无 __v_raw),则直接返回。

为什么需要 __v_raw 反向引用?

Proxy 本身不提供直接访问目标对象的 API。__v_raw 是 Vue 主动建立的桥梁,使得 toRaw 能够可靠地获取原始对象

toRaw 的必须使用场景

  • 避免不必要的响应式开销
  • 与不兼容 Proxy 的第三方库集成
  • 对象身份比较
  • 序列化与持久化
  • 绕过响应式限制

模板渲染

onMounted 的执行时机

生命周期的宏观流程

  • setup():执行组合式 API 的逻辑,返回渲染上下文。
  • onBeforeMount:在组件挂载前调用。
  • DOM 挂载:Vue 将虚拟 DOM (VNode) 转换为真实 DOM 并插入页面。
  • onMounted:在组件挂载完成后调用

Vue 的异步更新队列

Vue 2.x 和 3.x 都采用异步更新队列来优化性能。当你修改响应式数据时,Vue 不会立即更新 DOM,而是将组件的更新操作推入一个队列, 并在“下一个 tick”时批量执行。这个“下一个 tick”由 nextTick 实现, 它利用 Promise.then、MutationObserver 或 setTimeout 等微/宏任务机制,确保 DOM 更新在当前同步代码执行完毕后进行

onMounted 的实际执行时机

onMounted 并非在 mount 函数执行完毕后立即同步调用。它的执行被安排在 Vue 的异步更新队列中

关键点在于 queuePostFlushCb(instance.m)。它将 onMounted 回调放入一个队列,该队列会在DOM 更新刷新(flush)后执行

queuePostFlushCb 的执行时机与 nextTick 高度相关。具体来说

  • onMounted 的回调被安排在 nextTick 的回调队列中。
  • 它会在 DOM 更新完成后,但在浏览器下一次重绘之前执行。

因此,onMounted 的执行时机等价于在 nextTick 的回调中执行。

必须使用 nextTick 的场景

  • 访问更新后的 DOM 内容
  • 触发需要最终布局的第三方库
  • 动画的精确控制

onRenderTracked 和 onRenderTriggered

性能调优应用

  • 诊断不必要的渲染: 如果组件在数据未实际改变时被触发(例如,newValue === oldValue),说明存在不必要的更新。这可能是由于对象引用改变但内容相同
  • 定位性能瓶颈: 通过 trace 和 dep 信息,可以追踪到是哪个数据的改变导致了组件的重新渲染。这对于大型应用中排查“谁在触发渲染”非常有用
  • 优化响应式设计: 如果发现一个大型组件因为一个微小的、不相关的数据改变而重新渲染,说明响应式数据的粒度太粗。应考虑将状态拆分,或使用 shallowRef、markRaw 等工具

实际性能调优案例

场景:列表组件的过度渲染

假设有一个列表组件,当列表项的某个不相关属性改变时,整个列表重新渲染

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

const items = ref([
  { id: 1, name: 'A', status: 'active' },
  { id: 2, name: 'B', status: 'inactive' }
]);

// 模拟外部状态改变
const globalState = ref({ version: 1 });

onRenderTriggered((event) => {
  if (event.target === items.value[0] && event.key === 'status') {
    console.log('列表因 status 改变而渲染');
  } else if (event.target === globalState.value) {
    console.warn('警告:列表因 globalState 改变而渲染!', event);
  }
});
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

通过 onRenderTriggered,你可以立即发现 globalState 的改变错误地触发了列表渲染,从而定位到状态管理的问题

render与VNode创建过程

从模板到 DOM 的旅程

Vue 应用的渲染流程可以概括为

  1. 模板 (Template) 或 JSX:开发者编写的声明式 UI 代码。
  2. 编译 (Compile):在构建时,Vue 的编译器将模板编译成 render 函数。
  3. 执行 (Execute):运行时,render 函数被执行,返回一个 VNode 树。
  4. 挂载/更新 (Mount/Update):Vue 的渲染器(Renderer)将 VNode 树转换为真实 DOM,并响应数据变化进行高效更新

h() 函数:VNode 的创建工厂

函数签名

text
function h(
  type: string | Component,      // 节点类型
  props?: object | null,         // 属性/props
  children?: VNodeChildren        // 子节点
): VNode

h() 返回了什么?

h() 返回一个 VNode 对象,它是一个轻量级的、纯 JavaScript 的数据结构,用于描述一个 DOM 节点或组件实例

一个典型的 VNode 对象包含以下关键属性

text
{
  __v_isVNode: true,        // 私有标志,标识这是一个 VNode
  type: 'div',              // 节点类型:标签名、组件、函数式组件等
  props: { id: 'app' },     // 节点的属性/props
  children: [...],          // 子 VNode 数组或字符串
  key: 'unique-key',        // 用于 diff 算法的唯一标识
  el: null,                 // 指向对应的真实 DOM 元素(挂载后)
  component: null,          // 如果是组件 VNode,指向组件实例
  shapeFlag: 6,             // 形状标志,描述节点类型(如元素、组件、有 children 等)
  patchFlag: 0              // 优化标志,用于编译时静态提升
}

h() 的作用就是根据传入的参数,构造出这样一个结构化的对象。

h() 使用示例

text
import { h } from 'vue';

// 创建一个普通元素 VNode
const vnode1 = h('div', { id: 'container' }, [
  h('h1', 'Hello'),
  h('p', 'World')
]);

// 创建一个组件 VNode
const MyComponent = { /* 组件选项 */ };
const vnode2 = h(MyComponent, { msg: 'Hello' });

VNode 如何描述一个组件?

VNode 不仅能描述原生 DOM 元素,还能描述组件。这是 Vue 组件化能力的基础。

当 h() 的第一个参数是一个组件(对象或函数)时,生成的 VNode 会以不同方式描述组件:

组件 VNode 的关键属性

  • type: 指向组件的定义对象(如 { setup, render, ... })。
  • props: 传递给组件的 props。
  • children: 插槽内容(slot content)。
  • component: 在组件实例创建后,此属性会指向组件的 ComponentInternalInstance。

组件实例的创建过程

  1. 创建组件实例
  2. 设置组件 VNode 的引用
  3. 调用 setup()
  4. 执行组件的 render 函数
  5. 挂载子树

组件 VNode 的关键属性

VNode 的优化机制

Vue 3 通过 VNode 上的额外信息实现高效更新:

shapeFlag

一个位掩码(bitmask),描述 VNode 的“形状”:

  • 1:普通元素
  • 2:函数式组件
  • 4:有状态组件
  • 8:带有文本子节点
  • 16:带有数组子节点

渲染器根据 shapeFlag 快速判断节点类型,避免冗余检查。

patchFlag

由编译器生成的优化标志,标记 VNode 的动态部分:

  • 1:文本内容动态
  • 2:类名动态
  • 4:样式动态
  • 8:全属性动态
  • 16:关键属性动态(如 id、value)

在 patch(更新)过程中,渲染器可以跳过静态部分,只更新动态部分,极大提升性能。

diff 算法的核心逻辑

patch 过程概览

patch 函数是 Vue 渲染器的“大脑”,其主要职责是:

  • 比较新旧 VNode:判断它们是否代表同一个节点。
  • 更新节点:如果节点可复用,则更新其属性、子节点等。
  • 替换节点:如果节点不可复用,则销毁旧节点,创建并挂载新节点。

patch 是一个递归过程,从根节点开始,逐层对比子节点。

key 的作用:节点的唯一标识

key 是 VNode 上一个至关重要的属性,它为 diff 算法提供了节点的身份标识

当存在 key 时,算法可以基于 key 进行映射 算法只需执行三次移动操作(move),而无需更新文本内容,性能大幅提升

key 的最佳实践

  • 避免使用索引作为 key:当列表顺序改变时,索引会变,失去身份标识作用。
  • 使用稳定、唯一的值:如数据库 ID、UUID。
  • 不稳定的 key 会适得其反:频繁变化的 key 会导致节点无法复用,每次都重新创建

双端对比(Dual-mode Diffing)

Vue 3 采用了一种称为“双端对比”的 diff 策略,它通过两个指针(头尾指针)从数组的两端同时进行对比,以最大化复用效率。

双端对比能高效处理以下常见场景:

  • 列表末尾添加/删除元素。
  • 列表开头添加/删除元素。
  • 列表整体反转。
  • 列表部分元素移动。

快速路径优化:sameVNodeType

Vue 3 的 diff 算法包含多个“快速路径”优化,sameVNodeType 是最核心的一个

sameVNodeType 函数

它通过比较 type 和 key 来判断两个 VNode 是否代表同一个节点。

  • type 相同:确保是同一类节点(如都是 div 或都是 MyComponent)。
  • key 相同:确保是同一个实例。

快速路径的执行

如果 isSameVNodeType(n1, n2) 为 true,算法会进入快速更新流程

ts
if (isSameVNodeType(n1, n2)) {
  // 节点可复用,直接 patch
  patchElement(n1, n2);
  // 更新引用
  n2.el = n1.el;
  n2.component = n1.component;
}

patchElement 会进一步比较 props、children 等,并应用 patchFlag 进行细粒度更新

避免不必要的操作

快速路径优化避免了:

  • 销毁和重建 DOM 节点。
  • 重新创建组件实例。
  • 重新绑定事件监听器。

这些操作开销巨大,快速路径能显著提升性能

复杂 diff 与 key 映射表

当双端对比无法继续时,算法进入复杂 diff 阶段:

创建 key 映射表

ts
const keyToNewIndexMap = new Map();
for (let i = newStartIndex; i <= newEndIndex; i++) {
  const child = newChildren[i];
  if (child.key != null) {
    keyToNewIndexMap.set(child.key, i);
  }
}

遍历 oldChildren

  • 对每个 oldChild,在 keyToNewIndexMap 中查找其 key。
  • 如果找到,说明节点可复用,执行 patch 并标记为已处理。
  • 如果未找到,说明节点被删除,执行 unmount

处理新增节点

遍历 keyToNewIndexMap,找出未被复用的新节点,执行 mount

props变化触发子组件更新

起点:props 变化与 trigger

中间层:queueJob 与异步队列

queueJob 是更新流程的“交通指挥官”,它负责管理待执行的任务队列,确保更新的高效和去重

为什么需要队列?

  • 避免重复更新:如果同一组件在短时间内被多次触发,只需更新一次。
  • 批量更新:多个状态变化可以合并为一次 DOM 更新,提升性能。
  • 保证执行顺序:父组件先更新,子组件后更新。

queueJob 的实现

ts
const queue = [];        // 任务队列
const pending = false;   // 是否正在等待 flush

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job);
    queueFlush();
  }
}
  • 去重:检查 job 是否已在队列中,避免重复。
  • 调度 flush:调用 queueFlush() 安排队列的刷新。

queueFlush 与 nextTick

ts
function queueFlush() {
  if (!pending) {
    pending = true;
    nextTick(flushJobs);
  }
}
  • nextTick 将 flushJobs 推入微任务队列。
  • 确保所有同步代码执行完毕后,再批量执行更新任务。

终点:scheduler 与 flushJobs

scheduler 是 Vue 更新流程的“总调度器”,它协调所有 effect 的执行。

flushJobs:执行队列

ts
function flushJobs() {
  pending = false;
  // 对队列进行排序,确保父组件先于子组件更新
  queue.sort((a, b) => a.id - b.id);

  for (const job of queue) {
    job(); // 执行 effect.run
  }

  queue.length = 0; // 清空队列
}

关键点

排序:effect 有 id,按 id 升序执行,保证更新顺序。 批量执行:一次性执行所有任务。

总结

props 变化触发子组件更新的完整链条如下:

  1. trigger:响应式数据变化,触发依赖系统,找出所有依赖的 effect。
  2. queueJob:将 effect.run 加入异步队列,实现去重和批量更新。
  3. scheduler:通过 nextTick 调度 flushJobs,按顺序执行队列中的任务。

这个过程体现了 Vue 3 响应式系统的设计哲学:

  • 精准性:通过依赖收集,只更新真正受影响的组件。
  • 高效性:通过异步队列和批量更新,最小化 DOM 操作。
  • 有序性:通过 scheduler 排序,保证组件更新的正确顺序。

静态节点提升

在 Vue 的上下文中,静态节点指的是其内容和属性在组件运行时永远不会改变的节点。

常见的静态节点类型

  • 纯文本节点
  • 仅含静态属性的元素
  • 不含动态插槽的组件
  • 静态 <img> 标签

这些节点的共同特点是:它们不依赖任何响应式数据。无论组件的 data、props 或 computed 如何变化,这些节点的最终渲染结果都保持不变

为什么只创建一次?性能收益分析

静态节点提升带来了显著的性能优势

减少内存分配与垃圾回收

  • 无提升:每次 render 调用都创建新的 VNode 对象。
  • 有提升:VNode 对象只创建一次,后续渲染直接复用

这减少了内存分配压力和垃圾回收(GC)的频率,尤其在频繁更新的组件中效果显著

加速 diff 算法(patch 过程)

在 patch 过程中,Vue 会对比新旧 VNode

由于 _hoisted_1 在新旧 VNode 中引用完全相同(===),patch 算法可以立即判定:

“这个节点没有变化,无需进一步 diff,直接复用对应的真实 DOM。”

这跳过了对静态子树的深度遍历,极大提升了更新性能

降低 CPU 开销

避免了重复调用 h() 函数、对象创建和属性初始化等操作,降低了 CPU 计算开销

事件缓存

  • 事件缓存是 Vue 3 模板编译的一项自动优化,用于避免内联事件处理器在每次渲染时创建新函数。
  • 通过 this._cache 数组,Vue 惰性地创建并复用事件处理函数,保持引用稳定。
  • 结合 patchFlagpatch 过程能高效地处理动态事件,避免不必要的 DOM 操作。
  • 这项技术让开发者可以自由使用内联事件语法,而无需牺牲性能,体现了 Vue 约定优于配置,编译即优化的设计理念。

v-model 的编译原理

v-model 是一个指令(Directive),它在编译时被转换为

  • 一个 prop(默认为 modelValue)。
  • 一个事件监听器(默认为 update:modelValue)

这种设计遵循了 Vue 组件“单向数据流”的原则:父组件通过 prop 向子组件传递数据,子组件通过事件通知父组件数据变化。

v-if 和 v-show

在 Vue 中,v-if 和 v-show 都用于实现条件渲染,但它们的实现机制、性能特性和适用场景截然不同。这种差异源于它们在编译阶段产生的不同代码, 以及运行时采取的两种根本不同的策略:销毁重建(Destroy & Rebuild) 与 CSS 显示切换(Display Toggle)

v-if 采用存在性控制。当条件为假时,元素(及其子组件)被完全从 DOM 树中移除,并销毁其所有状态。

运行时行为

条件为 true

  • 创建 div 元素及其 VNode。
  • 触发组件的 mounted 钩子。
  • 子组件被完整初始化。

条件为 false

  • 移除 div 元素。
  • 销毁其 VNode。
  • 触发组件的 unmounted 钩子。
  • 子组件被销毁,所有状态丢失。

性能特点

  • 初始渲染快(当条件为假时):根本不创建 DOM。
  • 切换慢:每次切换都需要完整的 patch 过程——diff、创建/销毁 DOM、触发生命周期钩子。
  • 内存占用低:不显示的元素不占用内存。

v-show 采用可见性控制。无论条件如何,元素始终存在于 DOM 树中,仅通过 CSS 的 display 属性控制其显示

运行时行为

  • 元素始终存在:无论 isVisible 如何,div 元素都存在于 DOM 中。
  • 仅切换样式:当 isVisible 变化时,Vue 更新 style.display 属性。
  • 组件状态保留
  • 组件不会被销毁。
  • mounted 钩子只在首次渲染时调用一次。
  • 子组件及其状态(如表单输入、计时器)保持不变。

性能特点初始渲染慢:即使隐藏,也要创建 DOM 和 VNode。 切换极快:只需修改 CSS 属性,无需 diff 或 DOM 操作。 内存占用高:隐藏的元素仍占用内存。

v-if 和 v-show 的使用场景与最佳实践

使用 v-if 的场景

  • 条件很少改变:如用户权限、页面初始化状态。
  • 初始状态为假:希望延迟加载,减少初始渲染负担。
  • 包含昂贵的子组件:如图表、视频播放器,不显示时应完全销毁以节省资源。
  • 需要触发生命周期钩子:如 mounted 中的初始化逻辑。

使用 v-show 的场景

  • 频繁切换:如模态框、下拉菜单、标签页。
  • 保留用户状态:如表单输入、滚动位置。
  • 切换动画:配合 CSS 过渡效果。
  • 简单元素:对性能影响小。

避免滥用

  • 不要用 v-show 隐藏大型复杂组件:会持续占用内存。
  • 不要用 v-if 频繁切换简单元素:性能反而更差。

computed 的惰性求值机制

computed 是 Vue 响应式系统中一个强大而优雅的特性,它允许我们基于响应式数据派生出新的值,并且具备惰性求值(Lazy Evaluation) 和缓存的特性。 理解 computed 为何不会立即执行,以及其背后的 effect 的 lazy 选项,是掌握 Vue 响应式核心的关键。

computed 的惰性求值机制可以归结为:

  • lazy: true:computed 内部的 effect 在创建时不立即执行。
  • scheduler:当依赖变化时,不重新计算,而是将 dirty 标记为 true。
  • get value():只有在访问 value 时,才检查 dirty 标志,决定是否重新执行 getter。

这种设计使得 computed:

  • 高效:避免了不必要的计算。
  • 智能:仅在需要时才工作。
  • 可缓存:结果被记忆,直到依赖变化。
  • 理解 lazy 选项,就是理解 computed 如何在“响应式”和“性能”之间取得完美平衡。

watch 与 watchEffect

在 Vue 3 的响应式系统中,watch 和 watchEffect 是两个核心的侦听器(Watcher)API,它们都用于响应数据变化并执行副作用, 但背后的设计哲学、使用方式和适用场景截然不同。理解它们的差异,是编写高效、可维护代码的关键。

  • watch:显式声明依赖,适合精确控制。
  • watchEffect:自动收集依赖,适合简单副作用。

watchEffect:自动依赖收集的副作用函数

watchEffect 遵循运行即收集的哲学。它立即执行传入的函数,并在执行过程中自动追踪所有访问的响应式数据,将其作为依赖。

watch:显式声明依赖的精确控制

watch 采用声明式依赖哲学。你必须显式地告诉 Vue 要侦听什么,然后在回调中处理变化。

何时使用 watchEffect?

  • 简单的副作用
  • 初始化逻辑
  • 依赖关系简单且稳定
  • 运行即生效

何时使用 watch?

  • 需要新旧值对比
  • 避免过度执行
  • 侦听复杂表达式
  • 需要配置选项
  • 代码可读性和维护性优先

高级技巧与陷阱

watchEffect 的停止

ts
const stop = watchEffect(() => {
  // ...
});

// 停止侦听
stop();

清理副作用

ts
watchEffect((onInvalidate) => {
  const timer = setTimeout(() => {
    console.log('Done');
  }, 1000);

  // 在下次执行前或停止时清理
  onInvalidate(() => {
    clearTimeout(timer);
  });
});

watch 的 deep 选项

text
const obj = ref({ a: { b: 1 } });

watch(obj, (newVal) => {
  // 默认只侦听 obj 引用变化
}, { deep: true }); // 深度监听,a.b 变化也会触发

watch的源可以是哪些类型

在 Vue 3 的 watch API 中,源(source) 是你指定要侦听的目标。watch 的灵活性很大程度上源于其对多种数据源的支持。 理解 watch 可以接受哪些类型的 source,是高效使用侦听器的关键。

watch 的 source 参数可以是以下五种类型

  • Ref 对象
  • Reactive 对象
  • Getter 函数
  • 源数组(Array of Sources)
  • 字符串路径(仅限 setup 之外,如选项式 API)

最佳实践

  • 优先使用 getter 函数:在组合式 API 中,getter 函数是最灵活、最推荐的方式。
  • reactive 对象务必 deep: true:否则无法侦听到内部变化。
  • 避免过度侦听:只侦听必要的数据,减少性能开销。
  • 利用源数组:当多个源触发相同逻辑时,使用数组避免重复代码。

watch 的 flush 时机

flush 选项的三种模式

模式执行时机适用场景
pre在组件更新之前同步执行需要在 DOM 更新前读取旧状态
post在组件更新之后异步执行需要访问更新后的 DOM 或组件实例
sync同步执行,不缓冲需要立即响应,不依赖组件更新周期

flush: 'pre' —— 组件更新前执行

这是 watch 的默认模式

执行时机

  • 当侦听的源发生变化时,watch 回调会在当前组件的 render 函数执行之前同步运行。
  • 它发生在 Vue 的更新队列(queueJob)中,优先级高于渲染。

适用场景

  • 读取更新前的状态:比如记录某个值变化前的快照。
  • 性能敏感的计算:需要在渲染前准备好数据,避免在 render 中进行昂贵计算。
  • 与 computed 类似:computed 本质上也是在 pre 阶段求值。

注意事项

  • 回调执行时,组件的 DOM 还是旧的。
  • 如果你在回调中访问 DOM,看到的是变化前的状态。

flush: 'post' —— 组件更新后执行

执行时机

  • 当侦听的源发生变化时,watch 回调会被推入一个微任务队列,在组件完成渲染并更新 DOM 之后执行。
  • 它使用 Promise.then() 或 queuePostFlushCb 实现异步延迟。

适用场景

  • 操作更新后的 DOM:如测量元素尺寸、触发动画、聚焦输入框。
  • 依赖组件实例的方法或属性:确保组件已完全更新。
  • 避免 DOM 读写颠簸:将 DOM 读取和写入分开,提升性能。

为什么需要 post?

Vue 的更新是异步批处理的。如果你在 count.value 改变后立即访问 DOM,可能 DOM 还没更新。post 确保你在安全的时机操作 DOM。

flush: 'sync' —— 同步立即执行

执行时机

  • 当侦听的源发生变化时,watch 回调立即同步执行,不经过 Vue 的更新队列。
  • 它类似于 watchEffect 的默认行为,但更强制。

适用场景

  • 需要立即响应:如状态同步到外部系统(Redux、Pinia)、日志记录。
  • 不依赖 Vue 渲染周期:你的逻辑与组件更新无关。
  • 精确追踪每次变化:即使在一次事件循环中多次修改,也希望每次都被捕获。

性能警告

  • 性能开销大:每次变化都执行,可能造成性能瓶颈。
  • 破坏批处理:绕过 Vue 的异步更新优化,可能导致不必要的重复计算。
  • 谨慎使用:仅在必要时使用。

provide / inject

provide 和 inject 遵循依赖注入设计模式:

  • provide:祖先组件“提供”数据。
  • inject:后代组件“注入”并使用数据。

这种模式将组件间的直接依赖(通过 props)转变为间接依赖(通过“上下文”),提高了组件的复用性和灵活性。

实现原理:响应式穿透

响应式数据的“穿透”机制

提供时(provide)

  • 你 provide 的是一个 ref 或 reactive 对象。
  • Vue 并不复制或序列化这个对象,而是将原始的响应式引用存入当前组件的 provides 对象中。

注入时(inject)

  • inject 函数沿着组件树向上查找,找到最近的 provide。
  • 它返回的是同一个响应式对象的引用,而不是副本。

为什么能响应式?

本质上,provide/inject 只是传递了响应式对象的引用,真正的响应式机制由 ref/reactive 提供

如何绕过 props 传递?

provide/inject 绕过 props 的关键在于:

不依赖父子关系

  • props 只能在直接父子组件间传递。
  • provide/inject 基于组件树的层级结构,任何后代都可以注入祖先提供的数据,无论中间隔了多少层。

基于“上下文”查找:

  • inject 不是通过参数接收,而是通过一个字符串或 Symbol 键,在组件树的 provides 链上查找。
  • 这类似于“全局变量”,但作用域限定在组件树的子树内。

解耦组件

  • 后代组件不需要知道数据来源,只需知道“契约”(即注入的键名)。
  • 祖先组件可以随时改变数据,所有注入该数据的后代都会自动更新。

响应式穿透的实践:可变 vs 不可变

  • 提供 Ref(推荐)
  • 提供 Reactive 对象
  • 提供不可变值 + 方法(更安全)
text
// 祖先
const count = ref(0);
const increment = () => count.value++;
provide('count', readonly(count)); // 只读
provide('increment', increment);

// 后代
const count = inject('count');
const increment = inject('increment');
// count.value++; // 错误!只读
increment(); // 正确,通过方法修改

Symbol 作为注入键

为了避免命名冲突,推荐使用 Symbol 作为 provide/inject 的键

js
// keys.js
export const ThemeKey = Symbol('theme');
export const UserKey = Symbol('user');

// Parent.vue
import { ThemeKey } from './keys';
provide(ThemeKey, theme);

// DeepChild.vue
import { ThemeKey } from './keys';
const theme = inject(ThemeKey);