深入浅出 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 相关文章推荐
利用location.hash实现跨域iframe自适应
May 04 Javascript
jQuery创建自己的插件(自定义插件)的方法
Jun 10 Javascript
40个新鲜出炉的jQuery 插件和免费教程[上]
Jul 24 Javascript
js实现连续英文字符自动换行兼容ie6 ie7和firefox
Sep 06 Javascript
Flow之一个新的Javascript静态类型检查器
Dec 21 Javascript
jquery判断对象是否为空并遍历对象的简单实例
Jul 26 Javascript
JS实现获取图片大小和预览的方法完整实例【兼容IE和其它浏览器】
Apr 24 Javascript
vue实现密码显示隐藏切换功能
Feb 23 Javascript
对vuejs的v-for遍历、v-bind动态改变值、v-if进行判断的实例讲解
Aug 27 Javascript
js prototype和__proto__的关系是什么
Aug 23 Javascript
p5.js临摹动态图形实现方法详解
Oct 23 Javascript
记一次vue跨域的解决
Oct 21 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
PHP_Flame(Version:Progress)的原代码
2006/10/09 PHP
php判断终端是手机还是电脑访问网站的思路及代码
2013/04/24 PHP
PHP中数据类型转换的三种方式
2015/04/02 PHP
PHP如何通过传引用的思想实现无限分类(代码简单)
2015/10/13 PHP
php+javascript实现的动态显示服务器运行程序进度条功能示例
2017/08/07 PHP
图片自动缩小的js代码,用以防止图片撑破页面
2007/03/12 Javascript
jquery下json数组的操作实现代码
2010/08/09 Javascript
JavaScript传递变量: 值传递?引用传递?
2011/02/22 Javascript
十个迅速提升JQuery性能让你的JQuery跑得更快
2012/12/10 Javascript
用js格式化金额可设置保留的小数位数
2014/05/09 Javascript
详解vue模拟加载更多功能(数据追加)
2017/06/23 Javascript
JS实现获取汉字首字母拼音、全拼音及混拼音的方法
2017/11/14 Javascript
自定义PC微信扫码登录样式写法
2017/12/12 Javascript
JS 实现获取验证码 倒计时功能
2018/10/29 Javascript
微信小程序中的上拉、下拉菜单功能
2020/03/13 Javascript
es6函数之箭头函数用法实例详解
2020/04/25 Javascript
JavaScript如何操作css
2020/10/24 Javascript
python3实现TCP协议的简单服务器和客户端案例(分享)
2017/06/14 Python
在python中使用正则表达式查找可嵌套字符串组
2017/10/24 Python
使用python 爬虫抓站的一些技巧总结
2018/01/10 Python
python解析含有重复key的json方法
2019/01/22 Python
python调用接口的4种方式代码实例
2019/11/19 Python
6行Python代码实现进度条效果(Progress、tqdm、alive-progress​​​​​​​和PySimpleGUI库)
2020/01/06 Python
jupyter notebook参数化运行python方式
2020/04/10 Python
win10从零安装配置pytorch全过程图文详解
2020/05/08 Python
Python实现给PDF添加水印的方法
2021/01/25 Python
CSS3制作彩色进度条样式的代码示例分享
2016/06/23 HTML / CSS
在阿尔卑斯山或希腊度过快乐假期:Alpine Elements
2019/12/28 全球购物
澳大利亚电商Catch新西兰站:Catch.co.nz
2020/05/30 全球购物
中科软测试工程师面试题
2012/06/16 面试题
咨询公司各岗位职责
2013/12/02 职场文书
应届中专生自荐书范文
2014/02/13 职场文书
医院院务公开实施方案
2014/05/03 职场文书
小学优秀教师材料
2014/12/15 职场文书
工作简历的自我评价
2019/05/16 职场文书
python实现简单的聊天小程序
2021/07/07 Python