【51CTO.com原创稿件】前言
Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。----官方文档 本文将针对响应式原理做一个详细介绍,并且带你实现一个基础版的响应式系统。本文的代码请猛戳Github博客
什么是响应式
我们先来看个例子:
复制
<div id="app"> <div>Price :¥{{ price }}</div> <div>Total:¥{{ price * quantity }}</div> <div>Taxes: ¥{{ totalPriceWithTax }}</div> <button @click="changePrice">改变价格</button> </div>
1.
2.
3.
4.
5.
6.
复制
var app = new Vue({ el: '#app', data() { return { price: 5.0, quantity: 2 }; }, computed: { totalPriceWithTax() { return this.price * this.quantity * 1.03; } }, methods: { changePrice() { this.price = 10; } } })
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
上例中当price 发生变化的时候,Vue就知道自己需要做三件事情:
更新页面上price的值
计算表达式 price*quantity 的值,更新页面
调用totalPriceWithTax 函数,更新页面
发生变化后,会重新对页面渲染,这就是Vue响应式,那么这一切是怎么做到的呢?
想完成这个过程,我们需要:
侦测数据的变化
收集视图依赖了哪些数据
数据变化时,自动“通知”需要更新的视图部分,并进行更新
对应专业俗语分别是:
数据劫持 / 数据代理
依赖收集
发布订阅模式
如何侦测数据的变化
首先有个问题,在Javascript中,如何侦测一个对象的变化? 其实有两种办法可以侦测到变化:使用Object.defineProperty和ES6的Proxy,这就是进行数据劫持或数据代理。这部分代码主要参考珠峰架构课。
方法1.Object.defineProperty实现
Vue通过设定对象属性的 setter/getter 方法来监听数据的变化,通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。
复制
function render () { console.log('模拟视图渲染') } let data = { name: '浪里行舟', location: { x: 100, y: 100 } } observe(data) function observe (obj) { // 判断类型 if (!obj || typeof obj !== 'object') { return } Object.keys(obj).forEach(key => { defineReactive(obj, key, obj[key]) }) function defineReactive (obj, key, value) { // 递归子属性 observe(value) Object.defineProperty(obj, key, { enumerable: true, //可枚举(可以遍历) configurable: true, //可配置(比如可以删除) get: function reactiveGetter () { console.log('get', value) // 监听 return value }, set: function reactiveSetter (newVal) { observe(newVal) //如果赋值是一个对象,也要递归子属性 if (newVal !== value) { console.log('set', newVal) // 监听 render() value = newVal } } }) } } data.location = { x: 1000, y: 1000 } //set {x: 1000,y: 1000} 模拟视图渲染 data.name // get 浪里行舟
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
几个注意点补充说明:
这种方式无法检测到对象属性的添加或删除(如data.location.a=1)。
这是因为 Vue 通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。如果是删除属性,我们可以用vm.$delete实现,那如果是新增属性,该怎么办呢? 1)可以使用 Vue.set(location, a, 1) 方法向嵌套对象添加响应式属性; 2)也可以给这个对象重新赋值,比如data.location = {...data.location,a:1}
Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写
复制
function render() { console.log('模拟视图渲染') } let obj = [1, 2, 3] let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push'] // 先获取到原来的原型上的方法 let arrayProto = Array.prototype // 创建一个自己的原型 并且重写methods这些方法 let proto = Object.create(arrayProto) methods.forEach(method => { proto[method] = function() { // AOP arrayProto[method].call(this, ...arguments) render() } }) function observer(obj) { // 把所有的属性定义成set/get的方式 if (Array.isArray(obj)) { obj.__proto__ = proto return } if (typeof obj == 'object') { for (let key in obj) { defineReactive(obj, key, obj[key]) } } } function defineReactive(data, key, value) { observer(value) Object.defineProperty(data, key, { get() { return value }, set(newValue) { observer(newValue) if (newValue !== value) { render() value = newValue } } }) } observer(obj) function $set(data, key, value) { defineReactive(data, key, value) } obj.push(123, 55) console.log(obj) //[1, 2, 3, 123, 55]
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
这种方法将数组的常用方法进行重写,进而覆盖掉原生的数组方法,重写之后的数组方法需要能够被拦截。但有些数组操作Vue时拦截不到的,当然也就没办法响应,比如:
复制
obj.length-- // 不支持数组的长度变化 obj[0]=1 // 修改数组中***个元素,也无法侦测数组的变化
1.
2.
3.
ES6提供了元编程的能力,所以有能力拦截,Vue3.0可能会用ES6中Proxy 作为实现数据代理的主要方式。
方法2.Proxy实现
Proxy 是 JavaScript 2015 的一个新特性。Proxy 的代理是针对整个对象的,而不是对象的某个属性,因此不同于 Object.defineProperty 的必须遍历对象每个属性,Proxy 只需要做一层代理就可以监听同级结构下的所有属性变化,当然对于深层结构,递归还是需要进行的。此外**Proxy支持代理数组的变化。**
复制
function render() { console.log('模拟视图的更新') } let obj = { name: '前端工匠', age: { age: 100 }, arr: [1, 2, 3] } let handler = { get(target, key) { // 如果取的值是对象就在对这个对象进行数据劫持 if (typeof target[key] == 'object' && target[key] !== null) { return new Proxy(target[key], handler) } return Reflect.get(target, key) }, set(target, key, value) { if (key === 'length') return true render() return Reflect.set(target, key, value) } } let proxy = new Proxy(obj, handler) proxy.age.name = '浪里行舟' // 支持新增属性 console.log(proxy.age.name) // 模拟视图的更新 浪里行舟 proxy.arr[0] = '浪里行舟' //支持数组的内容发生变化 console.log(proxy.arr) // 模拟视图的更新 ['浪里行舟', 2, 3 ] proxy.arr.length-- // 无效
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
以上代码不仅精简,而且还是实现一套代码对对象和数组的侦测都适用。不过Proxy兼容性不太好!
我们之所以要观察数据,其目的在于当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。比如***例子中,模板中使用了price 数据,当它发生变化时,要向使用了它的地方发送通知。那如何收集依赖呢?
收集依赖与发布订阅模式
如何收集依赖,总结起来就一句话,在getter中收集依赖,在setter中触发依赖 我们先来实现一个 Dep 类,用于解耦属性的依赖收集和派发更新操作。
复制
// 通过 Dep 解耦属性的依赖和更新操作 class Dep { constructor() { this.subs = [] } // 添加依赖 addSub(sub) { this.subs.push(sub) } // 更新 notify() { this.subs.forEach(sub => { sub.update() }) } } // 全局属性,通过该属性配置 Watcher Dep.target = null
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
当需要依赖收集的时候调用 addSub,当需要派发更新的时候调用 notify。具体如何调用呢?
复制
let dp = new Dep() dp.addSub(() => { console.log('emit here') }) dp.notify()
1.
2.
3.
4.
5.
这就是一个简单实现的“事件发布订阅模式”,当然代码只是启发思路,真实应用还比较“粗糙”,没有进行事件名设置,APIs 也并不丰富,但完全能够说明问题了。
接下来我们先来简单的了解下 Vue 组件挂载时添加响应式的过程。在组件挂载时,会先对所有需要的属性调用 Object.defineProperty(),然后实例化 Watcher,传入组件更新的回调。在实例化过程中,会对模板中的属性进行求值,触发依赖收集。我们可以把Watcher理解成一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。
***需要对 defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码。
复制
function render () { console.log('模拟视图渲染') } let data = { name: '浪里行舟', location: { x: 100, y: 100 } } observe(data) let dp = new Dep() function observe (obj) { // 判断类型 if (!obj || typeof obj !== 'object') { return } Object.keys(obj).forEach(key => { defineReactive(obj, key, obj[key]) }) function defineReactive (obj, key, value) { // 递归子属性 observe(value) Object.defineProperty(obj, key, { enumerable: true, //可枚举(可以遍历) configurable: true, //可配置(比如可以删除) get: function reactiveGetter () { console.log('get', value) // 监听 // 将 Watcher 添加到订阅 if (Dep.target) { dp.addSub(Dep.target) } return value }, set: function reactiveSetter (newVal) { observe(newVal) //如果赋值是一个对象,也要递归子属性 if (newVal !== value) { console.log('set', newVal) // 监听 render() value = newVal // 执行 watcher 的 update 方法 dp.notify() } } }) } }
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
以上所有代码实现了一个简易的数据响应式,核心思路就是手动触发一次属性的 getter 来实现依赖收集。
总结
我们再来回顾下整个过程:
在 Vue 中模板编译过程中的指令或者数据绑定都会实例化一个 Watcher 实例,实例化过程中会触发 get() 将自身指向 Dep.target;
data在 Observer 时执行 getter 会触发 dep.depend() 进行依赖收集;依赖收集的结果:
data在 Observer 时闭包的dep实例的subs添加观察它的 Watcher 实例;
Watcher 的deps中添加观察对象 Observer 时的闭包dep;
当data中被 Observer 的某个对象值变化后,触发subs中观察它的watcher执行 update() 方法,***实际上是调用watcher的回调函数cb,进而更新视图。
参考文章和书籍
作者介绍
浪里行舟:硕士研究生,专注于前端。个人公众号:「前端工匠」,致力于打造适合初中级工程师能够快速吸收的一系列优质文章!
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】