JS高级-Vue2/3响应式原理

  • 在本章节中,我们会开始探索JS的响应式原理,难度不在于代码,而在于思维上的拓展,因此会引导大家主动的去思考响应式为什么会这样进行设计,而非枯燥的源码

    • 结合前面所学的Proxy/Reflect,Map/WeakMap,Set等API,我们会将响应式开发到什么程度呢?

    • Vue2、Vue3以及React18,他们的响应式使用方式又是怎么样的,我们所实现的响应式,会为未来掌握框架所牢牢打下基础吗?学会了本次章节,将会给大家带来豁然开朗的收获!曾经的问题不再是问题

一、什么是响应式

  • 我们先来通过一段代码,看一下响应式意味着什么

    • m有一个初始化的值,有一段代码使用了这个值

    • 那么在m值发生了变化,也就是有新值赋予时,这段代码与m有关的部分可以自动重新执行,这就是最基础的响应式原理所需要实现的效果

//初始值m
let m = 100

//一段操作代码
console.log(m)
console.log(m + 1)
console.log(m + 10)
console.log(m + 100)

//m发生变化,与m有关的控制台操作代码将会以m改变后的新值,重新进行执行
m = 200
  • 不管前端框架如何变化,在Vue3、React19中,响应式都是不会淘汰的,只会在基础响应式上,根据实际情况去不断的迭代优化

    • 根据最新的Vue3.5版本,响应式又进一步重构,重构后内存占用减少了56%,主要优化手段是通过版本计数双向链表数据结构实现,这是数据结构层面的优化

    • 但万变不离其宗,所有的奇迹与伟大,都是建立在第一步踏出中,然后不断生根发芽,我们也可以。在经过前面章节的学习,我们已经具备较为坚实的基础,能够支撑我们继续走下去,但依旧需要谨记勿在浮沙筑高台

    • Vue3.5响应式更新文档:Announcing Vue 3.5 | The Vue Point (vuejs.org)

图片

 
 
图25-1  Vue3.5响应式更新内容

  • 在实际框架中的响应式体现,我们更多的不是单独监听一个原始数据来实现响应式

    • 而是基于对象的响应式,例如Vue中的Ref

    • 这更多是出于性能的考虑,我们可以单独拎出一个原因:原始数据类型是不可变的,不能被直接修改,任何“修改”实际上是创建了一个新值。如果原始数据直接用作响应式数据,每次变更都需要替换旧值,检测该变化会变得复杂和低效,而封装成对象,可以持有一个可变的引用,当数据更新,只需修改对象状态,可以更容易地追踪和响应这些变化,且对象更方便扩展、不变动原有内容

  • 因此,在该章节中,我们要实现的响应式原理案例,针对的是对象形式数据

const obj = {
  name:"coderwhy",
  age:18
}
//与obj对象中属性有关的操作
console.log(obj.name)//往后省略100行与name属性相关操作...
console.log(obj.age)//往后省略100行与age属性相关操作...

obj.name = '小余'//当name属性发生变化后,与name属性相关的100行操作,也要同步进行自动重新执行

二、响应式函数和依赖收集

  • 我们要如何实现对象的响应?这是第一步需要思考的问题

    • 默认情况下,对象是静态的,JS引擎从上到下执行,对象改变发生在执行后,则不会重新执行

    • 我们需要一个开启对象响应式的契机,那就是通过函数,该函数是一个开关,放入该函数的对象,我们将其开启响应式,让返回的数据"活过来",动态起来

const obj = {
  name:"coderwhy",
  age:18
}
//响应式开关媒介
const objRef = 响应式函数(obj)//开启obj对象的响应式
  • 这里需要注意,我们并不是让原数据动态起来,而是返回一个具备响应式的相同数据,这主要有几点原因:

    1. 首先是数据的不可变性

    2. 其次我们不改变原始的数据结构,因为这有可能影响到并不需要响应式的应用场景并产生不必要的响应式监听消耗,例如有的地方需要展示固定的初始数据

    3. 使用封装的的响应式更利于我们中途增减操作,兼容性、扩展性都好,且易于调试维护

  • 因此在下方案例的foo函数与bar函数中,想要实现需要响应式和静态的两种效果,就需要不改变原数据,从而满足静态效果,通过函数返回包装响应的值来处理需要响应式情况

    • 这种方式的兼容性是更好的,而且实际使用中,兼容性是优先度很高的一件事情

    • 因为不能使用,哪怕再好也和我们没有关系

const obj = {
  name: "coderwhy",
  age: 18
}

function foo() {
  const newName = obj.name
  console.log('响应式数据:',obj.name);
}
//省略需要响应式处理的100行代码...
function bar() {
  console.log('普通函数,不需要任何响应式',obj.name);
}

foo()
bar()

