经常有小伙伴向师傅反映:
师傅啊~面试的时候面试官说要问 vue 相关的问题,我当时还挺兴奋的,心想:来吧,有什么问题都冲着我来吧,我的 vue 用得杠杠的!
结果面试官一问:说一下 vue 响应式的原理呢,我瞬间就懵逼了…
说起来也是,平时小伙伴们估计只是使用 vue 的情况更多一些,对于 vue 的响应式原理,编程又用不到,所以自然也没怎么去关心过,结果面试时一被问到,自然也就会懵逼了。
所以这里,就让沃师傅来给大家介绍一下这方面的知识。
Let’s Go!
在介绍 vue 的响应式原理之前,有几个知识点不得不提,首先就发布订阅模式的介绍。
发布订阅模式是属于设计模式的一种。那什么又是设计模式呢?
所谓设计模式,代表了软件开发中的一些最佳的实践,通常被有经验的面向对象的软件开发人员所采用。
什么意思呢?来举一个生活中的例子。
小明最近看上了一套房子,到了售楼处之后才被告知,该楼盘的房子早已售罄。好在售楼 MM 告诉小明,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买。但到底是什么时候,目前还没有人能够知道。
于是小明记下了售楼处的电话,以后每天都会打电话过去询问是不是已经到了购买时间。除了小明,还有小红、小强、小龙也会每天向售楼处咨询这个问题。一个星期过后,售楼 MM 决定辞职,因为厌倦了每天回答 1000 个相同内容的电话。
当然现实中没有这么笨的销售公司,实际上故事是这样的:小明离开之前,把电话号码留在了售楼处。售楼 MM 答应他,新楼盘一推出就马上发信息通知小明。小红、小强和小龙也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼 MM 会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们。
这其实就是一种设计模式,比起第一种方式,很明显第二种方式是最优解,所以以后再遇到相同的问题时,直接采用第二种方式来解决即可。
所以说,所谓设计模式,就是软件开发人员在软件开发中所面临的一些共通问题时的最优解决方案,这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
设计模式有很多,例如单例模式、观察者模式、工厂模式…等,在遇到不同类型的问题时,就可以采用不同的设计模式来解决。
而我们要介绍的发布订阅模式,也是设计模式中的一种。
说完了设计模式,我们回头再来看发布订阅模式。什么是发布订阅模式呢?
在刚刚的例子中,发送短信通知就是一个典型的发布订阅模式。
小明、小红等购买者都是订阅者,他们订阅了房子开售的消息。售楼处作为发布者,会在合适的时候遍历花名册上的电话号码,依次给购房者发布消息。
可以发现,在这个例子中使用发布—订阅模式有着显而易见的优点。
购房者不用再天天给售楼处打电话咨询开售时间,在合适的时间点,售楼处作为发布者会通知这些消息订阅者。
购房者和售楼处之间不再强耦合在一起,当有新的购房者出现时,他只需把手机号码留在售楼处,售楼处不关心购房者的任何情况,不管购房者是男是女还是一只猴子。而售楼处的任何变动也不会影响购买者,比如售楼 MM 离职,售楼处从一楼搬到二楼,这些改变都跟购房者无关,只要售楼处记得发短信这件事情。
好了,至此,关于设计模式与发布订阅模式,你已经有一定的了解了,接下来还有一个知识点,那就是属性特性与描述符。
这是通过一个Object.defineProperty()
的方法来设置的。举个例子,平常我们习惯定义一个对象,直接书写:
let obj = {
name : 'xiejie',
age : 18
}
这个对象定义好了后,外面可以随意的访问与修改该对象的属性。
通过Object.defineProperty()
方法,我们可以进行一定的限制。可以为属性所设置的选项如下:
get:一旦目标属性被访问时,就会调用相应的方法
set:一旦目标属性被设置时,就会调用相应的方法
value:这是属性的值,默认是 undefined
writable:这是一个布尔值,表示一个属性是否可以被修改,默认是 true
enumerable:这是一个布尔值,表示在用 for-in 循环遍历对象的属性时,该属性是否可以显示出来,默认值为 true
configurable:这是一个布尔值,表示我们是否能够删除一个属性或者修改属性的特性,默认值为 true
因为 vue 中涉及到 get 和 set 的设置,所以我们重点来看一下这两个选项。
举个具体的示例如下:
const stu = {};
const age = 18;
Object.defineProperty(stu, "stuAge", {
//get:当外界获取 stuAge 属性时会自动调用
get: function () {
return age;
},
//set:当外界要设置 stuAge 属性时会自动调用
set: function (value) {
if (value > 100 || value < 0) {
age = 20;
} else {
age = value;
}
}
})
console.log(stu.stuAge); // 18;
stu.stuAge = 30;
console.log(stu.stuAge); // 30
stu.stuAge = 1000;
console.log(stu.stuAge); // 20
console.log(stu.hasOwnProperty("stuAge")); // true
这里我们为 stu 对象自定义了一个 stuAge 属性。然后为其设置了 get 和 set 属性描述符。
当用户获取 stu 对象的 stuAge 属性值时,会自动调用 get 所对应的函数,然后返回 age 变量。当用户要设置 age 的值时,这时就会有一个限制,如果用户设置的值大于 100 或者小于 0,则将 age 的值设置为 20,否则就将用户设置的值赋值给 age。
关于属性特性的更多介绍,可以参阅《属性特性与描述符》一文。
好了,至此,我们的准备知识就已经 OK 了,下面我们来手把手的实现一下 vue 中的响应式。
首先,准备我们的 html 代码,代码如下:
// index.html
<body>
<div id="app">
{{msg}}<input type="text" v-model="msg">{{msg}}
</div>
<script src="./mvvm.js"></script>
<script>
const options = ({
el: "#app",
data: {
msg: 'hello vue'
}
})
const vm = new Vue(options);
</script>
</body>
在该 html 文件中,我们像 Vue 那样去实例化 Vue 对象,但是并没有引入 Vue,而是引入了一个 mvvm.js 文件,这是我们自己的 js 文件,目前里面没有写任何东西,所以现在打开 index.html,看到的应该是如下的效果:
接下来来到 mvvm.js 文件,首先,创建 Vue 构造函数,如下:
// mvvm.js
function Vue(options){
// this 代表 Vue 实例对象,也就是 vm
// options.data 等于 {msg: "hello vue"}
observer(this,options.data); // 对数据进行劫持
this.$el = options.el;
compile(this); // 遍历模板,绑定事件
}
在该构造函数中,调用到了 2 个函数,分别是 observer 和 compile。我们一个一个来看,先来看 observer,该函数的作用是对数据进行劫持,并添加到订阅者数组中。具体的代码如下:
// mvvm.js
//数据侦听
// 接收 2 个参数:vm 是 Vue 构造函数的实例对象,obj 为 {msg: "hello vue"}
function observer(vm,obj){
var dep = new Dep(); // 新增一个发布者
// 遍历数据
for(var key in obj){
// 将数据的每一项添加到 vm 里面,至此,vm 也有了每一项数据
// 但是不是单纯的添加,而是设置了 getter 和 setter
// 在获取数据时触发 getter,在设置数据时触发 setter,至于 Dep.target 之类的可以先放一放
Object.defineProperty(vm,key,{
get(){
console.log("触发get了");
if(Dep.target){
dep.addSub(Dep.target);
}
console.log(dep.subs);
return obj[key];
},
set(newVal){
console.log("触发set了");
obj[key] = newVal;
dep.notify();
}
});
}
}
通过 observer 函数,我们将用户所设置的 data 数据添加到了 vm(Vue 实例)上面,但是不是单纯的复制了一遍,而是设置了 getter 与 setter。
observer 函数执行完毕后,接下来是:
this.$el = options.el;
这句代码很简单,就是给 vm 添加了一个$el
属性,属性值为#app
。
接下来,就是 compile 函数了。该函数接收一个参数,就是我们的 Vue 实例对象 vm,具体代码如下:
// mvvm.js
// 遍历模板
function compile(vm){
var el = document.querySelector(vm.$el); // 首先找到 {{msg}}{{msg}} 这个节点
var documentFragment = document.createDocumentFragment(); // 创建一个文档碎片
var reg = /\{\{(.*)\}\}/; // 创建正则,匹配到的是 {{ }}
// 遍历子节点,遍历一个,就将其添加到文档碎片里面
// 由于使用的是 appendChild 将节点添加到文档碎片里面,所以添加一个少一个
// 最终 el 的子节点会变成空,然后就会退出 while,此时 el 变成了
while(el.childNodes[0]){
var child = el.childNodes[0]; // 将每一个子节点存储在 child 里面
// 如果该节点是元素节点,能匹配上的就是
if(child.nodeType == 1){
// 遍历该元素节点的每一个属性,也就是 type="text",v-model="msg
for(var key in child.attributes){
var attrName = child.attributes[key].nodeName; // 获取属性名
// 找到 v-model 这个属性
if( attrName == 'v-model'){
var vmKey = child.attributes[key].nodeValue; // 先获取属性值,也就是 msg
// 为该节点,也就是 绑定一个 input 事件
child.addEventListener('input',function(event){
vm[vmKey] = event.target.value; // 获取用户输入的值,然后改变 vm 里面的 msg 属性对应的值,注意这里会触发 setter
})
}
}
}
// 如果该节点是文本节点,进入此 if
if(child.nodeType == 3){
// 进行正则匹配,匹配上的就是两个{{msg}}
if(reg.test(child.nodeValue) ){
var vmKey = RegExp.$1; // 获取正则里面的捕获值,也就是 msg
// 实例化一个 Watcher,接收 3 个参数:Vue 实例,该文本节点,捕获值 msg
new Watcher(vm,child,vmKey);
}
}
documentFragment.appendChild(el.childNodes[0]);
}
// 将文档碎片中节点重新添加到 el,也就是 下面
el.appendChild(documentFragment);
}
可以看到,在该函数中,主要就是对节点进行分析,如果是元素节点,寻找是否有 v-model,有的话就绑定 input 事件,而如果是文本节点,就看能否匹配上{{ }}
,如果能,新实例化一个 watcher。
那么这个 watcher 又是什么呢?watcher 就是依赖数据的观察者,他们会随时观察数据是否发生改变,如果改变,就会更新自身的数据。相当于发布订阅模式中的订阅者。
Watcher 部分的代码如下:
// mvvm.js
// 新建观察者 Watcher 构造函数
// 接收 3 个参数:Vue 实例,文本节点 {{ msg }} 以及捕获内容 msg
function Watcher(vm,child,vmKey){
this.vm = vm; // vm
this.child = child; // {{ msg }}
this.vmKey = vmKey; // msg
Dep.target = this; // 将该观察者实例对象添加给 Dep.target
this.update(); // 执行节点更新方法
Dep.target = null; // 最后清空 Dep.target
}
Watcher.prototype ={
// 节点更新方法
update : function(){
// 相当于:{{ msg }}.nodeValue = this.vm['msg']
// 这样就更新了文本节点的值,由于这里在获取 vm.msg,所以会触发 getter
this.child.nodeValue = this.vm[this.vmKey];
}
}
在 Watcher 构造函数中,我们要做的最重要的操作就是更新文本节点自身的 nodeValue,注意这里赋值时由于涉及到了获取 vm 的 msg 值,所以会触发前面我们所设置的 getter,所以会执行下面的代码:
...
get(){
console.log("触发get了");
// 触发 getter 时,将该 watcher 添加到发布者维护的数组里面
if(Dep.target){
dep.addSub(Dep.target);
}
console.log(dep.subs);
return obj[key];
}
...
这时就会将该 watcher 添加到 Dep 所维护的数组里面。那么 Dep 以及 Dep.target 究竟是什么呢?
这实际上就是我们的发布者,类似于上面例子中说到的售楼部,它发布了消息后,观察者(类似于小明、小红、小强、小龙)就会去更新自身的节点内容。
发布者的代码如下:
// mvvm.js
// 新建发布者构造函数
function Dep(){
// 将观察者添加到发布者内部的数组里面
// 这样以便于通知所有的观察者去更新数据
this.subs = [];
}
Dep.prototype = {
// 将 watcher 添加到发布者内置的数组里面
addSub:function(sub){
this.subs.push(sub);
},
// 遍历数组里面所有的 watcher,通知它们去更新数据
notify:function(){
this.subs.forEach(function(sub){
sub.update();
})
}
}
在发布者里面维护了一个数组,该数组用于存放所有的观察者 watcher,还拥有 2 个方法 addSub 和 notify,addSub 方法负责将 watcher 添加到发布者内置的数组里面,而 notify 方法负责遍历数组里面所有的 watcher,通知它们去更新数据。
至此,我们整个程序就书写完毕了,来看一下效果,如下:
最后附上整个程序的完整代码。
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>index</title>
</head>
<body>
<div id="app">
{{msg}}<input type="text" v-model="msg">{{msg}}
</div>
<script src="./mvvm.js"></script>
<script>
const options = ({
el: "#app",
data: {
msg: 'hello vue'
}
})
const vm = new Vue(options);
</script>
</body>
</html>
// mvvm.js
function Vue(options){
// this 代表 Vue 实例对象,也就是 vm
// options.data 等于 {msg: "hello vue"}
observer(this,options.data); // 对数据进行劫持
this.$el = options.el;
compile(this); // 遍历模板,绑定事件
}
// 新建发布者构造函数
function Dep(){
// 将观察者添加到发布者内部的数组里面
// 这样以便于通知所有的观察者去更新数据
this.subs = [];
}
Dep.prototype = {
// 将 watcher 添加到发布者内置的数组里面
addSub:function(sub){
this.subs.push(sub);
},
// 遍历数组里面所有的 watcher,通知它们去更新数据
notify:function(){
this.subs.forEach(function(sub){
sub.update();
})
}
}
// 新建观察者 Watcher 构造函数
// 接收 3 个参数:Vue 实例,文本节点 {{ msg }} 以及捕获内容 msg
function Watcher(vm,child,vmKey){
this.vm = vm; // vm
this.child = child; // {{ msg }}
this.vmKey = vmKey; // msg
Dep.target = this; // 将该观察者实例对象添加给 Dep.target
this.update(); // 执行节点更新方法
Dep.target = null; // 最后清空 Dep.target
}
Watcher.prototype ={
// 节点更新方法
update : function(){
// 相当于:{{ msg }}.nodeValue = this.vm['msg']
// 这样就更新了文本节点的值,由于这里在获取 vm.msg,所以会触发 getter
this.child.nodeValue = this.vm[this.vmKey];
}
}
// 遍历模板
function compile(vm){
var el = document.querySelector(vm.$el); // 首先找到 {{msg}}{{msg}} 这个节点
var documentFragment = document.createDocumentFragment(); // 创建一个文档碎片
var reg = /\{\{(.*)\}\}/; // 创建正则,匹配到的是 {{ }}
// 遍历子节点,遍历一个,就将其添加到文档碎片里面
// 由于使用的是 appendChild 将节点添加到文档碎片里面,所以添加一个少一个
// 最终 el 的子节点会变成空,然后就会退出 while,此时 el 变成了
while(el.childNodes[0]){
var child = el.childNodes[0]; // 将每一个子节点存储在 child 里面
// 如果该节点是元素节点,能匹配上的就是
if(child.nodeType == 1){
// 遍历该元素节点的每一个属性,也就是 type="text",v-model="msg
for(var key in child.attributes){
var attrName = child.attributes[key].nodeName; // 获取属性名
// 找到 v-model 这个属性
if( attrName == 'v-model'){
var vmKey = child.attributes[key].nodeValue; // 先获取属性值,也就是 msg
// 为该节点,也就是 绑定一个 input 事件
child.addEventListener('input',function(event){
vm[vmKey] = event.target.value; // 获取用户输入的值,然后改变 vm 里面的 msg 属性对应的值,注意这里会触发 setter
})
}
}
}
// 如果该节点是文本节点,进入此 if
if(child.nodeType == 3){
// 进行正则匹配,匹配上的就是两个{{msg}}
if(reg.test(child.nodeValue) ){
var vmKey = RegExp.$1; // 获取正则里面的捕获值,也就是 msg
// 实例化一个 Watcher,接收 3 个参数:Vue 实例,该文本节点,捕获值 msg
new Watcher(vm,child,vmKey);
}
}
documentFragment.appendChild(el.childNodes[0]);
}
// 将文档碎片中节点重新添加到 el,也就是 下面
el.appendChild(documentFragment);
}
// 数据侦听
// 接收 2 个参数:vm 是 Vue 构造函数的实例对象,obj 为 {msg: "hello vue"}
function observer(vm,obj){
var dep = new Dep(); // 新增一个发布者
// 遍历数据
for(var key in obj){
// 将数据的每一项添加到 vm 里面,至此,vm 也有了每一项数据
// 但是不是单纯的添加,而是设置了 getter 和 setter
// 在获取数据时触发 getter,在设置数据时触发 setter
Object.defineProperty(vm,key,{
get(){
console.log("触发get了");
// 触发 getter 时,将该 watcher 添加到发布者维护的数组里面
if(Dep.target){
dep.addSub(Dep.target);
}
console.log(dep.subs);
return obj[key];
},
set(newVal){
console.log("触发set了");
obj[key] = newVal;
dep.notify();
}
});
}
}
所有具体的解释,都在代码的注释中了。阅读起来可能会有一定的难度,需要大家花一定的时间,逐行去剖析代码的意思。这是一个痛并快乐着的过程,各位小伙伴加油吧!
如果通过阅读本文,大家下次面试被问到 vue 响应式原理时,能够道出一二,那么本文也是有价值的。
这里送上沃师傅对大家面试时的祝福:
好了,今天的小小知识课堂就到这里,我们下次再见!
了解更多请关注公众号“朗沃IT学习”