响应式原理前置知识(属性描述符、虚拟DOM、数据劫持)

本文深入探讨了响应式原理的核心概念,包括属性描述符、虚拟DOM、数据劫持等关键技术。解析了ES5中Object.defineProperty的使用,以及ES6 Proxy在数据劫持中的优势。同时,介绍了虚拟DOM在提高开发效率和性能优化方面的作用。

响应式原理前置知识(属性描述符、虚拟DOM、数据劫持)

参考:

属性描述符

ES5之前,JavaScript语言本身没有提供可以直接检测属性特性的方法,比如判断属性是否只读。

Object.getOwnPropertyDescriptor()

ES5 在 Object 上提供了 getOwnPropertyDescriptor(obj, prop)来查看属性的特性:

参数:

  • obj:要获取属性描述符的对象
  • prop:要获取的属性的名称

返回值:

  • descriptor:如果指定的属性存在于对象上,返回其属性的描述符
  • undefined:否则返回undefined
var obj = { a: 2 }
var descriptor = Object.getOwnPropertyDescriptor(obj, 'a')
console.log(descriptor)

// {
//   value: 2,            // 属性值
//   writable: true,      // 可写(是否可以属性的值)
//   enumerable: true,    // 可枚举(是否可以枚举这个属性,是否可以遍历到这个属性)
//   configurable: true,  // 可配置(是否可以使用defineProperyu()方法修改属性的描述符)
// }

descriptor

value

描述符返回的是一个对象,可以确认,obj对象存储区间内包含的不是属性值,而是指向属性值的属性名。

对象的属性名指向这些属性真正存储的物理地址。

当访问obj.a时,实际上是隐式调用了一个获取obj.a.value的方法(后面讲解)。

writable

决定是否可以对属性重新赋值

当值为引用类型,依然可以修改引用对象的内容。

var obj = {}
Object.defineProperty(obj, 'a', {
  value: 2,
  writable: false // 禁止修改该属性的值
})
obj.a = 3 // 在严格模式下修改不可修改的属性值会报错
console.log(obj.a) // 2
configurable

决定属性是否是可配置的。

  • false:不可修改属性的描述符
  • true:可以使用defineProperty()方法修改属性描述符
    • 当修改为false,就无法再修改成true

可配置的范围是除了valuewritable以外的描述符上的属性。

不可配置的属性不能被删除,严格模式下删除不可配置的属性会报错。

var obj = {}
Object.defineProperty(obj, 'a', {
  value: 2,
  writable: true,
  configurable: false,
  enumerable: false,
})
Object.defineProperty(obj, 'a', {
  value: 3,
  writable: false,
})
console.log(obj) // { a: 3 }
Object.defineProperty(obj, 'a', { // TypeError: Cannot redefine property: a
  configurable: true,
  // enumerable: false, // 同样的报错
  // get() {}, // 同样的报错
  // set() {} // 同样的报错
})
enumerable

决定属性是否可以枚举,即遍历obj对象时是否会遍历到该属性,例如for...inObject.keys()

var obj = {}
Object.defineProperty(obj, 'a', {
  value: 2,
  enumerable: false
})
Object.defineProperty(obj, 'b', {
  value: 3,
  enumerable: true
})
var count = 0
for (var k in obj) {
  count++
  console.log(k)
}
console.log(count)
// 最终输出:
// b
// 1

get

属性的getter函数,默认为undefined。

当定义了get,访问该属性时,会调用此函数。

函数不接收任何参数,但在内部可以通过this访问obj对象(箭头函数不行)。

函数返回的值,就是该属性的值。

var obj = { b: 1 }

Object.defineProperty(obj, 'a', {
  get: function () {
    console.log(this)
    return 10
  }
})
console.log(obj.a)
// { b: 1 }
// 10

ES6的const常量的本质就是get返回的一直是指向同一个值或对象的内存地址,一个指针。

set

属性的setter函数,默认为undefined。

当定义了set,属性值被修改时,会调用此函数。

函数接收一个参数(也就是被赋予的新值),内部可以把这个值处理后存储起来,以便getter函数访问。

