Vue的响应式原理
- 面试的时候经常会问到,Vue的Vue2和Vue3的响应式原理有什么不同?
- 那我今天就来自己手动实现一下Vue2和Vue3的响应式,然后再去看看它们两个有什么不同
Vue的响应式的目的是什么?我们自己怎样实现它?
-
Vue开发出来响应式系统想要做到什么样的事情?
-
首先我们要知道,在Vue中,我们写在template中的HTML元素,在Vue源码中不是直接被通过append方法添加进那个id为app的元素中的
-
它是会先通过createVNode()这个函数,将我们编写在tempalte中的元素转换成VNode,再将VNode渲染到页面上的
-
所以,如果我们在template中使用了某个变量的话,它底层做的事就是,当我们改变了那个变量的值时,它再调用一次createVNode函数,生成一个新的VNode,再通过diff算法比较,最后将其渲染到页面上
-
-
所以,Vue响应式系统的目的就是:当变量发生改变时,自动再执行一次那些依赖这个变量的代码
那我们想要手动实现Vue响应式,就有以下几步:
第一步:当name发生变化的时候,再执行一次打印name的操作
<script>
const name = "Judy"
console.log(name)
name = "kobe"
console.log(name)
</script>
第二步:我们在第一步中,只是打印一下name,所以只有一行代码,可是如果我们依赖name的代码比较多呢?我们每次都一句一句调用就太麻烦了
-
所以,我们可以把那些对变量有依赖的代码都放进一个函数里面,这样在变量发生改变的时候,只调用那个函数就好了
<script> const obj = { name: "Judy", age: 18 } function foo() { console.log("name:", obj.name) console.log("age:", obj.age) } // 默认调用一次 foo() obj.name = "kobe" // 当依赖的obj对象发生变化了,foo需要被重新执行一次 console.log("-----------changed--------------") foo() </script>
第三步:但又有一个问题,在开发中我们是有很多的函数的,我们如何区分一个函数需要响应式,还是不需要响应式呢?
-
很明显,下面的函数中 foo 需要在obj的name发生变化时,重新执行,做出响应
-
但是下面的baz函数是一个完全独立于obj的函数,它在foo变化时,并不需要被重新执行
function foo() { console.log("name:", obj.name) console.log("age:", obj.age) } function baz() { const result = 20 + 30 console.log(result) }
-
所以我们如何区分呢?
- 我们可以再声明一个watchFn()
- 凡是传入watchFn中的函数,都是需要响应式的
<script> const reactiveFns = [] // 第一步:声明watchFn,传入watchFn中的函数都是需要响应式的 function watchFn(fn) { // 第三步:在watchFn中默认调用一次foo fn() // 第四步:将传入watchFn中的函数添加进一个全局的数组中,以便在obj发生变化的时候,对其进行调用 reactiveFns.push(fn) } const obj = { name: "Judy", age: 18 } // 第二步:因为foo需要响应式,所以把它传入watchFn中 watchFn(function foo() { console.log("name:", obj.name) console.log("age:", obj.age) }) obj.name = "kobe" // 第五步:当依赖的obj发生变化了,就调用reactiveFns中的函数 console.log("-----------changed--------------") reactiveFns.forEach(item => item()) </script>
第四步:可是现在又有一个问题,在开发中我们不可能只有一个需要响应式的函数,如果还有其他依赖的不是obj对象,是依赖user对象,依赖info对象的函数呢?把它们全都添加进reactiveFns这个数组中的话,那么obj对象改变,也会执行依赖user和info对象的函数,这样肯定是不行的。
-
所以我们可以创建一个类,对于不同的对象,让它们将依赖于自己的函数存在不同的reactiveFns中
-
并且在类中写一个addReactiveFn方法,这样在把那些需要响应式的函数添加进reactiveFns时,直接调这个方法就好了,很方便
-
并且还可以再写一个方法notify方法,这样在obj对象发生改变时,调用notify方法,通知一下dep对象即可,dep对象的notify方法就会自动调用那些已经添加进自己reactiveFns中的函数
<script> // 第一步:创建一个类,对于不同的响应式对象就可以有不同的reactiveFns存储依赖于自己的函数了 class depend { constructor() { this.reactiveFns = [] } addReactiveFn(fn) { this.reactiveFns.push(fn) } notify() { this.reactiveFns.forEach(item => item()) } } // 第二步:new一下depend类,拿到一个dep对象,将依赖于obj对象的函数添加进reactiveFns中 const dep = new depend() function watchFn(fn) { fn() dep.addReactiveFn(fn) } const obj = { name: "Judy", age: 18 } watchFn(function foo() { console.log("name:", obj.name) console.log("age:", obj.age) }) obj.name = "kobe" console.log("-----------changed--------------") // 第三步:现在调用需要响应式的函数时,就需要在dep的reactiveFns中拿这些函数了 dep.notify() </script>
第五步:到这一步的时候,相信有小伙伴已经能想到了,在这里就可以使用Vue2的Object.defineProerty()方法或Vue3的Proxy代理对象了
-
既然每次修改obj的name之后,都要调用一次notify方法,那么在监听到obj对象的name属性被设置的时候就调用这个方法即可
<script> class depend { constructor() { this.reactiveFns = [] } addReactiveFn(fn) { this.reactiveFns.push(fn) } notify() { this.reactiveFns.forEach(item => item()) } } const dep = new depend() function watchFn(fn) { fn() dep.addReactiveFn(fn) } const obj = { name: "Judy", age: 18 } // 第一步:使用Vue2的Object.defineProperty()监听obj属性的set Object.keys(obj).forEach(key => { let value = obj[key] Object.defineProperty(obj, key, { set: function(newValue) { value = newValue dep.notify() }, get: function() { return value } }) }) watchFn(function foo() { console.log("name:", obj.name) console.log("age:", obj.age) }) obj.name = "kobe" </script>
第六步:这一步就是整个Vue响应式原理最核心的一步了:如何收集依赖
-
目前我们又遇到一个新的问题,那就是如果依赖obj的函数不止foo一个,还有一个bar函数,但是bar函数只依赖obj的age属性,并不依赖obj的name属性,那么这就意味着,当obj的name属性改变的时候,bar函数并不应该被调用
-
可是现在我们实现的是,只要依赖obj对象,不管你依赖的是哪个属性,都放进一个reactiveFns中。那么这就意味着,目前无论obj对象的哪个属性被改变,依赖obj对象的所有函数都会被调用
-
要解决这个问题有什么办法呢?
- **办法就是:**为obj对象的每一个属性都创建一个dep对象
- 我们刚才做的是,为obj对象创建一个dep对象,所以造成的问题就是,无论依赖obj对象的哪个属性,都会被放进一个reactiveFns中
- 但是现在,我们为obj对象的每一个属性都创建一个dep对象的话,那么当某个属性发生变化的时候,去对应这个属性的dep中找reactiveFn就好了,现在找到的reactiveFns中存储的就只有依赖当前属性的函数了
-
可是将它们如何存储起来呢?
- 我们可以使用map存储它们,obj对象对应的就是一个map对象了,在它里面存储key为属性,value为dep对象的映射关系
-
可是如果不止一个obj还有一个user对象呢?那obj和user又应该被怎样存储?
- 我们可以再搞一个map对象,这个map对象存储key为obj/user,value为它们所对应的那个map对象的映射关系
-
这样说比较抽象,所以我画图说明:
-
代码实现:和图片对应着看,会更容易理解
- 在obj对象中的属性在第一次被使用的时候,我们就应该给每个属性创建好对应的dep对象
- 所以我们就在get方法中收集依赖(当属性第一次被使用的时候,就会触发get操作,从而为每一个属性都创建一个对应的dep)
<script> class depend { constructor() { this.reactiveFns = [] } addReactiveFn(fn) { this.reactiveFns.push(fn) } notify() { this.reactiveFns.forEach(item => item()) } } // 第四步:因为此时dep在watchFn中是拿不到的,所以需要把传入的这个函数保存起来,在能拿到dep的地方将这个函数保存在reactiveFns中,并且这个地方必须是在修改属性之前的。所以我们就可以在get方法中进行这一步操作 let reactiveFn = null function watchFn(fn) { reactiveFn = fn fn() reactiveFn = null } // 第一步:实现给每个对象都添加一个dep的操作 const objMap = new WeakMap() function getDepend(obj, key) { let map = objMap.get(obj) if (!map) { map = new Map() objMap.set(obj, map) } let dep = map.get(key) if (!dep) { dep = new depend() map.set(key, dep) } return dep } const obj = { name: "Judy", age: 18 } // 使用Vue2的Object.defineProperty()监听obj属性的set、get Object.keys(obj).forEach(key => { let value = obj[key] Object.defineProperty(obj, key, { set: function(newValue) { value = newValue // 第三步:这个时候就没有全局的dep了,所以当obj的某个属性被重新设置时,需要拿到dep对象才能调用notify const dep = getDepend(obj, key) dep.notify() }, get: function() { // 第二步:收集依赖,如果有哪句代码使用到了obj对象的某个属性时,就为这个属性创建一个dep对象 const dep = getDepend(obj, key) // 第五步:在这里可以拿到dep,并且可以将需要响应式的函数添加进reactiveFns中 dep.addReactiveFn(reactiveFn) return value } }) }) watchFn(function foo() { console.log("name:", obj.name) console.log("age:", obj.age) }) watchFn(function bar() { console.log("age:", obj.age) }) // obj.name = "kobe" obj.age = 20 </script>
第七步:现在还有一个问题,那就是目前我们写的这个响应式系统是只针对于obj对象的
-
所以我们就可以将Vue2的监听set、get操作的代码封装进一个函数中,然后哪个对象需要成为响应式的,就调用我这个函数,并且把对象传进来即可
<script> class depend { constructor() { this.reactiveFns = [] } addReactiveFn(fn) { this.reactiveFns.push(fn) } notify() { this.reactiveFns.forEach(item => item()) } } let reactiveFn = null function watchFn(fn) { reactiveFn = fn fn() reactiveFn = null } const objMap = new WeakMap() function getDepend(obj, key) { let map = objMap.get(obj) if (!map) { map = new Map() objMap.set(obj, map) } let dep = map.get(key) if (!dep) { dep = new depend() map.set(key, dep) } return dep } // 第一步:将Vue2的defineProperty封装进一个函数中,并且将这个对象返回出来,不然其他地方没法用这个对象了 function reactive(obj) { Object.keys(obj).forEach(key => { let value = obj[key] Object.defineProperty(obj, key, { set: function(newValue) { value = newValue const dep = getDepend(obj, key) dep.notify() }, get: function() { const dep = getDepend(obj, key) dep.addReactiveFn(reactiveFn) return value } }) }) return obj } // ===================================业务代码====================================== // 第二步:将这个对象传入reactive方法中,并且reactive方法会返回一个对象,供我们使用 const obj = reactive({ name: "Judy", age: 18 }) watchFn(function foo() { console.log("name:", obj.name) console.log("age:", obj.age) }) watchFn(function bar() { console.log("age:", obj.age) }) obj.name = "kobe" // obj.age = 20 </script>
第八步:这也是最后一步了,Vue2的响应式系统,目前为止基本就已经完成了。如果你能看到这里,就为自己点个赞吧!!!
-
最后我们再做三个小小的优化:
- 第一:如果foo函数中用到了两次key,比如name,那么这个函数就会被往reactiveFns中添加两次
- 解决方法:reactiveFns不使用数组了,使用Set
- set的特性之一就是里面不能保存重复的数据,它会自动去重
- 第二:我们的objMap不应该使用Map,因为Map是强引用,这样的话,如果obj对象被设置为了null了,但是Map对象还引用着它,所以obj对象就不会被销毁
- 解决方法:使用WeakMap即可,它对对象的引用是弱引用
- 第三:我并不想把添加reactiveFn的操作放在get方法中,没有为什么,就是看着不舒服
- 解决方法:在dep中重新写一个添加reactiveFn的实例方法,让它自己去全局里面找reactiveFn进行添加
- 第一:如果foo函数中用到了两次key,比如name,那么这个函数就会被往reactiveFns中添加两次
-
最终代码
<script> class depend { constructor() { // this.reactiveFns = [] // 第一步:使reactiveFns的值为Set,自动去重 this.reactiveFns = new Set() } // addReactiveFn(fn) { // this.reactiveFns.push(fn) // } notify() { this.reactiveFns.forEach(item => item()) } // 第二步:重写addReactiveFn方法 addReactiveFn() { if (reactiveFn) { this.reactiveFns.add(reactiveFn) } } } let reactiveFn = null function watchFn(fn) { reactiveFn = fn fn() reactiveFn = null } // 第三步:这步在前面我已经下意识完成了,使用WeakMap做弱引用 const objMap = new WeakMap() function getDepend(obj, key) { let map = objMap.get(obj) if (!map) { map = new Map() objMap.set(obj, map) } let dep = map.get(key) if (!dep) { dep = new depend() map.set(key, dep) } return dep } // 将Vue2的defineProperty封装进一个函数中,并且将这个对象返回出来,不然其他地方没法用这个对象了 function reactive(obj) { Object.keys(obj).forEach(key => { let value = obj[key] Object.defineProperty(obj, key, { set: function(newValue) { value = newValue const dep = getDepend(obj, key) dep.notify() }, get: function() { const dep = getDepend(obj, key) // dep.addReactiveFn(reactiveFn) dep.addReactiveFn() return value } }) }) return obj } // ==================业务代码====================================== const obj = reactive({ name: "Judy", age: 18 }) watchFn(function foo() { console.log("name:", obj.name) console.log("name:", obj.name) console.log("age:", obj.age) }) watchFn(function bar() { console.log("age:", obj.age) }) obj.name = "kobe" // obj.age = 20 </script>
Vue3响应式原理
-
想要把代码改成Vue3的响应式原理,现在就很简单了
-
只需要将reactive方法中的Object.defineProperty()改成Proxy代理对象即可
<script> class depend { constructor() { this.reactiveFns = new Set() } notify() { this.reactiveFns.forEach(item => item()) } addReactiveFn() { if (reactiveFn) { this.reactiveFns.add(reactiveFn) } } } let reactiveFn = null function watchFn(fn) { reactiveFn = fn fn() reactiveFn = null } const objMap = new WeakMap() function getDepend(obj, key) { let map = objMap.get(obj) if (!map) { map = new Map() objMap.set(obj, map) } let dep = map.get(key) if (!dep) { dep = new depend() map.set(key, dep) } return dep } // 使用Vue2的defineProperty // function reactive(obj) { // Object.keys(obj).forEach(key => { // let value = obj[key] // Object.defineProperty(obj, key, { // set: function(newValue) { // value = newValue // const dep = getDepend(obj, key) // dep.notify() // }, // get: function() { // const dep = getDepend(obj, key) // dep.addReactiveFn() // return value // } // }) // }) // return obj // } // 使用Vue3的new Proxy() function reactive(obj) { const objProxy = new Proxy(obj, { set: function(target, key, newValue, receiver) { Reflect.set(target, key, newValue, receiver) const dep = getDepend(target, key) dep.notify() }, get: function(target, key, receiver) { const dep = getDepend(target, key) dep.addReactiveFn() return Reflect.get(target, key, receiver) } }) return objProxy } // ==================业务代码====================================== const obj = reactive({ name: "Judy", age: 18 }) watchFn(function foo() { console.log("name:", obj.name) console.log("name:", obj.name) console.log("age:", obj.age) }) watchFn(function bar() { console.log("age:", obj.age) }) obj.name = "kobe" // obj.age = 20 </script>
总结:
- 其实Vue的响应式原理就是通过 ES5 的 Object.defindeProperty 或者ES6的Proxy代理对象中的set和get方法,进行数据的劫持
- 在数据第一次被使用的时候,就会自动调用get方法,并且通过get方法收集每个数据的依赖,看看都是哪些代码在依赖这个数据,然后将这些代码保存起来
- 最后当数据被修改的时候,就会自动调用set方法,在set方法中再通过某种方式调用在get方法中已经收集好的那些依赖这个数据的代码,并且将它们再执行一遍,生成新的VNode
- 最后通过diff算法比对新旧VNode,将修改过的部分记录下来,最后将其渲染到页面上