使用过vue的人都知道vue是的数据是双向绑定的,data里的属性赋值之后ui会自动更新。但是具体的代码实现,很多人并不完全明白。这篇文章主要对于响应式核心原理进行分解,由浅入深。
这是上篇,还有下篇
初学者要先掌握以下知识点:
Object.defineProperty的使用
Object.defineProperty
的使用,可以参考MDN 的API说明文档,es2015之后提供了提供了更强大的新接口Proxy
、可以实现代理getter
和setter
以及其他的属性和方法,vue3已升级为Proxy
方式,更多信息可参考MDN Proxy使用说明文档
使用方法如下:
var obj = {};
Object.defineProperty(obj,"newKey",{
get:function (){
console.log('getValue')
return 'getValue';
},
set:function (value){
console.log(value)
return value
}
})
let newValue = obj.newKey // 输出 'getValue'
obj.newKey = 'myValue' // 输出 'myValue'
newValue = obj.newKey // 输出 'getValue'
//结果:newValue = 'getValue'
复制代码
解释:obj
通过Object.defineProperty
设置newKey
属性之后,属性的取值操作会调用get
方法,赋值操作会调用set
方法,由于get
方法的返回值是'getValue'
,所以最终newValue
的值是'getValue'
发布订阅模式
- 事件发布订阅模型,这是程序设计中用得非常多的一种设计模式,简单实用。代码参考
class Event {
constructor() {
this.events = {};
}
on(event, callback) {
this.events[event] = callback;
}
emit(event, arg) {
this.events[event](arg);
}
off(event, arg) {
delete this.events[event];
}
}
let EventInstance = new Event();
// 订阅事件
EventInstance.on("eventName", (arg) => {
console.log(arg);
});
// 发布事件
EventInstance.emit("eventName", "message"); //输出:message
// 取消订阅
EventInstance.off("eventName");
复制代码
以上就是事件订阅发布模式的基本代码框架
观察者模式
观察者模式,总体思路的也是订阅和发布,先看代码:
// 被观测者
class Observed {
constructor() {
this.listener = [];
}
subscribe(subject) {
this.listener.push(subject);
}
unsubscribe(subject) {
this.listener = this.listener.filter((item) => item !== subject);
}
notify(message) {
this.listener.forEach((subject) => {
subject(message);
});
}
}
// 被观测者,用于添加观测方法或对象
let observer = new Observed();
let subject = () => {
console.log("subject is a function");
};
// 添加观察者subject
observer.subscribe(subject);
// 通知所有的观察者
observer.notify("message");
// 移除观察者subject
observer.unsubscribe(subject);
// 输出结果:
// subject is a function
复制代码
PS:上面说的观察者,如果不好理解的话,可以直接看作是回调函数
,被观测者就是事件对象
从上面的代码可以看出这两种模式,都要先注册,再发布,总的思想是一样的,二者几乎没区别。硬要说具体区别的话就是事件发布之前需要知道具体的事件名,而订阅者模式不需要。

