跳到主要内容

vue3 响应式原理

Vue 3 的响应式系统是基于 ES6 的 Proxy 特性重新实现的,与 Vue 2 基于 Object.defineProperty 的实现相比,它提供了更好的性能和更灵活的响应性追踪能力。

这种新的实现方式允许 Vue 3 直接监听对象和数组的变化,而无需预先定义 getter 和 setter,从而实现了更为自然和高效的数据响应式变化侦测。

流程图

用户操作或数据变化
|
v
响应式对象属性被读/写
|
+-----------------+
| |
| 使用 Proxy | <--- 创建响应式对象时封装原始对象
| 拦截操作 |
| |
+-----------------+
| |
读操作 <--- 依赖收集(track) ----> 收集当前执行的effect
| |
+-----------------+
| |
写操作 <--- 触发变更(trigger)--> 执行依赖的effect
| |
+-----------------+
|
v
更新DOM

响应式系统工作原理

  1. 响应式对象的创建:当你使用 reactive 或 ref 函数创建一个响应式对象时,Vue 通过 Proxy 将原始对象包裹起来。这个代理对象可以拦截对原始对象的访问和修改操作。
  2. 依赖收集(Track):当响应式对象的属性被读取时,如在渲染函数或计算属性中,Vue 会记录(track)这个属性和当前执行的“effect”(比如渲染函数)之间的依赖关系。
  3. 变更通知(Trigger):当响应式对象的属性被修改时,Vue 通过代理拦截这个修改操作,并触发(trigger)依赖于这个属性的所有“effect”重新执行,以响应这个变化。
  4. 更新 DOM:重新执行的“effect”会导致组件重新渲染,Vue 的渲染引擎会计算出需要更新的最小DOM范围,并应用这些变更。

这个过程确保了当响应式数据变化时,依赖于这些数据的视图和其他计算能够自动更新。

源码实现

相关概念

  • reactive:一个函数,用于创建一个响应式对象。
  • ref:一个函数,用于创建一个响应式的引用值。
  • effect:用于包装一个函数,这个函数的任何响应式状态的读取都会被跟踪,当状态变化时重新执行。
  • track 和 trigger:内部函数,用于依赖收集和变更通知。

响应式对象创建(reactive)

reactive 函数是通过 Proxy 创建响应式对象的入口。

源码地址: https://github.com/vuejs/core/blob/main/packages/reactivity/src/reactive.ts#L242

export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
// 如果目标对象是一个只读的响应数据,则直接返回目标对象
if (target && (target as Target).__v_isReadonly) {
return target
}

// 否则调用 createReactiveObject 创建 observe
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers
)
}

// Target 目标对象
// isReadonly 是否只读
// baseHandlers 基本类型的 handlers
// collectionHandlers 主要针对(set、map、weakSet、weakMap)的 handlers
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>
) {
// 如果不是对象
if (!isObject(target)) {
// 在开发模式抛出警告,生产环境直接返回目标对象
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
// 如果目标对象已经是个 proxy 直接返回
if (target.__v_raw && !(isReadonly && target.__v_isReactive)) {
return target
}
// target already has corresponding Proxy
if (
hasOwn(target, isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive)
) {
return isReadonly ? target.__v_readonly : target.__v_reactive
}
// only a whitelist of value types can be observed.

// 检查目标对象是否能被观察, 不能直接返回
if (!canObserve(target)) {
return target
}

// 使用 Proxy 创建 observe
const observed = new Proxy(
target,
collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
)

// 打上相应标记
def(
target,
isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive,
observed
)
return observed
}

// 同时满足3个条即为可以观察的目标对象
// 1. 没有打上__v_skip标记
// 2. 是可以观察的值类型
// 3. 没有被frozen
const canObserve = (value: Target): boolean => {
return (
!value.__v_skip &&
isObservableType(toRawType(value)) &&
!Object.isFrozen(value)
)
}

// 可以被观察的值类型
const isObservableType = /*#__PURE__*/ makeMap(
'Object,Array,Map,Set,WeakMap,WeakSet'
)

effect

effect 函数在这个过程中扮演着封装组件渲染逻辑和自动追踪依赖的角色。在组件初始化时,Vue 会将组件的渲染函数包装在一个 effect 函数中。这样,每当渲染函数被首次调用或因为响应式状态的变化而重新调用时,所有的状态访问都会被 track,并且状态变化时能够触发渲染更新。

function createRenderer() {
// 创建一个渲染器
}

function effect(fn) {
// 创建一个副作用来封装渲染逻辑
const effectFn = () => {
cleanup(effectFn); // 清理上一次的依赖
activeEffect = effectFn; // 设置当前激活的effect
fn(); // 执行渲染函数或其他副作用
};
effectFn.deps = []; // 依赖数组
effectFn(); // 首次执行
}

function cleanup(effectFn) {
// 清理逻辑,移除旧的依赖
}

const renderer = createRenderer({
patchProp, // 用于属性更新的函数
... // 其他渲染相关的函数
});

function mountComponent(vnode, container) {
const component = ... // 创建组件实例
effect(() => {
if (!component.isMounted) {
// 首次渲染
const subTree = component.render(); // 调用组件的渲染函数
renderer.patch(null, subTree, container);
component.isMounted = true;
} else {
// 更新渲染
const subTree = component.render();
const prevSubTree = component.subTree;
component.subTree = subTree;
renderer.patch(prevSubTree, subTree, container);
}
});
}

在这个简化示例中,mountComponent 函数展示了如何使用 effect 来封装组件的渲染逻辑。首次执行时,组件的渲染函数 component.render 被调用来生成虚拟 DOM,然后通过 renderer.patch 方法渲染到页面上。由于渲染逻辑被 effect 封装,任何在渲染函数中访问的响应式状态都会自动与这个 effect 建立依赖关系。当状态更新时,这个 effect 会被重新执行,触发组件的重新渲染。

Effect 的作用

effect 函数通常用于执行副作用操作,这些操作依赖于响应式状态。在 Vue 中,组件的渲染逻辑本身就是通过 effect 来封装的,这样 Vue 可以智能地计算和跟踪哪些响应式状态被组件访问。当这些状态更新时,Vue 知道需要重新执行哪个 effect,以此来更新 DOM。

激活的 Effect 如何工作

当一个 effect 函数被执行时,Vue 会将其标记为当前的激活 effect。这是通过设置一个全局的 activeEffect 变量来实现的。此后,任何被这个 effect 访问的响应式状态都会与这个 effect 关联起来(即依赖收集)。这一过程称为“track”。

当响应式状态变化时,Vue 通过之前的依赖收集知道哪些 effect 需要被重新激活和执行,这一过程称为“trigger”。

简化后的示例代码:

let activeEffect = null;

function effect(fn) {
// 将 fn 设置为当前激活的 effect
activeEffect = fn;
// 执行 fn,间接触发响应式状态的 get 操作,进行依赖收集
fn();
// 执行完毕后,清除当前激活的 effect
activeEffect = null;
}

// 响应式状态的简化实现
function reactive(target) {
const handler = {
get(target, key, receiver) {
if (activeEffect) {
// 依赖收集逻辑
}
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
// 触发更新
return result;
}
};
return new Proxy(target, handler);
}

在这个简化的例子中,effect 函数接收一个副作用函数 fn 作为参数,并在执行这个函数前后,通过 activeEffect 进行管理,确保在 fn 执行期间任何被访问的响应式状态都能将 fn 收集为依赖。这样,当响应式状态更新时,fn 可以被重新执行,从而响应状态变化。

依赖收集(track)和变更通知(trigger)

Vue 3 的响应式系统使用 Proxy 对象来监视应用状态的变化。当组件在渲染过程中访问响应式状态时,这些访问被 track 函数捕获,建立起组件渲染函数和状态之间的依赖关系。一旦依赖的状态发生变化,trigger 函数就会被调用,通知渲染函数重新执行,以更新视图。

track

当组件在渲染过程中读取响应式对象的某个属性时,Vue 会执行 track 操作。

这一操作记录了两个重要的信息:

  1. 哪个响应式对象的哪个属性被访问了(即“依赖”)。
  2. 哪个组件(或计算属性等)访问了这个属性。

track 的实现

track 实际上是在响应式对象的 get 陷阱(Proxy 的一个拦截函数)中调用的。这个过程大致如下:

  1. 当读取响应式对象的属性时(例如,state.count),Proxy 的 get 陷阱被触发。
  2. get 陷阱中调用 track 函数,传入当前响应式对象和属性作为参数。
  3. track 函数检查当前是否有激活的“effect”(effect 是 Vue 用来跟踪响应式状态变化到副作用执行的一种内部机制)。如果有,将这个 effect 与当前属性关联起来。

源码地址: https://github.com/vuejs/core/blob/main/packages/reactivity/src/reactiveEffect.ts#L65

function track(target, key) {
if (activeEffect) {
let depsMap = deps.get(target);
if (!depsMap) {
deps.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
}
}

为什么需要 track ?

track 使得 Vue 的响应式系统能够以非常细粒度地管理依赖关系。这样,只有当实际使用到的数据变化时,相关的 UI 才会更新,从而避免不必要的组件渲染,优化应用的性能。

trigger

在 Vue 3 的响应式系统中,trigger 是与 track 相对应的另一个核心概念。如果说 track 负责建立响应式状态和副作用之间的依赖关系,那么 trigger 的职责则是在响应式状态更新时,通知并执行与之相关的副作用,以更新应用的 UI。这个机制确保了数据和视图之间的响应式绑定。

trigger 的工作原理

当修改一个被 Proxy 代理的响应式对象的属性时(例如,通过赋值操作),trigger 函数会被调用。trigger 函数的主要任务是查找所有依赖于被修改属性的副作用(即在 track 阶段收集的副作用),并执行它们。这些副作用通常是组件的渲染函数或计算属性,它们需要重新执行以反映最新的状态。

trigger 的执行流程

  1. 确定修改操作的类型:Vue 需要区分是哪种类型的修改操作(如设置属性、删除属性等),因为不同的操作可能会触发不同的副作用。
  2. 查找依赖:Vue 查找所有注册为依赖于被修改属性的副作用。这些信息在 track 阶段被收集并存储在一个全局的依赖关系映射中。
  3. 执行副作用:一旦找到这些副作用,Vue 会逐一执行它们。如果副作用是组件的渲染函数,这将导致组件重新渲染。

示例代码

function trigger(target, key) {
const depsMap = deps.get(target);
if (!depsMap) return; // 如果没有依赖,直接返回

const dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => {
// 如果有 scheduler,则调用 scheduler,否则直接执行 effect
if (effect.scheduler) {
effect.scheduler();
} else {
effect();
}
});
}
}

