一、前言
老规矩, 每一篇文章都要加个前言,今天分享一篇文章,vue双向绑定原理,之前总觉得源码,原理这些东西离我有些远,现在发现并不是,面试中这个问题问的非常频繁,在北京面试,大概5家公司面试,有三家都会问这个问题,不想每次都说不行。
首先附一张图
![image.png](https://i-blog.csdnimg.cn/blog_migrate/8a75251fca1e93be8edf27a9413aa109.png)
大多数文章都是从这个流程开始讲实现,然而我的脑回路和他们不太一样,我觉得这个问题不应该这么思考
既然是双向绑定,那就应该从概念出发,vue是一个MVVM(Model,View,ViewModel)的前端框架,双向绑定是VM部分的核心,也就是通过双向绑定实现V和M的自动同步,因此这个问题也就被分解成两个子问题,实现V -> M的绑定以及从M -> V的绑定,这篇博客也会从这两方面入手
二、知识储备
双向绑定中涉及到的知识点非常多,核心的部分是通过Object.defineProperty()这个方法对vue中数据的get和set进行劫持,配合发布订阅模式,实现一处修改,多处更新的效果,还有一个就是通过DocumentFragment碎片化文档减少dom操作引起的页面回流和重绘
所有涉及到的知识点我会列出网址,具体细节自己看,我只提一下本文用到的地方
1. Object.defineProperty方法
一个对象的属性主要包括六种描述符,分别是
enumerable
,configurable
,writable
,value
,get
,set
这个方法可以给对象新加一个属性或者修改对象现有属性MDN链接
let obj = {};
let value = "666";
Object.defineProperty(obj, "test", {
get: function() {
return value;
},
set: function(newVal) {
console.log("操作set方法");
value = newVal;
}
})
obj.test = "666";
console.log(obj.test);
obj.test这行代码会出发get方法,也就是会返回value(值为666),通过调用obj.test = “666”;会触发set方法
本文中会通过这个方法劫持vue实例中的所有声明的数据
2. 发布订阅模式
发布订阅模式
发布订阅模式主要分三部分,发布者,订阅者和维系二者的调度中心(个人习惯这么叫)
通过发布订阅模式实现简单双向绑定
<!DOCTYPE html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>发布订阅模式版本双向绑定</title>
</head>
<body>
<input type="text" id="test">
<p id="t1"></p>
<p id="t2"></p>
<script>
/**
* 发布者
*/
function Publisher(dep) {
this.dep = dep;
this.publishArticle = function(value) {
dep.notify(value);
}
}
/**
* 这是调度中心
* 第一件事是维护一个订阅者列表
* 第二件事是通知所有的订阅者,我发布了新文章
*/
function Dep() {
this.subs = []; //订阅者列表
this.addSub = function(sub) {
this.subs.push(sub);
}
this.notify = function(value) {
this.subs.forEach(function(item) {
item.readArticle(value);
})
}
}
/**
* 订阅者
*/
function Subscriber(name, id) {
this.name = name;
this.id = id;
this.readArticle = function(value) {
console.log(this.name + ":收到文章");
document.getElementById(this.id).innerHTML = this.name + "," + value;
}
}
// 三个观察者
let sub1 = new Subscriber("张三", "t1");
let sub2 = new Subscriber("李四", "t2");
// 创建调度中心
let dep = new Dep();
dep.addSub(sub1);
dep.addSub(sub2);
let pub = new Publisher(dep);
document.getElementById("test").addEventListener("input", function(e) {
pub.publishArticle(e.target.value);
})
</script>
</body>
</html>
看明白这个例子也就对vue双向绑定有一定的理解了。
3. DocuemntFragment
关于这个碎片化文档,可以把他当成一个dom容器来用,可以减少dom操作引起的回流和重绘,具体使用直接在正文中介绍DocumentFragment
三、具体实现
注意:本文是引导思考,不是直接按照最正确的思路展开的,如果不喜欢这个风格,建议看其他版本。。
实现过程分两部分,第一部分是V->M的映射,也就是视图变更,vue中的数据发生改变,第二部分是M->V的映射,vue中的数据发生变化,页面随之更新。
第一部分
首先是第一部分View -> Model
先列一个简单页面结构,多余部分省略(html头等)
<div id="app">
<input type="text" v-model="test">
{{test}}
<input type="text" v-model="name">
{{name}}
</div>
<script>
let app = new MVVM({
el: "app",
data: {
test: "This is a test",
name: "test"
}
})
function MVVM(options) {
this.el = options.el;
this.data = options.data;
}
</script>
声明了一个MVVM构造方法,并且调用了这个方法
思考,V->M,也就是页面input输入框变化,将变化的数据同步到vue实例的对应部分
然而页面输入框可能不止一个,于是想到遍历id为app的dom节点的所有子节点,找到所有的input输入框,为输入框绑定input事件,在事件中将数据更新到vue中
这些任务的完成应该是在MVVM的构造函数中,声明一个方法,processNode,在这个方法中遍历dom节点,绑定事件,具体代码如下, 后面都只展示js,html部分不会变
let app = new MVVM({
el: "app",
data: {
test: "This is a test",
name: "test"
}
})
function MVVM(options) {
this.el = options.el;
this.data = options.data;
let dom = document.getElementById(this.el);
// 将id为app的dom节点传入这个方法,处理每一个dom节点
processNode(dom, this);
}
/**
* 处理所有的节点
*/
function processNode(dom, vm) {
for (let i = 0; i < dom.childNodes.length; i++) {
// 通过这个方法处理节点
compile(dom.childNodes[i], vm);
}
}
function compile(node, vm) {
// 节点有多种,只处理元素节点,也就是nodeType为1的节点
let type = node.nodeType;
if (type === 1) {
// 监听元素变化,将变化的数据同步到vue实例中
// 首先判断v-model绑定的哪一个数据
let attributes = node.attributes;
for (let attr of attributes) {
let name = attr.name;
if (name == "v-model") {
let key = attr.nodeValue;
// 当前元素有v-model,需要监听它的变化,绑定到vue对应属性中
node.addEventListener("input", function(e) {
vm.data[key] = e.target.value;
console.log(vm.data);
})
}
}
}
}
通过在两个input中输入内容,会发现vue中的数据已经发生了改变, 还有个办法,因为js代码已经执行了,因此可以直接在浏览器中输入app,按回车键,看看vue实例现在的各个属性值,都可以发现vue实例中的数据已经发生了改变,完成了第一步,V->M的绑定
第二部分
第二部分要完成M->V的同步,也是双向绑定难点。也就是之前说到的发布订阅模式了,html中{{test}}
如何知道vue中的test值发生了改变,这件事应该是发布者(input输入框)告诉调度中心的,然后在由调度中心告诉{{test}}
节点进行更新
可能很多人看到这里还是不知道该如何往下做,我当初也想了很久,感觉这个流程乱糟糟,但是根据发布订阅模式中说的,应该是由发布者通知订阅中心,那应该就是在那个监听事件中进行的操作
node.addEventListener("input", function(e) {
vm.data[key] = e.target.value;
// 在这里告诉调度中心,我的值改变了
})
声明一个调度中心,思考他的职责,根据发布-订阅模式中定义的,他应该维护一个订阅者列表,同时应该有一个方法通知所有的订阅者,代码如下
/**
* 调度中心
*/
function Dep() {
// 订阅者列表,用数组保存
this.subs = [];
// 添加订阅者的方法
this.addSub = function(sub) {
this.subs.push(sub);
}
this.notify = function() {
// 通知所有的订阅者进行更新
this.subs.forEach(function(item) {
// 订阅者更新的方法,没想好,先不定义
})
}
}
在事件中调用调度中心的notify方法。
node.addEventListener("input", function(e) {
vm.data[key] = e.target.value;
let dep = new Dep();
dep.notify();
})
在这里虽然通知了所有订阅者,但是并没有将订阅者添加到调度中心中,那订阅者又是谁呢,自然是{{test}}
这种节点了,但是页面中可能有多个这种 订阅者
他们订阅的可能不是同一个消息, 所以在应该让订阅者保持职责的唯一性,也就是一个订阅者只维护vue中的一个属性,在这个属性发生变化的时候,让对应的调度中心通知对应的所有订阅者。
既然我们明确了vue实例的每一个属性都要对应一个调度中心,也就是说我们需要遍历vue的所有数据,给每一个属性加一个调度中心
增加一个observe方法
function MVVM(options) {
this.el = options.el;
this.data = options.data;
let dom = document.getElementById(this.el);
// 为每一个属性增加一个调度中心
observe(this);
// 将id为app的dom节点传入这个方法,处理每一个dom节点
processNode(dom, this);
}
function observe(vm) {
let data = vm.data;
for (let key of Object.keys(data)) {
let dep = new Dep();
}
}
现在调度中心有了,订阅者又应该什么时候加入到调度中心呢?比如有个调度中心维护的是vue.data中的test属性,那如果有个dom节点访问的是test属性,那就应该加入到调度中心中,也就是访问这个属性的get方法的时候,我们只需要 拦截
或者叫 劫持
这个get方法,让访问这个属性的dom节点加入到订阅者中
因此修改observe方法,在 观察
vue实例的时候拦截他的get方法
function observe(vm) {
let data = vm.data;
for (let key of Object.keys(data)) {
defineReactive(data, key, data[key]);
}
}
function defineReactive(obj, key, value) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
// 将订阅者加入到调度中心中
return value;
}
})
}
代码改成这样,但是还没有订阅者的概念,订阅者应该包括哪些属性呢?比如有个dom节点是 {{test}}
,那他对应的订阅者应该保存vue实例,访问的属性(test),以及这个dom节点(方便赋值),还应该有个update方法,让调度中心通知他更新,订阅者代码如下
function Subscriber(node, vm, key) {
// dom节点
this.node = node;
// vue实例
this.vm = vm;
// 访问的属性,{{test}}中的test属性等
this.key = key;
// 更新dom方法
this.update = function() {
this.node.nodeValue = this.vm.data[this.key];
}
}
Dep的notify方法调用每个订阅者的update方法
this.notify = function() {
// 通知所有的订阅者进行更新
console.log("更新")
this.subs.forEach(function(item) {
item.update();
})
}
现在概念都有了,就应该修改get,让每个访问这个属性的订阅者加入到调度中心中,就应该在遍历dom节点的时候将 {{}}
这种格式的单独处理一下,修改compile函数,加入else判断
var current = null;
function compile(node, vm) {
// 节点有多种,只处理元素节点,也就是nodeType为1的节点
let type = node.nodeType;
if (type === 1) {
// 监听元素变化,将变化的数据同步到vue实例中
// 首先判断v-model绑定的哪一个数据
let attributes = node.attributes;
for (let attr of attributes) {
let name = attr.name;
if (name == "v-model") {
let key = attr.nodeValue;
// 当前元素有v-model,需要监听它的变化,绑定到vue对应属性中
node.addEventListener("input", function(e) {
vm.data[key] = e.target.value;
})
}
}
} else {
let regExp = /\{\{(.*)\}\}/;
let value = node.nodeValue.trim();
if (regExp.test(value)) {
let key = RegExp.$1;
new Subscriber(node, vm, key);
}
}
}
这里调用Subscriber构造函数,同时在构造函数中给Dep绑定一个对象current,用来保存当前是哪个订阅者,同时作为一把锁,保证调度中心每次只能加入一个订阅者
但是这样并不会触发vue对应属性的get方法,因此需要在实例化订阅者的时候手动赋值一次,代码如下
function Subscriber(node, vm, key) {
Dep.current = this;
// dom节点
this.node = node;
// vue实例
this.vm = vm;
// 访问的属性,{{test}}中的test属性等
this.key = key;
// 通过这行代码,调用get方法
this.node.nodeValue = this.vm.data[this.key];
// 更新dom方法
this.update = function() {
this.node.nodeValue = this.vm.data[this.key];
}
Dep.current = null;
}
由于发现页面报错,原来是在input事件中调用了dep.notify方法,改版后调度中心是在defineReactive中定义的,因此通知调度中心就行更新就应该在对应属性的set方法中,当input事件触发后,修改vm中的数据,自动触发对应属性的set方法,由调度中心通知所有的订阅者更新
修改后的defineReactive方法如下
function defineReactive(obj, key, value) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
// 将订阅者加入到调度中心中
if (Dep.current) {
dep.addSub(Dep.current);
}
return value;
},
set: function(newVal) {
value = newVal;
dep.notify();
}
})
}
完成这些发现页面已经实现了双向绑定,但是有个小问题就是页面初始化的时候input输入框没有值
原因是因为我们在处理元素节点的时候没有给他赋值,只绑定了input事件
在compile函数中给input赋值即可
node.value = vm.data[key];
附录
全部代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>vue双向绑定</title>
</head>
<body>
<div id="app">
<input type="text" v-model="test">
{{test}}
<input type="text" v-model="name">
{{name}}
</div>
<script>
let app = new MVVM({
el: "app",
data: {
test: "This is a test",
name: "test"
}
})
function MVVM(options) {
this.el = options.el;
this.data = options.data;
let dom = document.getElementById(this.el);
observe(this);
// 将id为app的dom节点传入这个方法,处理每一个dom节点
processNode(dom, this);
}
function observe(vm) {
let data = vm.data;
for (let key of Object.keys(data)) {
defineReactive(data, key, data[key]);
}
}
function defineReactive(obj, key, value) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
// 将订阅者加入到调度中心中
if (Dep.current) {
dep.addSub(Dep.current);
}
return value;
},
set: function(newVal) {
value = newVal;
dep.notify();
}
})
}
function Subscriber(node, vm, key) {
Dep.current = this;
// dom节点
this.node = node;
// vue实例
this.vm = vm;
// 访问的属性,{{test}}中的test属性等
this.key = key;
// 通过这行代码,调用get方法
this.node.nodeValue = this.vm.data[this.key];
// 更新dom方法
this.update = function() {
this.node.nodeValue = this.vm.data[this.key];
}
Dep.current = null;
}
/**
* 处理所有的节点
*/
function processNode(dom, vm) {
for (let i = 0; i < dom.childNodes.length; i++) {
// 通过这个方法处理节点
compile(dom.childNodes[i], vm);
}
}
function compile(node, vm) {
// 节点有多种,只处理元素节点,也就是nodeType为1的节点
let type = node.nodeType;
if (type === 1) {
// 监听元素变化,将变化的数据同步到vue实例中
// 首先判断v-model绑定的哪一个数据
let attributes = node.attributes;
for (let attr of attributes) {
let name = attr.name;
if (name == "v-model") {
let key = attr.nodeValue;
node.value = vm.data[key];
// 当前元素有v-model,需要监听它的变化,绑定到vue对应属性中
node.addEventListener("input", function(e) {
vm.data[key] = e.target.value;
})
}
}
} else {
let regExp = /\{\{(.*)\}\}/;
let value = node.nodeValue.trim();
if (regExp.test(value)) {
let key = RegExp.$1;
new Subscriber(node, vm, key);
}
}
}
/**
* 调度中心
*/
function Dep() {
// 订阅者列表,用数组保存
this.subs = [];
// 添加订阅者的方法
this.addSub = function(sub) {
this.subs.push(sub);
}
this.notify = function() {
console.log(this.subs);
// 通知所有的订阅者进行更新
this.subs.forEach(function(item) {
// 订阅者更新的方法,没想好,先不定义
item.update();
})
}
}
</script>
</body>
</html>