手动实现Vue双向绑定
1. 原理
Vue数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的。
Vue的双向绑定是前端面试中一个高频问题,不排除会让你手动实现Vue双向绑定这种问题,毕竟面试造火箭嘛。
下面的代码都通过ES6语法进行书写,如果对ES6不是太熟悉,可以直接参考原文章:vue 的双向绑定原理及实现,原作者是使用的ES5语法。
因为ES6引入了类的概念,所以代码会显得更清晰。
2. 数据劫持
vue是通过Object.defineProperty()
来实现数据劫持,Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象,有点类似于TypeScript中的装饰器。
比如说:
let Book = {
name: "Vue入门",
};
console.log(Book.name); // Vue入门
如果想要在执行console.log(Book.name)
时,用书名号将结果包含起来,这个时候就需要使用Object.defineProperty()
。
let Book = {};
let name = "";
Object.defineProperty(Book, "name", {
set(value) {
name = value;
console.log("你取了一个书名叫做" + value);
},
get() {
return "《" + name + "》";
},
});
Book.name = "Vue入门"; // 你取了一个书名叫做Vue入门
console.log(Book.name); // 《Vue入门》
上面的代码通过Object.defineProperty( )
对Book的属性name的get和set进行了重写操作,所以如果读取name属性时,就会触发get函数。如果设置name属性时,那么同样也会触发set函数。
这个时候我们已经明白了Vue数据劫持的方式,那么我们下面就可以写出一个简易版的双向绑定代码。
3. 简易双向绑定
使用过Vue后应该都清楚,data中的数据改变时,视图就会改变,而视图发生改变时,数据也会改变。
视图改变数据是一个非常好实现的功能,可以通过事件监听来实现,比如说onclick
点击事件,双向绑定的关键点在于数据是如何改变视图。
那么难点就在于数据改变视图了,所以为了实现双向绑定,我们需要引入一些概念:
- Observer:监听器,用来监听所有属性,如果属性发生了变化,则告诉订阅者Watcher。
- Watcher:订阅者,收到属性变化并执行对应的函数,从而更新视图。
- Dep:消息订阅器,因为订阅者是多个,消息订阅器就专门用来收集这些订阅者。
- Compile:解析器,扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
清楚了上面的概念后,我们就可以逐步进行实现一个简易的双向绑定Demo,整个双向绑定实现的核心方法即为上面介绍的Object.defineProperty( )
。
3.1 实现Observer
前面我们实现了一个简易的监听器,仅仅是针对Book对象下的name属性,如果我们要对对象下的每一个属性进行监听,那么我们就需要使用递归来遍历所有属性值,并对其进行Object.defineProperty( )
处理。
/*
传入的参数和上面的简单例子一样
data:对象 key:属性名 val:剩余的对象
*/
function defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
Object.defineProperty(data, key, {
enumerable: true, // 该属性会出现在对象的枚举属性中
configurable: true, // 该属性的描述能被改变,同时该属性也能从对象上删除
get() {
return val;
},
set(newVal) {
val = newVal;
console.log(
"属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”"
);
},
});
}
let library = {
book1: {
name: "",
},
book2: "",
};
observe(library);
library.book1.name = "Vue入门"; // 属性name已经被监听了,现在值为:“Vue入门”
library.book2 = "没有此书籍"; // 属性book2已经被监听了,现在值为:“没有此书籍”
3.2 添加Dep
在实现订阅者之前,还需要个消息订阅器,用来收集这些订阅者。
/* data:对象 key:属性名 val:剩余的对象 */
function defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
if (是否需要添加订阅者) {
dep.addSub(watcher); // 在这里添加一个订阅者
}
return val;
},
set(newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log(
"属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”"
);
dep.notify(); // 如果数据变化,通知所有订阅者
},
});
}
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
notify() {
this.subs.forEach((sub) => {
sub.update();
});
}
}
Dep.target = null;
在get方法中,判断是否添加一个订阅者到订阅器Dep中,而在set方法中,如果数据变化,就去通知所有订阅者,执行对应的函数。
3.3 实现Watcher
我们需要在Watcher初始化的时候触发对应的get方法去执行添加订阅者的操作。
class Watcher {
/* vm:绑定的对象,exp:属性名称,cb:回调函数 */
constructor(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
this.value = this.get(); // 将自己添加到订阅器的操作
}
update() {
this.run();
}
run() {
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
}
get() {
Dep.target = this; // 缓存自己
let value = this.vm.data[this.exp]; // 强制执行监听器里的get函数
Dep.target = null; // 释放自己
return value;
}
}
调整Observer中的defineReactive函数:
/* data:对象 key:属性名 val:剩余的对象 */
defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) { // 判断是否需要添加订阅者
dep.addSub(Dep.target); // 在这里添加一个订阅者
}
return val;
},
set(newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log("属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”");
dep.notify(); // 如果数据变化,通知所有订阅者
}
});
}
到这里,我们就已经实现了一个简易的双向绑定,但是这个时候我们还没有Compile解析器,所以不能直接解析dom,为了演示,暂且模拟一下Vue中双向绑定的dom写法:
<body>
<h1 id="name">{{name}}</h1>
</body>
将Observer和Watcher关联起来,而SelfVue类,就是将Observer,Watcher,Compile进行整合,也正是我们在使用Vue时的new Vue
,只不过这里变成了new SelfVue
,顺带一提,new Vue
做的事情远远不止双向绑定这么简单。:
/* data:数据,el:dom节点,exp:属性名称 */
function SelfVue(data, el, exp) {
this.data = data;
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
然后在页面上new一下SelfVue类,就可以实现数据的双向绑定了:
<body>
<h1 id="name">{{name}}</h1>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
let ele = document.querySelector('#name');
let selfVue = new SelfVue({
name: 'hello world'
}, ele, 'name');
window.setTimeout(function () {
console.log('name值改变了');
selfVue.data.name = 'canfoo';
}, 2000);
</script>
这时候打开页面,可以看到页面刚开始显示了是'hello world'
,过了2s后就变成'canfoo'
了。
到这里,总算大功告成一半了。
但是还有一个细节问题,我们在赋值的时候是这样的形式selfVue.data.name = 'canfoo'
而我们理想的形式是selfVue.name = 'canfoo'
。
我们在new SelfVue的时候做一个代理处理,让访问selfVue的属性代理为访问selfVue.data
的属性,实现原理还是使用Object.defineProperty( )
对属性值再包一层:
class SelfVue {
constructor(data, el, exp) {
let self = this;
this.data = data;
Object.keys(data).forEach(function (key) {
self.proxyKeys(key); // 绑定代理属性
});
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
proxyKeys(key) {
let self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
},
});
}
}
到目前为止,完整的Observer:
class Observer {
constructor(data) {
this.data = data;
this.walk(data);
}
walk(data) {
let self = this;
Object.keys(data).forEach((key) => {
self.defineReactive(data, key, data[key]);
});
}
/* data:对象 key:属性名 val:剩余的对象 */
defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) { // 判断是否需要添加订阅者
dep.addSub(Dep.target); // 在这里添加一个订阅者
}
return val;
},
set(newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log("属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”");
dep.notify(); // 如果数据变化,通知所有订阅者
}
});
}
}
function observe(value, vm) {
if (!value || typeof value !== "object") {
return;
}
return new Observer(value);
}
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
notify() {
this.subs.forEach((sub) => {
sub.update();
});
}
}
Dep.target = null;
3.4 实现Compile
上面已经实现了一个简单的双向绑定操作,但是整个过程中并没有解析dom节点,而是直接固定某个节点进行替换数据。
而Compile就是为了解决这个问题。而Compile主要做下面两件事情:
- 解析模板指令,并替换模板数据,初始化视图。(即Vue中用{{}}包裹起来的部分)
- 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器。(完成绑定)
先给出完整的compile,接下来我们再一步一步的进行讲解:
/* el:dom节点,vm:绑定的对象 */
class Compile {
constructor(el, vm) {
this.vm = vm;
this.el = document.querySelector(el);
this.fragment = null;
this.init();
}
init() {
if (this.el) {
this.fragment = this.nodeToFragment(this.el); // 将需要解析的dom节点存入fragment片段里再进行处理
this.compileElement(this.fragment); // 对特殊节点进行处理,比如{{}},V-
this.el.appendChild(this.fragment);
} else {
console.log("Dom元素不存在");
}
}
nodeToFragment(el) {
let fragment = document.createDocumentFragment();
let child = el.firstChild;
while (child) {
// 将Dom元素移入fragment中
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
compileElement(el) {
let childNodes = el.childNodes;
let self = this;
[].slice.call(childNodes).forEach(function (node) {
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (self.isElementNode(node)) {
self.compile(node);
} else if (self.isTextNode(node) && reg.test(text)) {
self.compiletext(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node);
}
});
}
compile(node) {
let nodeAttrs = node.attributes;
let self = this;
Array.prototype.forEach.call(nodeAttrs, function (attr) {
let attrName = attr.name;
if (self.isDirective(attrName)) {
let exp = attr.value;
let dir = attrName.substring(2);
if (self.isEventDirective(dir)) { // 事件指令
self.compileEvent(node, self.vm, exp, dir);
} else { // v-model 指令
self.compileModel(node, self.vm, exp, dir);
}
node.removeAttribute(attrName);
}
});
}
compiletext(node, exp) {
let self = this;
let initText = this.vm[exp];
this.updateText(node, initText);
new Watcher(this.vm, exp, function (value) {
self.updateText(node, value);
});
}
compileEvent(node, vm, exp, dir) {
let eventType = dir.split(":")[1];
let cb = vm.methods && vm.methods[exp];
if (eventType && cb) {
node.addEventListener(eventType, cb.bind(vm), false);
}
}
compileModel(node, vm, exp, dir) {
let self = this;
let val = this.vm[exp];
this.modelUpdater(node, val);
new Watcher(this.vm, exp, function (value) {
self.modelUpdater(node, value);
});
node.addEventListener("input", function (e) {
let newValue = e.target.value;
if (val === newValue) {
return;
}
self.vm[exp] = newValue;
val = newValue;
});
}
updateText(node, value) {
node.textContent = typeof value == "undefined" ? "" : value;
}
modelUpdater(node, value, oldValue) {
node.value = typeof value == "undefined" ? "" : value;
}
isDirective(attr) {
return attr.indexOf("v-") == 0;
}
isEventDirective(dir) {
return dir.indexOf("on:") === 0;
}
isElementNode(node) {
return node.nodeType == 1;
}
isTextNode(node) {
return node.nodeType == 3;
}
}
那么知道我们该做什么后,第一件事就是获取到含有相关指令的dom元素,这个环节对dom操作比较繁琐,可以先建一个fragment片段,将需要解析的dom节点存入fragment片段里再进行处理:
function nodeToFragment(el) {
let fragment = document.createDocumentFragment();
let child = el.firstChild;
while (child) {
// 将Dom元素移入fragment中
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
接下来对含有{{}}这种形式的指令进行处理:
compileElement(el) {
let childNodes = el.childNodes;
let self = this;
[].slice.call(childNodes).forEach(function (node) {
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (self.isTextNode(node) && reg.test(text)) { // 判断是否是符合这种形式{{}}的指令
self.compiletext(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
}
compiletext(node, exp) {
let self = this;
let initText = this.vm[exp];
this.updateText(node, initText); // 将初始化的数据初始化到视图中
new Watcher(this.vm, exp, function (value) { // 生成订阅器并绑定更新函数
self.updateText(node, value);
});
}
updateText(node, value) {
node.textContent = typeof value == "undefined" ? "" : value;
}
获取到最外层节点后,调用compileElement
函数,对所有子节点进行判断,如果节点是文本节点且匹配{{}}这种形式指令的节点就开始进行编译处理,编译处理首先需要初始化视图数据,对应上面所说的步骤1。
接下来需要生成一个并绑定更新函数的订阅器,对应上面所说的步骤2。
接下来需要将解析器Compile与监听器Observer和订阅者Watcher关联起来,这个时候就需要修改一下SelfVue整合类,因为之前的整合类没有关联Compile:
class SelfVue {
constructor(data, el, exp) {
let self = this;
this.vm = this;
this.data = options;
Object.keys(this.data).forEach(function (key) {
self.proxyKeys(key);
});
observe(this.data);
new Compile(options, this.vm);
return this;
}
proxyKeys(key) {
let self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
},
});
}
}
更改后,我们就不要像之前通过传入固定的元素值进行双向绑定了,可以随便命名各种变量进行双向绑定了:
<body>
<div id="app">
<h2>{{title}}</h2>
<h1>{{name}}</h1>
</div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
let selfVue = new SelfVue({
el: '#app',
data: {
title: 'hello world',
name: ''
}
});
window.setTimeout(function () {
selfVue.title = '你好';
}, 2000);
window.setTimeout(function () {
selfVue.name = 'canfoo';
}, 2500);
</script>
如上代码,在页面上可观察到,刚开始titile和name分别被初始化为hello world
和空,2s后title被替换成你好
3s后name被替换成canfoo
。
如果需要完善更多指令的解析编译,只要在上文说的compileElement
函数加上对其他指令节点进行判断,然后遍历其所有属性,看是否有匹配的指令的属性,如果有的话,就对其进行解析编译。
这里我们再添加一个v-model
指令和事件指令的解析编译,对于这些节点我们使用函数compile进行解析处理:
compile(node) {
let nodeAttrs = node.attributes;
let self = this;
Array.prototype.forEach.call(nodeAttrs, function (attr) {
let attrName = attr.name;
if (self.isDirective(attrName)) {
let exp = attr.value;
let dir = attrName.substring(2);
if (self.isEventDirective(dir)) { // 事件指令
self.compileEvent(node, self.vm, exp, dir);
} else { // v-model 指令
self.compileModel(node, self.vm, exp, dir);
}
node.removeAttribute(attrName);
}
});
}
最后我们在稍微改造下类SelfVue,使它更像Vue的用法:
class SelfVue {
constructor(data, el, exp) {
let self = this;
this.data = options.data;
this.methods = options.methods;
Object.keys(this.data).forEach(function (key) {
self.proxyKeys(key);
});
observe(this.data);
new Compile(options.el, this);
options.mounted.call(this); // 所有事情处理好后执行mounted函数
}
proxyKeys(key) {
let self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
},
});
}
}
到这里,Vue的双向绑定就已经实现了,代码这个东西,第一遍看可能并不理解,但是多写多看,Vue双向绑定也并不是一个太难实现的功能,其实代码不一定非要像作者这么写,主要是明白其原理,如果能自行实现代码那是更好的。
4. 参考文章
本篇文章的代码大部分都来源于:vue 的双向绑定原理及实现,该文章中的讲解更为详细,本文章仅仅做了一些精简。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!