vue3与vue2的区别之数据响应——手写vue3的reactive,理解vue3数据响应式原理

1、 数据响应式

首先请大家认真的思考一个问题:什么是数据响应式

答:数据变化是可侦测的,并且和数据相关的内容可以更新。

️这里一定要明确一个概念,数据响应式和视图更新是没有关系的!数据响应式是一种机制,一种数据变化的侦测机制。而实现数据响应式这种机制的方法不唯一。
那么,vue是如何实现数据响应式的?vue2和vue3的数据响应式有什么区别?

2、vue如何实现数据响应式?

要知道,vue3.x实现数据响应的方案跟vue2.x是不一样的,所以在这里我将vue2.xvue3.x分别说说。这也是理解vue2.xvue3.x区别的时候,可以指出来的一个巨大的区别。

2.1 vue2.x的实现方案

我贴上一个vue2.x源码-Object的变化侦测解读的链接,方便大家理解和后续关于vue2.x的学习需要。
(特别是还没阅读过vue源码的同学,可以独自过一遍这个文档,能对vue有一个更深的认识)

在下面vue2的源码中可以看到,Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象,所以我们可以知道vue2需要遍历对象的所有的key。其实现数据响应式的核心思想就是通过defineProperty,去定义getset等方法。从而能够拦截到对象属性的访问和变更。

/**
 * Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
 */
