一、前言
Vue可以说是最近比较火的一个框架了,自己也用vue写过几个小项目了,所以在空余时间研究了一下vue双向绑定的原理,最后形成博客让自己印象更加深刻,也算给大家分享一些经验。
二、实现原理
首先我们来说一下vue的双向绑定到底是如何实现的。其实vue是使用了数据劫持+订阅发布模式来实现的双向绑定。其中最主要的一个函数就是Object.definProperty(),如果不清楚这个函数的用法,可以查看MDN对于该函数的介绍。在这里,我们就是通过它的get/set来实现对数据的劫持。然后通过订阅发布模式实现双向绑定。我们来简单看一下如何实现数据劫持:
let demo = {
name:""
}
let initValue = "init";
Object.defineProperty(demo, "name", {
configurable:true,
enumerable:true,
get: function() {
console.log("get 方法");
return initValue;
},
set: function (value) {
console.log("set 方法");
initValue = value;
}
})
console.log(demo.name);
//get 方法
//init
demo.name = "demo";
//set 方法
这样我们每次在获取和设置demo.name的值的时候都会使用我们自定义的两个方法。那么,劫持了数据之后,我们该如何让数据和视图实现同步呢?此时我们应该从两个方面想:
- 视图改变,数据同步
- 数据改变,视图同步
其中第一点通过事件监听很容易实现,例如当输入框里面的内容改变时,我们可以设置input事件,视图改变的时候会触发这个事件,我们在将数据与视图同步。
那么第二点就是当数据改变的时候,我们如何同步到视图。此时我们应该有下面几个问题需要解决:
- 当数据改变时,视图如何知道数据发生了改变
- 数据改变时,我们如何知道应该更新哪个视图的数据
这里我们通过订阅发布模式来解决这个问题 ,我们来看下面这张图
我们一个个来分析。
- 我们需要一个监听器Observer来劫持data中的所有属性,这样我们就能知道数据何时发生了改变,
- 我们需要一个编译器Compile,当解析html页面时判断哪些数据需要建立双向绑定,生成对应的订阅者Watcher加入到数组中
- 我们将订阅者都存在一个数组内,数组是Dep类的一个属性,当数据改变时,调用Dep类的notify方法去通知每个订阅者,数据已经发生了改变
- 订阅者接到通知后调用自身的方法去更新视图
所以,总结起来就是,我们想要实现这个功能,需要实现一个监听器Observer,一个订阅者Watcher,一个Dep类存储订阅者,一个编译器Compile。下面我们一个个来实现,其中有的部分实现的很简单,但是可以帮助我们理解vue的原理。
三、实现监听器Observer
因为definProperty只能监听某个对象的某个属性,所以我们借助递归来实现监听整个对象,这一步实现起来比较简单:
class Observer {
constructor(data) {
this.observerAll(data);
}
observer(data, key, value) {
this.observerAll(value);
Object.defineProperty(data, key, {
configurable:true,
enumerable:true,
get: function () {
console.log("get " + value);
return value;
},
set: function (newVal) {
console.log("set " + newVal);
value = newVal;
}
})
}
observerAll(data) {
if(Object.prototype.toString.call(data) !== '[object Object]') return ;
Object.keys(data).forEach((key) => {
this.observer(data, key, data[key]);
})
}
}
let obj = {
name:"obj",
m: {
name:"m"
}
}
new Observer(obj);
obj.m.name;
// get [object Object]
// get m
obj.name = "nihao";
// set nihao
console.log(obj.name);
// get nihao
// nihao
同时我们简单的测试一下,证明我们的代码是ok的。
四、实现一个订阅者
对于订阅者,他应该有四个属性:vm, exp, cb, value。其中vm是监听的对象,exp是监听的属性,有了这两个属性我们才能知道某个订阅者监听了哪个属性。cb是当数据改变时订阅者应该执行的回调函数。value保存的是旧的值,当订阅者接受到一个通知时,应该比较新值和旧值,如果数据发生了改变,则更新视图,否则不需要更新视图。订阅者还有三个方法,其中run和update是更新是要调用的方法。get是初始化时调用的方法。我们着重讲解一下get方法。我们前面提到,订阅者是存储在一个数组中的。而我们是在编译模版时将订阅者存储到数组中去的,那么我们通过什么方式将订阅者加入到数组中呢?因为我们每次获取和改变数据都会使用到set和get函数,而订阅者value的初值就是他监听数据的值,所以我们势必要通过get方法来获取到数据。所以我们可以把将watcher实例加入数组的代码写在get函数中,即Observer中。那么问题又来了,我们不仅仅第一次会调用get方法,后面也会调用get方法,如何避免多次加入同一个watcher?我们只在初始化watcher的时候才需要将watcher加入数组,所以我们在Watcher的get方法中获取数据的同时在Dep.target上将这个订阅者缓存,在Observer的get方法中我们只需要判断Dep.target中是否有缓存,即可判断是否需要执行add操作。下面我们来写代码:
class Watcher {
constructor(vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get();
}
update() {
this.run();
}
run() {
let oldValue = this.value;
if(oldValue !== this.vm[this.exp]) {
this.value = this.vm[this.exp];
this.cb(this.value);
}
}
get() {
Dep.target = this;
let value = this.vm[this.exp];
Dep.target = null;
return value;
}
}
同时我们需要修改Observer中的代码
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
notify() {
this.subs.forEach((sub) => {
sub.update();
})
}
}
class Observer {
constructor(data) {
this.dep = new Dep();
this.observerAll(data);
}
observer(data, key, value) {
this.observerAll(value);
Object.defineProperty(data, key, {
configurable:true,
enumerable:true,
//第二处更改
get: () => {
//第三处修改
if(Dep.target) {
this.dep.addSub(Dep.target);
}
return value;
},
set: (newVal) => {
value = newVal;
this.dep.notify();
}
})
}
observerAll(data) {
if(Object.prototype.toString.call(data) !== '[object Object]') return ;
Object.keys(data).forEach((key) => {
this.observer(data, key, data[key]);
})
}
}
我们的修改有三处,第一处是添加了Dep类,他有一个数组存储这订阅者,同时提供addSub方法添加新的订阅者。他还有一个方法可以通知数组中所有的订阅者,通过代码可以看到,一旦某个数据发生改变,他会通知所有的订阅者,而订阅者根据自身缓存的值和新值比较来判断是否应该更新视图。第二处更改我们把set和get改成了箭头函数,因为我们在函数中需要使用this值,而如果使用普通函数会导致this指向错误,所以我们通过箭头函数来修正this值。第三处修改就是我们添加订阅者的代码,我们判断Dep上是否有缓存,如果有则将其加入数组。至此我们已经实现了监听者和订阅者,其实此时我们已经实现了一个简单的双向绑定,我们来看看效果。测试代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="Observer.js"></script>
<script src="Watcher.js"></script>
<script src="index.js"></script>
</head>
<body>
<div id="root">
emmm
</div>
<script>
let data = {
name:"msg"
}
new Observer(data);
new Watcher(data, "name", (value) => {
let root = document.getElementById("root");
console.log(root)
root.textContent = value;
})
</script>
</body>
</html>
首先页面显示的是hello
随后我们在代码中手动调用observer和watcher,并且为watcher传入回调函数:当数据改变时改变页面上div结点显示的内容。这里的各项数据都已经写死了,只是为了测试效果。当我们在控制台改变data的数据时,页面数据也发生了改变:
好了,最后我们来写一个简单的编译器,自动的为页面中的元素建立订阅者。
五、实现Compile
解析器需要获取到页面中的dom元素并且对其进行遍历,找出其中需要添加订阅者的地方,替换模版数据并且初始化一个订阅者。我们这里只实现vue中的{{}}。
class Compile {
constructor(vm, el) {
this.vm = vm;
this.el = el;
let root = document.querySelector("#root");
this.compile(root);
}
compile(root) {
let childNodes = root.childNodes;
let reg = /\{\{(.*)\}\}/;
[...childNodes].forEach((node) => {
let text = node.textContent;
if(node.nodeType === 3 && reg.test(text)) {
let exp = reg.exec(text)[1];
this.addWatcher(exp, node);
this.updateText(node, this.vm.data[exp]);
}
if(node.childNodes && node.childNodes.length) {
this.compile(node);
}
})
}
addWatcher(exp, node) {
new Watcher(this.vm, exp, (value) => this.updateText(node, value));
}
updateText(node, value) {
node.textContent = value;
}
}
我们获取到el结点之后,对其进行遍历,对于每个结点先判断他是否属于文本结点,即nodeType是否等于三。如果是文本结点,利用正则表达式判断其是否符合{{xxx}}格式将里面的内容。如果符合,将文本替换成对应的数据,并且初始化一个订阅者。否则不做任何事。最后,判断该结点是否还有子结点,继续递归。这样我们就能将页面中所有的{{xxx}}类的数据替换成我们想要的值。
六、整合
最后,我们将这几个文件整合到index.js。我们创建一个MyVue类,接受一个对象作为参数。同时我们在构造函数中调用Observer和Compile。
class MyVue {
constructor(option) {
this.data = option.data;
this.el = option.el;
new Observer(this.data);
new Compile(this, this.el);
}
}
最后我们来看一下效果:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="Observer.js"></script>
<script src="Watcher.js"></script>
<script src="Compile.js"></script>
<script src="index.js"></script>
</head>
<body>
<div id="root">
<div>
<div>
nihao
<div>
{{name}}
</div>
</div>
</div>
</div>
<script>
new MyVue({
el:"#root",
data: {
name: "data"
}
})
</script>
</body>
</html>
在页面上呈现的效果是这样的:
至此,我们就完成了一个非常简单的效果,但是我想,通过这个简单的例子也能让我们对于vue双向绑定的原理有更深刻的认识。
七、结语
最后,如果关心前端技术变化的同学可能会知道,vue3中使用了proxy来代替defineProperty。其实,proxy只是相当于一个defineProperty的升级版本,其最底层的思想还是不会改变的。当然,既然vue团队决定使用proxy来代替原来的方法,那么就证明后者肯定是优于前者的。那这两者到底有什么区别呢?我将会在下一篇博客中为大家介绍两者的区别。同时,因为自己水平有限,写的东西肯定还有很多错误,希望大家及时指出,我们共同进步。同时,文中涉及的代码已经上传到了github:https://github.com/klx-buct/myVue。