Object.defineProperty实现响应式
首先需要知道是Object.defineProperty只能监听对象,并且这个对象不是指对象类型(数组也是对象类型),而是Object构造器对象,也就是{}。
结构分为三个部分:
- updateView:更新视图的函数
- defineReactive:监听对象数据改变的函数
- observer:分解数据每一项属性,以便进行深度监听
监听对象
Vue2.x实现响应式视图更新,大概流程就是下面这样:
//设置简单的函数表示视图更新
function updateView() {
console.log("视图更新")
}
// 分解对象属性函数
function observer(target) {
if (typeof target !== "object" || target === null) {
// 不是对象或数组
return target
}
// 监听target每一个属性
for (let key in target) {
defineReactive(target, key, target[key])
}
}
function defineReactive(target, key, value) {
// 深度监听:如果value是对象就继续分解
observer(value)
// 核心API:Object.defineProperty()
Object.defineProperty(target, key, {
get() {
return value
},
set(newValue) {
if (newValue !== value) {
// 设置新值也要监听是否是对象和数组
observer(newValue)
// 设置新值
// 注意value一直在闭包中,此处设置完后再get时也是会获取到value的值
value = newValue
// 触发更新视图
updateView()
}
},
})
}
由于JavaScript的限制,Vue里的数据不能够动态添加根级响应式 property。也就是说必须在初始化实例前声明所有根级响应式 property,哪怕只是一个空值,不然是不会触发视图更新虽然值已经变了。
大家可以通过上面的代码运行一下。👆
监听数组
为了能够监听数组,Vue2重写了部分数组方法去实现视图更新,但是这部分和Object.defineProperty就没有关系了。
👇大概实现是这样实现的:
//设置简单的函数表示视图更新
function updateView() {
console.log("视图更新")
}
// 重新定义数组原型
const oldArrayProperty = Array.prototype
//这样新增方法也不会影响到Array原型
const arrPrototype = Object.create(oldArrayProperty)
// 假设添加了这些方法,那么如果数组调用这些方法就会触发视图更新
;["push", "pop", "unshift", "shift", "splice"].forEach(
(method) =>
(arrPrototype[method] = function () {
// 如果调用以上的方法就触发视图更新
updateView()
oldArrayProperty[method].call(this, ...arguments)
})
)
// 分解对象属性函数
function observer(target) {
if (typeof target !== "object" || target === null) {
// 不是对象或数组
return target
}
// 为了不污染全局Array原型:需要重新定义数组原型
if (Array.isArray(target)) {
target.__proto__ = arrPrototype
}
// 重新定义各个属性
for (let key in target) {
defineReactive(target, key, target[key])
}
}
因为Object.defineProperty的限制,Vue2中对数组的操作非常有限制。
Vue 不能检测以下数组的变动:
- 当你利用索引直接设置一个数组项时,例如:
vm.items[indexOfItem] = newValue - 当你修改数组的长度时,例如:
vm.items.length = newLength
官方推荐最好用splice方法对数组进行增删操作就是因为内部改写方法时改写了splice方法。或者使用Vue.set()|vm.$set()强行将数据添加到响应式里。
Proxy和Reflect实现响应式
在贴上代码之前必须要好好讲一下Proxy,感觉很多人都对Proxy有误解,以为Proxy只是ES6语法用来代替ES5语法中Object.defineProperty的语法糖,这种想法是大错特错的。Proxy和Object.defineProperty是两个完全不同的东西。所以Object.defineProperty也不能代替Proxy。
Proxy
Proxy 可以理解为:在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。
先上代码看看基本操作👇
var arr = [1,2]
var arrProxy = new Proxy(
arr,
{
get(target, propKey) {
console.log(`getting ${propKey}!`)
},
set(target, propKey, value) {
console.log(`setting ${propKey}!`)
},
}
)
//设置值
arrProxy[0] = 'change' //setting 0!
//读取值
arrProxy[1] //getting 1!
上面的代码对arr数组架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。
作为构造函数,Proxy接受两个参数:
- 第一个参数是所要代理的目标对象(上例是一个
arr对象),即如果没有Proxy的介入,操作原来要访问的就是这个arr对象。这里的对象是指对象类型(数组也是对象类型)。 - 第二个参数是一个配置对象
handler,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。比如,上面代码中,配置对象有一个get方法,用来拦截对目标对象属性的访问请求。get方法的两个参数分别是目标对象和所要访问的属性。
注意: 要使Proxy起作用,必须针对Proxy实例(上例是arrProxy对象)进行操作,而不是针对目标对象(上例是arr对象)进行操作。
下面是 Proxy 支持的拦截操作一览,一共 13 种。
- get(target, propKey, receiver):拦截对象属性的读取,比如
proxy.foo和proxy['foo']。 - set(target, propKey, value, receiver):拦截对象属性的设置,比如
proxy.foo = v或proxy['foo'] = v,返回一个布尔值。 - has(target, propKey):拦截
propKey in proxy的操作,返回一个布尔值。 - deleteProperty(target, propKey):拦截
delete proxy[propKey]的操作,返回一个布尔值。 - ownKeys(target):拦截
Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。 - getOwnPropertyDescriptor(target, propKey):拦截
Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。 - defineProperty(target, propKey, propDesc):拦截
Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。 - preventExtensions(target):拦截
Object.preventExtensions(proxy),返回一个布尔值。 - getPrototypeOf(target):拦截
Object.getPrototypeOf(proxy),返回一个对象。 - isExtensible(target):拦截
Object.isExtensible(proxy),返回一个布尔值。 - setPrototypeOf(target, proto):拦截
Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。 - apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如
proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。 - construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如
new proxy(...args)。
可以看出Proxy不仅可以实现Object.defineProperties的功能,还有其他的操作也可以拦截。
我有关Proxy的内容基本都是参考阮一峰的ES6教程,讲的特别好,大家可以看看。
Reflect
说完Proxy就必须要说一说Reflect这个ES6新增的API。Reflect对象和Proxy对象一样也是用来操作对象的,但是Reflect对象的设计目的有重大的意义。
-
将
Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。现阶段,某些方法同时在Object和Reflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。这样做的好处就是使Object类更纯粹,JavaScript更像一门语言,Object更像一个类,而不是什么乱七八糟的方法都往Object上塞。 -
修改某些
Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false。// 老写法 try { Object.defineProperty(target, property, attributes); // success } catch (e) { // failure } // 新写法 if (Reflect.defineProperty(target, property, attributes)) { // success } else { // failure } -
让
Object操作都变成函数行为。某些Object操作是命令式,比如name in obj和delete obj[name],而Reflect.has(obj, name)和Reflect.deleteProperty(obj, name)让它们变成了函数行为。// 老写法 'assign' in Object // true // 新写法 Reflect.has(Object, 'assign') // true -
(核心)
Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。
Reflect对象一共有 13 个静态方法。
- Reflect.apply(target, thisArg, args)
- Reflect.construct(target, args)
- Reflect.get(target, name, receiver)
- Reflect.set(target, name, value, receiver)
- Reflect.defineProperty(target, name, desc)
- Reflect.deleteProperty(target, name)
- Reflect.has(target, name)
- Reflect.ownKeys(target)
- Reflect.isExtensible(target)
- Reflect.preventExtensions(target)
- Reflect.getOwnPropertyDescriptor(target, name)
- Reflect.getPrototypeOf(target)
- Reflect.setPrototypeOf(target, prototype)
上面这些方法的作用,大部分与Object对象的同名方法的作用都是相同的,而且它与Proxy对象的方法是一一对应的。
我们来改写之前proxy的例子🌰,加上Reflect:
var arr = [1,2]
var arrProxy = new Proxy(
arr,
{
get(target, propKey, receiver) {
console.log(`getting ${propKey}!`)
return Reflect.get(target, propKey, receiver)
},
set(target, propKey, value, receiver) {
console.log(`setting ${propKey}!`)
return Reflect.set(target, propKey, receiver)
},
}
)
//设置值
arrProxy[0] = 'change' //setting 0! change
//读取值
arrProxy[1] //getting 1! 2
上面的代码中,每一个Proxy对象的拦截操作(get、set),内部都调用对应的Reflect方法,保证原生行为能够正常执行。Reflect在Proxy中调用还有一个重要的原因是recevier参数。
来,看一个🌰来说明recevier参数作用👇
let p = {
a: "a",
}
let handler = {
set(target, key, value, receiver) {
console.log("set")
Reflect.set(target, key, value, receiver)
},
defineProperty(target, key, attribute) {
console.log("defineProperty")
Reflect.defineProperty(target, key, attribute)
},
}
let obj = new Proxy(p, handler)
obj.a = "A"
// set
// defineProperty
上面代码中,Proxy.set拦截里面使用了Reflect.set,而且传入了receiver,导致触发Proxy.defineProperty拦截。
这是因为Proxy.set的receiver参数总是指向当前的 Proxy实例(即上例的obj),而Reflect.set一旦传入receiver,就会将属性赋值到receiver上面(即obj),导致触发defineProperty拦截。如果Reflect.set没有传入receiver,那么就不会触发defineProperty拦截。
所以receiver的作用就是让Proxy里的对象操作都指向当前的Proxy实例,这样就能拦截到所有对实例的操作。(嗯,非常严谨无懈可击:+1:
Proxy对比Object.defineProperty
看到这里,我相信大家已经明白了Object.defineProperties和Proxy的区别。但是我还是要再啰嗦一句Proxy并不是Object.defineProperties的语法糖!!!!
babel通过@babel/polyfill(corejs和re-generator)将ES6语法转译为大部分浏览器都支持的ES5语法。原理说得简单点就是一些ES6的特性可以用ES5代替,只不过用ES5写会很麻烦或者不直观。
比如我们看一段代码👇:
// ES6定义类
class Person{
constructor(name){
this.name = name
}
sayName(){
console.log(this.name)
}
}
这个定义类的方法为了让它能够被大部分浏览器识别,我们一般会用babel将它转译:
// 通过babel的polyfill后
function Person(name){
this.name = name
}
Person.prototype.sayName = function(){
console.log(this.name)
}
转译后的ES5代码和之前的ES6版本代码实现的功能完全一致。我们其实也可以说class定义类的方法是通过function定义类(构造函数)的语法糖。
但是Proxy是不能通过babel转译的,因为在ES5中完全没有一种语法可以模拟出Proxy的特性。因此Vue3.x版本没有办法兼任一些低版本浏览器。
响应式代码
function reactive(target = {}) {
if (typeof target !== "object" || target == null) {
return target
}
// 代理配置
const proxyConf = {
get(target, key, receiver) {
//只监听对象本身(非原型)属性
const ownKeys = Reflect.ownKeys(target)
if (ownKeys.includes(key)) {
//如果是本身的属性就监听,如果是对象原型的属性就不监听
console.log("get", key)
}
const result = Reflect.get(target, key, receiver)
//(惰性)深度监听-->提升性能
return reactive(result)
},
set(target, key, val, receiver) {
// 重复的数据不处理
if (val === target[key]) {
return true
}
// 监听是否是新增的key
const ownKeys = Reflect.ownKeys(target)
if (ownKeys.includes(key)) {
console.log("已有的key", key)
} else {
console.log("新增的key", key)
}
const result = Reflect.set(target, key, val, receiver)
console.log("set", key, val)
return result //通过return的值可以看出是否设置成功
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key)
console.log("delete property", key)
return result //是否删除成功
},
}
// 生成代理对象
const observed = new Proxy(target, proxyConf)
return observed
}
Vue3的响应式逻辑如何一步一步构造出来的,我放在另一篇博文Vue3响应式实现逻辑。
结语
ok,在最后我想说一些话。
有很多人觉得Vue3为了性能舍弃了部分浏览器是得不偿失的行为。但是,我并不觉得。
首先,Vue3现目前还在ra阶段,到真正发布估计还要一段时间。而且并不是Vue3发布了,我们做项目就必须用3,中间应该是漫长的Vue2.x到3的过渡时期。随着时间的拉长,我相信会有越来越多浏览器支持Proxy属性,我们也会抛弃对越来越古老的浏览器版本的兼容。
其次不能因为现目前部分浏览器的不兼容就不去考虑Vue的性能提升,技术的推动是慢慢迭代的。要是都以现状做导向的话,互联网也不会发展得这么快。
嗯,就这样吧。