手动实现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 的双向绑定原理及实现,该文章中的讲解更为详细,本文章仅仅做了一些精简。