本文已参与「新人创作礼」活动,一起开启掘金创作之路。
写在前面
随着 Vue3 的逐渐成熟,Vue3 相比 Vue2 的优点变得越来越明显,虽然社区关于 Vue3 是否优于 Vue2 ,是否有升级的必要,依然存在很多争议,但是不可否认的是,Vue3 已经逐渐转正,开始成为官方默认的 Vue 版本,那么在前端这个站着卷、坐着卷、躺着也卷的圈子里,学习 Vue3 已经是绕不开的一关了,而我们多多少少都知道,Vue3 之所以诞生,其实是为了 “还清Vue 留下的技术债务”,所以 Vue3 无论从架构设计,还是从写法的优化上,都做了很多的调整,那么,即使我们对 Vue2 原理已经很熟悉了,但是依然有必要好好了解下 Vue3 的设计原理以及设计的思路,从原理的层面对比 Vue2 和 Vue3 才能够让我们更加清晰的理解,Vue2 到 Vue3 演变的每一步是为了解决什么问题,为什么要做这种优化。学习这些设计思路和优化手段其实已经不仅仅停留在了解或者学习 Vue3 这个层面了,学习它更大的收获可能是能够提升我们的思路以及设计水平,在之后的编码过程中,能够给我们提供一些 Base Practice,让我们设计出更加合理、高效的架构
响应系统的实现
原生 API Proxy
作为一个 MVVM 系框架,Vue 中最最重要的应该就是响应式系统的实现了,在 Vue2 实现中所采用的原生 API 是 Object.defineProperty,在往期文章中做过详细的原理分析,感兴趣的可以浏览下 面试官: 能不能手写 Vue 响应式?(Vue 响应式原理【完整版】),而在 Vue3 中,尤大在衡量了性能问题后,弃用了 Object.defineProperty ,改用 Proxy 作为实现响应系统中监听数据变化功能的底层 API ,在铺开探索响应系统之前,我们先来了解下 Proxy 到底是什么?如何使用 Proxy 实现数据监听?
先看看 API 相关文档,MDN-Proxy,通过介绍我们大致可以了解到,Proxy 和 Object.defineProperty 其实大同小异,都是接收一个对象,然后对该对象进行监听,而不同点有两个:
对象Key值全监听
Object.defineProperty 只能监听指定的 key 所对应的 value 值的变化,而 Proxy 可以直接监听对象的所有key,换句话说,Proxy 不需要手动对需要监听的对象进行遍历,监听每个 Key,接下来看看一个简单的例子(Object.defineProperty 相关例子直接阅读往期文章面试官: 能不能手写 Vue 响应式?(Vue 响应式原理【完整版】))
// 模拟数据更新
function update(){
console.log('update view')
}
// proxy
function observe_proxy(data){
// 代理 data,并将生成的代理对象作为返回值返回
const res = new Proxy(data, {
// 代理对象的getter
get(target, key, receiver){
// Reflect.get/set 为官方标准写法,直接将 get、set 的参数赋值即可
// 不需要手动写 return target[key]
return Reflect.get(target, key, receiver)
},
// 代理对象的setter
set(target, key, value, receiver){
// 模拟触发更新
update()
return Reflect.set(target, key, value, receiver)
}
})
return res
}
// 测试数据
const data = {
name: 'yimwu',
tel: '134xxx54567'
}
// 代理返回的对象
const dataProxy = observe_proxy(data)
console.log(dataProxy.name) // yimwu
dataProxy.name = 'YI' // 更新成功 --> 输出 update view
复制代码
可以看到当 observe 函数的实现方式很简单,直接通过 new Proxy(), 将对象传入即可代理对象的所有 key,但是细心的朋友可能注意到了,示例代码触发更新的方式和Object.defineProperty 的并不一样,这也是接下来要说的第二个不同点
触发更新
Object.defineProperty 触发更新的方式很简单,直接修改原对象,因为 Object.defineProperty 直接对原对象进行监听,而 Proxy 的实现思路不同,它通过代理的方式,将自己作为中间人的角色,所以对原对象所有的操作需要通过 Proxy 进行,才能够得到响应式的效果,例子如下,我们 通过修改 observe 返回的代理对象 dataProxy 触发更新,而不是修改原对象 data
const dataProxy = observe_proxy(data)
console.log(dataProxy.name) // yimwu
dataProxy.name = 'YI' // 更新成功 --> 输出 update view
复制代码
嵌套代理
使用 Object.defineProperty 实现嵌套监听的方式其实很简单粗暴,直接递归调用,通过给所有层级的所有key建立对于的 getter、setter,完成所有嵌套的监听,而使用 Proxy 略微有些差别,Proxy 对于嵌套赋值,会触发 get,例如 obj.a.b = 2
, 会触发 obj.a 的 get,利用这个特性,可以在 get 中做判断,若获取的是 object 则返回 Proxy,而不是返回该值,通过这种方式能够对对象,以及对象内嵌套的子对象进行代理,例子如下
// 模拟数据更新
function update(){
console.log('update view')
}
// proxy
function observe_proxy(data){
const res = new Proxy(data, {
get(target, key, receiver){
// 若修改的为嵌套对象,如 obj.a.b = 2,则 obj.a 触发 get,
// 返回嵌套代理对象,完成嵌套对象代理
if(typeof target[key] === 'object'){
return observe_proxy(target[key])
}
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver){
update()
return Reflect.set(target, key, value, receiver)
}
})
return res
}
// 测试数据
const data = {
name: 'yimwu',
tel: '134xxx54567',
address: {
firstAdd: 'guangzhou',
second: 'shenzhen'
},
}
//
const dataProxy = observe_proxy(data)
dataProxy.address.firstAdd = 'jieyang' // 更新成功 --> 输出 update view
复制代码
可以看到,在开始监听对象的时候,并没有和采用和 Object.defineProperty 的实现方式一样,在监听开始时就递归完成所有 getter、setter 绑定,而是等到触发了某个嵌套对象,才进行监听,所以,从这一步上来说也是采用 Proxy 这种实现方式的一个性能优化的点。
依赖收集
前面介绍了 Vue3 中采用的 Proxy API,通过该 API,就能够实现对数据的变化进行特定的响应,但是,这在 Vue 的响应系统中,只是最基础的一部分,更加复杂的应该是与数据监听相配合的依赖收集,举个例子,在 Vue 中我们经常使用的计算属性 computed ,能够在依赖更新时更新,所谓的依赖,就是该 computed 值由什么值决定,const myComputed = computed(() => { return a+b })
,则myComputed
依赖a、b
,即当 a、b
变化时,myComputed
将随着变化
理解了什么是依赖之后,副作用函数就很清晰了,顾名思义,就是有副作用的函数,指的是 执行了该函数,会影响某些外部的变量,而副作用函数的绑定,恰恰就是为了 在所依赖的值变化时,重新执行副作用函数,重新对该变量进行影响
0.0 版本(基础实现)
当对数据进行监听时,我们为 Proxy 提供了 getter和setter 函数,那么,副作用函数自然也能够融合到这两个函数里面来,形成副作用函数绑定,如下图,我们在 getter 中将 effectFn 函数绑定到对应的对象上,然后在 setter 中执行该函数,也就能够实现简单的副作用函数绑定了
// 数据监听函数
function observe_proxy(data){
// 监听对象
const res = new Proxy(data, {
get(target, key, receiver){
if(typeof target[key] === 'object' && target[key] !== null){
return observe_proxy(target[key])
}
// 提前到 fnList.add 前执行,避免监听行为被 fnList.add 干扰
const res = Reflect.get(target, key, receiver)
// =============== 新增 ===============
// 将 effectFn 添加到 fnList 中
fnList.add(activeEffect)
// =============== 新增 ===============
return res
},
set(target, key, value, receiver){
// 提前到 fnList.forEach 前执行,避免 set 未生效而执行 effectFn
let res = Reflect.set(target, key, value, receiver)
// =============== 新增 ===============
// 执行 fnList 中所有副作用函数
fnList.forEach(fn => fn())
// =============== 新增 ===============
return res
}
})
return res
}
// 存储所有副作用函数的数组
// 这里采用 Set 替换 Array 实现自动去重
// const fnList = new Array()
const fnList = new Set()
// 存放需要添加到 funList 中的函数
let activeEffect
// 触发副作用函数 effectFn 绑定行为
function effect(effectFn){
activeEffect = effectFn
// 执行effectFn,触发 get
effectFn()
}
// 测试数据
const data = {
name: 'yimwu',
tel: '134xxx54567',
address: {
firstAdd: 'guangzhou',
second: 'shenzhen'
},
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
const effectFn = () => {
console.log('My tel-num is: ', dataProxy.tel)
}
// 第三步:触发副作用函数绑定
effect(effectFn) // My tel-num is: 134xxx54567 ==> 触发 get, 副作用函数绑定成功
// 测试效果
setTimeout(() => {
dataProxy.tel = '159000xxx54' // My tel-num is: 159000xxx54 ==> 副作用函数成功执行
}, 2000);
复制代码
1.0版本(key 单独绑定副作用函数)
在基础版中,只是让我们能够初步体会到依赖绑定的行为,但是还存在很多问题,比如依赖函数并没有针对 data 的每个 key 单独绑定,下面引入一个数据结构解决这个问题,如图
fnList 为一个 WeakMap,key 值为一个个的数据对象 data ,value 则是一个 Map,该 Map 中用 data 的 key值 作为 key,存储副作用函数的 Set 作为 value,这样就形成了针对每个数据对象的每个key的单独的副作用函数的 Set ,接下来对代码进行改造,支持新的数据结构
// 存储所有副作用函数的对象
const fnList = new WeakMap()
// 收集副作用函数
function track(target, key){
if(!activeEffect) return
// 依次获取data对应的Map以及Map中key所对应的Set,
// 有则将 activeEffect 加入,没有则新建后加入
let dataMap = fnList.get(target)
if(!dataMap){
dataMap = new Map()
fnList.set(target, dataMap)
}
let fnSet = dataMap.get(key)
if(!fnSet){
fnSet = new Set()
dataMap.set(key,fnSet)
}
fnSet.add(activeEffect)
}
// 执行副作用函数
function trigger(target, key){
// 获取副作用函数集并执行
const dataMap = fnList.get(target)
if(!dataMap) return
const effectFns = dataMap.get(key)
effectFns && effectFns.forEach(effectFn=>effectFn())
}
function observe_proxy(data){
const res = new Proxy(data, {
get(target, key, receiver){
if(typeof target[key] === 'object' && target[key] !== null){
return observe_proxy(target[key])
}
// 提前到 fnList.add 前执行,避免监听行为被 fnList.add 干扰
const res = Reflect.get(target, key, receiver)
// 将 effectFn 添加到 fnList 中
// fnList.add(activeEffect)
track(data, key) // 更新写法
return res
},
set(target, key, value, receiver){
// 提前到 fnList.forEach 前执行,避免 set 未生效而执行 effectFn
let res = Reflect.set(target, key, value, receiver)
// 执行 fnList 中所有副作用函数
// fnList.forEach(fn => fn())
trigger(target,key) // 更新写法
return res
}
})
return res
}
// 存放需要添加到 funList 中的函数
let activeEffect = null
// 触发 effectFn添加行为
function effect(effectFn){
activeEffect = effectFn
// 执行effectFn,触发 get
effectFn()
// 由于通过 set 触发副作用函数时也会导致 get 被触发,
// 将 activeEffect 设为 null 避免重复添加副作用函数
activeEffect = null
}
// 测试数据
const data = {
name: 'yimwu',
tel: '134xxx54567',
address: {
firstAdd: 'guangzhou',
second: 'shenzhen'
},
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
const effectFn = () => {
console.log('My tel-num is: ', dataProxy.tel)
}
// 第三步:触发副作用函数绑定
effect(effectFn) // My tel-num is: 134xxx54567 ==> 首次触发,监听成功
// 测试效果
setTimeout(() => {
dataProxy.tel = '159000xxx54' // My tel-num is: 159000xxx54 ==> 监听成功触发
}, 2000);
setTimeout(() => {
dataProxy.name = 'YI' // 没监听,不输出
}, 4000);
复制代码
2.0 版本(副作用函数重置)
通过 1.0 版本的改造,加入了新的数据结构之后,实现了副作用函数针对key的单独绑定,而 1.0 版本依然存在问题。在许多使用场景中,一个副作用函数往往关联的 key 不止一个,也就是说首次触发副作用函数后,多个 key 会同时绑定一个副作用函数,当任何一个 key 被修改时,都将触发该副作用函数重新执行,例如以下场景,通过两个变量决定电灯是否打开,一个是电源开关 powerOn,一个是电灯开关 turnOn
// 测试数据
const data = {
powerOn: true,
turnOn : true
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
const effectFn = () => {
console.log('是否开启电源', dataProxy.powerOn)
console.log('是否开启灯光', dataProxy.turnOn)
}
// 第三步:触发副作用函数绑定
effect(effectFn)
复制代码
执行后,powerOn、turnOn 同时绑定了 effectFn ,当任何一个状态发生变化时,effectFn 都将被触发,看似符合规则,没有任何问题,但是这并不符合场景设定,场景需求是两个变量共同决定电灯状态,并且如果电源关闭,无论电灯开关是否开启,电灯都为关闭状态,对上面的副作用进行一下修改,如下
// 测试数据
const data = {
powerOn: true,
turnOn : true
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
// 表示若电源(powerOn)开启,则灯光状态由(turnOn)决定,
// 若电源(powerOn)关闭,无论灯光(turnOn)是否开起,状态都为关闭(false)
const effectFn = () => {
console.log('是否开启灯光', dataProxy.powerOn ? dataProxy.turnOn : false)
}
// 第三步:触发副作用函数绑定
effect(effectFn)
// 测试效果
setTimeout(() => {
dataProxy.turnOn = false
}, 2000);
setTimeout(() => {
dataProxy.turnOn = true
}, 3000);
setTimeout(() => {
dataProxy.powerOn = false
}, 4000);
// 将powerOn 置为 false 后,turnOn 的变化依然触发 副作用函数
setTimeout(() => {
dataProxy.turnOn = false
}, 5000);
setTimeout(() => {
dataProxy.turnOn = true
}, 6000);
复制代码
初始化时,powerOn 为 true,执行表达式时同时触发 powerOn、turnOn 的 get 并绑定副作用函数,而当我们将 powerOn 置为 false 后,无论 turnOn 的值如何变化,表达式恒等于 false,也就是说当我们将 powerOn 置为 false 后,再修改 turnOn 就不需要再触发副作用函数 effectFn了,这也是一个性能优化的手段,那该如何实现呢
最直接的方法是 在每次触发副作用函数时将该副作用函数从 所有 key 的依赖集合中清除掉,通过副作用函数触发的 get 重新形成绑定,这样一来,当 powerOn 为 false 时,将不再触发 turnOn 的 get ,因此 turnOn 的变化将不再触发 副作用函数。具体改动如下,在副作用函数上定义一个数组,用于触发绑定了该副作用函数的依赖集(Set),当触发副作用函数时调用 reset 函数,从所有依赖集中将副作用函数清除掉
// 触发 effectFn 添加行为
function effect(effectFn){
const innerFn = () => {
// 清除依赖
reset(innerFn)
activeEffect = innerFn
effectFn()
}
// 存放依赖集的数组
innerFn.deps = []
// 间接执行effectFn,触发 get
innerFn()
}
// 用于清除依赖的函数
function reset(effectFn){
effectFn.deps && effectFn.deps.forEach(dep => {
dep.delete(effectFn)
})
effectFn.deps = []
}
// 修改 track 函数
function track(target, key){
if(!activeEffect) return
// 依次获取data对应的Map以及Map中key所对应的Set,有则将 activeEffect 加入,没有则新建后加入
let dataMap = fnList.get(target)
if(!dataMap){
dataMap = new Map()
fnList.set(target, dataMap)
}
let fnSet = dataMap.get(key)
if(!fnSet){
fnSet = new Set()
dataMap.set(key,fnSet)
}
fnSet.add(activeEffect)
// ================ 新增 ================
activeEffect.deps.push(fnSet)
// ================ 新增 ================
}
// 测试数据
const data = {
powerOn: true,
turnOn : true
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:定义副作用函数
// 表示若电源(powerOn)开启,则灯光状态由(turnOn)决定,
// 若电源(powerOn)关闭,无论灯光(turnOn)是否开起,状态都为关闭(false)
const effectFn = () => {
console.log('是否开启灯光', dataProxy.powerOn ? dataProxy.turnOn : false)
}
// 第三步:触发副作用函数绑定
effect(effectFn)
setTimeout(() => {
dataProxy.turnOn = false
}, 2000);
setTimeout(() => {
dataProxy.turnOn = true
}, 3000);
setTimeout(() => {
dataProxy.powerOn = false
}, 4000);
// powerOn = false turnOn 变化不再触发副作用函数
setTimeout(() => {
dataProxy.turnOn = false
}, 5000);
setTimeout(() => {
dataProxy.turnOn = true
}, 6000);
复制代码
当运行上面版本的代码的时候会发现陷入无限循环,其实问题主要出现在 trigger 函数,原因如图所示,reset 函数将 Set 中的 effectFn 删除后,执行track时,track 又重新将 effectFn 加入到 Set 中,导致 ForEach 无限循环,所以最直接的办法是 forEach 一个新的 Set,这样执行过程中对原来的 Set 进行修改就不会影响循环的执行,修改如下
// 修改
function trigger(target, key){
// 获取副作用函数集并执行
const dataMap = fnList.get(target)
if(!dataMap) return
const effectFns = dataMap.get(key)
const effectFnsTmp = new Set(effectFns)
// =============== 修改 ===============
effectFnsTmp.forEach(fn => fn())
// effectFns && effectFns.forEach(effectFn=>effectFn())
// =============== 修改 ===============
}
复制代码
3.0 版本(栈消除嵌套副作用函数bug)
2.0 版本已经基本上修复了大部分的坑了,而还有一个平时写程序经常会遇到的一个,那就是嵌套,我们先拿 2.0 版本的代码测试一下是否支持嵌套
// 测试数据
const data = {
name: 'Yimwu',
age: 18
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:嵌套监听
effect(function effectFn1(){
console.log('第一层开始')
effect(function effectFn2(){
// 触发副作用函数绑定
console.log('第二层', dataProxy.name)
})
// 在第二层执行完成后,触发副作用函数绑定,验证是否能够正常绑定
console.log('第一层结束', dataProxy.age)
})
// 测试效果
setTimeout(() => {
dataProxy.age = 30
// 第二层 Yimwu ==> 输出错误,age 绑定了第二层的函数,
// 而不是预想的第一层函数
}, 2000);
复制代码
很显然,2.0 版本并不支持嵌套,一旦发生嵌套,就会出现 activeEffect不匹配的问题,那么这个问题该如何解决呢
这里顺便说一下日常编程的一个小 Tip ,在平时我们遇到的问题中,有两个法宝可以使用,一个是队列,一个是栈,而顾名思义,队列,就是针对顺序进行的事情所定义的数据结构,它能够保证我们的程序按照我们所设想的顺序执行,而栈我们相对陌生,由于栈的特殊读取、输入规则(先进后出),栈最常被用于处理嵌套逻辑
从嵌套执行的 effect 可以看得出副作用函数错误绑定的原因主要是 全局 activeEffect 在进入第二层后,被第二层覆盖,当第二层结束后,activeEffect 没有恢复到第一层对应的 activeEffect,因此,我们使用栈结构,对每一层的 activeEffect 进行保存,通过出栈的方式保证 activeEffect 的对应关系是正确的,修改如下
// 修改 effect 函数
function effect(effectFn){
const innerFn = () => {
// 清除依赖
reset(innerFn)
activeEffect = innerFn
// ==================== 新增 ====================
// 入栈
activeStack.push(innerFn)
// ==================== 新增 ====================
effectFn()
// ==================== 新增 ====================
// 出栈
activeStack.pop()
// 执行外层 activeEffect
activeEffect = activeStack[activeStack.length-1]
// ==================== 新增 ====================
}
// 存放依赖集的数组
innerFn.deps = []
// 间接执行effectFn,触发 get
innerFn()
}
// 测试数据
const data = {
name: 'Yimwu',
age: 18
}
// 第一步:先启动数据监听
const dataProxy = observe_proxy(data)
// 第二步:嵌套监听
effect(function effectFn1(){
console.log('第一层开始')
effect(function effectFn2(){
// 触发副作用函数绑定
console.log('第二层', dataProxy.name)
})
// 在第二层执行完成后,触发副作用函数绑定,验证是否能够正常绑定
console.log('第一层结束', dataProxy.age)
})
// 测试效果
setTimeout(() => {
dataProxy.age = 30
}, 2000);
// 输出:
// 第一层开始
// 第二层 Yimwu
// 第一层结束 30
复制代码
最后解决一个特殊情况下会出现的 Bug,当副作用函数中出现 自增、自减 操作时,副作用函数会重复调用,导致栈溢出,例子如下
// 测试数据
const data = {
name: 'Yimwu',
age: 18
}
// 定义副作用函数
const effectFn = () => {
dataProxy.age++
}
// 触发副作用函数绑定
effect(effectFn)
复制代码
分析如下:
解决这个问题很简单,我们只需要在 trigger 中判断从 Set 中取出的 effectFn 与目前正在执行的 activeEffect 是否相同,相同则不执行,这样就能够避免无线循环,修改如下
// 修改副作用函数
function trigger(target, key){
// 获取副作用函数集并执行
const dataMap = fnList.get(target)
if(!dataMap) return
const effectFns = dataMap.get(key)
// =================== 新增 ===================
// 创建空 Set,筛选不等于 activeEffect 的副作用函数
const effectFnsTmp = new Set()
effectFns.forEach(fn => {
if(fn !== activeEffect) effectFnsTmp.add(fn)
})
effectFnsTmp.forEach(fn => fn())
// =================== 新增 ===================
// const effectFnsTmp = new Set(effectFns)
// effectFnsTmp.forEach(fn => fn())
// effectFns && effectFns.forEach(effectFn=>effectFn())
}
复制代码
总结
经过多次的 “版本迭代” ,总算是将 Vue 的响应系统的实现原理进行了全面的分析,通过这一次响应系统的分析和研究,不仅能够理清 Vue 响应系统到底是如何实现的,更加重要的是能够使我们对 Vue 这个框架更加熟悉,在使用的时候更加 知其所以然 而不是 盲目调 api 的 API仔,同时,在实现过程中所使用的架构设计方式,以及引入的数据结构,例如 解决嵌套问题的栈 和 解决存储问题的存储数据结构 WeakMap => Map => Set => function 等,也是学习 Vue 响应系统非常大的收获,通过学习底层原理,接触更加复杂的设计场景,不仅是了解框架,更是我们学习编程 Base practice 的 最佳手段!
写在最后
博主接下来将持续更新好文,欢迎关注博主哟!!
如果文章对您有帮助麻烦亲点赞、收藏 + 关注和博主一起成长哟!!❤❤❤
参考文献(致谢)
《Vue 设计与实现》---- 霍春阳(HcySunYang)