内部同样可以通过this访问obj对象(箭头函数不行)。

var obj = { b: 1 }
var aVal
Object.defineProperty(obj, 'a', {
  set: function (value) {
    aVal = value
  },
  get: function () {
    return aVal
  }
})
obj.a = 10
console.log(obj.a) // 10
描述符默认值
  • 拥有布尔值的键 configurableenumerablewritable 的默认值都是 false
  • 属性值和函数的键 valuegetset 字段的默认值为 undefined

当使用常规方式,向对象中添加一个属性时,writable、configurable、enumerable被设置为true。

使用defineProperty向对象添加一个属性时,如果未配置writable、configurable、enumerable中的其中某项,则默认为false。

使用definProperty修改对象的某个属性时,会用新的描述对象合并到旧的对象,类似Object.assign。

浏览器环境使用var声明的变量,默认会包含在全局变量window中,此时该变量的configurable是false。

var a = 1
console.log(Object.getOwnPropertyDescriptor(window, 'a'))
// {
//   value: 1,
//   writable: true,
//   enumerable: true,
//   configurable: false,
// }

var obj = { b: 2 }
console.log(Object.getOwnPropertyDescriptor(obj, 'b'))
// {
//   value: 2,
//   writable: true,
//   enumerable: true,
//   configurable: true,
// }

Object.defineProperty(obj, 'c', { value: 3 })
console.log(Object.getOwnPropertyDescriptor(obj, 'c'))
// {
//   value: 3,
//   writable: false,
//   enumerable: false,
//   configurable: false,
// }

Object.defineProperty(obj, 'b', {
  value: 4,
  writable: false,
})
console.log(Object.getOwnPropertyDescriptor(obj, 'b'))
// {
//   value: 4,
//   writable: false,
//   enumerable: true,
//   configurable: true,
// }

属性不变性

由此可知,属性描述符descriptor也是一个对象。

所以如果它的value属性也是一个引用值类型(数组、对象),那value指向的对象中的属性是否可以修改或配置也应该由这个对象的属性描述符决定。

也就是说,一个对象的属性的值如果是引用值类型,则它的值并不能由它的描述符完全控制。

var obj = {}
Object.defineProperty(obj, 'a', {
  value: [1, 2, 3],
  writable: false, // 不可写
  configurable: false, // 不可配置
  enumerable: false // 不可枚举
})
obj.a.push(4)
console.log(obj.a) // [1, 2, 3, 4]

obj.a = [5,6] // 直接修改属性的值,严格模式下报错
console.log(obj.a) // [1, 2, 3, 4]

get/set VS value/writable

get/set 不能同 value/writable 共用。

当先定义get/set,再定义value/writable,或者同时定义二者,都会报错。

var obj = { }

Object.defineProperty(obj, 'a', {
  value: 10,
  writable: true,
  configurable: true,
  enumerable: true
})
Object.defineProperty(obj, 'b', {
  get: function () {
    return 10
  }
})
Object.defineProperty(obj, 'c', {
  set: function (value) {
    this._c = value
  }
})

console.log(Object.getOwnPropertyDescriptors(obj))

// {
//   a: { value: 10, writable: true, enumerable: true, configurable: true },
//   b: {
//     get: [Function: get],
//     set: undefined,
//     enumerable: false,
//     configurable: false
//   },
//   c: {
//     get: undefined,
//     set: [Function: set],
//     enumerable: false,
//     configurable: false
//   }
// }
var obj = { }

Object.defineProperties(obj, {
  a: {
    value: 10,
    writable: true,
    configurable: true,
    enumerable: true
  },
  b: {
    set: function (value) {
      this._b = value
    }
  }
})
Object.defineProperty(obj, 'a', {
  get: function () {
    return 100
  }
})
console.log(Object.getOwnPropertyDescriptor(obj, 'a'))
// {
//   get: [Function: get],
//   set: undefined,
//   enumerable: true,
//   configurable: true
// }

Object.defineProperty(obj, 'b', {
  value: 11,
  writable: false
})
// TypeError: Cannot redefine property: b