obj.name = '小余'//当数据发生改动时,foo函数应该重新执行,而bar函数不执行
  • 在知道了触发媒介后,我们需要来完善这一媒介,从而实现响应式

    • 在该操作过程中,不断优化其对数据的处理,是各类框架会做的事情

    • 我们最核心需要掌握的,是通过该媒介实现响应式核心功能,在该基础上的优化处理,则需要长期的学习与掌握

2.1 依赖收集

  • 响应式核心之一在于依赖收集,那什么是依赖收集?

    • 依赖两字,很有意思,中文释义为依靠别人或事物而不能自立或自给

    • 因此可以看出有两方,一方是依赖者,一方是被依赖者

    • 在我们响应式中,被依赖者是响应式对象,依赖者是使用了响应式对象的操作者

    • 一旦被依赖者发生变化,依赖者就必须依附上去从而跟上脚步,无法脱离被依赖者单独改变

  • 所以,在我们这里,依赖收集就是收集这些使用了响应式对象的操作者

    • 我们将依赖者们收集起来,方便当被依赖者发生改变后,及时通知依赖者们跟上脚步

  • 但收集依赖也是有讲究的,我们要如何收集起来?以及收集到什么地方?

    • 用数组收集起来如何?不太行,因为数组不方便进行管理,数组是无序的(索引无实意),我们要清楚知道这些依赖者都对应哪一个被依赖者(obj对象中的xxx属性都被谁所依赖),数组是没办法这么细化的

//封装一个响应式的函数
let reactiveFns = []//name发生改变时,所有需要重新执行的函数
// 所有相关依赖
function watchFn(fn) {
  reactiveFns.push(fn)
}
//需要响应式的数据1
const obj = {
  name:'coderwhy',
  age:20
}
//需要响应式的数据2
const info = {
  friend:'小余',
  address:'福建'
}

//我们一个对象的响应式依赖对应一个数组
watchFn(obj响应式对应的依赖)
watchFn(info响应式对应的依赖)

图片

 
 
图25-2  如何处理复杂的依赖对应关系?

  • 因此我们使用类与对象的结合,所有统一的响应式操作,放在Depend类中

    • 好处有两个:

      1、便于管理,每个 Depend 实例可以独立管理一组依赖,而不会与其他实例的依赖混淆,例如obj的name与age都对应一个depend对象,管理它们分别对应的依赖者

      2、Depend 类可以被用在不同的项目或不同的部分中,每次用到响应式功能时都可以重用,无需重复编写。从而做到响应式的功能职责不用被单独划分出去,让功能的整体度产生零碎感

class Depend {
  constructor() {
    this.reactiveFns = []//依赖组
  }

  // 收集依赖
  addDepend(reactiveFn) {
    this.reactiveFns.push(reactiveFn)
  }

  //对所有依赖进行统一通知处理
  notify() {
    this.reactiveFns.forEach(fn => {
      //遍历依赖处理
      fn()
    })
  }
}

// 封装响应式函数
const depend = new Depend()

const obj = {
  name: "coderwhy",//depend实例对象
  age: 18//depend实例对象
}
//收集依赖者
function watchFn(fn) {
  depend.addDepend(fn)
}


function foo() {
  const newName = obj.name
  console.log('响应式数据:', newName);
}

//任何通过 watchFn 注册的函数都会成为响应式的依赖,即在数据发生变化时这些函数会被调用
//watchFn(函数)
watchFn(foo)//每一个属性都有对应一个depend,每一个depend都有对应一个watchFn来收集相关的依赖者
obj.name = '小余'
depend.notify()//与obj的name相关的所有操作进行重新加载

图片

 
 
图25-3  收集依赖与通知依赖逻辑

  • 当然现在这个代码,在我们看来,关联性不是很强,且存在一定的问题。比如obj对象是如何做到内部的每一个属性都能够有对应的depend实例对象?以及怎么直接修改原数据了,当然我们可以暂且默认这份obj对象数据是一份已经拷贝出来的数据,等待着变为响应式数据后进行返回

    • 一个完整的响应式由多个步骤组成,对一个单一属性进行收集依赖与通知依赖是一个迷你响应式的最小单元

    • 作为一个最小单元,具备响应式的最核心原理,在上方代码案例中,我在notify方法中对依赖进行调用,则形成以下操作步骤:封装响应式函数、依赖收集、监听对象变化、对象变化则通知依赖重新调用

  • 作为一个响应式的最核心基础框架已经搭建起来了,那就是收集依赖通知依赖这两大步骤,接下来我们要做的就算完善这两大步骤以及构建这两大步骤的中间桥梁(捕获通知依赖的时机)