在这个示例中,trigger 函数接收被修改的目标对象和属性键作为参数。它首先从全局的依赖关系映射中查找与该属性相关的所有副作用。如果找到了,就遍历这个依赖集合,并执行里面的每个副作用。这些副作用可能会有自己的调度器(scheduler),允许更细致地控制它们的执行(例如,延迟执行或合并多次更新)。

核心概念

使用 Proxy 实现响应式

Vue 3 的响应式系统利用 Proxy 来拦截对象的读取和写入操作。

Proxy 是 ES6 引入的一种新特性,允许创建一个对象的代理,从而可以拦截并自定义对象的基本操作,如属性读取、属性赋值、枚举属性等。

当使用 reactive 函数(或 ref 对于基本类型)创建一个响应式对象时,Vue 3 会返回这个对象的 Proxy 代理。这个代理能够拦截对对象的所有操作,Vue 通过这种方式来追踪依赖和触发更新。

追踪变化和依赖收集

当组件在渲染过程中访问响应式对象的属性时,Vue 3 的响应式系统会自动追踪这些属性的访问,并将当前的渲染函数或计算属性作为依赖收集起来。这是通过 Proxy 的 get 操作拦截实现的。

当响应式对象的属性发生变化时(通过 set 操作拦截),Vue 会通知所有依赖于这个属性的侧面重新计算或组件重新渲染,从而更新视图。

响应式 API

Vue 3 提供了几个核心的响应式 API,主要包括:

  • reactive(obj): 接收一个普通对象并返回该对象的响应式代理,用于创建复杂类型(如对象或数组)的响应式数据。
  • ref(value): 用于创建一个响应式的数据引用,ref 可以包裹一个基本类型值或对象,使其变得响应式。通过 .value 属性访问或修改它的值。
  • computed(fn): 接收一个 getter 函数,并基于其返回值返回一个不可变的响应式引用。该引用只会在依赖项改变时重新计算。
  • watch(source, callback): 用于观察响应式数据的变化,当数据变化时执行回调函数。

优势

使用 Proxy 实现响应式带来了几个优势:

  • 更好的性能和内存利用:因为不需要为每个属性创建 getter 和 setter,减少了初始化时的开销。
  • 更灵活的侦测能力:Proxy 能够拦截包括属性添加、属性删除、数组索引设置等更多类型的操作。
  • 更简洁的实现:Proxy 提供了一种更直接和统一的方法来拦截对象操作,简化了响应式系统的内部实现。

弊端

尽管 Vue 3 的响应式系统在许多方面都有所改进,它仍然有一些限制和潜在的缺陷,主要包括:

  1. Proxy 兼容性
  • 浏览器兼容性:Proxy 是 ES6 的一个特性,不支持 IE11 或更早版本的浏览器。对于需要支持旧浏览器的项目,这可能是一个限制。Vue 3 响应式系统的这一设计选择意味着它不能在不使用 Polyfill 的情况下直接在这些旧浏览器上运行。
  1. 性能考量
  • 大规模对象:虽然 Proxy 性能对于绝大多数应用场景都是足够的,但在处理非常大的对象或深层嵌套对象时,可能会遇到性能瓶颈。尽管 Vue 3 的性能已经得到了显著提升,但在极端情况下,响应式系统的开销仍然需要考虑。
  1. 响应式转换限制
  • 手动转换:Vue 3 不会自动将所有对象转换为响应式对象。例如,通过 ref 或 reactive 创建的响应式对象,如果被赋予一个新的普通对象,这个新对象不会自动转换为响应式的。这可能导致一些细微的 bug,特别是对于新接触 Vue 3 的开发者来说。
  1. 第三方类库兼容性
  • 外部类库的集成:在某些情况下,将 Vue 3 的响应式系统与没有专门为响应式编程设计的第三方库一起使用可能会遇到问题。这些库可能不会触发 Vue 的响应式更新,或者可能需要额外的封装或适配器来确保它们能够正常工作。
  1. 响应式侦测的边界
  • 原生对象变动侦测:Vue 3 的响应式系统无法侦测到使用原生方法直接修改数组长度(如 arr.length = 0)或直接通过索引设置数组项(如 arr[0] = 'newItem')的变化。虽然这在日常开发中较少遇到,但在某些特定场景下可能会引起注意。