通过上述代码可以得知:

  1. get/set 和 value/writable 不能共存
  2. 当使用了get/set,value/writable就会消失,并且不能被配置。
描述符可拥有的键值
configurableenumerablevaluewritablegetset
数据属性描述符可以可以可以可以不可以不可以
访问设置器/存取属性描述符可以可以不可以不可以可以可以
[[Get]] [[Put]] VS Getter Setter

当对象的属性配置了get/set,这个属性的操作实际上受这getter(访问器)/setter(设置器)函数控制。

当不配置属性的get/set时,属性的操作实际上调用了对象内置的[[Get]]、[[Put]]方法。

类似于对象通过[[Get]]()获取属性的值,通过[[Put]]()设置修改属性的值。

  • [[Get]]:首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。如果没有找到,就会遍历可能存在的原型链,如果最终依然没有找到,就会返回undefined。
  • [[Put]]:该方法的操作很复杂,其中包括在满足一定条件下,将属性的值设置为赋予的值。

控制台打印结果对比:

let defaultObj = {
  a: 1
}

let getterObj = {}
Object.defineProperty(getterObj, 'a', {
  get () {
    return 1
  }
})

console.log('defaultObj: ')
console.log(defaultObj)
console.log('getterObj: ')
console.log(getterObj)
/*
defaultObj:
{
  a: 1,
  __proto__: Object
}

getterObj:
{
  a: 1,
  get a: ƒ (),
  __proto__: Object
}
*/

在这里插入图片描述

当设置了get,对象打印结果中也会有一个对应的属性,虽然访问它取的是getter方法的返回结果。

控制台中打印的对象属性,有的是浅色的,是因为它配置了不可枚举(enumerable:false)

Object.getOwnPropertyDescriptors(obj)

可以使用这个方法获取对象所有属性的描述符。

Object.defineProperty() & Object.defineProperties()

ES5 提供了一个配置属性特性的方法 Object.defineProperty(obj, prop, descriptor)

它是无法 shim(降级处理)的特性,IE8及以下浏览器不支持。

参数:

  • obj:要定义属性的对象
  • prop:要定义或修改的属性的名称或Symbol
  • descriptor:要定义或修改的属性描述符

返回值:

  • obj:方法中传入的对象

此方法可以直接在对象上定义一个新的具有详细描述的属性,或者修改一个对象的现有属性,并返回这个对象。

Object.defineProperties(obj, props<object>)defineProperty()类似,区别是可以一次配置/修改多个属性。

约束属性的API

JavaScript除了defineProperty,还提供了一些用于配置固定模式的属性描述符,或约束对象属性的API,语法:

参数:

  • obj:禁止扩展的对象

返回:

  • obj:被禁止扩展后的对象(传入的obj对象)

禁止扩展 Object.preventExtensions(obj)

禁止对象添加新的属性。

封闭对象 Object.seal(obj)

在 preventExtensions 的基础上,禁止修改属性的描述符。

  • configurable:false的区别是:不能修改writable
  • writable:true时,可以修改属性的值

实际上就是限制一个对象不能添加新属性,不能删除已有属性,不能将属性由数据属性变为存取属性,反之亦然。

冻结对象 Object.freeze(obj)

在 seal 的基础上,禁止修改属性的值。

一个被冻结的对象,不能添加新属性,不能删除已有属性,不能修改已有属性的全部(包括valuewritable等)描述符。

此外冻结对象的原型也不能被修改。

特殊情况的修改

冻结一个对象,不能修改这个对象的属性的值,以下两个情况例外:

  • 属性为访问器属性(定义了getter和setter),由于是函数调用,给人的错觉是还可以修改这个属性。
  • 属性是引用类型(值是一个对象),那除非这个对象也是冻结对象,否则可以修改这个对象的属性。

以上API都返回的是传递的对象,而不是创建一个新的副本。

虚拟DOM渲染

原生JS程序中,我们直接对DOM进行创建和修改,而DOM元素通过我们监听的事件和应用程序进行通讯。