2.2 监听对象变化

  • 目前主要分四步骤,其响应式函数封装位于最外层用于应用层调用,在响应式函数封装内,存在依赖收集、监听对象变化、对象变化则通知依赖重新调用等核心操作

    • 我们目前已经通过类、对象、函数来实现清晰层级的watchFn依赖收集,并且在类中用于通知依赖的notify方法中,简单调用依赖

    • 但在依赖收集和通知依赖之间,还需要一个桥梁:什么时候通知依赖?

    • 而这就需要我们能够监听对象的变化,对象属性发生变化则是我们要抓住的时机

    • 在依赖收集的案例中,用于测试obj对象的name修改是否会让依赖重新调用,是通过手动捕获时机的,也就是当一修改对象name属性,在下一行则手动调用notify方法来通知依赖

  • 在该步骤中,通知依赖不应该是手动调用,而是当我们修改属性时能够自动执行,这非常重要

    1. 首先自动通知依赖的概念是声明式编程范式的核心组成部分,只需声明程序的目标状态或逻辑,而不需要详细说明如何实现这一状态的每一步(这是命令式编程的特点)。该声明式思想是各类框架的核心理念

    2. 其次计算机的更新是极为稳定不易出错的,可以避免因遗漏手动更新而导致的数据不一致问题,因为当每次状态改变所引起的更新都是自动和一致的,就可以清晰地追踪数据流和状态变化,可预测的数据非常重要

    3. 最后也是最明显的,自动依赖通知减少了重复代码,提高了代码复用率,开发效率和代码质量、产品质量、维护难度等等都得到更好的发展

  • 因此要通过监听对象变化,从而做到不手动调用notify方法,让我们开始吧!

    • 监听对象属性变化有两种方式,都是我们曾经学过的,分别是ProxydefineProperty,我们当然是选择更加强大且适用的前者Proxy

    • 而这两种方式,也对应了Vue3(Proxy)与Vue2(defineProperty)的响应式实现方式,我们先来实现Vue3的响应式,后续再了解Vue2的

//...重复内容省略
function foo() {
  const newName = obj.name
  console.log('响应式数据:', newName);
}

//任何通过 watchFn 注册的函数都会成为响应式的依赖,即在数据发生变化时这些函数会被调用
//watchFn(函数)
watchFn(foo)//每一个属性都有对应一个depend,每一个depend都有对应一个watchFn来收集相关的依赖者
obj.name = '小余'
depend.notify()
obj.name = 'JS高级'
depend.notify()
obj.name = 'coder'
depend.notify()
  • 上图手动操作代码冗余且麻烦,我们通过Proxy、Reflect来实现自动监听对象变化,在Proxy代理中,当修改值的下一刻自动调用依赖通知

class Depend {
  constructor() {
    this.reactiveFns = []//依赖组
  }

  // 收集依赖
  addDepend(reactiveFn) {
    this.reactiveFns.push(reactiveFn)
  }

  //对所有依赖进行统一通知处理
  notify() {
    this.reactiveFns.forEach(fn => {
      //遍历依赖处理
      fn()
    })
  }
}

// 封装响应式函数
const depend = new Depend()

const obj = {
  name: "coderwhy",//depend实例对象
  age: 18//depend实例对象
}

//使用Proxy监听对象变化
const objProxy = new Proxy(obj, {
  get:function(target,key,receiver){
    return Reflect.get(target,key,receiver)
  },
  set:function(target,key,newValue,receiver){
    Reflect.set(target,key,newValue,receiver)
    depend.notify()
  }
})

//收集依赖者
function watchFn(fn) {
  depend.addDepend(fn)
}


function foo() {
  const newName = obj.name
  console.log('响应式数据:', newName);
}

//任何通过 watchFn 注册的函数都会成为响应式的依赖,即在数据发生变化时这些函数会被调用
//watchFn(函数)
watchFn(foo)//每一个属性都有对应一个depend,每一个depend都有对应一个watchFn来收集相关的依赖者
objProxy.name = '小余'//响应式数据: 小余
objProxy.name = 'JS高级'//响应式数据: JS高级
objProxy.name = 'coder'//响应式数据: coder
objProxy.age = 66//响应式数据: coder
  • 通过proxy,修改objProxy从而通过代理拦截处理对应的依赖通知操作

    • 但到目前为止,仍不算完善,因为我们只在foo函数中只进行了name属性相关的赋值操作,但我们对age的改变,也重新调用了name依赖部分

    • 这是因为我们目前还没做到每一个属性都有属于自己的depend实例对象,当前的watchFn收集依赖函数只针对name属性进行收集的依赖,并没有收集age依赖的部分

    • 该问题属于在基础响应式单位上,进行裂变,从1个响应式到n个响应式,并做到每一个属性都与对应的收集依赖函数对应上

