由浅入深,带你用JavaScript实现响应式原理(Vue2、Vue3响应式原理)
由浅入深,带你用JavaScript实现响应式原理
前言
为什么前端框架Vue能够做到响应式?当依赖数据发生变化时,会对页面进行自动更新,其原理还是在于对响应式数据的获取和设置进行了监听,一旦监听到数据发生变化,依赖该数据的函数就会重新执行,达到更新的效果。那么我们如果想监听对象中的属性被设置和获取的过程,可以怎么做呢?
1.Object.defineProperty
在ES6之前,如果想监听对象属性的获取和设置,可以借助Object.defineProperty方法的存取属性描述符来实现,具体怎么用呢?我们来看一下。
在Vue2.x中响应式原理实现的核心就是使用的Object.defineProperty
,而在Vue3.x中响应式原理的核心被换成了Proxy,为什么要这样做呢?主要是Object.defineProperty
用来监听对象属性变化,有以下缺点:
- 首先,
Object.defineProperty
设计的初衷就不是为了去监听对象属性的,因为它的主要使用功能就是用来定义对象属性的; - 其次,
Object.defineProperty
在监听对象属性功能上有所缺陷,如果想监听对象新增属性、删除属性等等,它是无能为力的;
2.Proxy
在ES6中,新增了一个Proxy类,翻译为代理,它可用于帮助我们创建一个代理对象,之后我们可以在这个代理对象上进行许多的操作。
2.1.Proxy的基本使用
如果希望监听一个对象的相关操作,当Object.defineProperty不能满足我们的需求时,那么可以使用Proxy创建一个代理对象,在代理对象上,我们可以监听对原对象进行了哪些操作。下面将上面的例子用Proxy来实现,看看效果。
基本语法:const p = new Proxy(target, handler)
- target:需要代理的目标对象;
- handler:定义的各种操作代理对象的行为(也称为捕获器);
2.2.Proxy的set和get捕获器
在上面的例子中,其实已经使用到了set和get捕获器,而set和get捕获器是最为常用的捕获器,下面具体来看看这两个捕获器吧。
(1)set捕获器
set函数可接收四个参数:
- target:目标对象(被代理对象);
- property:将被设置的属性key;
- value:设置的新属性值;
- receiver:调用的代理对象;
(2)get捕获器
get函数可接收三个参数:
- target:目标对象;
- property:被获取的属性key;
- receiver:调用的代理对象;
2.3.Proxy的apply和construct捕获器
上面所讲的都是对对象属性的操作进行监听,其实Proxy提供了更为强大的功能,可以帮助我们监听函数的调用方式。
- apply:监听函数是否使用apply方式调用。
- construct:监听函数是否使用new操作符调用。
2.4.Proxy所有的捕获器
除了上面提到的4种捕获器,Proxy还给我们提供了其它9种捕获器,一共是13个捕获器,下面对这13个捕获器进行简单总结,下面表格的捕获器分别对应对象上的一些操作方法。
捕获器handler | 捕获对象 |
---|---|
get() | 属性读取操作 |
set() | 属性设置操作 |
has() | in操作符 |
deleteProperty() | delete操作符 |
apply() | 函数调用操作 |
construct() | new操作符 |
getPrototypeOf() | Object.getPrototypeOf() |
setPrototypeOf() | Object.setPrototypeOf() |
isExtensible() | Object.isExtensible() |
preventExtensions() | Object.perventExtensions() |
getOwnPropertyDescriptor() | Object.getOwnPropertyDescriptor() |
defineProperty() | Object.defineProperty() |
ownKeys() | Object.getOwnPropertySymbols() |
Proxy捕获器具体用法可查阅MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
3.Reflect
在ES6中,还新增了一个API为Reflect,翻译为反射,为一个内置对象,一般用于搭配Proxy进行使用。
3.1.Reflect有什么作用呢?
可能会有人疑惑,为什么在这里提到Reflect,它具体有什么作用呢?怎么搭配Proxy进行使用呢?
- Reflect上提供了很多操作JavaScript对象的方法,类似于Object上操作对象的方法;
- 比如:
Reflect.getPrototypeOf()
类似于Object.getPrototypeOf()
,Reflect.defineProperty()
类似于Object.defineProperty()
; - 既然Object已经提供了这些方法,为什么还提出Reflect这个API呢?
- 这里涉及到早期ECMA规范问题,Object本是作为一个构造函数用于创建对象,然而却将这么多方法放到Object上,本就是不合适的;
- 所以,ES6为了让Object职责单一化,新增了Reflect,将Object上这些操作对象的方法添加到Reflect上,且Reflect不能作为构造函数进行new调用;
3.2.Reflect的基本使用
在上述Proxy中,操作对象的方法都可以换成对应的Reflect上的方法,基本使用如下:
3.3.Reflect上常见的方法
对比Object,我们来看一下Reflect上常见的操作对象的方法(静态方法):
Reflect方法 | 类似于 |
---|---|
get(target, propertyKey [, receiver]) | 获取对象某个属性值,target[name] |
set(target, propertyKey, value [, receiver]) | 将值分配给属性的函数,返回一个boolean |
has(target, propertyKey) | 判断一个对象是否存在某个属性,和in运算符功能相同 |
deleteProperty(target, propertyKey) | delete操作符,相当于执行delete target[name] |
apply(target, thisArgument, argumentsList) | 对一个函数进行调用操作,可以传入一个数组作为调用参数,Function.prototype.apply() |
construct(target, argumentsList [, newTarget]) | 对构造函数进行new操作,new target(...args) |
getPrototypeOf(target) | Object.getPrototype() |
setPrototypeOf(target, prototype) | 设置对象原型的函数,返回一个boolean |
isExtensible(target) | Object.isExtensible() |
preventExtensions(target) | Object.preventExtensions(),返回一个boolean |
getOwnPropertyDescriptor(target, propertyKey) | Object.getOwnPropertyDescriptor(),如果对象中存在该属性,则返回对应属性描述符,否则返回undefined |
defineProperty(target, propertyKey, attributes) | Object.defineProperty(),设置成功返回true |
ownKeys(target) | 返回一个包含所有自身属性(不包含继承属性)的数组,类似于Object.keys(),但是不会受enumerable影响 |
具体Reflect和Object对象之间的关系和使用方法,可以参考MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect
3.4.Reflect的construct方法
construct方法有什么作用呢?具体的应用场景是什么?这里提一个需求,就明白construct方法的作用了。
需求:创建Person和Student两个构造函数,最终的实例对象执行的是Person中的代码,带上实例对象的类型是Student。
construct可接收的参数:
- target:被运行的目标构造函数(Person);
- argumentsList:类数组对象,参数列表;
- newTarget:作为新创建对象原型对象的
constructor
属性(Student);
打印结果:实例对象的类型为Student,并且实例对象原型指向Student构造函数的原型。
Reflect的construct方法就可以用于类继承的实现,可在babel工具中查看ES6转ES5后的代码,就是使用的Reflect的construct方法:
4.receiver的作用
在介绍Proxy的set和get捕获器的时候,其中有个参数叫receiver,具体什么是调用的代理对象呢?它的作用是什么?
如果原对象(需要被代理的对象)它有自己的getter和setter服务器属性时,那么就可以通过receiver来改变里面的this。
在没有使用receiver的情况下的打印结果为:name和age属性都被访问一次和设置一次。
但是由于原对象obj中对age进行了拦截操作,我们看一下age具体的访问步骤:
- 首先,打印
objProxy.age
会被代理对象objProxy中的get捕获器所捕获; - 紧接着
Reflect.get(target, key)
对obj中的age进行了访问,又会被obj中的get访问器所拦截,返回this._age
; - 很显然在执行
this._age
的时候_age
在这里是被访问了的,而这里的this指向的原对象obj; - 一般地,通过
this._age
的时候,应该也是要被代理对象的get捕获器所捕获的,那么就需要将这里的this修改成objProxy,相当于objProxy._age
,在代理对象objProxy中就可以被get捕获到了; - receiver的作用就在这里,把原对象中this改成其代理对象,同理age被设置也是一样的,访问和设置信息都需要被打印两次;
再来看一下打印结果:
也可以打印receiver,在浏览器中进行查看,其实就是这里的objProxy:
5.响应式原理的实现
5.1.什么是响应式呢?
当某个变量值发生变化时,会自动去执行某一些代码。如下代码,当变量num发生变化时,对num有所依赖的代码可以自动执行。
- 像上面这一种自动响应数据变化的代码机制,就称之为响应式;
- 在开发中,一般都是监听某一个对象中属性的变化,然后自动去执行某一些代码块,而这些代码块一般都存放在一个函数中,因为函数可以方便我们再次执行这些代码,只需再次调用函数即可;
5.2.收集响应式函数的实现
在响应式中,需要执行的代码可能不止一行,而且也不可能一行行去执行,所以可以将这些代码放到一个函数中,当数据发生变化,自动去执行某一个函数。但是在开发中有那么多函数,怎么判断哪些函数需要响应式?哪些又不需要呢?
- 封装一个
watchFn
的函数,将需要响应式的函数传入; watchFn
的主要职责就是将这些需要响应式的函数收集起来,存放到一个数组reactiveFns
中;
5.3.收集响应式函数的优化
上面实现的收集响应式函数,目前是存放到一个数组中来保存的,而且只是对name属性的的依赖进行了收集,如果age属性也需要收集,不可能都存放到一个数组里面,而且属性值改变后,还需要通过手动去遍历调用,显而易见是很麻烦的,下面做一些优化。
- 封装一个类,专门用于收集这些响应式函数;
- 类中添加一个
notify
的方法,用于遍历调用这些响应式函数; - 对于不同的属性,就分别去实例化这个类,那么每个属性就可以对应一个对象,并且对象中有一个存放它的响应式数组的属性
reactiveFns
;
5.4.自动监听对象的变化
在修改对象属性值后,还是需要手动去调用其
notify
函数来通知响应式函数执行,其实可以做到自动监听对象属性的变化,来自动调用notify
函数,这个想必就很容易了,在前面做了那么多功课,就是为了这里,不管是用Object.defineProperty还是Proxy都可以实现对象的监听,这里我使用功能更加强大的Proxy,并结合Reflect来实现。
注意:后面使用到的obj对象,需都换成代理对象objProxy,这样储能监听到属性值是否被设置了。
打印结果:name属性修改了三次,对应依赖函数就执行了三次。
5.5.对象依赖的管理(数据存储结构设计)
在上面实现响应式过程中,都是基于一个对象的一个属性,如果有多个对象,这多个对象中有不同或者相同的属性呢?我们应该这样去单独管理不同对象中每个属性所对应的依赖呢?应该要做到当某一个对象中的某一个属性发生变化时,只去执行对这个对象中这个属性有依赖的函数,下面就来讲一下怎样进行数据存储,能够达到我们的期望。
在ES16中,给我们新提供了两个新特性,分别是Map和WeakMap,这两个类都可以用于存放数据,类似于对象,存放的是键值对,但是Map和WeakMap的key可以存放对象,而且WeakMap对对象的引用是弱引用。如果对这两个类不太熟悉,可以去看看上一篇文章:ES6-ES12简单知识点总结
- 将不同的对象存放到WeakMap中作为key,其value存放对应的Map;
- Map中存放对应对象的属性作为key,其value存放对应的依赖对象;
- 依赖对象中存放有该属性对应响应式函数数组;
如果有以下obj1和obj2两个对象,来看一下它们大致的存储形式:
5.6.对象依赖管理的实现
已经确定了怎么存储了,下面就来实现一下吧。
- 封装一个
getDepend
函数,主要用于根据对象和key,来找到对应的dep; - 如果没有找到就先进行创建存储;
在Proxy的捕获器中获取对应的dep:
5.7.对象的依赖收集优化
可以发现上面打印的结果中的响应式函数数组全部为空,是因为在前面收集响应式函数是通过
watchFn
来收集的,而在getDepend
中并没有去收集对应的响应式函数,所以返回的dep对象里面的数组全部就为空了。如果对响应式函数,还需要通过自己一个个去收集,是不太容易的,所以可以监听响应式函数中依赖了哪一个对象属性,让Proxy的get捕获器去收集就行了。
- 既然get需要监听到响应式函数访问了哪些属性,那么响应式函数在被添加之前肯定是要执行一次的;
- 如何在Proxy中拿到当前需要被收集的响应式函数呢?可以借助全局变量;
- 下面就来对
watchFn
进行改造;
Proxy中get捕获器具体需要执行的操作:
下面测试一下看看效果:
5.8.Depend类优化
截止到上面,大部分响应式原理已经实现了,但是还存在一些小问题需要优化。
- 优化一:既然
currentReactiveFn
可以在全局拿到,何不在Depend类中就对它进行收集呢。改造方法addDependFn
; - 优化二:如果一个响应式函数中多次访问了某个属性,就都会去到Proxy的get捕获器,该响应式函数会被重复收集,在调用时就会调用多次。当属性发生变化后,依赖这个属性的响应式函数被调用一次就可以了。改造
reactiveFns
,将数组改成Set,Set可以避免元素重复,注意添加元素使用add。
Proxy中就不用去收集响应式函数了,直接调用addDependFn
即可:
5.9.多个对象实现响应式
前面都只讲了一个对象实现响应式的实现,如果有多个对象需要实现可响应式呢?将Proxy封装一下,外面套一层函数即可,调用该函数,返回该对象的代理对象。
看一下具体使用效果:
5.10.总结整理
通过上面9步完成了最终响应式原理的实现,下面对其进行整理一下:
-
watchFn函数:传入该函数的函数都是需要被收集为响应式函数的,对响应式函数进行初始化调用,使Proxy的get捕获器能捕获到属性访问;
-
Depend类:
reactiveFns
用于存放响应式函数,addDependFn
方法实现对响应式函数的收集,notify
方法实现当属性值变化时,去调用对应的响应式函数; -
reactive函数:实现将普通对象转成代理对象,从而将其转变为可响应式对象;
-
getDepend函数:根据指定的对象和对象属性(key)去查找对应的dep对象;
总结:以上通过Proxy来监听对象操作的实现响应式的方法就是Vue3响应式原理了。
6.Vue2响应式原理的实现
Vue3响应式原理已经实现了,那么Vue2只需要将Proxy换成Object.defineProperty就可以了。
- 将reactive函数改一下即可;
__EOF__

本文链接:https://www.cnblogs.com/MomentYY/p/16065162.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix