深入浅出 Vue 系列 -- 数据劫持实现原理
一、前言
数据双向绑定作为Vue核心功能之一,其实现原理主要分为两部分:
- 数据劫持
- 发布订阅模式
本篇文章主要介绍Vue实现数据劫持的思路,下一篇则会介绍发布订阅模式的设计。
二、针对Object类型的劫持
对于Object类型,主要劫持其属性的读取与设置操作。在JavaScript中对象的属性主要由一个字符串类型的“名称”以及一个“属性描述符”组成,属性描述符包括以下选项:
- value:该属性的值;
- writable:仅当值为true时表示该属性可以被改变;
- get:getter(读取器);
- set:setter(设置器);
- configurable:仅当值为true时,该属性可以被删除以及属性描述符可以被改变;
- enumerable:仅当值为true时,该属性可以被枚举。
上述setter和getter方法就是供开发者自定义属性的读取与设置操作,而设置对象属性的描述符则少不了Object.defineProperty()方法:
functiondefineReactive(obj,key){ letval=obj[key] Object.defineProperty(obj,key,{ get(){ console.log('===收集依赖===') console.log('当前值为:'+val) returnval }, set(newValue){ console.log('===通知变更===') console.log('当前值为:'+newValue) val=newValue } }) } conststudent={ name:'xiaoming' } defineReactive(student,'name')//劫持name属性的读取和设置操作
上述代码通过Object.defineProperty()方法设置属性的setter与getter方法,从而达到劫持student对象中的name属性的读取和设置操作的目的。
读者可以发现,该方法每次只能设置一个属性,那么就需要遍历对象来完成其属性的配置:
Object.keys(student).forEach(key=>defineReactive(student,key))
另外还必须是一个具体的属性,这也非常的致命。
假如后续需要扩展该对象,那么就必须手动为新属性设置setter和getter方法,**这就是为什么不在data中声明的属性无法自动拥有双向绑定效果的原因**。(这时需要调用Vue.set()手动设置)
以上便是对象劫持的核心实现,但是还有以下重要的细节需要注意:
1、属性描述符-configurable
在JavaScript中,对象通过字面量创建时,其属性描述符默认如下:
constfoo={ name:'123' } Object.getOwnPropertyDescriptor(foo,'name')//{value:'123',writable:true,enumerable:true,configurable:true}
前面也提到了configurable的值如果为false,则无法再修改该属性的描述符,所以在设置setter和getter方法时,需要注意configurable选项的取值,否则在使用Object.defineProperty()方法时会抛出异常:
//部分重复代码这里就不再罗列了。 functiondefineReactive(obj,key){ //... constdesc=Object.getOwnPropertyDescriptor(obj,key) if(desc&&desc.configurable===false){ return } //... }
而在JavaScript中,导致configurable值为false的情况还是很多的:
- 可能该属性在此之前已经通过Object.defineProperty()方法设置了configurable的值;
- 通过Object.seal()方法设置该对象为密封对象,只能修改该属性的值并且不能删除该属性以及修改属性的描述符;
- 通过Object.freeze()方法冻结该对象,相比较Object.seal()方法,它更为严格之处体现在不允许修改属性的值。
2、默认getter和setter方法
另外,开发者可能已经为对象的属性设置了getter和setter方法,对于这种情况,Vue当然不能破坏开发者定义的方法,所以Vue中还要保护默认的getter和setter方法:
//部分重复代码这里就不再罗列了 functiondefineReactive(obj,key){ letval=obj[key] //.... //默认gettersetter constgetter=desc&&desc.get constsetter=desc&&desc.set Object.defineProperty(obj,key,{ get(){ constvalue=getter?getter.call(obj):val//优先执行默认的getter returnvalue }, set(newValue){ constvalue=getter?getter.call(obj):val //如果值相同则没必要更新===的坑点NaN!!!! if(newValue===value||(value!==value&&newValue!==newValue)){ return } if(getter&&!setter){ //用户未设置setter return } if(setter){ //调用默认的setter方法 setter.call(obj,newValue) }else{ val=newValue } } }) }
3、递归属性值
最后一种比较重要的情况就是属性的值可能也是一个对象,那么在处理对象的属性时,需要递归处理其属性值:
functiondefineReactive(obj,key){ letval=obj[key] //... //递归处理其属性值 constchildObj=observe(val) //... }
递归循环引用对象很容易出现递归爆栈问题,对于这种情况,Vue通过定义ob对象记录已经被设置过getter和setter方法的对象,从而避免递归爆栈的问题。
functionisObject(val){ consttype=val returnval!==null&&(type==='object'||type==='function') } functionobserve(value){ if(!isObject(value)){ return } letob //避免循环引用造成的递归爆栈问题 if(value.hasOwnProperty('__ob__')&&value.__obj__instanceofObserver){ ob=value.__ob__ }elseif(Object.isExtensible(value)){ //后续需要定义诸如__ob__这样的属性,所以需要能够扩展 ob=newObserver(value) } returnob }
上述代码中提到了对象的可扩展性,在JavaScript中所有对象默认都是可扩展的,但同时也提供了相应的方法允许对象不可扩展:
constobj={name:'xiaoming'} Object.preventExtensions(obj) obj.age=20 console.log(obj.age)//undefined
除了上述方法,还有前面提到的Object.seal()和Object.freeze()方法。
三、针对Array类型的劫持
数组是一种特殊的对象,其下标实际上就是对象的属性,所以理论上是可以采用Object.defineProperty()方法处理数组对象。
但是Vue并没有采用上述方法劫持数组对象,笔者猜测主要由于以下两点:(读者有更好的见解,欢迎留言。)
1、特殊的length属性
数组对象的length属性的描述符天生独特:
constarr=[1,2,3] Object.getOwnPropertyDescriptor(arr,'length').configurable//false
这就意味着无法通过Object.defineProperty()方法劫持length属性的读取和设置方法。
相比较对象的属性,数组下标变化地相对频繁,并且改变数组长度的方法也比较灵活,一旦数组的长度发生变化,那么在无法自动感知的情况下,开发者只能手动更新新增的数组下标,这可是一个很繁琐的工作。
2、数组的操作场景
数组主要的操作场景还是遍历,而对于每一个元素都挂载一个get和set方法,恐怕也是不小的性能负担。
3、数组方法的劫持
最终Vue选择劫持一些常用的数组操作方法,从而知晓数组的变化情况:
constmethods=[ 'push', 'pop', 'shift', 'unshift', 'sort', 'reverse', 'splice' ]
数组方法的劫持涉及到原型相关的知识,首先数组实例大部分方法都是来源于Array.prototype对象。
但是这里不能直接篡改Array.prototype对象,这样会影响所有的数组实例,为了避免这种情况,需要采用原型继承得到一个新的原型对象:
constarrayProto=Array.prototype constinjackingPrototype=Object.create(arrayProto)
拿到新的原型对象之后,再重写这些常用的操作方法:
methods.forEach(method=>{ constoriginArrayMethod=arrayProto[method] injackingPrototype[method]=function(...args){ constresult=originArrayMethod.apply(this,args) letinserted switch(method){ case'push': case'unshift': inserted=args break case'splice': inserted=args.slice(2) break } if(inserted){ //对于新增的元素,继续劫持 //ob.observeArray(inserted) } //通知变化 returnresult } })
最后,更新劫持数组实例的原型,在ES6之前,可以通过浏览器私有属性proto指定原型,之后,便可以采用如下方法:
Object.setPrototypeOf(arr,injackingPrototype)
顺便提一下,采用Vue.set()方法设置数组元素时,Vue内部实际上是调用劫持后的splice()方法来触发更新。
四、总结
由上述内容可知,Vue中的数据劫持分为两大部分:
- 针对Object类型,采用Object.defineProperty()方法劫持属性的读取和设置方法;
- 针对Array类型,采用原型相关的知识劫持常用的函数,从而知晓当前数组发生变化。
并且Object.defineProperty()方法存在以下缺陷:
- 每次只能设置一个具体的属性,导致需要遍历对象来设置属性,同时也导致了无法探测新增属性;
- 属性描述符configurable对其的影响是致命的。
而ES6中的Proxy可以完美的解决这些问题(目前兼容性是个大问题),这也是Vue3.0中的一个大动作,有兴趣的读者可以查阅相关的资料。
以上所述是小编给大家介绍的数据劫持实现原理详解整合,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对毛票票网站的支持!