从defineProperty 和 proxy 了解vue2和vue3的响应式原理

2,537 阅读11分钟

说到响应式,大家肯定都有了解。 就拿vue举例, 我们只需要 在data 函数中注册一个 属性,那么这个属性在变化的时候,就可以触发dom渲染。 那这个过程是如何实现的呢, 我们就从底层以及实现原理上来剖析下。

一. defineProperty 

  1. 首先,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
  1. 我们运行上面的代码会返回什么呢?  首先 obj.value 会执行 console.log('get value') ;然后下面又访问了一次obj.value ;又会执行一次 console.log('get value') ; 然后执行console.log(undefined); 最后执行 console.log('set value', 1)

image2021-7-29_14-37-9.png 这是我在chrom 控制台运行的结果。

你以为就这么简单,这就完了?  如果你在后面再加一行console.log(obj.value),就是再打印一次 obj.value 呢? 你认为会输出一个 'get value'  和 1 ? 

我们来看结果:

image2021-7-29_14-41-9.png

咦? 为什么是undefined呢? 我们打印obj发现 image2021-7-29_14-41-56.png obj.value = 1 确实没有赋值上? 那这是为什么呢?

  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计算。

  1. 那么我们就来写一个方法,用于注册一组响应式数据, 让它该组数据中的每一项发生变化的时候 ,都执行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,为了让大家更好理解,我这里做个简单的示例

  1. 数组的响应式
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) // 渲染

数组和对象的响应式可以将上面两个 结合起来用。

  1. 我们在使用vue2的时候,有时候在对象里面添加属性,以及删除属性,是无法触发渲染的。 那么看到上面的原理后,我相信大家已经非常明白为什么不会触发渲染了。 因为这两个操作,根本无法让set执行。所以vue提供了 setset 和 delete 方法。 用于手动触发渲染。 

  2. react和vue

    说到手动触发渲染,就不得不提react。 其实大家看到这里也就明白了,react中是没有响应式的。 所有的渲染,都是需要你执行setState后,他才执行渲染的。 其实比喻起来就可以将vue 比作汽车的自动挡,react 比作手动挡。  vue在需要更新模型的时候只需要更改数据,就类似你加油门就行了,自动变速箱会给你自动完成离合和换挡动作。 而react 就需要你自己的操作来触发渲染,就是说需要你自己踩离合,挂挡了。  那么对比起来,优劣势也很像手动挡和自动挡。  手动挡玩的好的,非常省油,并能最大化的发挥汽车的性能。 比如你需要加速性能的时候,可以自己去操作在高转速换挡,而自动挡,需要使用预设定好的模式,比如运动,经济等。他们的换挡时机,是已经定义好的。对比起来更适合新手。   那么react 也是如此。玩的好的,会合理利用渲染时机,以及合理分配数据资源。保证性能。而vue不用考虑那么多。但是新手定义过多无用的的响应式数据,会增加内存消耗,降低性能。

二. proxy

  1. 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 // 不触发
  1. 从上面我们可以发现,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
  1.  我们再试试针对数组的操作,看看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 (给数组中的对象添加属性)
  1. 我们会发现 Proxy比defineProperty 更加的简单和强大。 对于替换上面的 reactive 方法,我们使用 Proxy 写个响应式方法这里就不做了。 自己有兴趣的可以尝试下。

  2. 对比两者总结一下区别;

    • 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 方便多。