非原始值的响应式方案
vue2的实现:
使Object数据变得“可观测”
数据的每次读和写能够被我们看的见,即我们能够知道数据什么时候被读取了或数据什么时候被改写了,我们将其称为数据变的‘可观测’。
要将数据变的‘可观测’,我们就要借助前言中提到的Object.defineProperty
方法了,在本文中,我们就使用这个方法使数据变得“可观测”。
首先,我们定义一个数据对象car
:
1 | let car = { |
我们定义了这个car
的品牌brand
是BMW
,价格price
是3000。现在我们可以通过car.brand
和car.price
直接读写这个car
对应的属性值。但是,当这个car
的属性被读取或修改时,我们并不知情。那么应该如何做才能够让car
主动告诉我们,它的属性被修改了呢?
接下来,我们使用Object.defineProperty()
改写上面的例子:
1 | let car = {} |
通过Object.defineProperty()
方法给car
定义了一个price
属性,并把这个属性的读和写分别使用get()
和set()
进行拦截,每当该属性进行读或写操作的时候就会触发get()
和set()
。如下图:
可以看到,car
已经可以主动告诉我们它的属性的读写情况了,这也意味着,这个car
的数据对象已经是“可观测”的了。
为了把car
的所有属性都变得可观测,我们可以编写如下代码:
1 | // 源码位置:src/core/observer/index.js |
在上面的代码中,我们定义了observer
类,它用来将一个正常的object
转换成可观测的object
。
并且给value
新增一个__ob__
属性,值为该value
的Observer
实例。这个操作相当于为value
打上标记,表示它已经被转化成响应式了,避免重复操作
然后判断数据的类型,只有object
类型的数据才会调用walk
将每一个属性转换成getter/setter
的形式来侦测变化。 最后,在defineReactive
中当传入的属性值还是一个object
时使用new observer(val)
来递归子属性,这样我们就可以把obj
中的所有属性(包括子属性)都转换成getter/seter
的形式来侦测变化。 也就是说,只要我们将一个object
传到observer
中,那么这个object
就会变成可观测的、响应式的object
。
observer
类位于源码的src/core/observer/index.js
中。
那么现在,我们就可以这样定义car
:
1 | let car = new Observer({ |
这样,car
的两个属性都变得可观测了。
依赖收集
什么是依赖收集
在上一章中,我们迈出了第一步:让object
数据变的可观测。变的可观测以后,我们就能知道数据什么时候发生了变化,那么当数据发生变化时,我们去通知视图更新就好了。那么问题又来了,视图那么大,我们到底该通知谁去变化?总不能一个数据变化了,把整个视图全部更新一遍吧,这样显然是不合理的。此时,你肯定会想到,视图里谁用到了这个数据就更新谁呗。对!你想的没错,就是这样。
视图里谁用到了这个数据就更新谁,我们换个优雅说法:我们把”谁用到了这个数据”称为”谁依赖了这个数据”,我们给每个数据都建一个依赖数组(因为一个数据可能被多处使用),谁依赖了这个数据(即谁用到了这个数据)我们就把谁放入这个依赖数组中,那么当这个数据发生变化的时候,我们就去它对应的依赖数组中,把每个依赖都通知一遍,告诉他们:”你们依赖的数据变啦,你们该更新啦!”。这个过程就是依赖收集。
何时收集依赖?何时通知依赖更新?
明白了什么是依赖收集后,那么我们到底该在何时收集依赖?又该在何时通知依赖更新?
其实这个问题在上一小节中已经回答了,我们说过:谁用到了这个数据,那么当这个数据变化时就通知谁。所谓谁用到了这个数据,其实就是谁获取了这个数据,而可观测的数据被获取时会触发getter
属性,那么我们就可以在getter
中收集这个依赖。同样,当这个数据变化时会触发setter
属性,那么我们就可以在setter
中通知依赖更新。
总结一句话就是:在getter中收集依赖,在setter中通知依赖更新。
把依赖收集到哪里
明白了什么是依赖收集以及何时收集何时通知后,那么我们该把依赖收集到哪里?
我们给每个数据都建一个依赖数组,谁依赖了这个数据我们就把谁放入这个依赖数组中。单单用一个数组来存放依赖的话,功能好像有点欠缺并且代码过于耦合。我们应该将依赖数组的功能扩展一下,更好的做法是我们应该为每一个数据都建立一个依赖管理器,把这个数据所有的依赖都管理起来。OK,到这里,我们的依赖管理器Dep
类应运而生,代码如下:
1 | // 源码位置:src/core/observer/dep.js |
在上面的依赖管理器Dep
类中,我们先初始化了一个subs
数组,用来存放依赖,并且定义了几个实例方法用来对依赖进行添加,删除,通知等操作。
有了依赖管理器后,我们就可以在getter中收集依赖,在setter中通知依赖更新了,代码如下:
1 | function defineReactive (obj,key,val) { |
在上述代码中,我们在getter
中调用了dep.depend()
方法收集依赖,在setter
中调用dep.notify()
方法通知所有依赖更新。
依赖到底是谁
我们明白了什么是依赖?何时收集依赖?以及收集的依赖存放到何处?那么我们收集的依赖到底是谁?
虽然我们一直在说”谁用到了这个数据谁就是依赖“,但是这仅仅是在口语层面上,那么反应在代码上该如何来描述这个”谁“呢?
其实在Vue
中还实现了一个叫做Watcher
的类,而Watcher
类的实例就是我们上面所说的那个”谁”。换句话说就是:谁用到了数据,谁就是依赖,我们就为谁创建一个Watcher
实例。在之后数据变化时,我们不直接去通知依赖更新,而是通知依赖对应的Watch
实例,由Watcher
实例去通知真正的视图。
Watcher
类的具体实现如下:
1 | export default class Watcher { |
谁用到了数据,谁就是依赖,我们就为谁创建一个Watcher
实例,在创建Watcher
实例的过程中会自动的把自己添加到这个数据对应的依赖管理器中,以后这个Watcher
实例就代表这个依赖,当数据变化时,我们就通知Watcher
实例,由Watcher
实例再去通知真正的依赖。
那么,在创建Watcher
实例的过程中它是如何的把自己添加到这个数据对应的依赖管理器中呢?
下面我们分析Watcher
类的代码实现逻辑:
- 当实例化
Watcher
类时,会先执行其构造函数; - 在构造函数中调用了
this.get()
实例方法; - 在
get()
方法中,首先通过window.target = this
把实例自身赋给了全局的一个唯一对象window.target
上,然后通过let value = this.getter.call(vm, vm)
获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter
,上文我们说过,在getter
里会调用dep.depend()
收集依赖,而在dep.depend()
中取到挂载window.target
上的值并将其存入依赖数组中,在get()
方法最后将window.target
释放掉。 - 而当数据变化时,会触发数据的
setter
,在setter
中调用了dep.notify()
方法,在dep.notify()
方法中,遍历所有依赖(即watcher实例),执行依赖的update()
方法,也就是Watcher
类中的update()
实例方法,在update()
方法中调用数据变化的更新回调函数,从而更新视图。
简单总结一下就是:Watcher
先把自己设置到全局唯一的指定位置(window.target
),然后读取数据。因为读取了数据,所以会触发这个数据的getter
。接着,在getter
中就会从全局唯一的那个位置读取当前正在读取数据的Watcher
,并把这个watcher
收集到Dep
中去。收集好之后,当数据发生变化时,会向Dep
中的每个Watcher
发送通知。通过这样的方式,Watcher
可以主动去订阅任意一个数据的变化。为了便于理解,我们画出了其关系流程图,如下图:
以上,就彻底完成了对Object
数据的侦测,依赖收集,依赖的更新等所有操作
不足之处
虽然我们通过Object.defineProperty
方法实现了对object
数据的可观测,但是这个方法仅仅只能观测到object
数据的取值及设置值,当我们向object
数据里添加一对新的key/value
或删除一对已有的key/value
时,它是无法观测到的,导致当我们对object
数据添加或删除值时,无法通知依赖,无法驱动视图进行响应式更新。
当然,Vue
也注意到了这一点,为了解决这一问题,Vue
增加了两个全局API:Vue.set
和Vue.delete
,这两个API的实现原理将会在后面学习全局API的时候说到。
总结
首先,我们通过Object.defineProperty
方法实现了对object
数据的可观测,并且封装了Observer
类,让我们能够方便的把object
数据中的所有属性(包括子属性)都转换成getter/seter
的形式来侦测变化。
接着,我们学习了什么是依赖收集?并且知道了在getter
中收集依赖,在setter
中通知依赖更新,以及封装了依赖管理器Dep
,用于存储收集到的依赖。
最后,我们为每一个依赖都创建了一个Watcher
实例,当数据发生变化时,通知Watcher
实例,由Watcher
实例去做真实的更新操作。
其整个流程大致如下:
Data
通过observer
转换成了getter/setter
的形式来追踪变化。- 当外界通过
Watcher
读取数据时,会触发getter
从而将Watcher
添加到依赖中。 - 当数据发生了变化时,会触发
setter
,从而向Dep
中的依赖(即Watcher)发送通知。 Watcher
接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。
vue3的实现:
理解proxy和reflect
proxy
简单地说,使用 Proxy 可以创建一个代理对象。它能够实现对其他对象 的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么,代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。这句话的关键词比较多,我们逐一解释。
什么是基本语义?给出一个对象 obj,可以对它进行一些操作, 例如读取属性值、设置属性值:
1 | 01 obj.foo // 读取属性 foo 的值 |
Proxy定义: 用于定义基本操作的自定义行为
proxy
修改的是程序默认形为,就形同于在编程语言层面上做修改,属于元编程(meta
programming
)
- 元编程(英语:Metaprogramming,又译超编程,是指某类计算机程序的编写,这类计算机程序编写或者操纵其它程序(或者自身)作为它们的数据,或者在运行时完成部分本应在编译时完成的工作
一段代码来理解元编程:
1 |
|
这段程序每执行一次能帮我们生成一个名为program的文件,文件内容为1024行echo
,如果我们手动来写1024行代码,效率显然低效
元编程优点:与手工编写全部代码相比,程序员可以获得更高的工作效率,或者给与程序更大的灵活度去处理新的情形而无需重新编译
proxy
译为代理,可以理解为在操作目标对象前架设一层代理,将所有本该我们手动编写的程序交由代理来处理,生活中也有许许多多的“proxy”, 如代购,中介,因为他们所有的行为都不会直接触达到目标对象
语法
- target 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理
- handler 一个通常以函数作为属性的对象,用来定制拦截行为
类似这种读取、设置属性值的操作,就属于基本语义的操作,即 基本操作。既然是基本操作,那么它就可以使用 Proxy 拦截:
1 | 01 const p = new Proxy(obj, { |
如以上代码所示,Proxy 构造函数接收两个参数。第一个参数是 被代理的对象,第二个参数也是一个对象,这个对象是一组夹子 (trap)。其中 get 函数用来拦截读取操作,set 函数用来拦截设置 操作。
在 JavaScript 的世界里,万物皆对象。例如一个函数也是一个对 象,所以调用函数也是对一个对象的基本操作:
1 | 01 const fn = (name) => { |
因此,我们可以用 Proxy 来拦截函数的调用操作,这里我们使用 apply 拦截函数的调用:
1 | 01 const p2 = new Proxy(fn, { |
上面两个例子说明了什么是基本操作。Proxy 只能够拦截对一个 对象的基本操作。那么,什么是非基本操作呢?其实调用对象下的方 法就是典型的非基本操作,我们叫它复合操作:
1 | 01 obj.fn() |
实际上,调用一个对象下的方法,是由两个基本语义组成的。第一个基本语义是 get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过 get 得到 obj.fn 的值后再调用它,也就是我们上面说到的 apply。理解 Proxy 只能够代理对象的基本语义 很重要,后续我们讲解如何实现对数组或 Map、Set 等数据类型的代 理时,都利用了 Proxy 的这个特点。
reflect
理解了 Proxy,我们再来讨论 Reflect。Reflect 是一个全局对象,其下有许多方法,例如:
1 | 01 Reflect.get() |
你可能已经注意到了,Reflect 下的方法与 Proxy 的拦截器方 法名字相同,其实这不是偶然。任何在 Proxy 的拦截器中能够找到的 方法,都能够在 Reflect 中找到同名函数,那么这些函数的作用是什 么呢?其实它们的作用一点儿都不神秘。拿 Reflect.get 函数来 说,它的功能就是提供了访问一个对象属性的默认行为,例如下面两 个操作是等价的:
1 | 01 const obj = { foo: 1 } |
可能有的读者会产生疑问:既然操作等价,那么它存在的意义是 什么呢?实际上 Reflect.get 函数还能接收第三个参数,即指定接 收者 receiver,你可以把它理解为函数调用过程中的 this,例如:
1 | 01 const obj = { foo: 1 } |
在这段代码中,我们指定第三个参数 receiver 为一个对象 { foo: 2 },这时读取到的值是 receiver 对象的 foo 属性值。实际 上,Reflect.* 方法还有很多其他方面的意义,但这里我们只关心并 讨论这一点,因为它与响应式数据的实现密切相关。为了说明问题, 回顾一下在上一节中实现响应式数据的代码:
1 | 01 const obj = { foo: 1 } |
这是上一章中用来实现响应式数据的最基本的代码。在 get 和 set 拦截函数中,我们都是直接使用原始对象 target 来完成对属性 的读取和设置操作的,其中原始对象 target 就是上述代码中的 obj 对象。 那么这段代码有什么问题吗?我们借助 effect 让问题暴露出 来。首先,我们修改一下 obj 对象,为它添加 bar 属性:
1 | 01 const obj = { |
可以看到,bar 属性是一个访问器属性,它返回了 this.foo 属 性的值。接着,我们在 effect 副作用函数中通过代理对象 p 访问 bar 属性:
1 | 01 effect(() => { |
我们来分析一下这个过程发生了什么。当 effect 注册的副作用 函数执行时,会读取 p.bar 属性,它发现 p.bar 是一个访问器属 性,因此执行 getter 函数。由于在 getter 函数中通过 this.foo 读取了 foo 属性值,因此我们认为副作用函数与属性 foo 之间也会建 立联系。当我们修改 p.foo 的值时应该能够触发响应,使得副作用函 数重新执行才对。然而实际并非如此,当我们尝试修改 p.foo 的值 时:
1 | 01 p.foo++ |
副作用函数并没有重新执行,问题出在哪里呢? 实际上,问题就出在 bar 属性的访问器函数 getter 里:
1 | 01 const obj = { |
当我们使用 this.foo 读取 foo 属性值时,这里的 this 指向的 是谁呢?我们回顾一下整个流程。首先,我们通过代理对象 p 访问 p.bar,这会触发代理对象的 get 拦截函数执行:
1 | 01 const p = new Proxy(obj, { |
在 get 拦截函数内,通过 target[key] 返回属性值。其中 target 是原始对象 obj,而 key 就是字符串 ‘bar’,所以 target[key] 相当于 obj.bar。因此,当我们使用 p.bar 访问 bar 属性时,它的 getter 函数内的 this 指向的其实是原始对象 obj, 这说明我们最终访问的其实是 obj.foo。很显然,在副作用函数内通 过原始对象访问它的某个属性是不会建立响应联系的,这等价于:
1 | 01 effect(() => { |
因为这样做不会建立响应联系,所以出现了无法触发响应的问 题。那么这个问题应该如何解决呢?这时 Reflect.get 函数就派上 用场了。先给出解决问题的代码:
1 | 01 const p = new Proxy(obj, { |
如上面的代码所示,代理对象的 get 拦截函数接收第三个参数 receiver,它代表谁在读取属性,例如:
1 | 01 p.bar // 代理对象 p 在读取 bar 属性 |
当我们使用代理对象 p 访问 bar 属性时,那么 receiver 就是 p,你可以把它简单地理解为函数调用中的 this。接着关键的一步发 生了,我们使用 Reflect.get(target, key, receiver) 代替 之前的 target[key],这里的关键点就是第三个参数 receiver。 我们已经知道它就是代理对象 p,所以访问器属性 bar 的 getter 函 数内的 this 指向代理对象 p:
1 | 01 const obj = { |
可以看到,this 由原始对象 obj 变成了代理对象 p。很显然,这 会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集 的效果。如果此时再对 p.foo 进行自增操作,会发现已经能够触发副 作用函数重新执行了。
Handler 对象常用的方法
方法 | 描述 |
---|---|
handler.has() | in 操作符的捕捉器。 |
handler.get() | 属性读取操作的捕捉器。 |
handler.set() | 属性设置操作的捕捉器。 |
handler.deleteProperty() | delete 操作符的捕捉器。 |
handler.ownKeys() | Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法的捕捉器。 |
handler.apply() | 函数调用操作的捕捉器。 |
handler.construct() | new 操作符的捕捉器 |
下面挑handler.get
重点讲一下,其它方法的使用也都大同小异,不同的是参数的区别
handler.get
get
我们在上面例子已经体验过了,现在详细介绍一下,用于代理目标对象的属性读取操作
授受三个参数 get(target, propKey, ?receiver)
- target 目标对象
- propkey 属性名
- receiver Proxy 实例本身
举个例子
1 | const person = { |
上面的代码表示在读取代理目标的值时,如果有值则直接返回,没有值就抛出一个自定义的错误
注意:
- 如果要访问的目标属性是不可写以及不可配置的,则返回的值必须与该目标属性的值相同
- 如果要访问的目标属性没有配置访问方法,即get方法是undefined的,则返回值必须为undefined
如下面的例子
1 | const obj = {}; |
可撤消的Proxy
1 | proxy`有一个唯一的静态方法,`Proxy.revocable(target, handler) |
Proxy.revocable()
方法可以用来创建一个可撤销的代理对象
该方法的返回值是一个对象,其结构为: {"proxy": proxy, "revoke": revoke}
- proxy 表示新生成的代理对象本身,和用一般方式 new Proxy(target, handler) 创建的代理对象没什么不同,只是它可以被撤销掉。
- revoke 撤销方法,调用的时候不需要加任何参数,就可以撤销掉和它一起生成的那个代理对象。
该方法常用于完全封闭对目标对象的访问, 如下示例
1 | const target = { name: 'vuejs'} |
Proxy的应用场景
Proxy
的应用范围很广,下方列举几个典型的应用场景
校验器
想要一个number
,拿回来的却是string
,惊不惊喜?意不意外?下面我们使用Proxy
实现一个逻辑分离的数据格式验证器
嗯,真香!
1 | const target = { |
私有属性
在日常编写代码的过程中,我们想定义一些私有属性,通常是在团队中进行约定,大家按照约定在变量名之前添加下划线 _ 或者其它格式来表明这是一个私有属性,但我们不能保证他能真私‘私有化’,下面使用Proxy轻松实现私有属性拦截
1 | const target = { |
Proxy
使用场景还有很多很多,不再一一列举,如果你需要在某一个动作的生命周期内做一些特定的处理,那么Proxy
都是适合的
为什么要用Proxy重构
在 Proxy
之前,JavaScript
中就提供过 Object.defineProperty
,允许对对象的 getter/setter
进行拦截
Vue3.0之前的双向绑定是由 defineProperty
实现, 在3.0重构为 Proxy
,那么两者的区别究竟在哪里呢?
首先我们再来回顾一下它的定义
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
上面给两个词划了重点,对象上,属性,我们可以理解为是针对对象上的某一个属性做处理的
语法
- obj 要定义属性的对象
- prop 要定义或修改的属性的名称或 Symbol
- descriptor 要定义或修改的属性描述符
1 | Object.defineProperty(obj, prop, descriptor) |
举个例子
1 | const obj = {} |
对比
一个优秀的开源框架本身就是一个不断打碎重朔的过程,上面做了些许铺垫,现在我们简要总结一下
Proxy
作为新标准将受到浏览器厂商重点持续的性能优化Proxy
能观察的类型比defineProperty
更丰富Proxy
不兼容IE,也没有polyfill
,defineProperty
能支持到IE9Object.definedProperty
是劫持对象的属性,新增元素需要再次definedProperty
。而Proxy
劫持的是整个对象,不需要做特殊处理- 使用
defineProperty
时,我们修改原来的obj
对象就可以触发拦截,而使用proxy
,就必须修改代理对象,即Proxy
的实例才可以触发拦截