Vue 最标志性的功能就是其低侵入性的响应式系统。组件状态都是由响应式的 JavaScript 对象组成的。当更改它们时,视图会随即自动更新。理解它是如何工作的也是很重要的,这可以帮助我们避免一些常见的陷阱。
什么是响应式数据
从一个例子开始:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // 仍然是 3
当我们更改 A0 后,A2 不会自动更新。
在 JavaScript 如何做到这一点呢?需要将其封装为一个函数:
let A2
function effect() {
A2 = A0 + A1
}
当调用 effect 函数时会修改全局变量 A2,我们称 effect 函数产生了一个副作用。这个 effect 就是副作用函数。
在 effect 函数中 A2 的值依赖 A0 和 A1,我们称 A0 和 A1 是这个副作用的依赖。我们希望当副作用的依赖变化后,副作用函数会自动重新执行,那么依赖就是响应式数据。
显然上面的代码还做不到这一点,我们可以通过 Vue3 的响应系统来可以创建响应式数据。我们用 Vue3 响应系统来改写上面例子:
import { ref, effect } from "vue";
let A0 = ref(1);
let A1 = ref(2);
let A2;
effect(() => {
A2 = A0.value + A1.value;
});
console.log(A2); // 3
A0.value = 2;
console.log(A2); // 4
可以看到,当我们改变传入 effect 函数的箭头函数(副作用函数)中的依赖 A0 时,副作用函数自动重新执行了。
接下来,我们来看看 Vue3 的响应系统是如何工作的。
Vue3 的响应系统如何工作的
原生 JavaScript 没有提供任何机制可以追踪局部变量的读写,但是我们可以追踪对象属性的读写。在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 proxy。Vue2 使用 getter / setter 完全是出于支持旧版本浏览器的限制。Vue3 中使用了 Proxy 来创建响应式对象,将 getter/setter 用于 ref。
非原始值的响应式方案——reactive
介绍 proxy
Proxy 是一种可以拦截并改变 JavaScript 引擎底层操作的包装器。
代理可以拦截目标对象的底层操作,这些底层操作被拦截后会触发特定操作的陷阱函数,可以在陷阱函数中改写底层操作。
常用的代理陷阱:
- set 陷阱,在读取一个属性值时触发;
-
- target 用于接收属性的对象(代理的目标)。
- key 要写入的属性键(字符串或 Symbol)
- value 被写入的属性值
- receiver 操作发生的对象(通常指代理)
- get 陷阱,在写入一个属性时触发。
-
- target 被读取属性的源对象(代理的目标)
- key 要读取的属性键(字符串或 Symbol)
- receiver 操作发生的对象(通常指代理)
了解 Proxy 后,我们来探究一下 Vue3 是如何使用 Proxy 来创建响应式对象的。
reactive 和 effect 的实现:
import { reactive, effect } from "vue";
const state = reactive({ count: 1 });
let num;
effect(() => {
num = state.count;
});
console.log(num); // 1
state.count++;
console.log(num); // 2
我们创建了一个响应式数据 state,在副作用函数中把 state 的 count 属性值赋值给了变量 num,当我们更改 state.count 的值时,副作用函数自动重新执行了,num 的值也就随着改变了。
问题:如何通过 reactive 和 effect 来创建响应式对象和自动响应的?
effect 伪代码:
let activeEffect;
export class ReactiveEffect {
constructor(fn) {
this._fn = fn;
}
run() {
...
// 把当前 ReactiveEffect 实例赋值给全局变量 activeEffect
activeEffect = this;
...
const res = this._fn();
...
return res;
}
}
// fn 就是传进来的副作用函数
export function effect(fn) {
const _effect = new ReactiveEffect(fn);
...
_effect.run();
...
}
reactive 伪代码:
export function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
...
// 收集依赖
track(target, key)
...
return Reflect.get(target, key, receiver);
},
set(target, key, value) {
...
// 触发依赖
trigger(target, key)
return Reflect.set(target, key, value);
}
})
}
const targetMap = new WeakMap();
export function track(target, key) {
// WeakMap(target : depsMap) => Map(key : dep) => Set(activeEffect)
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
if (dep.has(activeEffect)) return;
dep.add(activeEffect);
...
}
// 触发依赖
export function trigger(target, key) {
const depsMap = targetMap.get(target);
if (depsMap) {
const dep = depsMap.get(key);
for (const effect of dep) {
...
effect.run();
}
}
}
总结:通过 reactive 创建的响应式对象,在获取属性时通过 track 方法收集依赖的副作用函数,在设置属性时通过 trigger 方法触发依赖的副作用函数。
DOM 自动更新的实现:
<script setup>
import { reactive } from 'vue'
const state = reactive({ count: 0 })
function increment() {
state.count++
}
</script>
<template>
<button @click="increment">
{{ state.count }}
</button>
</template>
问题:响应式数据修改后,dom 是如何自动更新的?
要想在响应式对象 state.count 修改后 DOM 自动更新,就需要在获取 state.count 的值时,将DOM 的 render 副作用函数收集起来。
伪代码如下:
function setupRenderEffect(
instance,
initialVNode,
container,
anchor,
) {
// 在这里将 DOM 的渲染逻辑(副作用函数)传给了 effect
instance.update = effect(
() => {
const { proxy, isMounded, subTree: preSubTree } = instance;
if (!isMounded) {
...
const subTree = (instance.subTree = instance.render.call(
proxy,
proxy,
));
// 处理 component 和 element
patch(null, subTree, container, instance, anchor);
...
instance.isMounded = true;
} else {
...
// 处理 component 和 element
patch(preSubTree, subTree, container, instance, anchor);
}
})
...
}
总结:DOM 自动更新就是在获取响应式数据的属性时,通过 track 方法将 DOM 的渲染逻辑收集起来,在修改响应式对象的属性时,通过 trigger 方法触发收集起来的渲染逻辑,实现 DOM 自动更新。
另外通过前面两个例子对比,发现 Vue3 的响应系统除了可以在 Vue 项目中使用,也是可以单独使用的。因为 Vue3 的响应系统模块是独立的,与 Vue 的其他模块没有依赖关系。
DOM 的更新时机
DOM 的更新并不是同步的。相反,Vue 将缓冲它们直到更新周期的 “下个时机” 以确保无论你进行了多少次状态更改,每个组件都只更新一次。若要等待一个状态改变后的 DOM 更新完成,你可以使用 nextTick() 这个全局 API。
import { nextTick } from 'vue'
function increment() {
state.count++
nextTick(() => {
// 访问更新后的 DOM
})
}
问题:怎么实现多次状态更改,每个组件只更新一次?为什么要在 nextTick 中获取 dom 更新?
effect 调度器功能:
let activeEffect;
export class ReactiveEffect {
constructor(fn, scheduler) {
this._fn = fn;
this.scheduler = scheduler;
}
run() {
...
// 把当前 ReactiveEffect 实例赋值给全局变量 activeEffect
activeEffect = this;
...
const res = this._fn();
...
return res;
}
}
// fn 就是传进来的副作用函数
export function effect(fn, options) {
const _effect = new ReactiveEffect(fn, options.scheduler);
...
_effect.run();
const runner = _effect.run.bind(_effect);
...
return runner;
}
// 触发依赖
export function trigger(target, key) {
const depsMap = targetMap.get(target);
if (depsMap) {
const dep = depsMap.get(key);
for (const effect of dep) {
// 如果有调度器,执行调度器方法
if (effect.scheduler) {
effect.scheduler();
} else {
...
effect.run();
}
}
}
}
function setupRenderEffect(
instance,
initialVNode,
container,
anchor,
) {
// 在这里将 DOM 的渲染逻辑(副作用函数)传给了 effect
instance.update = effect(
() => {
const { proxy, isMounded, subTree: preSubTree } = instance;
if (!isMounded) {
...
const subTree = (instance.subTree = instance.render.call(
proxy,
proxy,
));
// 处理 component 和 element
patch(null, subTree, container, instance, anchor);
...
instance.isMounded = true;
} else {
...
// 处理 component 和 element
patch(preSubTree, subTree, container, instance, anchor);
}
},
{
// 利用 effect 调度器,把每一帧的更新任务先保存到队列里,等主任务完成后,在调用微任务一次更新。
scheduler() {
queueJobs(instance.update);
},
},
)
}
const queue = [];
let isFlushPending = false;
export function queueJobs(job) {
if (!queue.includes(job)) {
queue.push(job);
}
...
// 优化:避免重复创建 nextTick
if (isFlushPending) return;
isFlushPending = true;
nextTick(flushJobs);
}
function flushJobs() {
...
let job;
while ((job = queue.shift())) {
job && job();
}
isFlushPending = false;
}
nextTick 的实现:
const p = Promise.resolve();
export function nextTick(fn?: any) {
return fn ? p.then(fn) : p;
}
总结:通过 effect 的调度器功能,可以在触发依赖时执行调度器方法。在渲染 component 和 Element 的方法中,给 effect 传入一个调度器参数,在修改响应式数据触发依赖时会执行调度器方法,在调度器方法中把更新任务添加到一个队列中,通过 nextTick 把更新任务添加到微任务队列,等宏任务执行完再执行当前宏任务中的微任务队列。从而实现无论你进行了多少次状态更改,每个组件都只更新一次。
nextTick 返回一个已完成的 Promise,从而使传入的方法在微任务中执行。所以我们在一个状态改变后,需要在 nextTick 中访问更新后的 DOM。
深层响应性
在 Vue 中,状态都是默认深层响应式的。这意味着即使在更改深层次的对象或数组,你的改动也能被检测到。
import { reactive } from 'vue'
const obj = reactive({
nested: { count: 0 },
arr: ['foo', 'bar']
})
function mutateDeeply() {
// 以下都会按照期望工作
obj.nested.count++
obj.arr.push('baz')
}
你也可以直接创建一个浅层响应式对象。它们仅在顶层具有响应性,一般仅在某些特殊场景中需要。
问题:状态是如何实现深层响应的?如何创建浅层响应式对象的?
export function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
...
// 收集依赖
track(target, key)
...
const res = Reflect.get(target, key, receiver);
...
// 如果该属性的值是个对象,用 reactive 包裹,从而实现深层响应
if (isObject(res)) {
return reactive(res);
}
return res;
},
set(target, key, value) {
...
// 触发依赖
trigger(target, key)
return Reflect.set(target, key, value);
}
})
}
shallowReactive 伪代码:
export function shallowReactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
...
// 收集依赖
track(target, key)
...
const res = Reflect.get(target, key, receiver);
// 如果是浅层响应,直接返回 res
if (shallow) {
return res;
}
// 如果该属性的值是个对象,用 reactive 包裹,从而实现深层响应
if (isObject(res)) {
return reactive(res);
}
return res;
},
set(target, key, value) {
...
// 触发依赖
trigger(target, key)
return Reflect.set(target, key, value);
}
})
}
总结:如果代理对象的属性值也是一个对象,在 get 代理陷阱中,用 reactive 包裹属性值再返回,从而实现深层响应。
如果创建的是一个浅层响应对象,在 get 代理陷阱中,直接返回属性值,实现浅层响应。
reactive() 的局限性
reactive() API 有两条限制:
let state = reactive({ count: 0 })
// 上面的引用 ({ count: 0 }) 将不再被追踪(响应性连接已丢失!)
state = reactive({ count: 1 })
同时这也意味着当我们将响应式对象的属性赋值或解构至本地变量时,或是将该属性传入一个函数时,我们会失去响应性:
const state = reactive({ count: 0 })
// 失去响应性连接
let n = state.count
// 不影响原始的 state
n++
// count 也和 state.count 失去了响应性连接
let { count } = state
// 不会影响原始的 state
count++
// 该函数接收一个普通数字,并且
// 将无法跟踪 state.count 的变化
callSomeFunction(state.count)
原始值的响应式方案——ref
reactive() 是用 proxy 代理对象。原始类型怎么处理呢?Vue 提供了一个 ref() 方法来允许我们创建可以使用任何值类型的响应式 ref。
ref 的实现
上面用到的一个例子:
import { ref, effect } from "vue";
let A0 = ref(1);
let A1 = ref(2);
let A2;
effect(() => {
A2 = A0.value + A1.value;
});
console.log(A2); // 3
A0.value = 2;
console.log(A2); // 4
和响应式对象的属性类似,ref 的 .value 属性也是响应式的。同时,当值为对象类型时,会用 reactive() 自动转换它的 .value。
一个包含对象类型值的 ref 可以响应式地替换整个对象:
const objectRef = ref({ count: 0 })
// 这是响应式的替换
objectRef.value = { count: 1 }
问题:ref 是怎么实现的?
ref 伪代码:
class RefImpl {
constructor(value) {
this._rawValue = value;
this._value = isObject(value) ? reactive(value) : value;
this.dep = new Set();
this._v_isRef = true;
}
get value() {
if (this.dep.has(activeEffect)) return;
this.dep.add(activeEffect);
...
return this._value;
}
set value(newValue) {
if (hasChanged(this._rawValue, newValue)) {
this._rawValue = newValue;
this._value = isObject(newValue) ? reactive(newValue) : newValue;
for (const effect of this.dep) {
if (effect.scheduler) {
effect.scheduler();
} else {
...
effect.run();
}
}
}
}
}
export function ref(value) {
return new RefImpl(value);
}
总结:在 class 内使用 get 和 set 关键字, 可以对 value 属性设置存值函数和取值函数, 拦截该属性的存取行为。和 reactive 一样,在取值函数中收集依赖的副作用函数,在存值函数中触发依赖的副作用函数。
ref 在模板中的解包
当 ref 在模板中作为顶层属性被访问时,它们会被自动“解包”,所以不需要使用 .value。
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">
{{ count }} <!-- 无需 .value -->
</button>
</template>
问题:ref 在模板中如何自动“解包”?
export function isRef(ref: any): boolean {
return !!ref._v_isRef;
}
export function unRef(ref: any) {
return isRef(ref) ? ref.value : ref;
}
export function proxyRefs(objectWidthRefs: any) {
return new Proxy(objectWidthRefs, {
get(target, key) {
return unRef(Reflect.get(target, key));
},
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
// 特殊处理
return (target[key].value = value);
} else {
return Reflect.set(target, key, value);
}
},
});
}
// 挂载 setup 数据到组件实例
function handleSetupResult(instance, setupResult) {
if (typeof setupResult === "object") {
// 对 setup 返回值自动脱 ref
instance.setupState = proxyRefs(setupResult);
}
finishComponentSetup(instance);
}
总结:用 proxyRefs 代理一个包含 ref 的对象,获取属性值是 ref 时,返回 ref 的 value 属性值。在挂载 setup 数据到组件实例上时,用 proxyRefs 代理 setup 中返回的对象使其自动解包,所以在模板中不需要通过 value 属性获取 ref 的值。
ref 在响应式对象中的解包
当一个 ref 被嵌套在一个响应式对象中,作为属性被访问或更改时,它会自动解包,因此会表现得和一般的属性一样。例子:
const count = ref(0)
const state = reactive({
count
})
console.log(state.count) // 0
state.count = 1
console.log(count.value) // 1
问题:ref 在响应式对象中如何解包的?为什么 ref 在响应式数组或者 Map 中不会进行解包?
reactive 伪代码:
export function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
...
// 收集依赖
track(target, key)
...
const res = Reflect.get(target, key, receiver);
...
// 如果浅层响应直接返回
if (shallow) {
return res
}
// 该属性的值是 ref 时,如果 代理目标对象 是数组直接返回,否则其 value 值
if (isRef(res)) {
// ref unwrapping - skip unwrap for Array + integer key.
return targetIsArray && isIntegerKey(key) ? res : res.value
}
// 如果该属性的值是个对象,用 reactive 包裹,从而实现深层响应
if (isObject(res)) {
return reactive(res);
}
return res;
},
set(target, key, value) {
...
// 触发依赖
trigger(target, key)
return Reflect.set(target, key, value);
}
})
}
总结:当嵌套在一个深层响应式对象内时,会通过返回其 value 值将 ref 解包。
当其作为浅层响应式对象的属性被访问时,不会进行解包。
当 ref 作为响应式数组或像 Map 这种原生集合类型的元素被访问时,不会进行解包。