Vue.js 3.0响应式原理剖析
响应式实现方式
响应式
是 Vue.js
核心设计思想。它的本质是当数据变化后自动执行某个函数,从而触发视图的重新渲染。
响应式的实现基本都是靠 数据劫持。 在介绍 Vue.js 3.0
响应式实现之前,我们先来回顾一下 Vue.js 2.x
响应式实现的部分: 通过 Object.defineProperty
劫持数据的变化,在数据被访问的时候做 依赖收集,在数据被修改的时候做 派发更新。
const dep = new Dep()
const val = obj[key]
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
if (Dep.target) { Dep.target -> watcher
dep.depend() // 依赖收集
}
return val
},
set: function reactiveSetter (newVal) {
const value = val
if (newVal === value) {
return
}
val = newVal
dep.notify() // 派发更新
}
在 Vue.js 2.x
中,依赖就是 Watcher
,有专门针对组件渲染的 render watcher
。组件挂载过程中,首先会实例化 render watcher
,并且让 Dep.target
指向 render watcher
。 执行组件 render
函数的过程中,会访问模板中的数据,然后触发数据对应的 getter
劫持,此时会把 render watcher
作为 依赖收集 起来(dep.depend
);当对数据修改的时候,会触发数据对应的 setter
劫持,此时会通知已收集的 render watcher
做 派发更新(dep.notify
),从而触发了视图的重新渲染。 Vue2.0 响应式细节猛戳这里
而到了 Vue.js 3.0
,不仅使用 Proxy
重写了响应式部分,并独立维护和发布 reactivity
库。那么 Proxy
和 Object.defineProperty
有哪些区别呢?
Proxy VS Object.defineProperty
1、从 API 上来看,Proxy
劫持的是整个对象,那么对于对象属性的新增、删除、修改自然都可以劫持到;而 Object.defineProperty
API 劫持的对象某一个属性的访问和修改,因此它不能监听对象属性新增和删除(提供 Vue.set
及 Vue.delete
),并且不能修改数组的索引及长度来触发视图更新(重写数组的原型指向
)。 具体实现细节猛戳这里
2、从兼容性上来看,Object.defineProperty
支持所有主流浏览器,并兼容 IE9+,而 Proxy
支持现代主流浏览器,但唯独不支持 IE,浏览器的兼容性不够好,而且没有合适的 polyfill
。(过去时)
3、在初始化阶段
,Vue.js 2.x
内部把某个对象变成响应式的时候,如果遇到对象的某个属性的值仍然是对象的时候,会 递归 把子对象也变成响应式。到了 Vue.js 3.0
,并不会在初始阶段递归响应式,而是在对象属性被访问的时候才递归执行下一步 reactive
,这其实是一种 延时定义子对象
响应式的实现,在性能上会有较大的提升。
vue3.0响应式语法
下面我们通过一个简单的 demo 来看一下 Vue.js 3.0
响应式部分的实现细节。
<template>
<div>
<p>{{ state.count }}</p>
<button @click="increase">increase</button>
</div>
</template>
<script>
import { reactive } from 'vue'
export default {
setup() {
const state = reactive({
count: 0
})
const increase = function() {
state.count++
}
return {
increase,
state
}
}
}
</script>
页面初始化显示为 0
,当我们点击按钮的时候,state.count
的值开始增加。
这里我们引入了 reactive
API,它可以把一个对象数据变成响应式。那么它内部到底是怎么实现的呢?我们接下来一探究竟。
reactive 简版实现
function reactive(target) {
return createReactiveObject(target);
}
function createReactiveObject(target) {
if (!isObject(target)) {
console.warn(`value cannot be made reactive: ${String(target)}`);
return target;
}
const proxy = new Proxy(target, {
get: function get(target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set: function set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
}
});
return proxy;
}
仔细想想看,响应式的实现方式无非就是劫持数据,我们通过 Proxy
劫持了原对象,并返回了代理对象。由于 Proxy
劫持的是整个对象,所以我们可以检测到任何对对象的访问和修改,很好的弥补了 Object.defineProperty
的不足。我们可以在访问数据触发 get
函数时依赖收集。
依赖收集
get: function get(target, key, receiver) {
...
track(target, key) // 依赖收集
}
整个 get
函数最核心的部分其实是执行 track
函数收集依赖,下面我们重点分析这个过程。
我们先来看一下 track
函数的实现:
let activeEffect // 当前激活的 effect
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return; // 不需要收集依赖
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.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) // 收集当前激活的 effect 作为依赖
activeEffect.deps.push(dep) // 当前激活的 effect 收集 dep 集合
}
}
分析这个函数的实现前,我们先想一下要收集的依赖是什么。我们的目的是实现响应式,就是当数据变化的时候可以自动做一些事情,比如执行某些函数,所以我们收集的依赖就是数据变化后执行的 副作用函数 (后面会着重介绍)。
再来看实现,我们把 target
作为原始的数据,key
作为访问的属性。我们创建了全局的 targetMap
作为原始数据对象的 WeakMap
,它的键是 target
,值是 depsMap
作为依赖的 Map
;这个 depsMap
的键是 key
,值是 dep
对应的 set
集合,set
集合中存储的是依赖的副作用函数(此处用 set
可以实现副作用函数的去重)。为了方便理解,可以通过下图表示它们之间的关系:
WeakMap 中的对象都是弱引用,即垃圾回收机制不考虑 WeakMap 对该对象的引用。也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakMap 之中。
派发更新
派发通知发生在数据更新的阶段 ,由于我们用 Proxy
劫持了数据对象,所以当这个响应式对象属性更新的时候就会执行 set
函数。我们可以在修改数据触发 set
函数时做派发更新。
set: function set(target, key, value, receiver) {
...
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver) // 设置新值
if (oldValue !== value) { // 新老值不一致
trigger(target, key) // 派发更新
}
return result
}
整个 set
函数最核心的部分就是执行 trigger
函数派发通知 ,我们先来看一下 trigger
函数的实现:
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
let dep = depsMap.get(key); // 获取 set 集合
dep.forEach((effect) => {
// if (effect !== activeEffect) {
if (effect.scheduler) {
effect.scheduler(); // 优先执行调度,computed 计算属性会用到
} else {
effect.run(); // 普通场景调用 run 方法
}
// }
})
}
trigger 函数的实现也很简单,主要做了三件事情:
- 通过全局的
targetMap
拿到target
对应的依赖集合depsMap
。 - 根据
key
从depsMap
中找到对应的dep
集合。 - 遍历
dep
集合,如果副作用函数中有scheduler
会优先执行,普通场景下会执行run
方法。
所以每次 trigger
函数就是根据 target
和 key
,从 depsMap
中找到相关的所有副作用函数遍历执行一遍。
总结: 在描述依赖收集和派发更新的过程中,我们都提到了一个词:副作用函数
,那它又是什么呢?接下来我们来看一下副作用函数的庐山真面目。
副作用函数
介绍副作用函数前,我们先回顾一下响应式的原始需求,即我们修改了数据就能自动执行某个函数,举个简单的例子:
import { reactive } from 'vue'
const state = reactive({
count: 0
})
function printCount() {
console.log(state.count)
}
setTimeout(() => {
state.count++
}, 1000)
printCount()
可以看到,这里我们定义了响应式对象 state
,然后我们在 printCount
中访问了 state.count
,我们希望 1
秒后更改 state.count
值的时候,能自动执行 printCount
。我们可以把 printCount
作为 count
的依赖收集起来,修改 state.count
时让 printCount
重新执行一下即可。
顺着这个思路,我们可以利用 高阶函数 的思想,对 printCount 做一层封装,如下
let activeEffect
function wrapper(fn) {
const wrapped = function(...args) {
activeEffect = fn
fn(...args)
}
return wrapped
}
const wrappedPrint = wrapper(printCount)
wrappedPrint()
这里 wrapper
本身也是一个函数,它接受 fn
作为参数,返回一个新的函数 wrapped
。我们维护一个全局的 activeEffect
,当 wrapped
执行的过程中,首先会把 activeEffect
指向 fn
,然后执行 fn
。执行 fn
的过程中会对 state.count
取值,此时会触发了 track
函数,由于此时 activeEffect
已经指向了 fn(即 printCount)
,因此 count
会把 printCount
作为依赖收集起来。
当我们去修改 state.count
值时,会触发 trigger
函数,count
会找到先前已经收集的依赖 printCount
,然后重新执行该函数。
Vue.js 3.0
就是采用类似的做法,在它内部就有一个 effect
副作用函数,我们来看一下它的用法和实现。
import { reactive, effect } from Vue
const state = reactive({ name: 'vue3.0' })
effect(() => {
console.log(state.name) // 上来会打印 'vue3.0' 1秒钟后再次打印 'name被修改了'
})
setTimeout(() => {
state.name = 'name被修改了'
}, 1000)
特点: effect
接收函数作为参数,默认会执行该函数。当函数内部的响应式数据发生变化时, 会再次执行传入的回调函数。让我们看一下它的实现:
let activeEffect // 当前激活 activeEffect
function effect(fn, options = {}) { // options 支持传入 scheduler 选项,优先级更高
...
const _effect = new ReactiveEffect(fn, options.scheduler);
_effect.run(); // effect 函数初始化会执行 1 次
...
}
effect
函数主要做了2件事:
- 内部
new ReactiveEffect
得到_effect
实例 - 调用
_effect.run
方法。
ReactiveEffect
函数的实现: 接收用户传入的 fn
和 scheduler (调度)
作为参数,并且声明了 active
、deps
实例属性,run
、 stop
等实例方法。
const effectStack = []
class ReactiveEffect {
active: boolean = true;
deps = [];
constructor(public fn, public scheduler) {}
run() {
if (!this.active) {
return this.fn();
}
if (!effectStack.includes(this)) {
try {
effectStack.push(this)
activeEffect = this;
cleanup(this);
return this.fn();
}
finally {
effectStack.pop()
activeEffect = effectStack[effectStack.length-1]
}
}
}
stop() {
if (this.active) {
this.active = false;
}
cleanup(this);
}
}
比较关键的在于 run
函数的实现。
- 首先会判断
_effect
的状态是不是active
,这其实是一种控制手段,允许在非active
状态下直接执行原始函数fn
并返回。当active
为false
时,不会再做依赖收集,只会执行fn
函数 - 接着判断
effectStack
中是否包含_effect
,如果没有就把 _effect
压入栈内。之前我们在wrapper 高阶函数
中提到,只要设置activeEffect = _effect
即可,那么这里为什么要设计一个 栈 的结构呢?其实是考虑到嵌套 effect
的场景:
effect 嵌套场景
import { reactive, effect } from 'vue'
const state = reactive({
num: 0,
num2: 0
})
function printCount() {
effect(printCount2)
console.log('num:', state.num)
}
function printCount2() {
console.log('num2:', state.num2)
}
effect(printCount)
setTimeout(() => {
state.num++
}, 1000)
我们每次执行 effect
函数时,如果仅仅把当前实例的 _effect
函数赋值给 activeEffect
,那么针对这种嵌套场景,执行完 effect(printCount2)
后,activeEffect
还是 effect(printCount2)
返回的 _effect
函数,这样后续访问 state.num
的时候,依赖收集对应的 activeEffect
就不对了,此时我们修改 state.num
后执行的便不是 printCount
函数,而是 printCount2
函数,最终输出的结果如下:
而我们期望的结果应该如下:
因此针对嵌套 effect
的场景,我们不能简单地赋值 activeEffect
,应该考虑到函数的执行本身就是一种 入栈出栈
操作。因此我们可以设计一个全局的 effectStack
,这样每次执行 _effect.run
函数时就先把 _effect
入栈,然后 activeEffect
指向这个 _effect
实例,接着在 fn
执行完毕后出栈,把 activeEffect
指向 effectStack
最后一个元素,也就是最外层的 _effect
实例。
cleanup
这里还有一个细节,在执行 fn
函数前会执行 cleanup
函数清空 _effect
实例上存储的 deps
数组。这个 deps
是什么时候收集元素的呢。其实是在执行 track
函数的时候,除了收集当前激活的 activeEffect
,还通过 activeEffect.deps.push(dep)
把 dep
作为 activeEffect
的依赖,这样在 cleanup
的时候我们就可以找到当前activeEffect
对应的 dep
了,然后把 dep
从当前 activeEffect.deps
中删除。cleanup
函数的代码如下所示:
function cleanup(effect) {
const deps = effect.deps;
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect);
}
deps.length = 0 // 把 effect.deps 清空
}
为什么需要 cleanup
呢?如果遇到这种场景:
<div id="app"></div>
<script>
...
const state = reactive({ name: 'vue3.0', age: 2, flag: true })
effect(() => {
console.log('rerender')
app.innerHTML = state.flag ? state.name : state.age
})
setTimeout(() => {
state.flag = false
setTimeout(() => {
console.log("修改name, 原则上不更新")
state.name = '滴滴'
})
}, 500)
假设没有 cleanup
,在第一次渲染的时候,执行 effect
函数, 生成 _effect
实例指向 activeEffect
。由于 state.flag
为 true
,此时会访问 state.name
,所以 name、flag
会把 activeEffect
作为依赖收集起来。 然后 500 毫秒后,state.flag
变成了 false
,由于修改了 state.flag
就会派发更新,找到之前收集的 activeEffect
, 重新执行 activeEffect
的过程中,由于 state.flag
为假,此时会访问 state.age
,age
会把 activeEffect
收集起来。当我们再次修改 state.name
的时候,由于修改了 state.name
就会派发通知,找到了 activeEffect
并执行,就又触发了 effect
函数的重新渲染。
但这个行为实际上并不符合预期,因为此时 state.flag
为 false
,视图的渲染只依赖于 state.age
的变动 所以对 state.name
的改动并不应该触发视图的重新渲染。
因此在 fn
执行之前,如果通过 cleanup
清理依赖,我们就可以删除之前 state.name
收集的依赖。这样当我们修改 state.name
时,由于已经没有依赖了就不会触发组件的重新渲染,符合预期。
stop
stop
函数会把 active
置为 false
, 然后执行 cleanup
清空之前收集的依赖,基于此函数,我们可以暂停追踪。
理解 effect
函数是非常关键的,后面介绍的 computed
、组件渲染
都会用到它。
手动实现简版响应式
总结
本篇文章主要介绍了 Vue.js 3.0
响应式的实现原理,并对比了和 2.0
实现差异。依次介绍了什么时候依赖收集,什么时候派发更新,副作用函数的作用和设计原理。由于本篇文章重点在于阐明响应式核心流程,忽略了响应式 API
实现细节的介绍。因而在下一篇文章主要来介绍日常开发中常用的响应式 API
的实现细节。
转载自:https://juejin.cn/post/7142119932283600903