2.3 所有依赖保存结构

  • 哪怕我们把age属性所对应的依赖收纳到watchFn函数中,也不能够满足我们的需求

    • 因为name、age属性的依赖会收纳到同一个依赖组(reactiveFns)内,这会导致不管哪一个属性的变化,在带动自身相关依赖时,也会导致另外属性的调动,这是不合理的,当属性一多,会造成可观的性能浪费

    • 我们需要做到按需执行,只执行必要的部分

    • 这就需要做到每一个属性都有单独的depend实例对象,因为这样就可以做到每个属性都有对应的依赖组,而不会混淆

  • 此时我们就需要解决一个问题,如何为每一个属性自动实例化一个depend对象呢?并且这些depend对象的归属问题是怎么样的?

    • 例如我们有obj与info两个对象,两个对象里有多个属性,有一对一的depend实例对象,这些depend实例对象都属于obj或者info,应该有一个清晰的划分。而且还有可能在obj对象内的属性还是一个对象,则有可能又要继续细化下去,为对象中的对象内的属性添加depend实例对象,然后将这些对象.对象.属性的depend实例对象进行归类划分

    • 在这里所说明的depend实例对象包括了收集依赖、依赖组划分以及通知依赖的权限划分

    • 这些问题都需要在一个前提下才能够解决,这就涉及到我们对依赖收集的管理

const obj = {
  name:'coderwhy',
  age:20
}
//需要响应式的数据2
const info = {
  // 对象内还有对象(在这里只是用于举例)
  friends:{
    friend1:'小余',
    friend2:'JS高级',
    friend3:'李银河'
  },
  address:'福建'
}

图片

 
 
图25-4  属性与depend依赖对应结构

  • 对于依赖的收集,我们需要有两组对应关系:

    1. 响应式对象属性的对应

    2. 属性depend依赖(depend实例对象)的对应

  • 对于这一个需求,Map是非常不错的选择,首先是Map是键值对的数据结构,在一些方面相对于对象会更具备优势(Map章节有说明),比如说属性名不能重复,防止后者覆盖前者

    • Map的键值对,分别对应了属性名与depend依赖

  • 让我们以info、obj两组对象来举例说明:

const objMap = new Map()
objMap.set('name', 'depend依赖')
objMap.set('age', 'depend依赖')

const infoMap = new Map()
infoMap.set('friends','depend依赖')
infoMap.set('address', 'depend依赖')
  • 在原有基础上,我们继续叠加WeakMap,将obj与info响应式对象拉取过来形成关联

    • 第二步调用属性返回属性对应的依赖,通知依赖进行更新

    • 而这就是我们所需要完善的获取依赖、通知依赖的两大步骤

    • WeakMap的键值对,分别对象了响应式对象与(属性 & depend依赖)

    • 通过Map和WeakMap,我们将响应式对象、对象属性、属性的depend依赖联系在一起

    • 这种联系方式,意味着我们想要通知对应的依赖,只需要两步即可完成:

    1. 调用响应式对象

    2. 调用响应式对象中的属性

  • 而为什么响应式对象的对应关系不继续使用Map关联,而是采用WeakMap?

    • 这也是有讲究的,万事开头为主,响应式对象通过WeakMap弱引用关联,则我们高频繁的通知依赖调用不会一直影响正常垃圾回收

//info、obj数据省略
const objMap = new Map()
objMap.set('name', 'depend依赖')
objMap.set('age', 'depend依赖')

const infoMap = new Map()
infoMap.set('friends', 'depend依赖')
infoMap.set('address', 'depend依赖')

const targetMap = new WeakMap()
targetMap.set(obj,objMap)
targetMap.set(info,infoMap)

//通知属性对应的依赖重新调用进行响应式
const depend = targetMap.get(obj).get('name')//获取依赖
depend.notify()//通知依赖

图片

 
 
图25-5  依赖保存结构(依赖链)

  • 在做到了每一个属性都有对应depend依赖后,我们就需要进行下一步正确通知依赖

    • 在上一小节监听对象变化中,objProxy代理中的set方法中,我们直接通知固定的依赖组,这是不正确的

    • 我们需要在这一阶段,拿到正确修改依赖,而这对于现在的我们属于可以实现的部分

    • 我们使用Map与WeakMap搭建成功一条依赖链,只需要拿到响应式对象名称、改动的属性名称,就能够拿到正确的依赖组进行通知

  • 那我们需要来封装一下一个专门获取依赖的方法,这个方法接收两个参数,也就是所需的:响应式对象名称、改动的属性名称

    • 在最外层写一个targetMap对象,作为总依赖组,存取响应式对象

    • 封装getDepend函数,主要分两步:获取响应式对象返回map(属性与depend依赖)、获取属性返回依赖

    • 在获取map和依赖时,分别会面临map与依赖是否存在的问题,此时需要进行判断

    1. map不存在,则new一个map并建立响应式对象与map之间的联系

    2. 依赖不存在,则new一个Depend依赖,并使用map将传入的属性与新建依赖进行绑定

  • 通过两步判断,搭建完整依赖链,实现传入需要响应式的对象和对象属性的传递,如果对象或者属性是第一次使用,则自动创建并开启依赖收集