用大白话讲,比如我们要把学校所有年级的教室门打开。
事件模式
要这么喊 :“一年级芝麻开门”、“二年级芝麻开门”、“三年级芝麻开门” ……
订阅者模式
则直接大喊一声:“芝麻开门”, 完事!
分割线
那Vue
里的订阅和发布是用的那种模式呢,答案是:观察者模式
这里留一个小小的疑问,为什么要使用观察者模式呢?可以用事件模型来实现吗?
那基于上面的了解,我们可以预先设想一下Vue
响应式的基本思路:
1、设计一个被观测者,用于添加观测者,观测者可以处理发布的消息
2、 被观测对象通过Object.defineProperty
进行包装,劫持get
、set
方法。
3、设计一个观测者,用于处理发布更新后的操作,即订阅后的回调
4、观测时机:取值操作时,触发get
用于添加观测者。赋值操作时,触发set
通知观测者进行更新
接下来,我们根据上面的思路一步一步来对响应式原理进行实现,为了便于理解Vue
的源码,我们尽量和Vue
中所使用的的属性和方法名保持一至,同时忽略大部分兼容和边界情况的处理细节
设计Dep类
第1步设计一个被观察者,Vue
里面叫Dep
,这个和上面的示例Observed
没有太大的区别,只是对应的方法名和属性有所不同。
let uid = 0;
class Dep {
constructor() {
this.id = ++uid;
this.subs = [];
}
// 添加被观测者
addSub(sub) {
this.subs.push(sub);
}
// 移除被观测者
removeSub(sub) {
const index = this.subs.indexOf(sub);
if (index > -1) {
return this.subs.splice(index, 1);
}
}
// 发布更新
notify() {
const subs = this.subs.slice();
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
depend() {}
}
复制代码
这样Dep
就可以订阅和发布了,比如:
let dep = new Dep();
let watcher = { update: () => { console.log("update"); }};
dep.addSub(watcher);
dep.notify();
复制代码
比如我们要用Vue
写一个名片Card
组件,大概是这样,html
,css
已省略
class Card extends Vue {
data = {
name: "",
phone: "",
};
mounted() {
this.data.name = "xiaomin";
this.data.phone = 1234557;
}
}
复制代码
那么我们使用Vue
的时候,要观测变化的对象是什么呢,是上面的Dep
吗?显然不是,我们都知道是data
。但是Dep
既然被定义为被观察者,那说明它和data
之间肯定是有一个绑定的关系。那具体是怎么关联起来的呢?
我们可以按照第2步的思路来看,既然被观测者是data
,那么我们要劫持的对象也是data
我们定义一个方法叫defineProperty
function defineReactive(obj, key, val) {
const property = Object.getOwnPropertyDescriptor(obj, key);
const getter = property && property.get;
const setter = property && property.set;
val = val || obj[key];
// 这里就是 data 和 Dep 结合起来的地方
const dep = new Dep()
Object.defineProperty(obj, key, {
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val;
dep.depend()
return value;
},
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val;
if (value === newVal) {
return;
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
dep.notify()
},
});
}
复制代码
上面data
和Dep
结合起来的地方,乍一看,好像看不出个所以然。这么说你就明白了,其原理就是利用js
闭包的机制。在每个obj[key]
之下,都有一个dep
变量,并且一直被get
和set
引用。
这样我们在取值和赋值的时候就具有了初步的响应式效果,我们用下面的代码进行测试
let data = { name: "aaa", phone: 1212 };
defineReactive(data, "name");
let name = data.name; // 取值调用 dep.addSub()
data.name = "bbb"; // 赋值调用 dep.notify()
// subs[i].update(); 报错,因为此方法未实现,先将其注释
let phone = data.phone;
data.phone = "bbb";
复制代码
从上面的测试代码代码可以看出,data.name
具有响应式的效果,而data.phone
不具备,因为没有调用defineReactive(data, "phone")
。接下来我们提供observe
方法让data
的所字段都具备响应式效果。
function observe(value) {
for (var key in value) {
defineReactive(value, key);
}
}
observe(data);
observe(data);
复制代码
上面的方法可以让data
所有的字段都具备响应式效果,但如果多次调用observe(data)
会发现get
set
被多次拦截,造成同样的观察者被重复添加。所以我们要对已添加过get
set
的属性进行标识,避免重复添加。
设计Observer类
为了避免get
set
被重复拦截,Vue
设计了Observer类,在每个value
上都添加__ob__
标识,下面是具体的实现代码
class Observer {
constructor(value) {
// 这里的dep主要是用于对数组的观测支持
this.dep = new Dep();
Object.defineProperty(value, "__ob__", { value: this });
this.walk(value);
}
walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
}
}
function observe(value) {
let ob;
if (value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else if (Object.isExtensible(value)) {
ob = new Observer(value);
}
return ob;
}
复制代码
这样就避免了重复拦截get
set
的问题,但现在只支持单层的data
,比如:
let data = { person: { name: "aaa", phone: 1212 } };
observe(data);
let name = data.person.name;
data.person.name = "bbb";
复制代码
上面的赋值是不会被Dep
监听到的,这里只指出问题,具体的解决不在这里展开。解决方法是在defineReactive
里实现的,可以参考Vue
的源码 或 Vue响应式原理和实现方式,Observer、Dep、Watch源码解析(下)深入浅出
设计Watcher类
现在前两步已经实现,接下看下一步,第3步我们要定义一个观察者,对发布的订阅进行处理
上面我们看到subs[i].update();
这一步会报错,说明观察者有一个update
方法,除此之外,在设计Watcher
之前,首先要清楚回调要处理什么问题。
根据上面的源码我们知道Dep.subs
存放的是观察者,也就是要设计的Watcher
类,那观察者来源有哪些呢?
我们来举个例子:
<template>
<Component1>{{ name }}</Component1>
</template>;
// 一段时间后页面展示如下
<template>
<Component1>{{ name }} - {{ age }}</Component1>
</template>;
// 最终展示如下
<template>
<Component1>{{ name }}</Component1>
<Component2>{{ phone }}</Component2>
</template>;
复制代码
说明:上面是页面显示的变化过程,我们要根据显示数据的变化,来修改对应的观察者,新增数据引用时添加观察者,数据引用消失时,则将原有观察者删除。
我们暂且先简化理解为观察者来源就是页面上显示的数据,当页面数据变化时,那么Dep.subs
也应该相应的发生变化。
所以Watcher
要处理的主要问题就可以简化为:根据数据的引用关系动态修改Dep.subs
里的Watcher
到了这里,还剩最后一个问题,既然Dep.subs
里存放的是Watcher
实例,那它是 什么时候创建呢? 我们提供两种方案:
- 每个引用的数据都创建一个
Watcher
实例 - 每个组件创建一个
Watcher
实例
显然第2种更具优势,如果是第1种方案,每个Watcher
只能处理自身的响应,局限性太强,其次会面临性能问题,因为会创建很多的Watcher
。第2种的优势在于:
- 一个
Watcher
可以观测多个变量的变化,性能优于第1种。 Watcher
是根据组件生成的,所以可以和dom
树一样,形成树形结构,便于管理
弄清楚了这些问题,下面我们可以开始代码的实现,上面defineProperty
中的get
是添加观察者的入口,其调用方法是dep.depend()
,首先来实现depend
这个方法。
由于每个组件下只有一个Watcher
,先简化为watcher
实例是个全局变量,通过全局变量的方法管理Dep.subs
来简化其逻辑。
// Dep.target来作为全局变量,用于存放watcher,通过 addDep 方法来管理 Dep.subs
function depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
复制代码
接下来实现Watcher
类的,他的主要功能如上所述
class Watcher {
constructor(vm, expOrFn, cb) {
this.id = ++uid;
this.cb = this.cb;
this.vm = vm; // 执行上下文
this.getter = expOrFn;
this.deps = [];
this.newDeps = [];
this.value = this.get();
}
get() {
Dep.target = this;
let value = this.getter.call(this.vm);
Dep.target = null;
// this.cleanupDeps();
return value;
}
cleanupDeps() {
let i = this.deps.length;
while (i--) {
const dep = this.deps[i];
if (!this.newDeps.includes(dep)) {
dep.removeSub(this);
}
}
this.deps = this.newDeps;
this.newDeps = [];
}
addDep(dep) {
// 避免重复添加
if (!this.deps.includes(dep)) {
this.newDeps.push(dep);
dep.addSub(this);
}
}
update() {
// 触发所有的defineProperty下get方法,重新收集依赖
let oldValue = this.value
this.value = this.get();
this.cb && this.cb.call(this.vm,this.value,oldValue);
}
}
复制代码
以上就是Watcher
类的基本代码,下面我们通过代码来测试,看一下效果
let data = { name: "aaa", phone: 1212, age: 18 };
observe(data);
// 初始化时触发this.get(),更新 Dep.subs
new Watcher(data, () => {
let name = data.name;
console.log('name');
});
new Watcher(data, () => {
let phone = data.phone;
console.log('phone');
});
复制代码
data.name = 'bbb'
// 输出:name
复制代码
data.phone = 1123
// 输出:phone
复制代码
可以看到,对使用过的值进行赋值,会触发的对应的Watcher
处理函数expOrFn
Vue中对应的处理函数expOrFn
则是updateComponent
可以对组件进行重新取值渲染。完成页面更新
结尾
如有兴趣了解更详细的源码解读请点击下方链接
Vue响应式原理和实现方式,Observer、Dep、Watch源码解析(下)深入浅出
参考链接