前端核心知识点 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 方法的对象
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 与源对象的属性保持同步。
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 是一个通用工具函数,用于解包一个值。它的行为取决于输入值的类型
实现原理
function unref(value) {
return isRef(value) ? value.value : value;
}unref 的本质是一个条件解包器,它对 ref 进行解包,对普通值透明传递。
使用场景
- unref 常用于编写通用函数,这些函数需要同时处理 ref 和普通值
- 在 computed 或 watch 中也常见
toRaw:直达原始对象的通道
toRaw 的实现依赖于在创建 Proxy 时建立的反向引用
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 函数的实现
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 等工具
实际性能调优案例
场景:列表组件的过度渲染
假设有一个列表组件,当列表项的某个不相关属性改变时,整个列表重新渲染
<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 应用的渲染流程可以概括为
- 模板 (Template) 或 JSX:开发者编写的声明式 UI 代码。
- 编译 (Compile):在构建时,Vue 的编译器将模板编译成 render 函数。
- 执行 (Execute):运行时,render 函数被执行,返回一个 VNode 树。
- 挂载/更新 (Mount/Update):Vue 的渲染器(Renderer)将 VNode 树转换为真实 DOM,并响应数据变化进行高效更新
h() 函数:VNode 的创建工厂
函数签名
function h(
type: string | Component, // 节点类型
props?: object | null, // 属性/props
children?: VNodeChildren // 子节点
): VNodeh() 返回了什么?
h() 返回一个 VNode 对象,它是一个轻量级的、纯 JavaScript 的数据结构,用于描述一个 DOM 节点或组件实例
一个典型的 VNode 对象包含以下关键属性
{
__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() 使用示例
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。
组件实例的创建过程
- 创建组件实例
- 设置组件 VNode 的引用
- 调用 setup()
- 执行组件的 render 函数
- 挂载子树
组件 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,算法会进入快速更新流程
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 映射表
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 的实现
const queue = []; // 任务队列
const pending = false; // 是否正在等待 flush
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job);
queueFlush();
}
}- 去重:检查 job 是否已在队列中,避免重复。
- 调度 flush:调用 queueFlush() 安排队列的刷新。
queueFlush 与 nextTick
function queueFlush() {
if (!pending) {
pending = true;
nextTick(flushJobs);
}
}- nextTick 将 flushJobs 推入微任务队列。
- 确保所有同步代码执行完毕后,再批量执行更新任务。
终点:scheduler 与 flushJobs
scheduler 是 Vue 更新流程的“总调度器”,它协调所有 effect 的执行。
flushJobs:执行队列
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 变化触发子组件更新的完整链条如下:
- trigger:响应式数据变化,触发依赖系统,找出所有依赖的 effect。
- queueJob:将 effect.run 加入异步队列,实现去重和批量更新。
- 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惰性地创建并复用事件处理函数,保持引用稳定。 - 结合
patchFlag,patch过程能高效地处理动态事件,避免不必要的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 的停止
const stop = watchEffect(() => {
// ...
});
// 停止侦听
stop();清理副作用
watchEffect((onInvalidate) => {
const timer = setTimeout(() => {
console.log('Done');
}, 1000);
// 在下次执行前或停止时清理
onInvalidate(() => {
clearTimeout(timer);
});
});watch 的 deep 选项
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 对象
- 提供不可变值 + 方法(更安全)
// 祖先
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 的键
// 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);