1. 响应式的本质
提到 Vue 的响应式,通常指的是视图跟随数据的改变而更新。开发上带来的便利是,在需要更新视图呈现时,只需修改视图渲染所需要的数据即可,而不用手动操作DOM。从实现来说,可以分为两个部分:
- 监听数据改变
- 更新视图
我们很熟悉如何监听鼠标的点击,键盘的输入等用户事件,但是很少直接去监听一个数据改变的事件。虽然,不存在数据改变这个事件,但是监听数据改变是可以做到的,并且从程序设计角度来说,和给事件绑定一个回调函数没有本质的不同。
为了比较监听普通事件和监听数据改变的区别,我们先使用事件的方式,来实现“响应式”视图更新。
下面的代码中,我们定义了数据变量data
和视图更新函数update
。update
函数在更新视图时,读取了data
的text
属性作为视图节点的文本内容。然后监听一个input
元素的input
事件,事件的回调函数中,将用户输入的值替换data.text
的当前值,然后调用update
函数,通知视图进行更新。
1 | <input id='text' /> |
借助input
事件,我们间接实现了“响应式”,但它只是起到一个纽带的作用,不能直接对数据的改变作出响应。
2. 监听数据改变
2.1 Object.defineProperty
Object.defineProperty(obj, prop, descriptor)
可以给对象添加或者修改已有属性。函数接受三个参数:
- obj: 要定义属性的对象
- prop: 要定义或修改的属性的名称,可以
String
或Symbol
类型 - descriptor: 要定义或修改的属性描述符,必须是
Object
类型
这里重点需要了解的是属性描述符对象 descriptor
。descriptor
支持以下字段:
configurable
:Boolean
,为true
时,才能改变属性描述符,以及删除属性enumerable
:Boolean
,为true
时,可以通过for ... in
或Object.keys
方法枚举value
: 该属性对应的值。可以是任何有效的 JavaScript 值writable
:Boolean
,为true
时,属性值,也就是value
才能被赋值运算符改变get
: 属性的 getter 函数,当访问该属性时,会调用此函数set
: 属性的 setter 函数,当属性值被修改时,会调用此函数
其中 value
和 writable
只能出现在数据描述符
中;而get
和set
只能出现在存取描述符
中。一个属性描述符descriptor
只能是其中之一,因此当定义了 value
或 writable
,就不能再定义 get
或 set
,否则报错 Cannot both specify accessors and a value or writable attribute
。反之亦然。
由于,我们需要在对象属性改变时获得通知,我需要使用存取描述符
来定义对象属性,即定义set
来响应属性值的修改,定义get
来响应属性的访问。
以上文的data
为例,我们希望在通过data.text = xxx
的方式改变对象的属性值时,更新视图,所以要重新定义属性text
的描述符,在set
函数中调用视图更新函数update
。这里还需要定义get
,因为,我不但需要对属性值更改时作出响应,同时在update
函数中,我们还需要读取data.text
的值,而如果不定义get
,获取的值就为undefined
。
1 | var data = { |
这样定义后,我们便可以直接修改data.text
值更新视图了。读者可以将以下完整代码,保存到一个 html
文件中,然后在浏览器控制台中通过data.text = 'world'
赋值的方式,查看视图的变化。
1 | <div id='app'></div> |
这里只是针对data
的属性text
定义响应式。为了代码更加通用,以用于任意对象,可以编写一个函数defineReactive(obj, key, update)
(函数名参考了 Vue2 的定义,读者可以在 Vue2 源码中搜索该函数)。
1 | function defineReactive(obj, key, update) { |
于是上面的代码可以改写成:
1 | var data = { |
2.2 Proxy
响应对象属性改变,除了Object.definProperty
外,浏览器还支持另一个全局的构造函数Proxy
,用于自定义对象的基本操作,如:属性查找,赋值,枚举,函数调用等。相比而言,前者只能自定义对象属性的访问和赋值。
Proxy
的使用方法如下:
1 | const proxy = new Proxy(target, handler) |
- target: 需要代理的目标对象。可以是任何类型的对象,包括原生数组,函数,甚至另一个代理
- handler: 以函数作为属性的对象。属性中的函数分别定义了在对 proxy 实例执行各种操作的自定义行为
handelr
对象支持的方法(通常被称为traps
,中文翻译为陷阱,可以理解为钩子或者执行某项操作的回调函数)有:
- get: 读取属性值时调用
- set: 对属性赋值时调用
- has: 使用
in
操作符时调用 - deleteProperty: 使用
delete
操作符时调用 - ownKeys: 使用
Object.getOwnPropertyNames
方法和Object.getOwnPropertySymbols
方法时调用 - apply: 函数调用操作时调用
- construct: 使用
new
操作符时调用 - defineProperty: 使用
Object.defineProperty
方法时调用 - getOwnPropertyDescriptor: 使用
Object.getOwnPropertyDescriptor
方法时调用 - getPrototypeOf: 使用
Object.getPrototypeOf
方法时调用 - setPrototypeOf: 使用
Object.setPrototypeOf
方法时调用 - isExtensible: 使用
Object.isExtensible
方法时调用 - preventExtensions: 使用
Object.preventExtensions
方法时调用
可以看到Proxy
对对象自定义行为的控制比Object.defineProperty
更加全面。这里,我们重点关注和后者
相同部分,即get
和set
。虽然名称都是get
和set
,但方法的传参不同。Object.defineProperty
是针对对象的某个属性定义get
和set
,而Proxy
是针对整个对象。并且通过Proxy
构造函数返回的是一个proxy
实例,而不是原对象。因此,Proxy
中的get
和set
参数比Object.defineProperty
的多了两个参数:
- obj: 要代理的目标对象,即
target
- key: 代理对象访问或设置的属性
以前文的data
对象为例,定义get
和set
方法如下:
1 | const dataProxy = new Proxy(data, { |
这里和Object.defineProperty
还有最大不同的是,前者响应式在新返回的代理对象生效,而对原对象属性尽心访问和修改是不会触发set
和get
回调的。因此,如果使用Proxy
重写前文的响应式视图更新,需要在读取和设置对象属性时使用dataProxy
,完整代码如下:
1 | <div id='app'></div> |
如果同样在浏览器控制台修改数据,我们应该使用dataProxy.text = 'xxx'
而不是 data.text = 'xxxx'
。
3. 基于虚拟DOM的视图更新
在《手写 Vue (一)》中,我们实现了基于虚拟 DOM 的视图挂载。现在结合响应式实现虚拟 DOM 的到真实 DOM 的响应式更新。
完整代码如下:
1 | function Vue(options) { |
将以上代码保存到文件myvue_2.js
中,再新建html文件myvue_2.html
,替换以下内容:
1 | <div id="app"></div> |
尝试在浏览器控制台输入:
1 | vm.text = 'anything you like!!!' |
如果看到显示内容即时更新为你修改的内容,那么,恭喜你成功做到了和 Vue 一样的响应式视图更新。
小结
我们成功利用set
拦截,实现了响应式视图更新,但是还不够完美,因为,我们对data
对象中任何属性的赋值都会执行视图更新操作,而不管update
是否用到了这个属性。这意味着,如果data
有很多个属性,但并非所有属性都会用于视图的渲染,这样我们就会做一些多余的视图更新操作,显然这是没有意义的性能开销。要做到自动根据update
中实际使用的到属性,只对用到的属性执行视图更新,就涉及到依赖的搜集
。关于依赖搜集
的实现,我们在下一篇文章中继续探讨。