Vue和Reacr采用了虚拟DOM渲染。

所谓虚拟DOM,就是用JS来模拟DOM结构,这个模拟DOM的JS对象,就是虚拟DOM。

这个虚拟DOM将被转换成真实DOM,渲染到页面。

JS模拟DOM结构,例如:

<div id="app">
  <h1>Hello world</h1>
</div>

// to

var vDom = {
  tag: 'div',
  attr: {
    id: 'app'
  },
  children: [
    {
      tag: 'h1',
      children: 'Hello world'
    }
  ]
}

使用虚拟DOM提高开发效率:

  • 把DOM的变化操作放在JS层来做,使开发者只需要关注如何更新虚拟DOM对象,即业务逻辑,而不用关心对DOM的操作。
  • 然后通过Diff算法,对比前后两次虚拟DOM的变化,只更新渲染变化了的部分(通过虚拟DOM对象,可以很方便拿到要更新的节点,通过document去更新)。相比人为的去判断和实现DOM的操作,自动对比帮开发者节省了很多精力和时间,并且做的更精准。
  • 兼容性、性能优化、放置XSS等优势

snabbdom库是一个开源的vdom库。

主要作用就是通过h()函数将换入的JS模拟的DOM结构转换成虚拟的DOM节点,再通过patch()函数将虚拟DOM转换成真是的DOM渲染到页面中。

使用数据劫持的方式监听这个虚拟DOM(JS对象)变化。

对这个虚拟DOM的修改,再通过patch()更新渲染,内部使用Diff算法,对比前后两个虚拟DOM的差异,只更新改变了的DOM节点。

Vue2.x采用基于snabbdom的vdom,通过h函数和patch函数实现虚拟DOM渲染。

React使用React.createElement或JSX(+babel),生成虚拟DOM,通过ReactDOM.render将虚拟DOM渲染到指定容器。

数据劫持

访问或修改对象的某个属性时,通过一段代码拦截这个行为,除了执行基本的数据获取和修改操作之外,还基于数据的操作行为,以数据为基础去执行额外的操作。

典型的数据劫持的方法就是:ES5的Object.defineProperty() 和 ES6的 Proxy。

为什么使用数据劫持?

在前端页面渲染中,最经典的触发渲染方案必然是基于事件机制实现:

  • 通过事件监听机制触发JS事件
  • 然后JS通过document获取需要重新渲染的DOM
  • 然后在js的DOM模型上修改数据触发document渲染页面

这个方案的弊端:

在浏览器中document只是提供给JS操作文档模型的接口,双方通讯通道资源有限,基于事件机制触发页面渲染会消耗这个通道的大量资源,降低浏览器性能。

基于数据劫持实现数据渲染的方案:

  • JS管理Data数据,基于模板,生成虚拟DOM,渲染到页面。
  • 当Data数据发生变化,重新生成虚拟DOM,对比修改范围,向DOM发送最小范围渲染指令。
  • document直接定位到需要修改数据的节点,执行渲染。

JS与document的通讯仅仅只需要一次,而且基于虚拟DOM的支持,还可以实现最精准的DOM渲染。

数据劫持实现原理

Object数据劫持

使用ES5的 Object.defineProperty(obj, prop, descriptor{getter,setter}) 对对象的属性设置访问器getter 和 设置器 setter,实现数据监听与劫持。

Object.defineProperty 实现数据劫持的问题:

  1. 不能监听数组的变化
  2. 必须遍历对象的每个属性
  3. 必须深层遍历嵌套的对象(递归)
Array数据劫持

基于数组的数据修改方法push、pop、unshift、shift、slice、reverse实现数据劫持。

Array的索引不能使用setter和getter数据描述,所以不能对Array的元素使用Object.defineProperty实现数据劫持。

这也是Vue中无法通过Array[index]修改元素值触发数据渲染的原因。

但是数组的原型是一个Object,可以通过修改数组上的方法实现数据劫持:

let arr = []
let { push } = Array.prototype
Object.defineProperty(Array.prototype, 'push', {
  value: (function(){
    return function (...args) {
      push.apply(arr, args)
      // 数据劫持后要做的事情...
      console.log('打劫',args)
    }
  })()
})
arr.push(1)

Vue把 push、pop、unshift、shift、splice、reverse、sort 定义为变异方法(mutation method),指的是会修改原数组的方法。

与之对应的是非变异方法(non-mutation method),例如filter、concat、slice

Vue的做法是把这些方法重写来实现数组的劫持,简单模拟:

// 需要封装的数组方法的名称
const arrayMethodNames = ['push','pop','shift','unshift','splice','sort','reverse']
// 存储封装后的数组方法
const arrayVueMethods = []

arrayMethodNames.forEach(method => {
  // 获取原生 Array 的原型方法
  let original = Array.prototype[method]

  // 封装原生方法,存储在arrayVueMethods
  arrayVueMethods[method] = function(...args) {
    // 自定义处理代码
    console.log('打劫')

    // 调用原生方法并返回结果
    return original.apply(this, args)
  }
})

let arr = [1, 2, 3]

// 修改要劫持的数组的原型指针,指向存储了封装后的方法的arrayVueMethods
arr.__proto__ = arrayVueMethods

arr.push(4) // 打劫

// 普通数组不受影响
let arr2 = [11, 22, 33]
arr2.push(44) // 不输出内容
Proxy

Proxy是ES6的新特性。

它为要代理的对象设置一系列捕获器。

new Proxy(target, handler)

Proxy是一个类,通过new创建一个代理对象。

参数:

  • target 要使用Proxy包装的目标对象,可以是任意类型的对象(数组、函数、甚至是另一个proxy对象)
  • handler 一个通常以函数作为属性的对象,每个属性的函数分别定义了在执行各种操作时,代理对象的行为。
let obj = {}
let proxyObj = new Proxy(obj, {
  get(target, key) {},
  set(target, key, value) {}
})

let arr = [1, 2, 3]
let proxyArr = new Proxy(arr, {
  get(target, property) {
    return `arr[${key}] => ${target[key]}`
  }
})
console.log(proxyArr[2]) // arr[2] => 3

相比于Object.defineProperty实现数据劫持:

  1. Proxy通过key参数获取对象的属性或数组的下标,解决了defineProperty必须遍历全部属性和无法劫持数组的问题。
  2. Proxy有13种拦截器,相比defineProperty更丰富。
  3. Proxy的性能比Object.defineProperty高很多
var obj = {
  foo: 'foo',
  bar: 'bar'
}

// 测试Object.defineProperty整个过程的速度
console.time('defineProperty')

var proxyData = {
  foo: 'foo',
  bar: 'bar'
}
Object.keys(obj).forEach(key => {
  Object.defineProperty(obj, key, {
    get() {
      return proxyData[key]
    },
    set(val) {
      if (proxyData[key] !== val) {
        proxyData[key] = val
      }
    }
  })
})

// 测试Object.defineProperty getter/setter 速度
console.time('defineProperty - getter/setter')
obj.foo = 20
console.log(obj.foo)
console.timeEnd('defineProperty - getter/setter')

console.timeEnd('defineProperty')



// 测试Proxy整个过程的速度
console.time('Proxy')
var obj2 = {
  foo: 'foo',
  bar: 'bar'
}

var proxyObj = new Proxy(obj2, {
  get (target, key) {
    return target[key]
  },
  set (target, key, value) {
    target[key] = value
  }
})

// 测试Proxy getter/setter 速度
console.time('Proxy - getter/setter')
proxyObj.foo = 20
console.log(proxyObj.foo)
console.timeEnd('Proxy - getter/setter')

console.timeEnd('Proxy')

// 20
// defineProperty - getter/setter: 5.843ms
// defineProperty: 6.005ms
// 20
// Proxy - getter/setter: 0.387ms
// Proxy: 0.543ms

缺点:

  1. Proxy是ES6新特性,兼容性比不上ES5的defineProperty
  2. Proxy同样不能向下兼容(无法shim)
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值