深入浅出 Vue 系列 -- 数据劫持实现原理


Posted in Javascript onApril 23, 2019

一、前言

数据双向绑定作为 Vue 核心功能之一,其实现原理主要分为两部分:

  1. 数据劫持
  2. 发布订阅模式

本篇文章主要介绍 Vue 实现数据劫持的思路,下一篇则会介绍发布订阅模式的设计。

二、针对 Object 类型的劫持

对于 Object 类型,主要劫持其属性的读取与设置操作。在 JavaScript 中对象的属性主要由一个字符串类型的“名称”以及一个“属性描述符”组成,属性描述符包括以下选项:

  1. value: 该属性的值;
  2. writable: 仅当值为 true 时表示该属性可以被改变;
  3. get: getter (读取器);
  4. set: setter (设置器);
  5. configurable: 仅当值为 true 时,该属性可以被删除以及属性描述符可以被改变;
  6. 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 的情况还是很多的:

  1. 可能该属性在此之前已经通过 Object.defineProperty() 方法设置了 configurable 的值;
  2. 通过 Object.seal() 方法设置该对象为密封对象,只能修改该属性的值并且不能删除该属性以及修改属性的描述符;
  3. 通过 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 中的数据劫持分为两大部分:

  1. 针对 Object 类型,采用 Object.defineProperty() 方法劫持属性的读取和设置方法;
  2. 针对 Array 类型,采用原型相关的知识劫持常用的函数,从而知晓当前数组发生变化。

并且 Object.defineProperty() 方法存在以下缺陷:

  1. 每次只能设置一个具体的属性,导致需要遍历对象来设置属性,同时也导致了无法探测新增属性;
  2. 属性描述符 configurable 对其的影响是致命的。

而 ES6 中的 Proxy 可以完美的解决这些问题(目前兼容性是个大问题),这也是 Vue3.0 中的一个大动作,有兴趣的读者可以查阅相关的资料。

以上所述是小编给大家介绍的数据劫持实现原理详解整合,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对三水点靠木网站的支持!

Javascript 相关文章推荐
基于jQuery的消息提示插件之旅 DivAlert(三)
Apr 01 Javascript
jQuery通过扩展实现抖动效果的方法
Mar 11 Javascript
javascript正则表达式基础知识入门
Apr 20 Javascript
基于jquery实现简单的手风琴特效
Nov 24 Javascript
jquery获取复选框checkbox的值实现方法
May 30 Javascript
深入理解Node.js的HTTP模块
Oct 12 Javascript
使用Bootstrap Tabs选项卡Ajax加载数据实现
Dec 23 Javascript
jQuery基于事件控制实现点击显示内容下拉效果
Mar 07 Javascript
javaScript实现滚动条事件详解
Mar 24 Javascript
jfinal与bootstrap的登出实战详解
Nov 27 Javascript
Vue 组件传值几种常用方法【总结】
May 28 Javascript
JS eval代码快速解密实例解析
Apr 23 Javascript
vue 中 beforeRouteEnter 死循环的问题
Apr 23 #Javascript
JavaScript中十种一步拷贝数组的方法实例详解
Apr 22 #Javascript
vue watch关于对象内的属性监听
Apr 22 #Javascript
vue项目中仿element-ui弹框效果的实例代码
Apr 22 #Javascript
对于防止按钮重复点击的尝试详解
Apr 22 #Javascript
Vue render函数实战之实现tabs选项卡组件
Apr 22 #Javascript
详解Vue依赖收集引发的问题
Apr 22 #Javascript
You might like
基于mysql的bbs设计(二)
2006/10/09 PHP
浅析PHP水印技术
2007/02/14 PHP
PHP 中检查或过滤IP地址的实现代码
2011/11/27 PHP
PHP设计模式之模板方法模式定义与用法详解
2018/04/02 PHP
HTML-CSS群中单选引发的“事件”
2007/03/05 Javascript
用Jquery实现滚动新闻
2014/02/12 Javascript
js如何调用qq互联api实现第三方登录
2014/03/28 Javascript
javascript数组排序汇总
2015/07/07 Javascript
jquery实现超简洁的TAB选项卡效果代码
2015/08/28 Javascript
jquery简单倒计时实现方法
2015/12/18 Javascript
jQuery自定义插件详解及实例代码
2016/12/29 Javascript
Echarts基本用法_动力节点Java学院整理
2017/08/11 Javascript
如何把vuejs打包出来的文件整合到springboot里
2018/07/26 Javascript
vue中的mvvm模式讲解
2019/01/31 Javascript
微信小程序云开发实现云数据库读写权限
2019/05/17 Javascript
JS实现普通轮播图特效
2020/01/01 Javascript
详解javascript void(0)
2020/07/13 Javascript
[02:33]DOTA2亚洲邀请赛趣味视频之吐真话筒
2018/03/31 DOTA
python转换字符串为摩尔斯电码的方法
2015/07/06 Python
Python的for和break循环结构中使用else语句的技巧
2016/05/24 Python
Python实现matplotlib显示中文的方法详解
2018/02/06 Python
Python堆排序原理与实现方法详解
2018/05/11 Python
Python实现快速傅里叶变换的方法(FFT)
2018/07/21 Python
Python 实现异步调用函数的示例讲解
2018/10/14 Python
详解Django-channels 实现WebSocket实例
2019/08/22 Python
python的命名规则知识点总结
2019/10/04 Python
python numpy实现rolling滚动案例
2020/06/08 Python
印尼旅游网站:via
2017/11/12 全球购物
如何将整数int转换成字串String
2014/03/21 面试题
法学院方阵解说词
2014/01/29 职场文书
毕业生自荐书
2014/02/02 职场文书
超市开店计划书
2014/04/26 职场文书
毛主席纪念堂观后感
2015/06/17 职场文书
浅谈:电影《孔子》观后感(范文)
2019/10/14 职场文书
JavaScript实现简单计时器
2021/06/22 Javascript
python实现MD5进行文件去重的示例代码
2021/07/09 Python