深入浅出 Vue 系列 -- 数据劫持实现原理
栏目: JavaScript · 发布时间: 5年前
内容简介:数据双向绑定作为 Vue 核心功能之一,其实现原理主要分为两部分:本篇文章主要介绍 Vue 实现数据劫持的思路,下一篇则会介绍发布订阅模式的设计。对于 Object 类型,主要劫持其属性的读取与设置操作。在 JavaScript 中对象的属性主要由一个字符串类型的“名称”以及一个“属性描述符”组成,属性描述符包括以下选项:
数据双向绑定作为 Vue 核心功能之一,其实现原理主要分为两部分:
- 数据劫持
- 发布订阅模式
本篇文章主要介绍 Vue 实现数据劫持的思路,下一篇则会介绍发布订阅模式的设计。
二、针对 Object 类型的劫持
对于 Object 类型,主要劫持其属性的读取与设置操作。在 JavaScript 中对象的属性主要由一个字符串类型的“名称”以及一个“属性描述符”组成,属性描述符包括以下选项:
- value: 该属性的值;
- writable: 仅当值为 true 时表示该属性可以被改变;
- get: getter (读取器);
- set: setter (设置器);
- configurable: 仅当值为 true 时,该属性可以被删除以及属性描述符可以被改变;
- enumerable: 仅当值为 true 时,该属性可以被枚举。
上述 setter 和 getter 方法就是供开发者自定义属性的读取与设置操作,而设置对象属性的描述符则少不了 Object.defineProperty() 方法:
function defineReactive (obj, key) { let val = obj[key] Object.defineProperty(obj, key, { get () { console.log(' === 收集依赖 === ') console.log(' 当前值为:' + val) return val }, set (newValue) { console.log(' === 通知变更 === ') console.log(' 当前值为:' + newValue) val = newValue } }) } const student = { 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 中,对象通过字面量创建时,其属性描述符默认如下:
const foo = { name: '123' } Object.getOwnPropertyDescriptor(foo, 'name') // { value: '123', writable: true, enumerable: true, configurable: true } 复制代码
前面也提到了 configurable 的值如果为 false,则无法再修改该属性的描述符,所以在设置 setter 和 getter 方法时,需要注意 configurable 选项的取值,否则在使用 Object.defineProperty() 方法时会抛出异常:
// 部分重复代码 这里就不再罗列了。 function defineReactive (obj, key) { // ... const desc = 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 方法:
// 部分重复代码 这里就不再罗列了 function defineReactive (obj, key) { let val = obj[key] //.... // 默认 getter setter const getter = desc && desc.get const setter = desc && desc.set Object.defineProperty(obj, key, { get () { const value = getter ? getter.call(obj) : val // 优先执行默认的 getter return value }, set (newValue) { const value = 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、递归属性值
最后一种比较重要的情况就是属性的值可能也是一个对象,那么在处理对象的属性时,需要递归处理其属性值:
function defineReactive (obj, key) { let val = obj[key] // ... // 递归处理其属性值 const childObj = observe(val) // ... } 复制代码
递归循环引用对象很容易出现递归爆栈问题,对于这种情况,Vue 通过定义 ob 对象记录已经被设置过 getter 和 setter 方法的对象,从而避免递归爆栈的问题。
function isObject (val) { const type = val return val !== null && (type === 'object' || type === 'function') } function observe (value) { if (!isObject(value)) { return } let ob // 避免循环引用造成的递归爆栈问题 if (value.hasOwnProperty('__ob__') && value.__obj__ instanceof Observer) { ob = value.__ob__ } else if (Object.isExtensible(value)) { // 后续需要定义诸如 __ob__ 这样的属性,所以需要能够扩展 ob = new Observer(value) } return ob } 复制代码
上述代码中提到了对象的可扩展性,在 JavaScript 中所有对象默认都是可扩展的,但同时也提供了相应的方法允许对象不可扩展:
const obj = { name: 'xiaoming' } Object.preventExtensions(obj) obj.age = 20 console.log(obj.age) // undefined 复制代码
除了上述方法,还有前面提到的 Object.seal() 和 Object.freeze() 方法。
三、针对 Array 类型的劫持
数组是一种特殊的对象, 其下标实际上就是对象的属性,所以理论上是可以采用 Object.defineProperty() 方法处理数组对象 。
但是 Vue 并没有采用上述方法劫持数组对象,笔者猜测主要由于以下两点:(读者有更好的见解,欢迎留言。)
1、特殊的 length 属性
数组对象的 length 属性的描述符天生独特:
const arr = [1, 2, 3] Object.getOwnPropertyDescriptor(arr, 'length').configurable // false 复制代码
这就意味着无法通过 Object.defineProperty() 方法劫持 length 属性的读取和设置方法。
相比较对象的属性,数组下标变化地相对频繁,并且改变数组长度的方法也比较灵活,一旦数组的长度发生变化,那么在无法自动感知的情况下,开发者只能手动更新新增的数组下标,这可是一个很繁琐的工作。
2、数组的操作场景
数组主要的操作场景还是遍历,而对于每一个元素都挂载一个 get 和 set 方法,恐怕也是不小的性能负担。
3、数组方法的劫持
最终 Vue 选择劫持一些常用的数组操作方法,从而知晓数组的变化情况:
const methods = [ 'push', 'pop', 'shift', 'unshift', 'sort', 'reverse', 'splice' ] 复制代码
数组方法的劫持涉及到原型相关的知识,首先数组实例大部分方法都是来源于 Array.prototype 对象。
但是这里不能直接篡改 Array.prototype 对象,这样会影响所有的数组实例,为了避免这种情况,需要采用原型继承得到一个新的原型对象:
const arrayProto = Array.prototype const injackingPrototype = Object.create(arrayProto) 复制代码
拿到新的原型对象之后,再重写这些常用的操作方法:
methods.forEach(method => { const originArrayMethod = arrayProto[method] injackingPrototype[method] = function (...args) { const result = originArrayMethod.apply(this, args) let inserted switch (method) { case 'push': case 'unshift': inserted = args break case 'splice': inserted = args.slice(2) break } if (inserted) { // 对于新增的元素,继续劫持 // ob.observeArray(inserted) } // 通知变化 return result } }) 复制代码
最后,更新劫持数组实例的原型,在 ES6 之前,可以通过浏览器私有属性 proto 指定原型,之后,便可以采用如下方法:
Object.setPrototypeOf(arr, injackingPrototype) 复制代码
顺便提一下, 采用 Vue.set() 方法设置数组元素时,Vue 内部实际上是调用劫持后的 splice() 方法来触发更新 。
四、总结
由上述内容可知,Vue 中的数据劫持分为两大部分:
- 针对 Object 类型,采用 Object.defineProperty() 方法劫持属性的读取和设置方法 ;
- 针对 Array 类型,采用原型相关的知识劫持常用的函数,从而知晓当前数组发生变化 。
并且 Object.defineProperty() 方法存在以下缺陷:
- 每次只能设置一个具体的属性,导致需要遍历对象来设置属性,同时也导致了无法探测新增属性 ;
- 属性描述符 configurable 对其的影响是致命的 。
而 ES6 中的 Proxy 可以完美的解决这些问题(目前兼容性是个大问题),这也是 Vue3.0 中的一个大动作,有兴趣的读者可以查阅相关的资料。
如果本文对您有所帮助,那么点个关注,鼓励一下笔者吧。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
JavaScript精粹
爱德华兹 / 高铁军 / 人民邮电出版社 / 2007-6 / 49.00元
《JavaScript 精粹》主要介绍JavaScript应用中一些常见的问题及其解决方法,从最基础的数字、字符串、数组到进阶的DOM、表单验证、cookie,再到较为高级的Ajax,书中均有涉及。《JavaScript 精粹》覆盖现在非常流行和通用的技术,提出很多出现频率较高的Web开发常见问题,并提供了大量的技巧和解决方案,具有很强的实用性和通用性,书中的代码也具有很强的兼容性。《JavaSc......一起来看看 《JavaScript精粹》 这本书的介绍吧!