// 封装一个获取depend函数
const targetMap = new WeakMap()
function getDepend(target, key) {
  // 1、根据target对象获取map的过程
  let map = targetMap.get(target)
  if (!map) {
    map = new Map()
    targetMap.set(target, map)
  }
  // 2、根据对象属性获取depend依赖
  let depend = map.get(key)
  if (!depend) {
    depend = new Depend()
    map.set(key, depend)
  }
  return depend
}
  • 我们在objProxy代理中就能够针对性获取每一个属性的依赖,从而针对性的通知依赖

    • 但此时还存在一个问题,我们封装的getDepend函数,并没有解决如何处理收集依赖的问题,也就是addDepend方法在什么时候收集依赖,如何自动收集依赖?

    • 因此这时候对depend依赖进行通知,只会通知到一团空气,reactiveFns依赖组目前是空数组

    • 到目前为止,所形成的这个依赖保存结构已经属于一种数据结构了(使用某种结构保存所想保存的数据即为数据结构)

图片

 
 
图25-6  依赖者与被依赖者之间的互动逻辑

const objProxy = new Proxy(obj, {
  get: function (target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set: function (target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    //做到一个属性都有一个对应的depend依赖
    const depend = getDepend(target,key)//target:需要响应式的对象,key:对象属性
    depend.notify()//如何保证在通知依赖前,依赖已经被正确收集?
  }
})

2.4 正确收集依赖

  • 在完善通知依赖后,我们需要进一步完善依赖收集的问题

    1. 依赖收集时机

    2. 依赖收集位置

    3. 自动收集依赖

    4. 收到到的依赖能够对应到正确的属性上(依赖者与被依赖者)

//obj对应的依赖
watchFn(obj对应的依赖)
//info对应的依赖
watchFn(info对应的依赖)
//如何做到watchFn收集的依赖能够正确放到对应属性中?而非obj与info的依赖混在同一个依赖组中
watchFn...
watchFn...
watchFn...
  • 目前我们的依赖没有做到根据属性,区分到不同的依赖组中,而这要如何做到?

    • 我们需要找到能够获取到属性的地方,更准确的说是能够获取属性每一次使用的位置

    • 有这样的位置吗?当然是有的,通常这个位置是非常关键的,也非常适合用于做拦截操作,因此可以先找找看拦截器的位置或者代理之类的位置

    • 恰好我们有一个objProxy代理,在这里的get方法就能够做到每一次正常调用都可以获取到对象本身target以及对应的属性key

  • 大家有没有觉得,这实在是太巧了,在set方法中我们可以做通知依赖,而在get方法中,可以做收集依赖

    • 这一切当然不是巧合,都是编程的设计艺术

    • 但此时我们还需要解决一件问题:在get中,我们没办法获取到依赖,依赖目前收集在watchFn中,从作用域的角度来看,我们是拿不到watchFn函数内部的数据的

    • 这时候,我们可以使用一个讨巧的方式,在全局创建一个全局变量activeReactiveFn,将收集的依赖传递到全局变量中,此时我们在objProxy代理中的get方法中就可以访问到该依赖,将其收取到对应的依赖组中

//原方式-响应式函数
function watchFn(fn) {
  depend.addDepend(fn)
}
  • 在使用全局变量进行中转站时,我们要清晰的知道该函数的作用仅作为转接,用完后要置为空,以防对垃圾回收造成影响

    • 在get中,我们通过getDepend方法,先确定了对应的属性,然后为确定的属性添加正确对应的依赖

    • 在getDepend方法中,已经有实现过,当第一次使用会创建对应的depend实例对象并搭建对应的依赖链,第二次以及更多次使用则会顺着该依赖链,通过响应式对象对象属性两点来精准获取正确依赖

    • 在这里,我们做到了正确填入对应依赖,而正确取出依赖在前面就已经完成

    • 因此现在如果在obj对象调用对应的name与age属性,一旦进行修改,会调用对应的正确依赖,而非所有依赖同时调动,只针对修改属性的响应式,符合我们的预期

// 封装响应式函数
let activeReactiveFn = null
function watchFn(fn) {
  activeReactiveFn = fn
  fn()
  //用完置空,释放内存
  activeReactiveFn = null
}

//使用Proxy监听对象变化
const objProxy = new Proxy(obj, {
  get: function (target, key, receiver) {
    //1.锚定确定的对象属性
    const depend = getDepend(target,key)
    //2.为确定的属性进行添加对应的依赖
    depend.addDepend(activeReactiveFn)
    return Reflect.get(target, key, receiver)
  },
  set: function (target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    const depend = getDepend(target, key)
    depend.notify()
  }
})
  • 此时我们来进行测试,目标为obj对象中的name与age属性

    • 测试为三个步骤,采取单一变量控制法

    • 测试1与测试2分别为单独的name与age属性,测试3则是name与age属性同时具备。此时我们对name属性与age属性进行单独修改(一次只针对一个修改变量)

    • 修改name属性,会调用测试1与测试3的依赖,而修改age属性,会调用测试2与测试3的依赖。在这里测试3为交集,而交集的测试3依赖,只会响应修改的部分,未修改部分不会发生变化

//测试代码
class Depend {
  constructor() {
    this.reactiveFns = []//依赖组
  }

  // 收集依赖
  addDepend(reactiveFn) {
    this.reactiveFns.push(reactiveFn)
  }

  //对所有依赖进行统一通知处理
  notify() {
    console.log(this.reactiveFns)
    this.reactiveFns.forEach(fn => {
      //遍历依赖处理
      if(fn) fn()
    })
  }
}

const obj = {
  name: "coderwhy",//depend实例对象
  age: 18//depend实例对象
}

// 封装响应式函数
let activeReactiveFn = null
function watchFn(fn) {
  activeReactiveFn = fn
  fn()
  activeReactiveFn = null
}

// 封装一个获取depend函数
const targetMap = new WeakMap()
function getDepend(target, key) {
  // 1、根据target对象获取map的过程
  let map = targetMap.get(target)
  if (!map) {
    map = new Map()
    targetMap.set(target, map)
  }
  // 2、根据对象属性获取depend依赖
  let depend = map.get(key)
  if (!depend) {
    depend = new Depend()
    map.set(key, depend)
  }
  return depend
}


//使用Proxy监听对象变化
const objProxy = new Proxy(obj, {
  get: function (target, key, receiver) {
    const depend = getDepend(target,key)
    depend.addDepend(activeReactiveFn)
    return Reflect.get(target, key, receiver)
  },
  set: function (target, key, newValue, receiver) {
    Reflect.set(target, key, newValue, receiver)
    const depend = getDepend(target, key)
    depend.notify()
  }
})
//测试1:name属性对应的依赖
watchFn(function(){
  console.log('测试1 name:',objProxy.name);
})
//测试2:age属性对应的依赖
watchFn(function(){
  console.log('测试2 age:',objProxy.age);
})
//测试3:name属性和age属性共同的依赖
watchFn(function(){
  console.log('测试3 name和age:',objProxy.name,objProxy.age);
})

objProxy.name = '小余'//修改name属性,其相关属性依赖重新调用
objProxy.age = 66//修改age属性,其相关属性依赖重新调用
  • 由于我们在封装响应式函数watchFn时,依赖在函数内部自调用一次,因此可以看到响应前与响应后的对比情况

    • 以下测试均为name与age单独测试验证结果

//修改name属性,对应的依赖调用前后比对
测试1 name: coderwhy
测试2 age: 18
测试3 name和age: coderwhy 18
[ [Function (anonymous)], [Function (anonymous)] ]
测试1 name: 小余
测试3 name和age: 小余 18
//测试age属性,对应的依赖前后比对
测试1 name: coderwhy
测试2 age: 18
测试3 name和age: coderwhy 18
[ [Function (anonymous)], [Function (anonymous)] ]
测试2 age: 66
测试3 name和age: coderwhy 66

2.5 对Depend类的优化与重构

  • 响应式最核心的部分已经完成,可以针对每一个传入的对象(案例假设为obj对象)进行针对性收集依赖,并正确调动通知依赖,在Vue的官方文档中,再来理解这段关于Ref响应式的理解时,就会非常好理解

图片

 
 
图25-7  Vue响应式工作原理

  • 而Vue框架对响应式对象的本质,也有直接的阐述,而JavaScript代理高亮部分,会直接跳转到MDN文档的Proxy部分,这里可以作为一个基础了解,在后续学习Vue或者React时,将游刃有余

图片

 
 
图25-8  Vue的响应式对象本质说明

  • 完成响应式后,我们再来对depend类进行一定程度的优化

    const objProxy = new Proxy(obj, {
      get: function (target, key, receiver) {
        const depend = getDepend(target,key)
        //get方法不应该关心外界的activeReactiveFn变量
        depend.addDepend(activeReactiveFn)
        return Reflect.get(target, key, receiver)
      }
    })
    
    • 通过该优化,我们将收集逻辑放到了Depend类的层面中,而非Proxy代理上

    • 数据的拆分,让代理层更加存粹,让类的功能性质更加集中

    • 因此我们可以对addDepend方法进行一定的优化(不优化其实也不影响使用)

    • 在depend类的get方法中,我们给depend对象添加响应式依赖函数,采用了activeReactiveFn变量进行记录

    • 但最好是不要让get方法直接依赖外部的 activeReactiveFn 变量,主要是为了提高代码的模块性和可维护性

    • 将依赖收集与外部状态解耦,可以让 get 方法更加独立,便于理解维护。如果将收集依赖的逻辑放在外部变量上,可能会导致代码在变化时难以追踪

//测试代码
class Depend {
  constructor() {
    this.reactiveFns = []//依赖组
  }
  // 收集依赖
  addDepend(reactiveFn) {
    this.reactiveFns.push(reactiveFn)
  }
  // 更好的收集依赖(优化)
  depend(){
    if(activeReactiveFn) this.reactiveFns.push(activeReactiveFn)
  }
}

const objProxy = new Proxy(obj, {
  get: function (target, key, receiver) {
    const depend = getDepend(target,key)
    //优化效果
    depend.depend()
    return Reflect.get(target, key, receiver)
  }
})
  • 而此时,我们的响应式依旧存在一些问题

    • 在调用属性内容时,无论属性是否有改变,都会进行拦截且调用

watchFn(function(){
  console.log('重复测试1',objProxy.name);
  console.log('重复测试2',objProxy.name);
})

objProxy.name = '小余'//修改name属性,其相关属性依赖重新调用
  • 返回的结果进行了重复调用,原因来自我们打印了两次name

    • 每次打印,都会触发get方法,并且触发depend方法,重复收集依赖

    • 所以会导致在调用时,调用了重复的依赖,两个一致依赖调用两次,n个一致依赖调用n次,当重复调用一多,会造成极大的性能浪费,也就是O(n^2)

重复测试1 coderwhy
重复测试2 coderwhy
[ [Function (anonymous)], [Function (anonymous)] ]
重复测试1 小余
重复测试2 小余
重复测试1 小余
重复测试2 小余
  • 在面对该问题时,我们需要先抽离问题原因,在根据因果关系进行问题的顺序排查

    1. 找到收集依赖的位置

    2. 解决重复收集依赖的问题

  • 首先我们在Depend类中的depend方法中收集依赖

    • 在该方法中,可以看到是将依赖push进reactiveFns依赖函数组中

    • 此时我们有两种选择,方式1是限制activeReactiveFn的重复性,方式2是限制reactiveFns接受相同依赖

  • 方式1并不好实现,因为我们永远不知道下一个传递进来的内容是什么

    • 但我们可以知道我们已经有什么,而手里的内容就在reactiveFns中,因此我们选择方式2会是更好的选择

    • 那此时我们怎么做?按照以前的方式的话,我们可以使用高阶函数方法进行实现

//高阶函数方法
if(activeReactiveFn) if(!this.reactiveFns.includes(activeReactiveFn)) this.reactiveFns.push(activeReactiveFn)
  • 但这未免长了一点,我们有更好的方式,也就是通过Set数据结构做出限制

    • 此时对Set数据结构的添加内容方式就需要从push改为Set的独特add方法

    • 不管是哪种方式,都能够达到我们的目的,方法之间没有高下之分,取决于个人的风格习惯

//测试代码
class Depend {
  constructor() {
    this.reactiveFns = new Set()//依赖组
  }

  // 更好的收集依赖
  depend() {
    if (activeReactiveFn) this.reactiveFns.add(activeReactiveFn)
  }
}
  • 这就是我们对depend方法的优化,用来取代之前的addDepend方法

  • 最终我们来看下完整修改后的案例代码

//测试代码
class Depend {
  constructor() {
    this.reactiveFns = new Set()//依赖组
  }

  // 更好的收集依赖
  depend() {
    if (activeReactiveFn) this.reactiveFns.add(activeReactiveFn)
  }

  //对所有依赖进行统一通知处理
  notify() {
    console.log(this.reactiveFns)
    this.reactiveFns.forEach(fn => {
      //遍历依赖处理
      if (fn) fn()
    })
  }
}

// 封装响应式函数
let activeReactiveFn = null
function watchFn(fn) {
  activeReactiveFn = fn
  fn()
  activeReactiveFn = null
}

// 封装一个获取depend函数
const targetMap = new WeakMap()
function getDepend(target, key) {
  // 1、根据target对象获取map的过程
  let map = targetMap.get(target)
  if (!map) {
    map = new Map()
    targetMap.set(target, map)
  }
  // 2、根据对象属性获取depend依赖
  let depend = map.get(key)
  if (!depend) {
    depend = new Depend()
    map.set(key, depend)
  }
  return depend
}


//使用Proxy监听对象变化
function reactive(obj) {
  return new Proxy(obj, {
    get: function (target, key, receiver) {
      const depend = getDepend(target, key)
      depend.depend()
      return Reflect.get(target, key, receiver)
    },
    set: function (target, key, newValue, receiver) {
      Reflect.set(target, key, newValue, receiver)
      const depend = getDepend(target, key)
      depend.notify()
    }
  })
}

const obj = {
  name: "coderwhy",//depend实例对象
  age: 18//depend实例对象
}

const info = {
  name:"小余",
  age:18
}
//响应式开关媒介
const objRef = reactive({
  name: "coderwhy",
  age: 18
})
const infoRef = reactive({
  name:"小余",
  age:18
})

watchFn(() => {
  console.log(infoRef.name);  
})
watchFn(() => {
  console.log(objRef.name);  
})

infoRef.name = '响应式-小余'
objRef.name = '响应式-coderwhy'

// 小余
// coderwhy
// Set(1) { [Function (anonymous)] }
// 响应式-小余
// Set(1) { [Function (anonymous)] }
// 响应式-coderwhy

响应式操作对比(Vue2 or Vue3)

3.1 Vue3响应式操作

  • 做到了正确保存依赖和通知依赖,但我们在前面保留了一个问题,这些依赖都是针对单独一个对象的

    • 此时我们如果不用obj对象了,换成了info对象,那不就完蛋了,info对象可没有Proxy代理

    • 而这是我们真正实现通用响应式应用的最后拦路虎,在之前的各种做法中,我们由细节之处,一步步的扩大

    • 由只能解决单个属性收集 => 通用性对 对象的每一个属性进行收集

    • 只能收集所有依赖 => 针对属性去收集对应依赖

  • 此时,我们要做的就是由只能针对单个对象响应式 => 通用性的对每一个对象都进行响应式

    • 解决了这一个问题,我们就不需要每做一个响应式,就写一次Proxy代理,那太麻烦了

    • 那我们要怎么做呢?答案是封装一个函数来处理该过程

  • 最终就能够实现我们一开始说明的效果:

    • 对象传递进去,返回一个新的响应式对象,这很棒不是吗?

const obj = {
  name:"coderwhy",
  age:18
}
//响应式开关媒介
const objRef = 响应式函数(obj)//开启obj对象的响应式
  • 让我们来实现这一个函数吧!

    • 回想我们的思路,我们需要改变什么?

    • 这太简单了,我们的Proxy代理逻辑都是通用的抽象封装,因此我们只需要动态确定Proxy的代理对象是谁就好了

    • 函数接受需要响应式的对象,然后将对象开启代理,返回xxxProxy代理即可,让我们来实现一下

function reactive(obj) {
  return new Proxy(obj, {
    get: function (target, key, receiver) {
      const depend = getDepend(target, key)
      depend.depend()
      return Reflect.get(target, key, receiver)
    },
    set: function (target, key, newValue, receiver) {
      Reflect.set(target, key, newValue, receiver)
      const depend = getDepend(target, key)
      depend.notify()
    }
  })
}
  • 此时我们再进行测试,即可完成自由选择对象进行响应式操作

    • 不管是obj或者是info对象,都是可以使用reactive函数将对象转化为响应式

    • 后续再多的对象想要进行响应式,都只需要一行代码即可实现

const obj = {
  name: "coderwhy",//depend实例对象
  age: 18//depend实例对象
}

const info = {
  name:"小余",
  age:18
}
//响应式开关媒介
const objRef = reactive(obj)//开启obj对象的响应式
const infoRef = reactive(info)//开启obj对象的响应式

watchFn(() => {
  console.log(infoRef.name);  
})
watchFn(() => {
  console.log(objRef.name);  
})

infoRef.name = '响应式-小余'
objRef.name = '响应式-coderwhy'

// 小余
// coderwhy
// Set(1) { [Function (anonymous)] }
// 响应式-小余
// Set(1) { [Function (anonymous)] }
// 响应式-coderwhy
  • 而在该基础上,我们可以直接将对象本身内容直接传递进reactive函数中也是没有问题的,可以少取一个变量名

    • 而这是Vue3的响应式使用方式,也就是基于Proxy代理的响应式

//objRef与infoRef为可响应对象
const objRef = reactive({
  name: "coderwhy",
  age: 18
})
const infoRef = reactive({
  name:"小余",
  age:18
})

3.2 Vue2响应式操作

  • 相对于Vue3而言,Vue2的响应式逻辑是保持一致的

    • 唯一不同之处,在于函数reactive内,对操作拦截的不是Proxy,而是defineProperty

    • 其中的逻辑,也是我们曾经实现过的

function reactive(obj) {
  // {name: "why", age: 18}
  // ES6之前, 使用Object.defineProperty
  Object.keys(obj).forEach(key => {
    let value = obj[key]
    Object.defineProperty(obj, key, {
      get: function() {
        const depend = getDepend(obj, key)
        depend.depend()
        return value
      },
      set: function(newValue) {
        value = newValue
        const depend = getDepend(obj, key)
        depend.notify()
      }
    })
  })
  return obj
}

3.3 React响应式操作

  • React的响应式从18版本开始,采用Hook形式的setState函数

    • 一样是传递进所以想要转变为响应式的内容(初始值),但在返回值的使用上略有不同

    • 返回了indexsetIndex,这和我们前面所讲的响应式关键节点通知依赖非常相似

    • index为响应式内容,和Vue保持一致

    • 不同之处在于React将通知依赖的时机交给开发者决定,其setIndex是一个方法,和我们Depend类中的notify方法相同

    • Vue自动处理,React手动设置。在这方面上没有谁的设计更优秀的说法,这是设计理念风格的不同

    • React将选择交给开发者,因此操作更加自由,但难度也会进行提升,是一把双刃剑

  • 但对于掌握响应式原理的我们,都可以掌握,坚实的脚步已经踏出,这些收获本就是我们的囊中之物

const [index, setIndex] = useState(0)

图片

 
 
图25-9  React响应式使用操作

猜你喜欢

转载自blog.csdn.net/cui137610/article/details/142920476