前言
emm...关于这道题之前已经被好几个面试官问过了,虽然是自己在自我介绍时提到过我了解vue响应式底层原理,但我总感觉没说清楚,所以现在有时间就把过程写下来,方便自己加深理解和口头表达。
正文
先提供一个简单的双向绑定的模板吧,也就是所谓的入口文件,接下来响应式就发生在这个模板中。
html
// index.html
<body>
<div id="mvvm-app" style="text-align: center;margin-top: 100px;">
<h2>{{title}}</h2>
<button v-on:click="clickBtn">数据初始化</button>
</div>
</body>
下面是一个简单的vue2创建vue实例的过程,一个挂载点el:'#mvvm-app'
,一个简单的数据title: 'hello world
,再加上一个简单的方法用来触发响应式机制从而替换页面上的hello world
。即简单地使用按钮方法来实现MVVM。
js
import MVVM from './mvvm';
var vm = new MVVM({
el: '#mvvm-app',
data: {
title: 'hello world'
},
methods: {
clickBtn: function(e) {
this.title = '你好'
}
}
})
其中MVVM是一个自定义的构造函数,该构造函数的主要功能:
(1) 把传入MVVM中的el,data,methods进行一些处理(发生在mvvm.js
中);
(2) 进行一些数据监听(发生在observer.js
);
(3) 把vue文件template
模板编译 成html文件(发生在compile.js
);
(4) 在编译 时对vue中特有的{{}},属性和指令等进行监听(发生在watcher.js
中),并把这些监听全部存入一个管家中Dep
(订阅与发布者设计模式),当数据发生改变时,就会触发在编译时存入管家中的监听,然后更新数据。
以上这些就是我们需要实现的函数了,一共四个模块mvvm.js
,observer.js
,compile.js
,watcher.js
。
mvvm.js
在mvvm.js
中,options
就是 {el: '', data: '', methods: ''} 这些对象属性,我们需要把data
和methods
从options
中取出,存入自己属性中方便后续进行一些操作。
observe(this.data) 是对data中的数据进行监听,通过defineProperty
这个特有的方法来进行get
读取操作,set
修改设置操作。比如console.log(this.data.title)
就触发了get
操作,this.data.title = '你好'
触发了set
操作。
new Compile(options.el, this) 中,需要传入el和this(即vue实例),因为在浏览器中浏览器只能 识别.html
文件,而无法 直接识别.vue
文件,因此需要Compile()
这个函数把.vue文件转换成.html文件,并且顺便给它作一个响应监听,即观察者(Watcher
)。
js
// mvvm.js
import { observe } from './observer';
import Compile from './compile';
function MVVM(options) {
this.data = options.data; // 存入data属性
this.methods = options.methods; // 存入methods方法
observe(this.data); // 对data中的数据进行响应式监听
// 编译index.html文档中的vue模板
new Compile(options.el, this);
};
observer.js
接下来就是对this.data进行具体的监听操作了,先判断是否为对象或者已经没有值了,如果不满足,就说明传过来的value需要进行深层次的监听,对value中数据类型为object
的进行递归
js
// observer.js
export const observe = (value) => {
if (!value || typeof value !== 'object') { // 如果data中没有值或data不是对象而是函数
return;
}
return new Observer(value);
}
function Observer(data) {
this.data = data;
this.walk(data);
}
walk方法是先用 Object.keys(data)
来把data对象中的属性一个一个遍历出来,因为data中的数据可能是个对象;然后传入defineReactive
中进行监听,但需要考虑这个data里是否还包含了一个对象,所以需要再调用一下observer(data)
,直到这个属性不为object
或没有值为止,然后再一个一个的给它们加上监听,其实实现的思想就是使用递归。
js
// observer.js 接上文
Observer.prototype = {
walk: function(data) {
var self = this;
Object.keys(data).forEach(function(key) {
self.defineReactive(data, key, data[key])
})
},
defineReactive: function(data, key, val) {
var childObj = observe(val); // 递归
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function getter() {
return val;
},
set: function setter(newVal) {
// 如果值没变,就不需要修改
if (newVal === val) {
return ;
}
val = newVal;
}
})
}
}
最后,我们来对Object.defineProperty
中get
和set
来进行详细地分析
当数据发生改变时,就会触发set
操作 (这点非常的重要,贯穿整个响应式是,千万要记牢 ),而我们在set
方法中向Dep这个管家添加一个触发更新模板数据的事件,当你修改了某个值时,就会触发这个事件,然后这个事件就把你修改值所在的模板进行重新编译,替换成新的值,从而实现了一个MVVM响应式。
js
// get和set详情
Dep.target = null; // 指向 get 的对象 templage 计算属性 生命周期 watch
var dep = new Dep(); // 解耦,使用订阅发布者模式 pub sub
get: function getter() {
// 收集订阅者
if (Dep.target) {
dep.addSub(Dep.target); // 添加了订阅者
};
return val;
},
set: function setter(newVal) {
// 如果值没变,就不需要修改
if (newVal === val) {
return ;
}
val = newVal;
// 发布者告知 订阅者执行
dep.notifiy();
}
export const Dep = function() {
this.subs = []; // 订阅者数组
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notifiy: function() {
this.subs.forEach(function(sub){
sub.update();
})
}
}
这样,就完整地完成了对data中的数据进行监听了。不过,这里只是对数据进行了一个响应式地功能,代码还不完整。你想想,当你修改数据,数据确实是完成了修改更新,但你浏览器中页面还没进行更新 呢,因此需要你在set
那里设置一个更新页面的函数,只要你一修改数据,就会触发set
操作,然后在set
中进行更新页面操作,就完成了真正地响应式了。所以接下来就是对编译模板那块进行细聊了。
compile.js
关于compile.js
这块,由于我们是面向面试的,所以只需要对面试官说清楚大概地流程就行,如果时间充裕并且想理解地更加仔细,可以看看源码
首先引入watcher.js
中的Watcher函数,Watcher
的意思就是观察,用于观察vue模板中的数据有没有变化,进行相应响应式的更新。
然后就是找到挂载节点el
的DOM元素,即<div id="mvvm-app></div>
,方便把vue模板转化为正常模板时挂载到子节点下;以及初始化一个fragment,为了存储vue模板中的所有子节点。
js
// compile.js
// 注意这里引入了Watcher
import Watcher from './watcher';
function Compile(el, vm) {
this.vm = vm;
this.el = document.querySelector(el)
// 每次编译时都需要一个
this.fragment = null;
this.init();
}
之后就是把vue模板转化为浏览器能够识别的正常html,由于篇幅问题且该篇文章是为了对响应式有个大致的理解,所以代码会有所省略,方便理解。
针对下述方法会单独拎出来讲解
js
// 接上文
// compile.js
Compile.prototype = {
init: function() {},
compileElement: function(el) {},
compileText: function (node, exp) {},
updateText: function(node, value) {}
}
export default Compile;
init()函数
通过nodeToFragment
函数,来寻找el挂载节点的子节点,并把挂载el的子节点存入fragment中,该函数未写,理解功能就行。
通过compileElement
函数,把vue模板编译成浏览器能识别的html,把自定义的v-on编译成js原生监听事件,把自定义的{{}}模板数据转成正常模板,
然后再通过appendChild
把所有html模板加入el
节点的子节点中
js
init: function() {
if (this.el) {
this.fragment = this.nodeToFragment(this.el); // 把挂载el的子节点存入fragment中
// 编译子节点,把自定义的v-on编译成js原生监听事件,把自定义的{{}}模板数据转成正常模板
this.compileElement(this.fragment);
this.el.appendChild(this.fragment); // 把编译成正常的模板后挂载到el节点上
} else {
console.log('DOM元素不存在')
}
},
compileElement()函数
拿到子节点后,通过正则表达式进行判断节点是否为模板字符串{{}}
,如果是元素节点,先判断节点中有无自定义的事件v-on:click
,然后通过compile
函数把它转为原生js监听事件,使用addEventListener进行监听。
再判断节点是否为文本节点,即{{msg}}
也是一个节点,类似<div>Hello</div>
中的Hello,但这里面有div元素节点和Hello文本节点。如果是文本节点,就把模板数据{{msg}}转为正常文本
最后就是进行递归来检查节点是否有子节点,然后逐一把子节点进行转化。
js
compileElement: function(el) {
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {、
var reg = /\{\{(.*)\}\}/; // 正则判断是否为模板字符串{{}}
var text = node.textContent; // 文本节点
if (self.isElementNode(node)) { // 元素节点
// 把自定义命令v-on:click转化为原生监听事件,使用addEventListener
self.compile(node);
} else if (self.isTextNode(node) && reg.test(text)) {
// 把模板数据{{msg}}转为正常文本
self.compileText(node, reg.exec(text)[1]);
}
// 递归,如果节点包含了子节点,那么继续编译
if (node.childNodes && node.childNodes.length) {
self.compileElement(node);
}
});
},
compileText函数
这里就是简单的把模板数据{{}}
转化为正常数据,但重点 是在这里新建了一个Watcher实例,用于观察当前转化的模板数据会不会发生变化,如果发生了变化,就会触发在observer.js
中的Object.defineProperty中的get函数里的dep.notice()
函数触发这个Watcher实例里的回调函数进行更新,响应式就大致完成了。这里你可以往上看看get函数并结合下面的Watch.js进行分析。
js
compileText: function (node, exp) {
var self = this;
// 这里会触发Object.defineProperty的get函数
// 数据 this.vm.data[exp],拿到vue实例的初始数据,exp表示{{msg}}中的msg
var initText = this.vm[exp];
this.updateText(node, initText); 把{{}}转化成正常数据
// 将这个节点添加到订阅者中,实例化订阅者
// 注意,真正触发响应式机制,留个Watcher实例在这,方便后面修改时直接调用修改数据
// 使用call调用了回调函数,并传回了新值
new Watcher(this.vm, exp, function(value) {
self.updateText(node, value);
})
},
updateText: function(node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
}
Watch.js
这里就是引入observer.js
中创建的Dep,在一开始时就调用自身的get函数,用于存储模板中新建的那个Watcher实例,实例中的回调函数和拿到最新的值。
js
// 订阅发布者
// vdom js html
import { Dep } from './observer';
function Watcher(vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get();
};
在get函数中,一开始就会触发get函数,直接把this代表的watcher实例存入Dep中的target,并且在下一行代码的this.vm.data[this.exp]
中会触发Object.defineProperty的get函数,把watcher实例存入dep中。所以当发生模板数据改变时,就会触发Object.defineProperty的set函数,然后就会执行watcher实例中的回调函数进行响应式更新数据。请结合上述的observer.js
中的Object.defineProperty。
js
Watcher.prototype = {
get: function() {
Dep.target = this; // 缓存这个watcher实例
// 注意,这里触发了一次Object.defineProperty的get函数,
// 并且有了Dep.target,可以在Object.defineProperty的get函数中存储这个watcher实例
var value = this.vm.data[this.exp]; // hello world
Dep.target = null;
return value;
},
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) { // 更新
this.value = value;
// 使用call调用了回调函数,并传回了新值
this.cb.call(this.vm, value, oldVal);
}
}
};
export default Watcher;
自述
面对面试官时,大致可以描述为:
我举个场景吧,在点击按钮时触发文本发生响应式的更新,也就是当我点击更新按钮时,原本的'你好'变为了英文的'hello world'。
先是创建一个vue的模板,包含了一个挂载节点,文本{{msg}}和vue的v-on:click来响应式更新数据。
然后新建一个自定义vue的实例,包含了el,data和methods。整个响应式的过程我大致分为四个模块,mvvm.js
,observer.js
,compile.js
,watcher.js
。
先是在mvvm.js
中解析el,data,methods,并把el,data,methods传入observer和compile,相当于一个中转站。
然后在observer.js
中对数据使用Object.defineProperty的get和set进行数据监听
接着在compile.js
中把vue模板数据{{}}和自定义vue的v-on:click事件转化为正常的数据和原生js的监听事件,但在把模板数据{{}}进行转化时,会在对应的模板数据中添加一个watcher观察者的实例进行观察模板数据有没有发生变化,如果发生了变化,就会触发observer.js
中的Object.defineProperty的set函数,来调用留存在这里的watcher实例,从而触发watcher实例里的回调函数来更新数据。
总结
这里只是针对了点击事件时触发的响应式更新,因为我个人认为这是最好理解的。其他的比如表单的v-model等其他vue事件,可以在compile.js中进行相应的添加。
以上是我在回答面试官时的自述,非常地恳切希望大家有不同观点的讨论让我来进行修改。