说起 Vue3 的响应式系统,大家估计都会第一时间不约而同地想到 Proxy
;诚然,proxy
在整个响应式系统中扮演了非常重要的角色,但是它并不是响应式系统的全部。
今天我们就来一起探索一下 Vue3 的响应式系统,看看它究竟还藏了什么小技巧!
Proxy 介绍
为了防止有的小伙伴遗忘了,正式开篇前我们先来回顾一下响应式系统中这个一把手 Proxy
的基本用法。
Proxy
接收两个参数:
- 第一个参数是需要代理的对象
target
; - 第二个参数是一个对象
handler
,里面定义了代理对象的一些操作;
同时,Proxy
会返回一个被代理过的对象:
const data = {
name: 'lisi',
age: 18
}
const proxyData = new Proxy(data, {
get (target, key) {
return target[key]
},
set (target, key, value) {
target[key] = value
}
})
proxyData.name // 这里会触发 Proxy 的 get 逻辑
proxyData.age = 19 // 这里会触发 Proxy 的 set 逻辑
上面的代码中,我们通过 Proxy
对 data
进行了代理;
当我们去 设置 data
的属性时,会触发 Proxy
的 get
逻辑;当我们去获取 data
的属性时,会触发 Proxy
的 set
逻辑。
这就是 Proxy
的基本用法。
当然除了上面最基本的属性值的读取,Proxy
还可以拦截许多其他的操作,比如 has
、deleteProperty
、ownKeys
等等。
Proxy
的所有可拦截操作可以参考 MDN 文档: Proxy
注意事项
在使用 Proxy 的时候,需要注意以下几点:
只能代理对象
Proxy
只能代理对象,不能代理基本数据类型:
const ProxyData = new Proxy(1, {
get (target, key) {
console.log('get', key)
return target[key]
},
set (target, key, value) {
console.log('set', key, value)
target[key] = value
}
})
ProxyData.name // 这里会报错,因为 `Proxy` 只能代理对象
只能代理基本操作
Proxy
只能代理基本的操作,无法代理复合操作;
那么什么是基本操作和复合操作呢?
基本操作
基本操作就是 直接对对象进行操作 ,比如:
- 对象的读取操作,比如:
ProxyData.name
; - 对象的设置操作,比如:
ProxyData.age = 19
; - 函数调用,比如:
ProxyData()
; in
操作符,比如:'name' in ProxyData
;
复合操作
而复合操作就是 多个基本操作的组合,比如:ProxyData.fn()
;
这里的操作实际上是:先读取 ProxyData
的 fn
属性,然后再调用 fn
函数;是一个复合操作。
简单回顾了一下 Proxy
的基本用法,下面我们就来正式讲解 Vue3 是如何利用 Proxy
来实现响应式系统的。
响应式系统
假设我们有一个副作用函数:
function effectFn () {
document.getElementById('app').innerHTML = ProxyData.name
}
在函数中使用到了 Proxy
代理过的数据,我们希望 —— 在代理数据发生变化时,能够触发副作用函数的重新执行;
为此,我们可以利用 Proxy
的特性,在 get
逻辑中进行 依赖收集;在 set
逻辑中进行 依赖触发;
代码如下:
const ProxyData = new `Proxy`(data, {
get (target, key) {
// TODO 依赖收集
return target[key]
},
set (target, key, value) {
target[key] = value
// TODO 触发依赖
}
})
这里需要大家思考两个问题:
- 我们要将 依赖收集到哪里?
- 当代码运行到
ProxyData.name
时,我们要如何知道 当前正在执行的是哪个函数?
针对第一个问题,可以使用一个全局的 Map
结构来保存依赖:
const targetMap = new Map()
而对于第二个问题,这里可以使用一个通用的 effect
函数来包裹我们要执行的函数;
在 effect
函数中,使用一个全局变量将当前正在执行的函数保存起来,这样一来在依赖收集时,就只需要 将这个全局变量作为依赖进行收集 就可以了。
代码如下:
// 当前激活的effectFn
let activeEffect
function effect (fn) {
activeEffect = fn
fn()
}
effect(effectFn)
依赖收集与依赖触发
解决了上面的问题,接下来我们再来看看具体如何实现 依赖的收集与触发,代码如下:
// 当前激活的effectFn
let activeEffect
// 用于保存依赖的容器
const targetMap = new Map()
function effect (fn) {
activeEffect = fn
fn()
}
const ProxyData = new Proxy(data, {
get (target, key) {
if (!activeEffect) return
// 依赖收集
const dep = targetMap.get(target)
if (!dep) {
// 在 target 和 activeEffect 之间建立依赖关系
targetMap.set(target, activeEffect)
}
return target[key]
},
set (target, key, value) {
// 触发依赖
// 通过 target 找到对应的依赖并执行
const dep = targetMap.get(target)
if (dep) {
dep()
}
target[key] = value
}
})
上面的代码里,我们在 get
逻辑中 通过一个 Map
在 target
和 activeEffect
之间建立了依赖关系;
在 set
逻辑中,我们 通过 target
在 Map
中找到对应的依赖并执行。
我们来测试测试一下:
function effectFn () {
console.log('我是副作用函数,我被触发了')
document.getElementById('app').innerHTML = ProxyData.name
}
effect(effectFn)
ProxyData.name = 'zhangsan' // 输出:我是副作用函数,我被触发了
现在,当我们修改 ProxyData
的 name
属性时,能够触发对应的副作用函数重新执行;这样我们就实现了一个简单的响应式系统。
依赖关系的建立
但是,这里还存在一些问题 ——
首先,当我们修改 ProxyData
的 age
属性时,也会触发副作用函数执行,但是我们的副作用函数中并没有使用到 age
属性:
ProxyData.age = 19 // 输出:我是副作用函数,我被触发了
这是因为我们在依赖收集的逻辑中,直接在 target
对象和 effectFn
之间建立了依赖关系;
这样一来,无论 target
上的什么属性发生变化,都会触发 effectFn
函数执行;这显然是不正确的。
其次,一个对象属性可能会被多个副作用函数使用,比如:
function effectFn () {
document.getElementById('app').innerHTML = ProxyData.name
console.log('我是副作用函数,我被触发了')
}
function effectFn1 () {
document.getElementById('app').innerHTML = ProxyData.name
console.log('我是副作用函数1,我被触发了')
}
我们 目前的依赖关系是一对一的,新加入的 effectFn1
函数会覆盖掉 effectFn
函数;
所以当我们修改 ProxyData
的 name
属性时,只会触发 effectFn1
函数执行,而不会触发 effectFn
函数执行。
根据上面两点,我们再修改一下依赖收集与触发的相关代码:
const ProxyData = new Proxy(data, {
get (target, key) {
if (!activeEffect) return
// 依赖收集
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = dep.get(key)
if (!dep) {
// 用一个 Set 来保存 effectFn,满足一对多的同时保证不会有重复的 effectFn
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
return target[key]
},
set (target, key, value) {
// 触发依赖
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
dep.forEach(fn => {
fn()
})
}
target[key] = value
}
})
修改后的代码中,我们在 target
、key
和 effectFn
三者之间建立了依赖关系:
上图中:
targetMap
是一个Map
结构,它的key
是target
,value
是depsMap
;depsMap
同样是一个Map
结构,它的key
是访问的具体值,value
是depSet
;depSet
是一个Set
结构;这里使用Set
是为了利用Set
的特性,保证不会有重复的effectFn
。
这样一来,我们在 修改 ProxyData
的 age
属性时,就不会触发 effectFn
函数执行了;
同时,多个函数使用到了同一个 ProxyData
属性时,也能够正确的触发对应的函数执行。
下面我们通过这个简易的响应式系统来看看 Vue3 中的一些响应式 API 是如何实现的。
reactive
reactive
接收一个对象作为参数,并返回一个响应式的对象:
const reactiveData = reactive({
name: 'lisi',
age: 18
})
我们将前面响应式系统中依赖收集和依赖触发的逻辑单独封装:
// 依赖收集
function track (target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = dep.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
}
// 依赖触发
function trigger (target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
dep.forEach(fn => {
fn()
})
}
}
然后基于封装好的 track
函数与 trigger
函数,对传入 reactive
中的对象进行代理:
function reactive (target) {
const handler = {
get (target, key) {
// 依赖收集
track(target, key)
return target[key]
},
set (target, key, value) {
// 触发依赖
trigger(target, key)
target[key] = value
}
}
return new Proxy(target, handler)
}
这里的实现其实很简单,就是 基于我们响应式系统进行了一层封装,不再赘述了。
ref
在介绍 Proxy
时我们说过,Proxy
只能代理对象,不能代理基本数据类型;
而 Vue3 中提供了一个 ref
可以将基本数据类型转换成一个响应式的对象,它又是怎么实现的呢?
其实在 Vue3 源码中,是通过访问器属性 value 来实现这个功能的:
function ref(value) {
if (value._isRef === true) {
return value;
}
const RefImpl = {
_isRef: true,
_value: value,
dep: new Set(),
get value() {
// 依赖收集
this.dep.add(activeEffect);
return this._value;
},
set value(newValue) {
this._value = newValue;
// 触发依赖
this.dep.forEach(fn => fn());
}
}
return RefImpl;
}
在 Vue3 中使用 ref
时,我们需要通过 .value
的形式来获取到它的值,这样一来就会 命中访问器属性的 get
和 set
逻辑;
同时,因为 ref 是被设计用于代理基本数据类型的,所以它在依赖收集时不会在 key
和副作用函数之间建立依赖关系;而是 直接将副作用函数放到当前实例的 dep 中;
当我们修改 ref
的 value
时,就会命中访问器 set
逻辑;在 set
逻辑中,就可以直接触发 dep
中的所有副作用函数执行。
watch
可调度性
watch
函数允许我们观察一个响应式对象的变化,当响应式对象发生变化时,才会去触发传入的回调函数;
并且,我们也可以通过传递 immediate
参数,来控制 watch
是否在初始化时立即执行一次副作用函数:
watch(() => proxyData.name, () => {
console.log('响应式数据改变')
}, {
immediate: true // 立即执行
})
它的这些特性意味着我们需要想办法 控制副作用函数的执行时机,也就是所谓的可调度性;
在目前的 effet
函数逻辑中,拿到副作用函数就会立即执行;所以我们需要对 effect
函数进行改造:
function effect (fn, options = {}) {
function effecFn () {
// 全局变量 activeEffect 指向当前的 effectFn
activeEffect = effecFn
// 执行传入的副作用函数拿到返回值,并 return 出去
const result = fn()
return result
}
// 将 options 挂载到 effectFn 上,这样就能在 effectFn 中拿到 options 了
effecFn.options = options
// 如果是 lazy 模式,那么就不会立即执行副作用函数
if (!options.lazy) {
effecFn()
}
return effectFn
}
改造后的 effect
函数,接收一个 options
参数,它是一个对象,里面包含了 lazy
属性;
如果 lazy
为 true
,则 不会立即执行副作用函数,而是把副作用函数返回,让外部来控制它的执行时机;
接下来,我们基于新的 effect
函数,来实现 watch
:
function watch (source, cb, options = {}) {
const effecFn = effect(
() => source,
{
lazy: true,
scheduler: () => {
cb()
}
}
)
}
可以看到,watch
实际上就是对 effect
函数的一层封装,它会把用户传入需要 watch
的值封装成一个函数作为副作用函数传递给 effect
;
除此之外,还给 effect
函数传递了第二个参数,是一个对象,里面包含了 lazy
和 scheduler
两个属性;
lazy
属性在前面已经介绍过,它是用来 控制副作用函数的执行时机;那 scheduler
又是用来做什么的呢?
实际上,scheduler
函数是用来 控制副作用函数的执行方式的;
如果存在 scheduler
函数,那么在触发依赖的时候就不会执行 effectFn
,而是去执行 scheduler
函数;
为了实现这个功能,我们还需要对 trigger
函数进行改造:
function trigger (target, key) {
if (!activeEffect) return
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
dep.forEach(fn => {
// 在这里,我们通过判断 fn 上是否有 scheduler 参数,来决定执行 fn 还是执行 options.scheduler 函数
if (fn.options.scheduler) {
fn.options.scheduler()
} else {
fn()
}
})
}
}
这么一来,我们就能控制副作用函数的执行时机与方式了。
新旧值
通过上面的改造,watch
的基本框架就实现了;在这个基础上,还需要进一步完善一些功能。
我们知道,在传递给 watch
函数的 cb
函数中,可以拿到 newVal
和 oldVal
;接下来就来实现这个功能:
function watch (source, cb, options = {}) {
let oldValue
let newValue
const job = () => {
const newValue = effecFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effecFn = effect(
() => source,
{
lazy: true,
scheduler: job
}
)
// 如果是 immediate 模式,那么就立即执行 job 函数
if (options.immediate) {
job()
} else {
oldValue = effecFn()
}
}
在上面的代码中,我们将 scheduler
的逻辑抽离出来,放到了一个 job
函数中;
job
函数主要做了几件事:
- 执行副作用函数,拿到
newValue
; - 执行用户传入的
cb
函数,将newValue
和oldValue
传递给cb
函数; - 将
newValue
赋值给oldValue
,为下一次求值做准备。
有的小伙伴可能会疑惑 —— 为什么 在 job(scheduler)
函数中执行 effectFn
时,拿到的是 newValue
;而在 watch
函数中立即执行 effectFn
时,拿到的却是 oldValue
呢?
这是因为我们前面改造了 trigger
触发依赖时的逻辑,job(scheduler)
的执行时机实际上是在 trigger
中;而 trigger
之所以被触发正是因为 修改了响应式变量的值,此时对 effectFn
的求值,拿到的就是 newValue
了。
而手动执行 effectFn
时是在 watch
函数中,此时 响应式变量的值并没有发生变化,所以拿到的就是 oldValue
。
computed
computed
函数用于创建一个计算属性,它接收一个函数作为参数,返回一个响应式对象:
const count = ref(1)
const double = computed(() => count.value * 2)
computed
同样利用了 effect
函数可以 控制副作用函数执行时机的特性,它的实现也很简单:
function computed (getter) {
// 将用户传入的 getter 函数作为副作用函数传递给 effect
// 并且通过 lazy: true 的方式来控制 effect 的执行时机
const effectFn = effect(getter, { lazy: true })
// 当用户通过 .value 获取计算属性的值时,会执行 effectFn
const computedImpl = {
get value () {
return effectFn()
}
}
return computedImpl
}
在 computed
函数中,我们 将用户传入的 getter
函数作为副作用函数传递给 effect
函数;
并且通过 lazy: true
的方式来 控制 effect
的执行时机;
当用户通过 .value
获取计算属性的值时,就会去 执行 effectFn
,从而触发 getter
函数的执行拿到计算属性的值。
脏检查
computed
的一个特点就是实现了数据的脏检查逻辑 ——
只有当计算属性的依赖发生变化时,才会重新计算计算属性的值;
下面我们来实现这个功能:
function computed (getter) {
// 这里有一个 dirty 变量,用来标识计算属性的依赖是否发生变化
let dirty = true
// 保存计算属性的值
let value
const effectFn = effect(getter, {
lazy: true,
scheduler: () => {
// 当 scheduler 函数执行时,也就是计算属性的依赖发生变化时,会重新将 dirty 置为 true
dirty = true
}
})
const computedImpl = {
get value () {
if (dirty) {
value = effectFn()
// 在获取计算属性的值后,会将 dirty 置为 false
// 如果后续依赖的值未更改,将不再重新求值
dirty = false
}
// 如果不需要重新计算,那么就直接返回上一次计算的结果
return value
}
}
return computedImpl
}
在修改后的逻辑中,使用一个变量来 标识计算属性的依赖是否发生变化;
首次获取计算属性的值时,会执行 effectFn
,实际上就是执行了用户传入的 getter
函数,拿到计算属性的值并保存下来;
然后将 dirty
置为 false
,表示当前的 计算属性的依赖没有发生变化;
当计算属性的依赖发生变化时,会命中 trigger
函数的逻辑,从而执行 scheduler
函数将 dirty
置为 true
;
那么,computed
函数就会 再次进行求值计算。
总结
Vue3 响应式不仅依赖于 Proxy
,在具体实现上还需要 effect
函数打配合。
基于 可调度性的设计,让 Vue3 可以自由控制副作用函数的执行时机和执行方式;使得整个响应式系统更加灵活,并在此基础上拓展出了 watch
、computed
等 API;
当然,除了文章提到的这些,Vue3 还基于这种设计实现了很多其他的功能,比如更新队列的实现、异步更新的实现等等......
这些有机会再和大家唠唠吧~