(封面图片来源于网络,侵删)
「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战」。
Vue源码学习系列已更新两篇,可参阅。
使用过Vue的前端开发者,都对其数据实时变化的这一特性爱不释手。Vue将这一特性称之为数据响应式,数据响应式作为Vue最独特的特性之一,给前端开发提供了很大的便利,但同时它又存在一定的缺陷,以至于在Vue3中,响应式原理相较于Vue2做了很大的更改。Vue3的响应式原理后面再探讨。那到底什么是响应式原理呢?
1、什么叫做响应
百度百科中响应的意思,指其如回响的应答声,如“响应你的要求”。具体意思是:1、回声响应。2、比喻应答敏捷。3、反应。可以看出,响应一词本身就是指某种反应,某种应答。结合Vue实践,我们可以发现,当操作数据时,页面中对应数据也发生了变化,这种变化在用户角度来说,是实时的、无感知的。
那数据会发生变化,这种变化如何追踪?
1.1 追踪数据变化
Vue在设计时,巧妙的想到了使用Object.defineProperty()这个方法,我们知道该方法可以直接在一个对象上定义一个新属性,或者修改一个对象的现有属性
,并返回此对象。这个方法中有两种方式可以修改现有对象的属性,一种是通过设置Writable属性,进行更改。一种则是通过属性的setter函数进行修改。设置Writable属性的方式,只是单纯更改当前属性的值,而不能做其他复杂逻辑,显然,自定义setter函数最合适。
当访问对象某个属性时,会调用get函数,当修改对象某个属性值时,会调用set函数。Vue对data选项对象中的每个属性,利用Object.defineProperty()增加getter/setter属性,就能轻易追踪到数据变化了。这里我们先不谈依赖注入模式。仅仅看一下源码中如何使用Object.defineProperty()的。下面的代码中,我抽取了主要的内容,描述主要流程。
初始化选项对象一文已经提到,在initData中,主要对数据进行初始化处理,初始化过程里有一个很重要的内容,就是为data对象属性增加getter/setter,因此定位源码可以看到,从initData()-> observe() -> new Observer() -> walk() -> defineReactive() -> Object.defineProperty()
,属性的getter/setter也就成功增加进来了。
// 初始化数据函数
function initData (vm) {
// 获取传入的data对象
var data = vm.$options.data;
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {};
observe(data, true);
}
// observe函数
function observe (value, asRootData) {
ob = new Observer(value);
}
// Observer
var Observer = function Observer (value) {
this.walk(value);
}
// walk方法
Observer.prototype.walk = function walk (obj) {
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
};
// defineReactive中定义了具体的get/set操作
function defineReactive (
obj,
key,
val,
customSetter,
shallow
) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
return value
},
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val;
}
});
}
复制代码
1.2 追踪过程中的特殊性
- data对象属性外的设置:由于Vue,只能对
data
对象上存在的属性增加getter/setter转化,对于其他新设置的属性,如给vue对象直接增加某个属性(vm.prototype1 = ''),这时vue是无法追踪该属性变化情况的。如果想要把该属性增加到响应式系统中,则需要使用Vue.set()或者vm.$set()方法,通过源码,其实我们可以发现该set方法的本质也是去调用了defineReactive()方法。
// Vue.set方法中,通过调用defineReactive为属性增加setter/getter属性
function set (target, key, val) {
defineReactive(ob.value, key, val);
}
复制代码
- 数组:Object.defineProperty()仅仅对对象有效,那么如果data某个属性是数组形式该如何响应呢。这里Vue通过
数据劫持
将能改变数组的方法push、pop、shift、unshift、splice、sort、reverse
,进行了重写。同时在后续为其增加依赖监听,完成数组的响应式。
var arrayProto = Array.prototype; // 获取数组的所有方法和属性
var arrayMethods = Object.create(arrayProto); // 创建新的对象
// 需要重写的方法,这些方法都有一个共同点是能够改变原始数组
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
methodsToPatch.forEach(function (method) {
// 重写上面定义的7种方法
def(arrayMethods, method, function mutator () {
}
}
复制代码
2、响应后的反馈
我们知道,数据会在多个组件中进行共享,同时会存在多种触发数据变化的方式。当数据变化后,如何做到所有用到该数据的地方都能实时更新呢,这就必须考虑到依赖收集与通知更新
的概念了。
2.1 依赖收集
依赖收集,顾名思义就是将所有改变数据的东西都放在一起,在vue当中相当于Watcher实例。即在执行get函数时,会做依赖收集的动作,将Watcher实例增加到依赖收集器Dep中。
Object.defineProperty(obj, key, {
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend() // 将Watcher增加到Dep依赖收集器中
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
}
// Dep依赖收集器中新增Watcher实例
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// Watcher实例
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
};
复制代码
2.2 更新数据
数据变化后会执行set函数,通知上面提到的依赖收集器中的所有Watcher实例,更新数据。调用dep.notify()方法,该方法通知watcher实例,实例调用update()函数就完成了数据更新。
Object.defineProperty(obj, key, {
set: function reactiveSetter (newVal) {
dep.notify() // 依赖收集器中的notify方法,通知收集的watcher实例更新数据
}
}
// Dep中的notify方法
subs: Array<Watcher>;
notify () {
// 调用watcher中的update更新数据
subs[i].update()
}
// Wwatcher去更新数据
Watcher.prototype.update = function update () {
}
复制代码
从上面的流程梳理中可以看出,依赖收集-通知更新这个流程,刚好和观察者模式
相符,Watcher相当于观察者、data属性相当于被观察者、Dep作为两者中间的桥梁将两者结合起来。
3、手写实践
3.1 观察者模式
观察者模式指的是对象间存在一对多关系,当一个对象被修改时,会自动通知依赖他的对象。其属于一种行为模式。详情可在此参考。 观察者模式在代码上如何体现呢?
// 被观察者
class Subject {
constructor(name) {
this.name = name,
this.observers = []
this.state = '奔跑速度50km/h'
}
// 被观察者上绑定观察者
attach(o) {
this.observers.push(o)
}
// 被观察者状态发生变化
setState(newState) {
this.state = newState
this.observers.forEach(o => o.update(this))
}
}
// 观察者
class Observe{
constructor (name) {
this.name = name
}
update(s) {
console.log(`我是${this.name},速度提升为`,s.state)
}
}
const sub = new Subject('千里马')
const observe = new Observe('伯乐')
sub.attach(observe)
sub.setState('60km/h')
// 定是函数模拟状态变化
setTimeout(function() {
sub.setState('70km/h')
}, 1000)
复制代码
结果打印:
我是伯乐,速度提升为 60km/h
我是伯乐,速度提升为 70km/h
复制代码
3.2 vue中如何体现
这里写出大概实现逻辑,具体的后续再优化。
- 被观察者
// 被观察者
let horse = {}
speed= 60;
Object.defineProperty(horse, 'speed', {
enumerable: true,
configurable: true,
set(newValue) {
speed = newValue
dep.notify(horse); // 更新数据时,进行通知
},
get() {
dep.depend(watcher) // 读取数据时,进行依赖收集
return speed
}
})
console.log("设置速度值以前,为"+horse.speed)
horse.speed = 70;
复制代码
- 观察者
// 观察者
class Watcher{
constructor (name) {
this.name = name
}
update(horse) {
// 更新后读取数据
console.log(`我是${this.name},速度提升为${horse.speed}`)
}
}
复制代码
- 依赖收集器
// 依赖收集器
class Dep {
constructor(name) {
this.name = name,
this.watchers = []
}
depend(o) {
// 依赖收集watcher,这里还可以优化成调用watcher中的addWatcher方法
this.watchers.push(o)
}
notify(horse) {
if (this.watchers.length > 0) {
// update更新watcher
this.watchers.forEach(o => o.update(horse))
}
}
}
复制代码
结果打印:
设置速度值以前,为60km/h
我是伯乐,速度提升为70km/h
复制代码
3.3 总结
- 通过Object.defineProperty()方法将data属性转变成具有getter/setter属性的被观察者。
- 通过Watcher观察属性变化,同时更新最新值。
- 通过Dep依赖收集器,连接观察者与被观察者,提供依赖收集和通知方法。
- 被观察者只要变化,就可自动更新。
4、弊端
响应式原理虽然对数据变化提供了很大的便利,但同时也存在弊端。
- 使用Object.defineProperty()对复杂数据结构会进行深度遍历,这种深度监听,会消耗大量性能。
- 对数组的响应式不支持,需要重写数组方法。
- 除了data上定义的属性之外,其他的属性都无法具备响应式特点。
今天这篇文章,侧重用最简便的方式描述vue的响应式原理,所有源码的分析与手写实践上都只提供了主要流程,试图用最简便的方式让大家能看懂。有的在自己的理解上可能存在偏差,欢迎大家指正。