说到响应式,大家肯定都有了解。 就拿vue举例, 我们只需要 在data 函数中注册一个 属性,那么这个属性在变化的时候,就可以触发dom渲染。 那这个过程是如何实现的呢, 我们就从底层以及实现原理上来剖析下。
一. defineProperty
- 首先,vue2.0 使用的是通过 Object.defineProperty 方法来实现响应式的。我们来看看 Object.defineProperty方法的参数
Object.defineProperty(obj, prop, descriptor)
参数
- obj: 要在其上定义属性的对象
- prop: 要定义或修改的属性的名称
- descriptor: 将被定义或修改的属性的描述
obj代表的是你要处理的对象,
prop为你要定义或者修改的属性的key
descriptor是一个对象,具体为:
configurable 类型: boolean 释义:是否可以修改默认属性
enumerable 类型: boolean 释义:是否可以被枚举
writable 类型: boolean 释义:是否可以修改修改这个属性的值\
value 类型: any 释义:初始值
get 类型: Function 释义:被修饰的属性,在被访问的时候执行
set 类型: Function 释义:被修饰的属性,在被修改的时候执行
2. 因为我们今天主要了解响应式原理,所以着重的讲下 descriptor 中的 get 和set 方法:
响应式我们理解起来很简单,就是我们在给一个值赋值的时候,我们不用做特殊的处理,就直接可以触发dom渲染,或者其他附带操作的功能。 类似于监听一个值,改变时候,我们执行一个功能。那么出现在大家脑子中的会有很多方法来实现这个逻辑。 比如我们最先想到, 用一个不停循环或者 setInterval 去监听一个数据的变化。 当然,原理能理解, 但是这个的性能太差了。并且会有很多问题。我们有没有更好的方法去解决这个需求呢? 细心的同学就会发现,defineProperty提供了一个 set 方法,是不是通过这个方法可以实现响应式呢? 答案是对的!
3. 我们来看看defineProperty是如何在访问以及赋值的时候执行get 和 set 的:
const obj = {}
Object.defineProperty(obj, 'value', {
get() {
console.log('get value')
},
set(newVal) {
console.log('set value', newVal)
}
})
obj.value
console.log(obj.value)
obj.value = 1
- 我们运行上面的代码会返回什么呢? 首先 obj.value 会执行 console.log('get value') ;然后下面又访问了一次obj.value ;又会执行一次 console.log('get value') ; 然后执行console.log(undefined); 最后执行 console.log('set value', 1)
这是我在chrom 控制台运行的结果。
你以为就这么简单,这就完了? 如果你在后面再加一行console.log(obj.value),就是再打印一次 obj.value 呢? 你认为会输出一个 'get value' 和 1 ?
我们来看结果:
咦? 为什么是undefined呢? 我们打印obj发现
obj.value = 1 确实没有赋值上? 那这是为什么呢?
- 我们再来举个例子你就明白了
const obj = {}
Object.defineProperty(obj, 'value', {
get() {
console.log('get value')
return 1
},
set(newVal) {
console.log('set value', newVal)
}
})
obj.value
console.log(obj.value) // 1
obj.value = 2
console.log(obj.value) // 1
obj.value = 3
console.log(obj.value) // 1`
细心的同学肯定发现了,其实并不是值没有赋进去,而是外面的get没有设定返回值,所以get方法一直返回的是undefined,才会出现你没有赋值进去的假象。其实值是进去了, 但是你访问的时候走了get方法,get返回了undefined。 上面代码外面给get方法添加返回值后,访问该属性会一直返回你设定的那个值。
那我们如何让get也返回正确的值呢? 其实很简单,我们只需要使用一个变量去接收修改后的值,然后在get 方法中return回去就可以了, 来看代码:
let _value
const obj = {}
Object.defineProperty(obj, 'value', {
get() {
return _value
},
set(newVal) {
_value = newVal
}
})
obj.value
console.log(obj.value) // undefined
obj.value = 2
console.log(obj.value) // 2
obj.value = 3
console.log(obj.value) // 3
obj.xxxxx = 3 // 不执行set 因为 xxxxx这个属性并没有注册
console.log(obj.xxxxx) // 不执行get 因为 xxxxx这个属性并没有注册
没问题吧,这样就完美的完成了我们的功能。 回到响应式来说,我们只需要注册一个属性,然后在该属性的 set 中 执行渲染方法,那么只要这个属性被赋值那就会重新渲染。
没错,是只要被赋值就会执行set哪怕你这次赋值和上次一样,也会执行。 所以我们要提升性能,还需要在render 进行diff计算。
- 那么我们就来写一个方法,用于注册一组响应式数据, 让它该组数据中的每一项发生变化的时候 ,都执行render 函数:
const render = () => {
console.log('渲染')
}
const defineReactive = (obj, key, val) => {
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newVal) {
if (val === newVal) { // 模仿diff
return
}
//把新值赋值给旧值
val = newVal;
//执行 渲染函数
render()
}
})
}
const reactive = (obj) => {
for (const key in obj) {
defineReactive(obj, key, obj[key]);
}
}
const data = {
a: 1,
b: 2,
c: 3
}
reactive(data)
data.a = 5 // 打印渲染
data.b = 7 // 打印渲染
data.c = 3 // 不打印,因为值没有变化
不错吧? 但是你们会发现,这个功能很单一,首先如果对象嵌套怎么办? 那我们给他来个递归
5.响应式方法的递归
const render = () => {
console.log('渲染')
}
const defineReactive = (obj, key, val) => {
reactive(val) // 我们在这里进行递归
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newVal) {
if (val === newVal) { // 模仿diff
return
}
//把新值赋值给旧值
val = newVal;
//执行 渲染函数
render()
}
})
}
const reactive = (obj) => {
if (typeof obj === 'object') { // 这里需要添加一个递归结束的条件
for (const key in obj) {
defineReactive(obj, key, obj[key]);
}
}
}
const data = {
a: 1,
b: 2,
c: {
c1: {
af: 999
},
c2: 4
}
}
reactive(data)
data.a = 5 // 渲染
data.b = 7 // 渲染
data.c.c2 = 4 // 不渲染
data.c.c1.af = 121 // 渲染
对比vue 你会发现,vue除了这些,针对数组的变化,也会是响应式的。 其实原理很简单,因为数组的变化大部分你要使用 数组的方法,vue将数组的原型拿出来,在常用的方法里面,注入render逻辑,再重新赋值给 Array 的原型。 这样,你们在使用数组方法的时候,就会触发render,为了让大家更好理解,我这里做个简单的示例
- 数组的响应式
const render = () => {
console.log('渲染')
}
const arrPrototype = Array.prototype // 保存数组的原型
cosnt newArrProtoType = Object.create(arrPrototype) // 创建一个新的数组原型
['push', 'prop', 'shift', 'unshift', 'sort', 'splice', 'reverse'].forEach(methodName => {
newPropType[methodName] = function () { // 重新修改原型中指定的这几个修改数组的方法,功能不变,只是在其修改后执行render函数
oldPropType[method].call(this, ...arguments);
//注入渲染
render();
}
})
const reactive = (obj) => {
if (Array.isArray(obj)) { // 如果是数组
obj.__proto__ = newArrProtoType // 则把新定义的原型对象赋值给 这个数组的 proto, 这样数组在执行那些方法时候就会处理渲染
}
}
const data = [1, 2, 3, 4]
reactive(data)
data.push(5) // 渲染
data.splice(0, 2) // 渲染
数组和对象的响应式可以将上面两个 结合起来用。
-
我们在使用vue2的时候,有时候在对象里面添加属性,以及删除属性,是无法触发渲染的。 那么看到上面的原理后,我相信大家已经非常明白为什么不会触发渲染了。 因为这两个操作,根本无法让set执行。所以vue提供了 delete 方法。 用于手动触发渲染。
-
react和vue
说到手动触发渲染,就不得不提react。 其实大家看到这里也就明白了,react中是没有响应式的。 所有的渲染,都是需要你执行setState后,他才执行渲染的。 其实比喻起来就可以将vue 比作汽车的自动挡,react 比作手动挡。 vue在需要更新模型的时候只需要更改数据,就类似你加油门就行了,自动变速箱会给你自动完成离合和换挡动作。 而react 就需要你自己的操作来触发渲染,就是说需要你自己踩离合,挂挡了。 那么对比起来,优劣势也很像手动挡和自动挡。 手动挡玩的好的,非常省油,并能最大化的发挥汽车的性能。 比如你需要加速性能的时候,可以自己去操作在高转速换挡,而自动挡,需要使用预设定好的模式,比如运动,经济等。他们的换挡时机,是已经定义好的。对比起来更适合新手。 那么react 也是如此。玩的好的,会合理利用渲染时机,以及合理分配数据资源。保证性能。而vue不用考虑那么多。但是新手定义过多无用的的响应式数据,会增加内存消耗,降低性能。
二. proxy
- proxy的使用
const obj = new Proxy(target, handler)
参数
- target: 要监听的对象 类型: 对象,数组,函数,代理对象(Proxy代理的对象)
- handler: 回调的方法集合 类型:对象 , 回调方法的合集
- handler.getPrototypeOf()
- handler.setPrototypeOf()
- handler.isExtensible()
- handler.preventExtensions()
- handler.getOwnPropertyDescriptor()
- handler.defineProperty()
- handler.has()
- handler.get(target, property)
- handler.set(target, property, value)
- handler.deleteProperty()
- handler.ownKeys()
- handler.apply()
- handler.construct()
2. 我们很自然的发现了,其中也有get 和set方法。和defineProperty比起来, proxy 接收的target为任何类型的对象,包括原生数组,函数,甚至另一个代理对象, 有了这个,我们会清晰的发现,实现响应式不再那么麻烦了。那么我们先来看看,Proxy如何使用set和get监听数据变化。至于其他方法不是本次讲解重点,有兴趣的同学可以自己去研究
const obj = {
a: 1,
b: { a1: 32,
b1: { a2: 31 }
}
}
const handler = {
get(obj, prop) {
console.log('get', obj[prop])
return obj[prop] // 返回obj[prop]
},
set(obj, prop, value) {
console.log('set', prop)
obj[prop] = value
return true // set需要返回true 代表赋值完成,否则会报错
}
}
const p = new Proxy(obj, handler);
console.log(p.a) // 打印get p.a的值
console.log(p.b.a1) // 打印p.b.a1的值
console.log(p.b.b1.a2) // 打印p.b.a1的值
从上面我们可以发现,嵌套再深,我们都可以通过监听到属性的访问,那么set 也是这样吗 3. set方法,我们继续吧上面代码进行简单改造
Proxy(obj, handler);
const obj = {
a: 1,
b: { a1: 32,
b1: { a2: 31 }
}
}
const handler = {
get(obj, prop) {
console.log('get', obj[prop])
return obj[prop] // 返回obj[prop]
},
set(obj, prop, value) {
console.log('set', prop)
obj[prop] = value
return true // set需要返回true 代表赋值完成,否则会报错
}
}
const p = new Proxy(obj, handler);
p.a = 2 // 打印set
p.a.a1 = 12 // 不触发
- 从上面我们可以发现,set并不像get自带递归,所以我们想要实现响应式,就需要对嵌套的对象(或者数组,再次进行响应式处理) 我们可以这么实现:
const obj = {
a: 1,
b: { a1: 32,
b1: { a2: 31 }
}
}
const handler = {
get(obj, prop) {
const val = obj[prop]
if(val !== null && typeof val=== 'object'){
return new Proxy(val, handler);//代理内层
}else{
return val; // 返回obj[prop]
}
},
set(obj, prop, value) {
console.log('set', prop)
obj[prop] = value
return true // set需要返回true 代表赋值完成,否则会报错
}
}
const p = new Proxy(obj, handler);
p.a = 5 // 打印set
p.b.a1 = 10 // 打印set
p.b.b1.a3 = 2 // 打印set (添加新属性)
delete p.b.b1.a3 // 不执行 proxy的set 不支持删除
我们发现除了删除,set 中都能监听到,但是我们如果需要用到监听删除怎么办? 这就需要请出和 set同级的 deleteProperty方法 5. 我们来看看示例
const obj = {
a: 1,
b: { a1: 32,
b1: { a2: 31 }
}
}
const handler = {
get(obj, prop) {
const val = obj[prop]
if(val !== null && typeof val=== 'object'){
return new Proxy(val, handler);//代理内层
}else{
return val; // 返回obj[prop]
}
},
set(obj, prop, value) {
console.log('set', prop)
obj[prop] = value
return true // set需要返回true 代表赋值完成,否则会报错
},
deleteProperty(obj: any, prop: any,) { // 我们加个删除的回调
console.log('del', prop);
delete obj[prop];
return true; // 和set 一样需要返回true 表示删除完成
}
}
const p = new Proxy(obj, handler);
p.b = 3 // 打印set
delete p.a // 打印del
- 我们再试试针对数组的操作,看看Proxy有没有作用
const obj = [1, 2, { a: 1 }]
const handler = {
get(obj, prop) {
const val = obj[prop]
if(val !== null && typeof val=== 'object'){
return new Proxy(val, handler);//代理内层
}else{
return val; // 返回obj[prop]
}
},
set(obj, prop, value) {
console.log('set', prop)
obj[prop] = value
return true // set需要返回true 代表赋值完成,否则会报错
}
}
const p = new Proxy(obj, handler);
p[0] = 5 // 打印set
p.push(4) // 打印set
p[2].a = 10 // 打印set (修改数组中的对象的值)
p[2].b = 12 // 打印set (给数组中的对象添加属性)
-
我们会发现 Proxy比defineProperty 更加的简单和强大。 对于替换上面的 reactive 方法,我们使用 Proxy 写个响应式方法这里就不做了。 自己有兴趣的可以尝试下。
-
对比两者总结一下区别;
- Proxy 是对整个对象的代理,而 Object.defineProperty 只能代理某个属性。所以我们在编写响应式函数的时候,defineProperty 需要用for in 去给每个属性添加监听
- 对象上新增属性,Proxy 可以监听到,Object.defineProperty 不能。
- 数组新增修改,Proxy 可以监听到,Object.defineProperty 不能。
- 若对象内部属性要全部递归代理,Proxy 可以只在调用的时候递归,而 Object.definePropery 需要一次完成所有递归,性能比 Proxy 差。 这个我们可以对比两个递归,definePropery 是在一开始,将传入的对象,所有属性,包括内不熟悉全部进行递归。之后才取处理set get。 但是Proxy的递归是在set中,这样,我们就可以根据需求,来调整递归原则,也就是说,在一些条件下,让其不进行递归。 举个很简单的例子。 我们页面上需要渲染一个对象,这个对象总是 会被整体重新赋值。不会单独的去修改其中的属性。那么我们就可以通过Proxy控制不让其递归这个对象,从而提高性能
- Proxy 不兼容 IE,Object.defineProperty 不兼容 IE8 及以下
- Proxy 使用上比 Object.defineProperty 方便多。