Vue2 的响应式原理
所谓响应式就是首先建立响应式数据和依赖之间的关系,当这些响应式数据发生变化的时候,可以通知那些绑定这些数据的依赖进行相关操作,可以是 DOM 更新,也可以是执行一个回调函数。
vue2 的对象数据是通过 Object.defineProperty 对每个属性进行监听,当对属性进行读取的时候,就会触发 getter,对属性进行设置的时候,就会触发 setter。
1 | /** |
在 Watcher 里面进行属性读取,Watcher 也就是所谓的依赖。在 Watcher 里面读取数据的时候,会把自己设置到一个全局的变量中。
1 | /** |
在 Watcher 读取数据的时候也就触发了这个属性的监听 getter,在 getter 里面就需要进行依赖收集,这些依赖存储的地方就叫 Dep,在 Dep 里面就可以把全局变量中的依赖进行收集,收集完毕就会把全局依赖变量设置为空。将来数据发生变化的时候,就去 Dep 中把相关的 Watcher 拿出来执行一遍。
1 | /** |
总的来说就是通过 Object.defineProperty 监听对象的每一个属性,当读取数据时会触发 getter,修改数据时会触发 setter。
然后我们在 getter 中进行依赖收集,当 setter 被触发的时候,就去把在 getter 中收集到的依赖拿出来进行相关操作,通常是执行一个回调函数。
我们收集依赖需要进行存储,对此 Vue2 中设置了一个 Dep 类,相当于一个管家,负责添加或删除相关的依赖和通知相关的依赖进行相关操作。
在 Vue2 中所谓的依赖就是 Watcher。值得注意的是,只有 Watcher 触发的 getter 才会进行依赖收集,哪个 Watcher 触发了 getter,就把哪个 Watcher 收集到 Dep 中。当响应式数据发生改变的时候,就会把收集到的 Watcher 都进行通知。
响应式对象
由于 Object.defineProperty 只会对属性进行监测,而不会对对象进行监测,所以 Vue2 中设置了一个 Observer 类来管理对象的响应式依赖,同时也会递归侦测对象中子数据的变化。Observer 类的作用就是把一个对象全部转换成响应式对象,包括子属性数据,当对象新增或删除属性的时候负债通知对应的 Watcher 进行更新操作。
1 | // 定义一个属性 |
vm.$set
1 | function set(target, key, val) { |
当向一个响应式对象新增属性的时候,需要对这个属性重新进行响应式的设置,即使用 defineReactive 将新增的属性转换成 getter/setter。
我们在前面讲过每一个对象是会通过 Observer 类型进行包装的,并在 Observer 类里面创建一个属于这个对象的依赖收集存储对象 dep, 最后在新增属性的时候就通过这个依赖对象进行通知相关 Watcher 进行变化更新。
vm.$delete
1 | function del(target, key) { |
可以看到 vm.$delete 的实现原理和 vm.$set 的实现原理是非常相似的。
通过 vm.$delete 和 vm.$set 的实现原理,我们可以更加清晰地理解到 Observer 类的作用,Observer 类就是给一个对象也进行一个监测,因为 Object.defineProperty 是无法实现对对象的监测的,但这个监测是手动,不是自动的。
监听数组的变化
Vue2 对数组的监测是通过重写数组原型上的 7 个方法来实现的,但其实 Object.defineProperty 是可以监听数组的变化的。
1 | const arr = [1, 2, 3] |
其实数组就是一个特殊的对象,它的下标就可以看作是它的 key。
所以 Object.defineProperty 也能监听数组变化,那么为什么 Vue2 弃用了这个方案呢?
首先这种直接通过下标获取数组元素的场景就比较少,其次即便通过了 Object.defineProperty 对数组进行监听,但也监听不了 push、pop、shift 等对数组进行操作的方法,所以还是需要通过对数组原型上的那 7 个方法进行重写监听。所以为了性能考虑 Vue2 直接弃用了使用 Object.defineProperty 对数组进行监听的方案。
Vue2使用拦截器覆盖 Array.prototype,之后再去使用 Array 原型上的方法的时候,其实使用的是拦截器提供的方法,在拦截器里面才真正使用原生 Array 原型上的方法去操作数组。
1 | // 拦截器其实就是一个和 Array.prototype 一样的对象。 |
所以通过拦截器之后,我们就可以追踪到数组的变化了,然后就可以在拦截器里面进行依赖收集和触发依赖了。
接下来我们就使用拦截器覆盖那些进行了响应式处理的 Array 原型,数组也是一个对象,通过上文我们可以知道 Vue2 是在 Observer 类里面对对象的进行响应式处理,并且给对象也进行一个依赖收集。所以对数组的依赖处理也是在 Observer 类里面。
1 | class Observer { |
因为 Vue2 的实现方法决定了在 Vue2 中对数组的一些操作无法实现响应式操作,例如:
1 | this.list[0] = xxx |
由于 Vue2 放弃了 Object.defineProperty 对数组进行监听的方案,所以通过下标操作数组是无法实现响应式操作的。
又例如:
1 | this.list.length = 0 |
这个动作在 Vue2 中也是无法实现响应式操作的。
Vue3 的响应式原理
Vue3 是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。
具体是副作用函数里面读取响应式对象的属性值时,会触发代理对象的 getter,然后在 getter 里面进行一定规则的依赖收集保存操作。
1 | // 使用一个全局变量存储被注册的副作用函数 |
通过上面的代码我们可以知道 Vue3 中依赖收集的规则,首先把响应式对象作为 key,一个 Map 的实例做为值方式存储在一个 WeakMap 的实例中,其中这个 Map 的实例又是以响应式对象的 key 作为 key, 值为一个 Set 的实例。而且这个 Set 的实例中存储的则是跟那个响应式对象 key 相关的副作用函数。
WeakMap 可以接受一个对象作为 key 的,而 WeakMap 对 key 是弱引用的。所以当 WeakMap 的 key 是一个对象时,一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候,垃圾回收器 就会把该对象从内存移除,我们就无法该对象从 WeakMap 中获取内容了。
另外副作用函数使用 Set 类型,是因为 Set 类型能自动去除重复内容。
上述方法只实现了对引用类型的响应式处理,因为 Proxy 的代理目标必须是非原始值。在 JavaScript 中,原始值是按值传递的,而非按引用传递。这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。
Vue3 中是通过对原始值做了一层包裹的方式来实现对原始值变成响应式数据的。最新的 Vue3 实现方式是通过属性访问器 getter/setter 来实现的。
1 | class RefImpl{ |
ref 本质上是一个实例化之后的 “包裹对象”,因为 Proxy 无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。 由于实例化之后的 “包裹对象” 本质与普通对象没有任何区别,所以为了区分 ref 与 Proxy 响应式对象,我们需要给 ref 的实例对象定义一个 _v_isRef 的标识,表明这是一个 ref 的响应式对象。
与Vue2比较
Vue2 的响应式存在很多的问题,例如:
- 初始化时需要遍历对象所有 key,如果对象层次较深,性能不好
- 通知更新过程需要维护大量 dep 实例和 watcher 实例,额外占用内存较多
- 无法监听到数组元素的变化,只能通过劫持重写了几个数组方法
- 动态新增,删除对象属性无法拦截,只能用特定
set/delete API代替 - 不支持 Map、Set 等数据结构
而 Vue3 使用 Proxy 实现之后,则以上的问题都不存在了。
监测数组的变化
Vue2 中是需要对数组的监听进行特殊的处理的,其中在 Vue3 中也需要对数组进行特殊的处理。在 Vue2 是不可以通过数组下标对响应式数组进行设置和读取的,而 Vue3 中是可以的,但数组中仍然有很多其他特别的读取和设置的方法,这些方法没经过特殊处理,是无法通过普通的 Proxy 中的 getter/setter 进行响应式处理的。
当数组响应式对象使用 includes、indexOf、lastIndexOf 这方法的时候,它们内部的 this 指向的是代理对象,并且在获取数组元素时得到的值要也是代理对象,所以当使用原始值去数组响应式对象中查找的时候,如果不进行特别的处理,是查找不到的,所以我们需要对上述的数组方法进行重写才能解决这个问题。
首先 arr.indexOf 可以理解为读取响应式对象 arr 的 indexOf 属性,这就会触发 getter 拦截器,在 getter 拦截器内我们就可以判断 target 是否是数组,如果是数组就看读取的属性是否是我们需要重写的属性,如果是,则使用我们重新之后的方法。
1 | const arrayInstrumentations = {} |
上述重写方法的主要是实现先在代理对象中查找,如果没找到,就去原始数组中查找,结合两次的查找结果才是最终的结果,这样就实现了在代理数组中查找原始值也可以查找到。
在一些数组的方法中除了修改数组的内容之外也会隐式地修改数组的长度。
比如进行 arr.push 的操作触发了 getter 拦截器,并且触发了两次。其中一次就是数组 push 属性的读取,还有一次就是调用 push 方法会间接读取 length 属性,那么问题来了,进行了 length 属性的读取,也就会建立 length 的响应依赖,可 arr.push 本意只是修改操作,并不需要建立 length 属性的响应依赖。所以我们需要 “屏蔽” 对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系。
1 | const arrayInstrumentations = {} |
在调用数组的默认方法间接读取 length 属性之前,禁止进行依赖跟踪,这样在间接读取 length 属性时,由于是禁止依赖跟踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。
总结
Vue2 部分
Vue2 是通过 Object.defineProperty 将对象的属性转换成 getter/setter 的形式来进行监听它们的变化,当读取属性值的时候会触发 getter 进行依赖收集,当设置对象属性值的时候会触发 setter 进行向相关依赖发送通知,从而进行相关操作。
由于 Object.defineProperty 只对属性 key 进行监听,无法对引用对象进行监听,所以在 Vue2 中创建一个了 Observer 类对整个对象的依赖进行管理,当对响应式对象进行新增或者删除则由响应式对象中的 dep 通知相关依赖进行更新操作。
Object.defineProperty 也可以实现对数组的监听的,但因为性能的原因 Vue2 放弃了这种方案,改由重写数组原型对象上的 7 个能操作数组内容的变更的方法,从而实现对数组的响应式监听。
Vue3 部分
Vue3 则是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。
Vue3 对数组实现代理时,用于代理普通对象的大部分代码可以继续使用,但由于对数组的操作与对普通对象的操作存在很多的不同,那么也需要对这些不同的操作实现正确的响应式联系或触发响应。这就需要对数组原型上的一些方法进行重写。
比如通过索引为数组设置新的元素,可能会隐式地修改数组的 length 属性的值。同时如果修改数组的 length 属性的值,也可能会间接影响数组中的已有元素。另外用户通过 includes、indexOf 以及 lastIndexOf 等对数组元素进行查找时,可能是使用代理对象进行查找,也有可能使用原始值进行查找,所以我们就需要重写这些数组的查找方法,从而实现用户的需求。原理很简单,当用户使用这些方法查找元素时,先去响应式对象中查找,如果没找到,则再去原始值中查找。
另外如果使用 push、pop、shift、unshift、splice 这些方法操作响应式数组对象时会间接读取和设置数组的 length 属性,所以我们也需要对这些数组的原型方法进行重新,让当使用这些方法间接读取 length 属性时禁止进行依赖追踪,这样就可以断开 length 属性与副作用函数之间的响应式联系了。
参考:
- 本文标题:Vue2和Vue3响应式原理
- 本文作者:灵感胜于汗水
- 创建时间:2022-11-03 13:24:53
- 本文链接:https://cjhsyc.github.io/2022/11/03/Vue2和Vue3响应式原理/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!