export class Observer {
  constructor (value) {
    this.value = value
    // 给value新增一个__ob__属性,值为该value的Observer实例
    // 相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
    def(value,'__ob__',this)
    if (Array.isArray(value)) {
      // 当value为数组时的逻辑
      // ...
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}
/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */
function defineReactive (obj,key,val) {
  // 如果只传了obj和key,那么val = obj[key]
  if (arguments.length === 2) {
    val = obj[key]
  }
  if(typeof val === 'object'){
      new Observer(val)
  }
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取了`);
      return val;
    },
    set(newVal){
      if(val === newVal){
          return
      }
      console.log(`${key}属性被修改了`);
      val = newVal;
    }
  })
}

在日常开发中,产品经理总是会跟我们说,我们做了xxxx就是为了解决客户的xxxx痛点。
那么,在继续往下阅读的时候,可以先思考一下vue2这样的实现方案的痛点有什么?或者说缺点有什么?
因为作为客户(使用vue开发的前端同学)的我们需要知道,vue3是否解决了我们的痛点?

vue2的缺点:(仅仅是关于数据响应造成的缺点哦!)

  • 1、影响初始化速度、数据过大时的资源问题
    (在源码的Observer方法上,对象的每一个属性都要被拦截。所有的key都要有一次循环和递归)
  • 2、数组的特殊处理,导致其修改数据不能使用索引
    (原因在于defineProperty不支持数组,参考vue源码-Array的变化侦测
  • 3、动态添加或删除对象属性无法被侦测
    defineProperty哭着对我说:臣妾的的setter函数办不到呀)

对于没阅读过vue源码的前端开发来说,应该也遇到过修改了数组,或者修改对象后发现,啥变化也没有,一头雾水,拍桌子直呼:vue真垃圾,有bug。
其实这些雾水大都是上面的2、3两点引发的,vue也都提供了解决方案:$set$delete,我都整理好了,需要理解的直接移步深入响应式原理
但是,这就体验极差

🤣小故事一则:去年还没阅读源码的时候,公司一个大版本的发布后,出现了一个不是很严重,却影响使用范围很广的一个bug,我们从凌晨2点修到4点,最后还是一个大牛搞了几轮实验发现了问题,说vue有bug,某某地方赋值需要用$set。没错,就是上面痛点里的第3点。原因还是我们太菜呀,没有阅读相关源码。

2.2 vue3.x的实现方案

文章开头我就强调了:数据响应式是一种机制,一种数据变化的侦测机制。而实现数据响应式这种机制的方法不唯一。于是乎,vue3.x来了,他带着vue2.x痛点的解决方案来了!

解决方案其实一点也不神秘,在ES6之后,出现了一个新的特性:ProxyVue3.x在使用了Proxy之后,痛点们一下子就全都解决了。Proxy是怎么解决的呢?请听下回...请继续往下看哈看完手写reactive之后,就全都明白啦。
顺便给个Proxy的MDN地址: Proxy MDN传松门

3、手写reactive

在vue3.x中,定义响应式对象的方法如下:

const obj = reactive({
  name: 'chenjing',
  age: 18
})

3.1 测试Proxy是否生效

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      console.log('target, key', target, key, target[key])
      return target[key]
    }
}

proxy-get.png

ok,生效。在简易版的reactive,我们要添加基本的属性getsetdeleteProperty。同时,在上面代码的get里直接return target[key],一来不太优雅、二来可能报错。我们先来看看vue3是怎么处理的:
vue3源码图1.png

再来一个传送门:Reflect - MDN

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers的方法相同。Reflect不是一个函数对象,因此它是不可构造的。
与大多数全局对象不同Reflect并非一个构造函数,所以不能通过new运算符对其进行调用,或者将Reflect对象作为一个函数来调用。Reflect的所有属性和方法都是静态的(就像Math对象)。
Reflect 对象提供了以下静态方法,这些方法与proxy handler methods的命名相同.
其中的一些方法与 Object相同, 尽管二者之间存在 某些细微上的差别 .

3.2 reactive基本形态

让我们来学习一下vue3的写法后,加上了Reflect后,于是我们最基本的reactive就是下面这样的:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      const res = Reflect.get(target, key) // 可以直接return target[key],避免报错和代码的优雅性,模仿源码采用Reflect
      console.log('get', key)
      return (typeof res === 'object') ? reactive(res) : res // 子属性若是对象 需要再次代理
    },
    set(target, key, val) {
      const res = Reflect.set(target, key, val)
      console.log('set', key)
      return res
    },
    deleteProperty() {
      const res = Reflect.deleteProperty(target, key)
      console.log('deleteProperty', key)
      return res
    }
  })
}

reactive基本形态.png

通过跑脚本后的控制台,可以看到访问属性成功的触发了get。同时新增属性也触发了set
到这里为止,vue2中的数据响应式在vue3里其实已经完全实现了。回过头来想想,是不是没那么难理解了吧。没有vue2的循环遍历递归,只是上了Proxy的车
当然了在Vue3内真正的实现,肯定不是这么几行代码就搞定的。只是响应式的原理就是利用了Proxy

既然要手写实现一个简易的reactive函数,让我们继续往下阅读。
目前只是想简单理解vue3数据响应式原理,了解vue3数据响应和vue2数据响应的区别的同学可以直接点赞了哈哈,鼓励一下互相学习进步😁

3.3 依赖的收集、触发

既然要手写实现一个简易的reactive函数,我们就继续。
要实现reactive函数,我们就要在get内进行依赖收集,在set中进行触发。即便是vue2也是通过类似的发布订阅模式体现。在这里,我们也是通过发布订阅模式去完成。

首先是依赖收集:在get内,我们需要对依赖进行收集。在依赖收集的时候,将其按照依赖关系放入map中映射。
然后就是依赖触发:在set中,需要触发响应式函数。即完成了发布订阅。

下面代码 有需要的可以直接复制粘贴,直接跑。可以自行断点看看,有疑问的欢迎交流。

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      const res = Reflect.get(target, key)
      console.log('get', key)
      // 依赖收集
      track(target, key)
      return (typeof res === 'object') ? reactive(res) : res
    },
    set(target, key, val) {
      const res = Reflect.set(target, key, val)
      console.log('set', key)
      // 触发
      trigger(target, key)
      return res
    },
    deleteProperty() {
      const res = Reflect.deleteProperty(target, key)
      console.log('deleteProperty', key)
      return res
    }
  })
}

// 保存副作用函数
const effectStack = []
// 添加副作用函数
function effect (fn) {
  const e = createReactiveEffect(fn)

  // 立即执行
  e()
  return e
}

function createReactiveEffect(fn) {
  // 封装fn,处理其错误,执行之,存放到stack
  const effect = () => {
    try {
      // 0入栈
      effectStack.push(effect)
      // 1 执行fn
      return fn()
    } finally {
      // 2 出栈
      effectStack.pop
    }
  }
  return effect
}

// 保存映射关系的数据结构
const targetMap = new WeakMap()

// 当副作用函数触发响应式数据之后,执行track,进项依赖收集工作
// 目标是将target, key和前面effectStack中的副作用函数之间建立映射关系
function track (target, key) {
  // 1.先拿出响应函数
  const effect = effectStack[effectStack.length - 1]
  if (effect) {
    // 获取target对应的map
    let depMap = targetMap.get(target)
    if (!depMap) {
      // 初始化的时候 depMap不存在 初始化一次
      depMap = new Map()
      targetMap.set(target, depMap)
    }

    // 从depMap中 获取对应的set
    let deps = depMap.get(key)
    if (!deps) {
      // 初始化需要创建一个Set
      deps = new Set()
      depMap.set(key, deps)
    }

    // 将副作用函数放到集合中
    deps.add(effect)
  }
}

// 触发响应式函数
function trigger (target, key) {
  // 从targetMap中获取对应副作用函数集合
  // 1. 获取target对应的map
  const depMap = targetMap.get(target)
  if (!depMap) return

  // 根据key获取对应的deps
  const deps = depMap.get(key)
  if (deps) {
    // 遍历执行他们
    deps.forEach(dep => dep())
  }
}
const obj = reactive({
  name: 'chenjing',
  age: 18,
  look: {
    height: '180cm'
  }
})
effect(() => {
  console.log('effect1', obj.name)
})
effect(() => {
  console.log('effect2', obj.name, obj.look.height)
})

setTimeout(() => {
  console.log('----  分割线   -----')
  obj.name = 'jay'
  obj.look.height = '178cm'
}, 1000)
执行结果.png

4. 结尾

好了,到此手写简易版vue3的reactive函数完成,希望可以帮助到打击爱理解vue3数据响应原理。

单纯的理解数据响应原理可以理解到Proxy就差不多了
后面依赖收集触发就是具体到响应后要做的事。

推荐阅读更多精彩内容