面试官:能不能手写 Vue3 响应式(Vue3 原理解析之响应系统的实现)

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

写在前面

随着 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 中执行该函数,也就能够实现简单的副作用函数绑定了

image.png

// 数据监听函数
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 单独绑定,下面引入一个数据结构解决这个问题,如图

image.png 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 函数,从所有依赖集中将副作用函数清除掉

image.png

// 触发 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 进行修改就不会影响循环的执行,修改如下

image.png

// 修改
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 的对应关系是正确的,修改如下

image.png

// 修改 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)
复制代码

image.png

分析如下:

image.png

解决这个问题很简单,我们只需要在 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)

猜你喜欢

转载自juejin.im/post/7084915514434306078