前言
很多人在面试过程中都有问到Vue双向绑定的原理和实现,这是一个老生常谈的面试题了,虽然网上也有很多实现双向绑定的文章,但是我看后觉得对于大多数前端小白来说,不是很容易理解,所以,这篇文章我就用最简单的代码教大家怎么实现一个Vue的双向绑定。
双向绑定的原理
用过Vue框架的都知道,页面在初始化的时候,我们可以把data里的属性渲染到页面上,改动页面上的数据时,data里的属性也会相应的更新,这就是我们所说的双向绑定,所以,简单来说,我们要实现一个双向绑定要实现以下3点操作:
- 首先需要在Vue实例化的时候,解析代码中
v-modle
指令和{{}}
指令,然后把data里的属性绑定到相应的指令上,所以我们要实现一个解析器Compile,这是第一点; - 接着我们在改变页面的属性的时候,要知道哪个属性改变了,这时候我们需要用到
Object.defineProperty
中的getter
和setter
方法对属性进行劫持,这里我们要实现一个监视器Observer,这是二点; - 我们在知道具体哪个属性改变后,要执行相应的函数,更新视图,这里我们要实现一个消息订阅,在页面初始化的时候订阅每个属性,并且在
Object.defineProperty
数据劫持的时候接收属性改变通知,更新视图,所以我们要实现一个订阅者Watcher,这是第三点。
1. 实现Compile
首先,我们从最基本的解析指令开始,话不多说,先上代码:
我们在写Vue的时候,用了v-model
和{{}}
指令,但是页面渲染的时候,我们在浏览器看到的节点是这样的。
我们从上面的图片可以看到,代码里写的指令都消失了,但是data里的属性都正常渲染到页面上了,
原理其实很简单,在Vue实例化的时候,Vue便利循环,扫描和解析每个节点的相关指令,然后再根据对应的指令赋值,最后把相应的指令替换删除,再重新渲染页面。
所以,接下来我们要实现一个解析器Compile,先从解析v-model
和{{}}
开始。
话不多说,上代码:
<!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>MVVMdemo</title>
</head>
<body>
<div id="app">
<input type="text" v-model="text">
<div>{{text}}</div>
</div>
</body>
<script type="text/javascript">
var vm = new Vue({
el: 'app',
data: {
text: 'hello world',
}
})
function Vue(options) {
this.data = options.data;
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文档片段)
document.getElementById(id).appendChild(dom); //将处理好的DocumentFragment重新添加到Dom中
}
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm);
flag.appendChild(child)
}
return flag
}
//解析节点
function compile(node, vm) {
var reg = /\{\{(.*)\}\}/;
//判断是否有子节点
if (node.childNodes && node.childNodes.length) {
node.childNodes.forEach(function (node) {
compile(node, vm)
})
} else {
//解析v-model
if (node.nodeType === 1) {
var attr = node.attributes;
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == "v-model") {
var name = attr[i].nodeValue;
node.value = vm.data[name]; //将data里的值赋给node
node.removeAttribute('v-model'); //移除v-model属性
}
};
}
//解析{{}}
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
node.nodeValue = vm[name]
}
}
}
}
</script>
</html>
上面这段代码就是解析指令的简单方法,我来简单解释一下:
document.createDocumentFragment()
document.createDocumentFragment()
相当于一个空的容器, 是用来创建一个虚拟的节点对象,在这里我们要做的就是:在遍历节点的同时对相应指令进行解析,解析完一个指令将其添加到createDocumentFragment
中,解析完后再重新渲染页面,这样的好处就是减少页面渲染dom的次数,详细内容可参考文档 createDocumentFragment()用法总结function compile (node, vm)
compile()
方法里面我们对每个节点进行判断,首先判断节点是否包含有子节点,有的话继续调用compile()方法进行解析。没有的话就判断节点类型,我们主要是判断element元素类型
和文本text元素类型
,然后分别对这两种类型进行解析。
完成了以上步骤后,我们的代码就可以正常显示在页面上了,
但是,有一个问题,我们页面上绑定了data里的属性,但是在改变input框里的数据的时候,相应的data里面的数据没有同步更新。所以,接下来我们要对数据的更新进行劫持,通过Object.defineProperty()
劫持data里的对应属性变化。
2. 实现Observer
要实现数据的双向绑定,我们需要通过Object.defineProperty()来实现数据劫持,监听属性的变化。
所以,接下来我们先通过一个简单的例子来了解Object.defineProperty()
的工作原理。
var obj ={};
var name="hello";
Object.defineProperty(obj,'name',{
get:function(val) {//获取属性
console.log('get方法被调用了');
return name
},
set:function(val) { //设置属性
console.log('set方法被调用了');
name=val
}
})
console.log(obj.name);
obj.name='hello world'
console.log(obj.name);
运行代码,我们可以看到控制台输出:
从控制台的输出我们可以看出,我们通过Object.defineProperty( )
设置了对象obj的name属性,对其get和set进行重写操作,顾名思义,get就是在读取name属性这个值触发的函数,set就是在设置name属性这个值触发的函数,关于Object.defineProperty()
这里就不多说了,具体可以参考文档defineProperty()使用教程
所以,接下来我们要做的是当我们在输入框输入数据的时候,首先触发 input 事件(或者 keyup、change 事件),在相应的事件处理程序中,我们获取输入框的 value 并赋值给 vm 实例的 text 属性。话不多说,上代码。
<!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>MVVMdemo</title>
</head>
<body>
<div id="app">
<input type="text" v-model="text">
<div>{{text}}</div>
</div>
</body>
<script type="text/javascript">
var vm = new Vue({
el: 'app',
data: {
text: 'hello world',
}
})
function Vue(options) {
this.data = options.data;
var id = options.el;
observe(this.data,this); //初始化的时候对data里的所有属性进行监听
var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文档片段)
document.getElementById(id).appendChild(dom); //将处理好的DocumentFragment重新添加到Dom中
}
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm);
flag.appendChild(child)
}
return flag
}
//解析节点
function compile(node, vm) {
var reg = /\{\{(.*)\}\}/;
//判断是否有子节点
if (node.childNodes && node.childNodes.length) {
node.childNodes.forEach(function (node) {
compile(node, vm)
})
} else {
//解析v-model
if (node.nodeType === 1) {
var attr = node.attributes;
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == "v-model") {
var name = attr[i].nodeValue;
node.addEventListener('input',function(e){
vm[name]=e.target.value;
})
node.value= vm[name];//将data里的值赋给node
node.removeAttribute('v-model'); //移除v-model属性
}
};
}
//解析{{}}
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
node.nodeValue = vm[name]
}
}
}
}
function defineReactive(obj,key,val) {
Object.defineProperty(obj,key,{
get:function() {
return val;
},
set:function(newval) {
if(newval === val) return;
val = newval;
console.log(val);//打印(监听数据的修改)
}
})
}
//地递归遍历所有data属性
function observe(obj,vm) {
Object.keys(obj).forEach(function(key){
defineReactive(vm,key,obj[key])
})
}
</script>
</html>
我们在页面初始化的时候,通过递归遍历data所有子属性,给每个属性添加一个监视器,在监听到数据变化时候,就会触发defineProperty( )里的set方法,我们可以在控制台输出看到set方法里监听到属性的变化。
从上图我们可以看到,set方法触发了,input里text的属性也变化了, 但是文本节点的内容并没有同步变化,如何让同样绑定到 text 的文本节点也同步变化呢?所以,接下来我们要实现一个之前我们说的订阅者Watcher,在set方法触发时,接受属性改变通知,更新视图。3. 实现Watcher
很多人看过网上的其他实现MVVM实现的代码,但是都说对Watcher订阅者不是很了解,其实抛开代码,Watcher实现的功能其实很简单,就是当Vue实例化的时候,给每个属性注入一个订阅者Watcher,方便在Object.defineProperty()
数据劫持中监听属性的获取(get方法),在Object.defineProperty()
监听到数据改变的时候(set方法),通过Watcher通知更新,所以简单来说,Watcher就是起到一个桥梁的作用。我们上面已经通过Object.defineProperty()
监听到数据的改变,接下来我们通过实现Watcher 来完成双向绑定的最后一步。
<!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>MVVMdemo</title>
</head>
<body>
<div id="app">
<input type="text" v-model="text">
<div>{{text}}</div>
</div>
</body>
<script type="text/javascript">
function Vue(options) {
this.data = options.data;
var id = options.el;
observe(this.data, this); //初始化的时候对data里的所有属性进行监听
var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文档片段)
document.getElementById(id).appendChild(dom); //将处理好的DocumentFragment重新添加到Dom中
}
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm);
flag.appendChild(child)
}
return flag
}
//解析节点
function compile(node, vm) {
var reg = /\{\{(.*)\}\}/;
//判断是否有子节点
if (node.childNodes && node.childNodes.length) {
node.childNodes.forEach(function (node) {
compile(node, vm)
})
} else {
//解析v-model
if (node.nodeType === 1) {
var attr = node.attributes;
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == "v-model") {
var name = attr[i].nodeValue;
node.addEventListener('input', function (e) {
vm[name] = e.target.value;
});
node.value = vm[name];//将data里的值赋给node
node.removeAttribute('v-model'); //移除v-model属性
}
};
new Watcher(vm, node, name, 'input');//生成一个新的Watcher,标记为input
}
//解析{{}}
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
new Watcher(vm, node, name, 'text');//生成一个新的Watcher,标记为文本text
}
}
}
}
//地递归遍历所有data属性
function observe(obj, vm) {
Object.keys(obj).forEach(function (key) {
defineReactive(vm, key, obj[key])
})
}
function defineReactive(obj, key, val) {
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function () {
// 添加订阅者 watcher 到主题对象 Dep;
if (Dep.target) dep.addSub(Dep.target);
return val
},
set: function (newVal) {
if (newVal === val) return
val = newVal;
// 作为发布者发出通知
dep.notify();
}
});
}
//将所有初始化的生成的订阅者都收集到一个数组中
function Dep() {
this.subs = []
}
Dep.prototype = {
addSub: function (sub) {
this.subs.push(sub)
},
notify: function () {
this.subs.forEach(function (sub) {
sub.update();
})
}
}
//订阅者Watcher
function Watcher(vm, node, name, nodeType) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.nodeType = nodeType;
this.update();
Dep.target = null;
}
Watcher.prototype = {
//执行对应的更新函数
update: function () {
this.get();
if (this.nodeType == 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType == 'input') {
this.node.value = this.value;
}
},
// 获取 data 中的属性值
get: function () {
this.value = this.vm[this.name]; // 触发相应属性的 get
}
}
</script>
<script type="text/javascript">
var vm = new Vue({
el: 'app',
data: {
text: 'hello world',
}
})
</script>
</html>
我们在第二步的代码基础上,加了一个订阅者Watcher和一个消息收集器Dep,接下来我就跟大家说说他们都做了什么。 首先:
function Watcher(vm, node, name, nodeType) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.nodeType = nodeType;
this.update();
Dep.target = null;
}
Watcher.prototype = {
//执行对应的更新函数
update: function () {
this.get();
if (this.nodeType == 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType == 'input') {
this.node.value = this.value;
}
},
// 获取 data 中的属性值
get: function () {
this.value = this.vm[this.name]; // 触发相应属性的 get
}
}
Watcher()方法接收的参数为vm实例,node节点对象,name传入的节点类型的名称,nodeType节点类型。
首先,将自己赋给了一个全局变量 Dep.target;
其次,执行了 update 方法,进而执行了 get 方法,get 的方法读取了 vm 的访问器属性,从而触发了访问器属性的 get 方法,get 方法中将该 watcher 添加到了对应访问器属性的 dep 中;
再次,获取属性的值,然后更新视图。
最后,将 Dep.target 设为空。因为它是全局变量,也是 watcher 与 dep 关联的唯一桥梁,任何时刻都必须保证 Dep.target 只有一个值。
在实例化的时候,我们针对每个属性都添加一个Watcher()订阅者,在observe()的监听属性赋值的时候,将每个属性绑定的订阅者存储在Dep数组中,在set方法触发的时候,调用dep.notify()方法通知Watcher()更新数据,最后实现了视图的更新。
4. 结语
以上就是Vue双向绑定的基本实现原理及代码,当然,这只是基本的实现代码,简单直观的展现给大家看,如果大家想更深入了解的话,推荐大家去阅读这篇文章 vue的双向绑定原理及实现 。
好啦,以上就是本次的分享,希望对大家理解Vue双向绑定的理解有所帮助,也希望大家有什么不懂或者建议,可以留言互动。