目录
Object.defineProperty 详解
在解释 Vue2 实现响应式的时候我们需要了解一下
Object.defineProperty
,Object.defineProperty 也叫作属性描述符
,如果对这个属性已经知道的可直接观看后面的部分,MDN 文档解释:Object.defineProperty
Object.defineProperty 基本使用
-
Object.defineProperty(obj, prop, descriptor) 接收三个参数:
obj
:要定义属性的对象prop
:要定义或修改的属性的名称descriptor
:要定义或修改的属性描述符
-
可能前面两个都好理解,这个 descriptor 的属性描述符又是什么呢?比如可以描述一个属性 a,它的值是 10,还有什么可以描写呢?它是可以被枚举的,即可遍历的,遍历大家应该都懂吧,还有这是一个可重写的属性,意思为可以被重新赋值,还有是不是可以重新配置的,这些都是对一个属性的描述
-
了解一些基本概念后,我们就先尝试一下,首先是
描述值
,如下:const obj = {} // 属性名称是字符串,描述符是一个配置对象 Object.defineProperty(obj, 'a', { value: 10 }) console.log(obj.a)
-
我们再来看一下什么是
可枚举
,如下:// 我们给 obj 加一个属性 b const obj = { b: 20 } Object.defineProperty(obj, 'a', { value: 10 }) console.log(obj.a) // 我们来遍历看一下 for (const key in obj) { console.log('我是obj对象遍历的属性值', obj[key]) }
-
我们可以看一下输出的结果,如图:
-
大家是不是发现,能够被遍历出来的属性值只有 b 啊,因为正常情况下在创建对象的时候,比如:
const obj = { b: 20 }
,它默认就是可遍历可重写的,但是如果我们这个属性如果是通过我们使用 Object.defineProperty 来定义的时候,如果没有设置,它的遍历和重写默认为 false 也就是禁止的,那现在我们将 a 属性设置为可遍历
的,如何设置呢,如下:const obj = { b: 20 } Object.defineProperty(obj, 'a', { value: 10, enumerable: true // 可遍历 }) console.log(obj.a) for (const key in obj) { console.log(`我是obj对象遍历的属性${key}`, obj[key]) }
-
输出的结果如图:
-
这次是不是就把属性 a 给遍历出来了,那么根据这个依据我们可以尝试一下同时修改 a 和 b 的值会发生什么,代码如下:
const obj = { b: 20 } Object.defineProperty(obj, 'a', { value: 10, enumerable: true // 可遍历 }) // 修改 b obj.b = 200 console.log('b的值为:', obj.b) // 修改 a obj.a = 100 console.log('a的值为:', obj.a)
-
输出结果如图:
-
是不是再次验证了我们前面所提到的依据,那现在就好办了,我们只需要再次将
属性 a 设置为可重写
即可,如下:const obj = { b: 20 } Object.defineProperty(obj, 'a', { value: 10, enumerable: true, // 可遍历 writable: true // 可重写 }) // 修改 b obj.b = 200 console.log('b的值为:', obj.b) // 修改 a obj.a = 100 console.log('a的值为:', obj.a)
-
输出结果如图:
-
那什么是
可配置的
呢?意思就是如果我设置了 可配置的 属性为 true,则表示你可以在后面再次使用 Object.defineProperty 来重新配置我这个属性的属性描述符
,我们先来看看如果现在直接再次定义属性描述符来定义属性 a 会发生什么,代码如下:const obj = { b: 20 } Object.defineProperty(obj, 'a', { value: 10, enumerable: true, // 可遍历 writable: true // 可重写 }) // 重新配置属性 a 的属性描述符 Object.defineProperty(obj, 'a', { value: 101, enumerable: false, // 可遍历 writable: false // 可重写 })
-
输出结果如图:
-
看到什么,直接产生了报错,也就表示如果我们没有设置
可配置
描述符为 true 的话,是不允许定义属性描述符的,那么应该如何定义呢?如下:const obj = { b: 20 } Object.defineProperty(obj, 'a', { value: 10, enumerable: true, // 可遍历 writable: true, // 可重写 configurable: true // 可配置 }) // 重新配置属性 a 的属性描述符 Object.defineProperty(obj, 'a', { value: 101, // 重新设置 a 的值 enumerable: false, // 不可遍历 writable: false // 不可重写 }) // 我们可以验证一下: // - 输出 a 的值,验证 value 是否生效 console.log('a的值为:', obj.a) // - 遍历 obj,验证 enumerable 是否生效 for (const key in obj) { console.log(`遍历的属性${key}的值是:`, obj[key]) } // - 重新对 a 属性赋值,验证 writable 是否生效 obj.a = 10086 console.log('重新赋值a属性:', obj.a)
-
输出结果如图:
-
通过这些演练,相信你对 Object.defineproperty 已经有了一个初步的了解,但是这不是利用它实现 Vue2 实现响应式原理的方式,那你可能就会想上面提到的这些又什么作用呢?上述这些可以帮你实现一个公共库或者依赖的时候,达到一些需要的效果
-
比如,当你写的公共库可能某些对象的属性并不允许被外界的使用者所操纵,也有可能在开发的时候,需要保证你的原始数据不被破坏等等
get 和 set
- 大家是不是好奇这 get 和 set 是什么,它们是
设置访问器
,get 是读取器
,set 是设置器
- 当我们在 Object.defineProperty 中配置了 get 与 set 之后,只要一个属性被访问就会触发 get,如果被重写就会触发 set
- 而它们
正是数据实现响应式的关键所在
get 访问器
-
那我们来看一下 get 读取器,如下:
const obj = { a: 10 } Object.defineProperty(obj, 'a', { get() { console.log('get 访问器被触发了') } }) console.log('a的值是:', obj.a)
-
输出结果如图:
-
可以看到我们在访问属性 a 的时候,是会触发 get 的,那么为什么 a 的值是 undefined 了呢?命名我在 obj 对象中定义了 a 的值是 10 啊,这是因为在设置了 get 访问器之后,这个属性的返回值就
由 get 方法的返回值所决定了
,那是否真的如此呢?我们来测试一下,代码如下:const obj = { a: 10 } Object.defineProperty(obj, 'a', { get() { console.log('get 访问器被触发了') return 'Hello World' } }) console.log('a的值是:', obj.a)
-
输出结果如图:
-
上图的结果验证了我们的说法是正确的,那现在我们如何才能返回原有的 a 属性的值呢?我相信大家第一想法是不是 obj.a 呢?暂且不说对不对,我们测试一下即可,如下:
const obj = { a: 10 } Object.defineProperty(obj, 'a', { get() { console.log('get 访问器被触发了') return obj.a } }) console.log('a的值是:', obj.a)
-
输出结果如图:
-
这个是个什么错误啊?翻译一下就是
超过最大调用堆栈大小
,表示栈溢出,为什么会导致这个现象呢?因为在打印 obj.a 的时候一直返回的是 obj.a,就会导致每次都是访问,也就会一直触发这个 get 访问器,最后栈溢出 -
那问题好像又回到了起点,怎么返回属性 a 本身的值呢,我们可以借助与一个中间变量来实现,具体实现如下:
const obj = { a: 10 } // 我们将 obj.a 的值赋值给一个中间变量,get 返回这个变量即可 let temp = obj.a Object.defineProperty(obj, 'a', { get() { console.log('get 访问器被触发了') return temp } }) console.log('a的值是:', obj.a)
-
输出结果如图:
set 设置器
-
相信大家看完 get 访问器后,对 set 也一定有了大概的理解,顾名思义就是当一个
属性重新被赋值时
就会触发 set 方法 -
并且 set 设置器会接收一个参数
newValue
,它的值就是这个属性被重新赋值的值,如下:const obj = { a: 10 } let temp = obj.a Object.defineProperty(obj, 'a', { get() { console.log('get 访问器被触发了') return temp }, set(newValue) { console.log('set 设置器被触发了') console.log('重新赋值给属性 a 的值是:', newValue) } }) // 对 obj.a 重新赋值 obj.a = 'aaa'
-
输出结果如图:
-
既然我们可以获取到这个新改动的值,那么如何将新值赋值给属性 a 呢?还记得我们之前定义的中间变量吗?我们将新值赋值给这个中间变量,那么当再次获取属性 a 时,get 访问器就会返回这个被重新设置过的中间变量,是否真的如我们所设想的这样呢,代码如下:
const obj = { a: 10 } let temp = obj.a Object.defineProperty(obj, 'a', { get() { console.log('get 访问器被触发了') return temp }, set(newValue) { console.log('set 设置器被触发了') temp = newValue } }) console.log('重新赋值前属性a的值为:', obj.a) console.log('~~~~~~~~~~~~~~~~~~~~~~~~~') // 对 obj.a 重新赋值 obj.a = 'aaa' console.log('重新赋值后属性a的值为:', obj.a)
-
输出结果如图:
-
到这里我们相信大家对 Object.defineProperty 已经有足够的认识了,既然如此,就让我们进入正题,如何实现 Vue2 的响应式
Vue2的响应式实现
响应式初体验
-
我认为学习一个知识,必然要先认识一个知识,你才能够真正的去理解它,因此我们先看一下,这个响应式帮我们实现了什么事情,在这里我已经提前写好了一个案例,给大家展示一下初始效果:
-
从上图可以看出,面板的数据来源于 obj 这个对象,那么我们现在在控制台改变一下这个对象的属性值看看会发生,如图:
-
我们是不是发现展示面板的数据随着我们数据的改变而改变,这就是响应式的一个体现了
-
这里展示一下 HTML 页面的代码,方便大家后续测试,如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } .container { padding-top: 100px; padding-left: 100px; } .box { width: 300px; padding: 20px; background-color: rgb(225, 101, 56); border-radius: 4px; } .box p { color: #fff; } .box h2:nth-child(1) { color: #3915ef; margin-bottom: 10px; } .box h2:nth-child(2) { color: #fff; } </style> </head> <body> <div class="container"> <div class="box"> <h2>王者荣耀英雄名称介绍</h2> <h2 class="name"></h2> <p class="desc"></p> </div> </div> <script src="./defineProperty.js"></script> </body> </html>
初步利用 Object.defineProperty 实现数据响应式
-
如何利用 Object.defineProperty 来让数据实现响应式呢?我们先不提,如果我们需要改变页面上展示的文本信息,我们需要做什么,是不是要先获取 dom 元素,然后赋值给 dom 的元素啊,由此,我们可与得出如下两个函数,如下:
// 修改名称函数 function updateName() { const dom = document.querySelector('.name') dom.innerText = obj.name } // 修改介绍函数 function updateDesc() { const dom = document.querySelector('.desc') dom.innerText = obj.desc }
-
有了这两个函数之后我们是不是就可以让页面展示数据的初始值呢?代码如下:
const obj = { name: '韩信', desc: '描述:韩信机动性超强, 伤害十足, 团战中韩信要避免被先手控制集火秒掉, 需要谨慎走位, 找一个合适的时机入场, 突入敌方后排, 将后排击杀, 如果技能用完, 不要深追, 先战术撤退, 韩信的CD很短, 需要等待下一波技能CD继续突进将其击杀' } // 修改名称函数 function updateName() { const dom = document.querySelector('.name') dom.innerText = obj.name } // 修改介绍函数 function updateDesc() { const dom = document.querySelector('.desc') dom.innerText = obj.desc } updateName() updateDesc()
-
通过我们第一次手动调用就实现了案例的初始效果,这时候我们不难发现,只要
改变一下 obj 属性的值后再次调用这两个函数就可以让页面的文本信息重新刷新啊
,那我们应该怎么实现呢? -
这时候就可以利用我们的 Object.defineProperty 中的
set 设置器
,我们知道,当一个属性的值被重新渲染的时候,是不是就会触发 set,那么在此时我们是不是只需要在此时调用者两个函数就可以实现了,如下:// Object.keys() 方式是获取对象的 key 值,并返回一个数组 // - Tips:当然如果这属性被设置了不可枚举,那么就不会被此方法检测到 Object.keys(obj).forEach(key => { let temp = obj[key] Object.defineProperty(obj, key, { get() { return temp }, set(newValue) { temp = newValue updateName() updateDesc() } }) })
-
效果大家可以自行测试,我这里就不在演示了,效果与开始展示案例一致
优化之监听需要实现响应式的函数
-
上面我们初步实现了响应式的效果,但是我们现在又遇到一个情况,如果,我不仅仅只有这一个对象需要实现这个效果呢,可能我还有 obj2、obj3… 等等,我们可以给每一个对象都单独设置内部属性的属性描述符来实现,不过这样的写法可能我不用演示,大家想想都觉得有一点恶心自己是不是
-
那我们有没有什么好的办法可以帮助我们来完成这件事情呢?当然,我们可以封装一个函数,只需要传入一个对象,我们就可以在函数内部自动将变成响应式的函数,那是不是很简单啊,只需要将我们开始写的那一段遍历对象属性设置属性描述符的代码放入一个函数就可以了啊,下面我们来看一下代码,如下:
// 监听对象的属性并自动设置为响应式 function listener(obj) { Object.keys(obj).forEach(key => { let temp = obj[key] Object.defineProperty(obj, key, { get() { return temp }, set(newValue) { temp = newValue } }) }) } listener(obj)
-
是不是感觉很简单,这样我们就可以让需要响应式的对象,传入这个函数就可以了,写道这里我们又遇到了一个新的问题,
我们怎么才能知道该执行那些函数呢
?不可能所有的函数都是我们定义好的两个函数啊,我们应该怎么收集呢? -
我们在前面提到过的 get 访问器,不知道大家还有没有印象,是不是当一个属性被使用获取时,就会触发 get 啊,利用这一点我们就可以确定应该
在 get 中来收集那些函数是应该被执行的函数
,并且将这些函数存入一个数组,这样在 set 触发时,遍历这个数组,依次执行存储的函数,是不是就可以实现我们的需求了 -
不过我们好像左看右看都没有发现在 get 中那里可以获取这个需要被执行的函数啊,所以我们需要定义
一个全局变量
和一个监听函数
,全局变量存储当前在执行的函数,监听函数会帮助我们将函数挂载在全局变量并销毁,可能在看到这一段话的时候你会有一点点的困惑,这个没关系,看完后续代码之后就会明白了,如下:// 定义一个数组存储需要执行的函数 const funList = [] // 定义一个全局变量存储函数 let _fn = null // 创建监听函数 function watchFn(fn) { // 将传入的函数赋值给全局变量 _fn = fn // 自动执行一次函数 fn() _fn = null }
-
我们先来看懂上面这段代码,这块应该只有
watchFn
这个函数大家可能会有疑惑吧,首先接收一个函数,将这个函数赋值给_fn
,然后在执行一次函数,在赋值为 null,为什么要执行一次函数,先来看看我们开始写的两个操作 dom 的函数内容是啥,大家看到这可能有点忘记了,如下:// 修改名称函数 function updateName() { const dom = document.querySelector('.name') dom.innerText = obj.name } // 修改介绍函数 function updateDesc() { const dom = document.querySelector('.desc') dom.innerText = obj.desc }
-
在这两个函数内部是不是都使用对象的属性,那么使用属性时是不是会触发一个 get 访问器,那么触发 get 访问器的时候是不是将 _fn 存入用来存储执行函数的数组即可,这不就实现了我们的需求吗,在 get 访问器触发时手机需要执行的函数,收集完毕后在将 _fn 清空
-
看完上面的代码,我们就可以来实现后续的代码的了,后续的代码是不是 get 负责收集依赖,set 执行函数啊,如下:
function listener(obj) { Object.keys(obj).forEach(key => { let temp = obj[key] Object.defineProperty(obj, key, { get() { if (!_fn) { console.log('本次函数为空, 不允许存储') } else { // 收集 funList.push(_fn) } return temp }, set(newValue) { temp = newValue // 遍历执行 funList.forEach(item => { item() }) } }) }) }
-
大家是不是好奇为什么还需要一个非空判断啊,这是为了防止如果是一个空值,也存入数组,
为什么会出现空值呢
,我们初次利用 watchFn 来触发 get 访问器收集依时,可以保证每一次都非空值,但是如果这个属性后续在被修改的时候,是不是会触发 set 设置器啊,此时在 set 里面遍历执行函数的之后,那些函数内部又一个获取了 obj 对象的属性,就会再次触发 get,但是此时的_fn
变量并没有被赋值为一个函数,如果存入进去,在下次改变属性的时候遍历数组时,一个 null 当做函数调用,肯定会报错 -
解析完毕后,我们看一下执行结果,是不是真的可以实现呢,如图:
-
可以看到效果依然是实现了,但是现在还是有一点不完善,比如当一个函数内部两次或多次使用同一个属性,比如 obj.name,那么就会造成在数组中多次存储了这个函数,我们先改一下函数,在内部两次使用 obj.name,如下:
function updateName() { obj.name const dom = document.querySelector('.name') dom.innerText = obj.name }
-
我们来看一下存储函数的数组,是否真的存储了两次,如图:
-
这就会导致我们在 set 遍历执行的时候,会执行重复的函数,但是本身是不是只需要执行一次啊
解决函数重复执行的问题
-
解决这个函数重复非常简单,我们使用 Set 数据结构即可, Set 是不会存储重复元素的,我们修改一下,如下:
const funList = new Set()
-
当然因为我们改成了 Set 结构,所以 push 方法就需要该成 add,如下:
// 仅展示修改的部分 if (!_fn) { console.log('本次函数为空, 不允许存储') } else { funList.add(_fn) // 改为 add 方法即可 }
-
我们现在看一下存储函数的数组是否没有了重复的数据,如图:
-
如果大家不理解 Set 这种方法,我这里就使用一些其他方法来实现,这种方法就会麻烦一点,首先我们将存储函数的数组改为普通的数组,如下:
const funList = []
-
我们在添加前在做一次判断,如果当前函数存在的话就不添加,如下:
if (!_fn) { console.log('本次函数为空, 不允许存储') } else { // 如果存在就不加入 if (funList.includes(_fn)) return funList.push(_fn) }
-
我们可以来看一下结果,如图:
-
第二种方式我相信大家应该都可以看懂
关于响应式后续优化
- 写到这里其实大家都应该明白响应式具体是怎么回事了,但是我们的代码依然具备优化的空间
- 比如:我么改动 name 属性时,就应该只执行 name 属性相关的函数,而不是所有的都执行
- 比如:每一个对象都应该单独具备一个数组等等,
- 关于这些实现方式,在我的另一篇文章中都有介绍,并详细的解析和实现了,大家如果有兴趣可以去参考一下,我这里就不在赘述了
文档地址:https://blog.csdn.net/qq_53109172/article/details/129434407