下面这段代码非常简单,编写过Vue的同学都能看懂它在干什么,但是你能准确的说出这段代码在第一秒,第二秒,第三秒页面上分别有什么变化吗?
<!DOCTYPE html> <html> <script src="https://unpkg.com/vue/dist/vue.js"></script> <body> <div id="app"> <div>{{ list }}</div> </div> <script> new Vue({ el: '#app', data: { list: [], }, mounted() { setTimeout(()=>{ this.list[0] = 3 }, 1000) setTimeout(()=>{ this.list.length = 5 }, 2000) setTimeout(()=>{ this.$set(this.list, this.list) }, 3000) } }) </script> </body> </html>复制代码
大家最好能动手拷贝上面的代码,本地新建HTML文件保存后打开调试查看,我这里直接说一下结果。当执行这段代码后,页面在第一秒和第二秒无变化,直到第三秒时候才会发生变化,思考一下第一秒和第二秒改变了list的值,为什么Vue的双向绑定在这里失效了呢?围绕这个问题下面开始一步一步看看Vue的数据变化监听实现机制。
这里由浅入深的去看,先从要监听普通数据类型看起。
监听普通数据类型,即要监听的对象属性的值为非对象的五种基本类型变化,这里不直接看源码,每一步都自己手动的去实现,更加便于理解。
<!DOCTYPE html> <html> <div> name: <input id="name" /> </div> </html> <script> // 监听Model下的name属性,当name属性有变化时要引起页面id=name的响应变化 const model = { name: 'vue', }; // 利用Object.defineProperty创建一个监听器 function observe(obj) { let val = obj.name; Object.defineProperty(obj, 'name', { get() { return val; }, set(newVal) { // 当有新值设置时,执行setter console.log(`name变化:从${val}到${newVal}`); // 解析到页面 compile(newVal); val = newVal; } }) } // 解析器,将变化的数据响应到页面上 function compile(val) { document.querySelector('#name').value = val; } // 调用监听器,对model开始监听 observe(model); </script>复制代码
在控制台调试过程。复制代码
上面的代码在调试的时候,我先查看了model.name初始值后,进行了重新设置,可以引起setter函数的触发执行,从而页面达到响应式效果。
但是当给name属性赋值为对象类型后,再给新对象里插入key1一个属性后,接着改变这个key1的值,这时候页面并不能得到响应式触发。
所以上面的observe的实现中,当name是普通数据类型的时候监听没有问题,而要监听的内容是对象的变化里的时候,上面的写法就有问题了。
下面看看监听对象类型属性observe函数要怎么实现。
从上面的例子里,检测属性值为对象时,不能满足监听需求,接下来进一步改造observe监听函数,解决思路很简单,如果是对象,只需再一次将当前对象下的所有普通类型的监听变化即可,如果该对象下还有对象属性,继续监听就可以了,如果你对递归很熟,马上就知道该如何解决这个问题。
<!DOCTYPE html> <html> <div> name: <input id="name" /> val: <input id="val" /> list: <input id="list" /> </div> </html> <script> // 监听Model下的name属性,当name属性有变化时要引起页面id=name的响应变化 const model = { name: 'vue', data: { val: 1 }, list: [1] }; // 监听函数 function observe(obj) { // 遍历所有属性,各自监听 Object.keys(obj).map(key => { // 将object属性特殊处理 if (typeof obj[key] === 'object') { // 是对象属性的再次监听 observe(obj[key]); } else { // 非对象属性的做监听 defineReactive(obj, key, obj[key]); } }) } // 利用Object.defineProperty做对象属性的做监听 function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get() { return val; }, set(newVal) { // 当有新值设置时,执行setter console.log(`${key}变化:从${val}到${newVal}`); if (Array.isArray(obj)) { document.querySelector(`#list`).value = newVal; } else { document.querySelector(`#${key}`).value = newVal; } val = newVal; // 新增的属性再次进行监听 observe(newVal); } }) } // 监听model下的所有属性 observe(model); </script>复制代码
在控制台调试过程。复制代码
在上面的实际操作中,我先改变了属性name的值,触发了setter,页面收到响应,再次改变了model.data这个对象下的val属性,页面也得到响应式变化,这说明我们在之前是想observe监听不到对象属性变化的问题在上面的改造下得到了解决。
接下来要注意,在最后我改变了数组属性list下的第一个下标里的值为5,页面也得到了监听结果,但是我改变了第二个下标后,没有触发setter,接着特意去改变list的length,或者push都没有触发数组的setter,页面没有变化响应。
这里抛出两个问题:
a、我修改了数组list的第二个下标的值,并且调用length、push改变数组list后页面也没有响应到变化,是怎么回事?
b、回到文章开始示例的那一段Vue代码里的实现,我改变了Vue的data下list的下标属性值,页面是没有响应变化的,但是这里我改了list的内的值从1到5,页面响应了,这又是怎么回事?
请带着a、b两个问题继续往下看。
这里分析一下a问题修改数组下标的值和调用length、push方法改变数组时不触发监听器的setter函数的原因。我之前看到很多文章写Object.defineProperty不能监听到数组内的值变化,真的是这样么?
请看下面的例子,这里不绑定页面,只观察Object.defineProperty监听的数组元素,是否能监听到变化。
从上面代码里,首先监听了model数组里所有的属性,然后通过各种数组的方法来修改当前数组,得出以下几个结论。
1、直接修改数组中已有的元素是可以被监听的。
2、数组的操作方法如果是操作已经存在的被监听的元素也是可以触发setter被监听的。
3、只有push、length、pop一些特殊的方法确实不能触发setter,这跟方法的内部实现与Object.defineProperty的setter钩子的触发实现有关系,是语言层面的原因。
4、改变超过数组长度的下标的值时,值变化是不能监听到的。这个其实很好理解,不存在的属性当然是不能监听到,因为绑定监听操作在之前已经执行过了,后添加的元素属性在绑定当时都还没有存在,当然没有办法提前去监听它了。
所以综上,Object.defineProperty不能监听到数组内的值变化的说法是错误的,同时也得出了a问题的答案,语言层面不支持用Object.defineProperty监听不存在的数组元素,并且通过一些能造成数组的方法造成数组改变也不能监听到。
对于b问题,则需要去看看Vue的源码里,为何Object.defineProperty明明能监听到数组值的变化,而它却没有实现呢?
这里分享一下我看源码的技巧,如果直接打开github一行一行看看源码是很懵逼的,我这里是直接用Vue-cli在本地生成一个Vue项目,然后在安装的node_modules下的Vue包里进行断点查看的,大家可以尝试下。
测试代码很简单,如下;
import Vue from './node_modules/_vue@2.6.11@vue/dist/vue.runtime.common.dev' // 实例化Vue,启动起来后直接 new Vue({ data () { return { list: [1, 3] } }, })复制代码
解释一下这一块儿的源码,下面的hasProto的源码是看是否有原型存在,arrayMethods是被重写的数组方法,代码流程是如果有原型,直接修改原型上的push,pop,shift,unshift,splice, sort,reverse七个方法,如果没有原型的情况下,走copyAugment去新增这七个属性后赋值这七个方法,并没有监听。
/** * Observe a list of Array items. */ observeArray (items: Array<any>) { for (let i = 0, l = items.length; i < l; i++) { // 监听数组元素 observe(items[i]) } }复制代码
最后就是this.observeArray函数了,它的内部实现非常简单,它对数组元素进行了监听,什么意思呢,就是改变数组里的元素不能监听到,但是数组内的值是对象类型的,修改它依旧能得到监听响应,如改变list[0].val可以得到监听,但是改变list[0]不能,但是依旧没有对数组本身的变化进行监听。
再看看arrayMethods是如何重写数组的操作方法的。
// 记录原始Array未重写之前的API原型方法 const arrayProto = Array.prototype // 拷贝一份上面的原型出来 const arrayMethods = Object.create(arrayProto) // 将要重写的方法 const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] /** * Intercept mutating methods and emit events */ methodsToPatch.forEach(function (method) { def(arrayMethods, method, function mutator (...args) { // 原有的数组方法调用执行 const result = arrayProto[method].apply(this, args) const ob = this.__ob__ let inserted switch (method) { case 'push': case 'unshift': inserted = args break case 'splice': inserted = args.slice(2) break } // 如果是插入的数据,将其再次监听起来 if (inserted) ob.observeArray(inserted) // 触发订阅,像页面更新响应就在这里触发 ob.dep.notify() return result }) })复制代码
从上面的源码里可以完整的看到了Vue2.x中重写数组方法的思路,重写之后的数组会在每次在执行数组的原始方法之后手动触发响应页面的效果。
看完源码后,问题a也水落石出了,Vue2.x中并没有实现将已存在的数组元素做监听,而是去监听造成数组变化的方法,触发这个方法的同时去调用挂载好的响应页面方法,达到页面响应式的效果。
但是也请注意并非所有的数组方法都重新写了一遍,只有push,pop,shift,unshift,splice, sort,reverse这七个。至于为什么不用Object.defineProperty去监听数组中已存在的元素变化。
作者尤雨溪的考虑是因为性能原因,给每一个数组元素绑定上监听,实际消耗很大,而受益并不大。
issue地址:https://github.com/vuejs/vue/issues/8562。
前一篇说了Vue3.0的监听采用的是ES6新的构造方法Proxy来代理原对象做变化检测,(对于Proxy不熟的同学可以翻看上一篇内容)而Proxy作为代理的存在,当异步触发Model里的数据变化时,必须经过Proxy这一层,在这一层则可以监听数组以及各种数据类型的变化,看看下面的例子。
简直完美,无论是数组下标赋值引起变化还是数组方法引起变化,都可以被监听到,而且既可以避开监听数组每个属性下造成的性能问题,还可以解决像pop、push方法,length方法改变数组时监听不到数组变化的问题。
接下来使用Proxy和Reflect实现Vue3.0下的双向绑定。
<!DOCTYPE html> <html> <div> name: <input id="name" /> val: <input id="val" /> list: <input id="list" /> </div> </html> <script> let model = { name: 'vue', data: { val: 1, }, list: [1] } function isObj (obj) { return typeof obj === 'object'; } // 监控器 function observe(data) { // 将属性都做监控 Object.keys(data).map(key => { if (isObj(data[key])) { // 对象类型的继续监听它的属性 data[key] = observe(data[key]); } }) return defineProxy(data); } // 生成Proxy代理 function defineProxy(obj) { return new Proxy(obj, { set(obj, key, val) { console.log(`属性${key}变化为${val}`); compile(obj, key, val); return Reflect.set(...arguments); } }) } // 解析器,响应页面变化 function compile(obj, id, val) { if (Array.isArray(obj)) { // 数组变化 document.querySelector('#list').value = model.list; } else { document.querySelector(`#${id}`).value = val; } } model= observe(model); </script>复制代码
利用Proxy和Reflect实现之后,不用在考虑数组的操作是否触发setter,只要操作经过proxy代理层,各种操作都会被被捕获到,达到页面响应式的要求。
在Vue2.x中数组变化监听的问题,其实不是Object.definePropertype方法监听不到,而是为了性能和收益比例综合考虑之下,改变了监听方式,从原本的直接监听结果变化这种思路变换到监听会导致结果变化的方法上,也就上面所提到的对数组的重写。
而Vue3.0中利用Proxy的方式则完美解决了2.0中出现的问题,所以以后面试中如果遇到Vue中对于数组监听的处理的时候,一定要分清楚是哪一个版本,本文完。