前言
网上讲 vue 原理,mvvm 模式的实现,数据双向绑定的文章一搜一大堆,不管写的谁好谁坏,都是写的自己的理解,我也发一篇文章记录自己的理解,如果对看官有帮助,那也是我莫大的荣幸,不过看完之后,你们以后如果再被面试官问到 vue 的原理的时候,千万不要只用一句【通过 javascrit 的 Object.defineProperty 将 data 进行劫持,发生改变的时候改变对应节点的值】这么笼统的话来应付了。如果有不懂的,可以问我。话不多说,上效果图:
效果
以及代码
<body>
<div id="root">
<h1>{{a}}</h1>
<button v-on:click="changeA">changeA</button>
<h2 v-html="b"></h2>
<input type="text" v-model="b">
</div>
</body>
<script src="./Watcher.js"></script>
<script src="./Compile.js"></script>
<script src="./Dep.js"></script>
<script src="./Observe.js"></script>
<script src="./MVVM.js"></script>
<script>
var vue = new MVVM({
el: '#root',
data: {
a: 'hello',
b: 'world'
},
methods: {
changeA () {
this.a = 'hi'
}
}
})
</script>
怎么样,是不是跟vue的写法很像,跟着我的思路,你们也可以的。
原理
talk is cheap, show you the picture
如图,实现一个mvvm,需要几个辅助工具,分别是 Observer, Compile, Dep, Watcher。每个工具各司其职,再由 MVVM 统一掉配从而实现数据的双向绑定,下面我分别介绍下接下来出场的几位菇凉
- Compile 能够将页面中的页面初始化,对指令进行解析,把 data 对应的值渲染上去的同时,new 一个 Watcher,并告诉它,当渲染的这个数据发生改变时告诉我,我好更新视图。
- Observer 能够实现将 data 中的数据通过Object.defineProperty进行劫持,当获取 data 中的值的时候,触发get里方法,把 Compile 新建的 Watcher 抓过来,关到 Dep(发布订阅者模式)的小黑屋里狂...,当值修改的时候,触发 set 里的方法,通知小黑屋(Dep)里所有 Watcher 菇凉们,你们解放啦。
- Dep 就是传说中的小黑屋了,其内在原理是发布订阅者模式,不了解发布订阅者模式的话可以看我 这篇文章
- Watcher 们从小黑屋里逃出来之后就赶紧跑到对应的 Compile 那,告诉他开始更新视图吧,看,我是爱你的。
哈哈,通过我很(lao)幽(si)默(ji)的讲解。你们是不是都想下车了?
嗯,知道大概是怎么回事之后,我分别讲他们的功能。不过话说前面,mvvm 模式之前有千丝万缕的联系,必须要全部看完,才能真正理解 mvvm 的原理。
Observe
我的 mvvm 模式中 Observe 的功能有两个。1.对将data中的数据绑定到上下文环境上,2.对数据进行劫持,当数据变化的时候通知 Dep。下面用一个 demo 来看看,如何将数据绑定到环境中,并劫持数据
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
</body>
<script>
var data = {
a: 'hello',
b: 'world'
}
class Observer {
constructor(obj, vm) {
this.walk(obj, vm);
}
walk(obj, vm) {
Object.keys(obj).forEach(key => {
Object.defineProperty(vm, key, {
configurable: true,
enumerable: true,
get () {
console.log('获取obj的值' + obj[key])
return obj[key];
},
set(newVal) {
var val = obj[key];
if (val === newVal) return;
console.log(`值更新啦`);
obj[key] = newVal;
}
})
})
}
}
new Observer(data, window);
console.log(window.a);
window.a = 'hi';
</script>
</html>
可以看到将 data 数据绑定到 window 上,当数据变化时候,会打印 '值更新啦',那么 data 变化 是如何通知 Dep 的呢?首先我们要明白,observe 只执行一遍,将数据绑定到 mvvm 实例上,Dep也只有一个,之前说把所有的 Watcher 抓过来,全放在这个 Dep 里,还是看代码说话把。
function observe (obj, vm) {
if (!obj || typeof obj !== 'object') return;
return new Observer(obj, vm)
}
class Observer {
constructor(obj, vm) {
// vm 代表上下文环境,也是指向 mvvm 的实例 (调用的时候会传入)
this.walk(obj, vm);
// 实例化一个 Dep;
this.dep = new Dep();
}
walk (obj, vm) {
var self = this;
Object.keys(obj).forEach(key => {
Object.defineProperty(vm, key, {
configurable: true,
enumerable: true,
get () {
// 当获取 vm 的值的时候,如果 Dep 有 target 时执行,目的是将 Watcher 抓过来,后面还会说明
if (Dep.target) {
self.dep.depend();
}
return obj[key];
},
set (newVal) {
var val = obj.key;
if (val === newVal) return;
obj[key] = newVal;
// 当 劫持的值发生变化时候触发,通知 Dep
self.dep.notify();
}
})
})
}
}
Dep
接下来讲讲 Dep 的实现,Dep 功能很简单,难点是如何将 watcher 联系起来,先看代码吧。
class Dep {
constructor (props) {
this.subs = [];
this.uid = 0;
}
addSub (sub) {
this.subs.push(sub);
this.uid++;
}
notify () {
this.subs.forEach(sub => {
sub.update();
})
}
depend (sub) {
Dep.target.addDep(this, sub);
}
}
Dep.target = null;
subs 是一个数组,用来存储 Watcher 的,当数据更新时候(由Observer告知),会触发 Dep 的 notify 方法,调用 subs 里所有 Watcher 的 update 方法。
接下来是不是迫不及待的想知道 Dep 是如何将 Watcher 抓过来的吧(污污污),别着急我们先看看 Watcher 是如何诞生的。
Compile
我觉得 Compile 是 mvvm 中最劳苦功高的一个了,它的任务是页面过来时候,初始化视图,将页面中的{{.*}}解析成对应的值,还有指令解析,如绑定值的 v-text、v-html 还有绑定的事件 v-on,还有创造 Watcher 去监听值的变化,当值变化的时候又要更新节点的视图。
我们先看看 Compile 是如何初始化视图的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="root">
<h1>{{a}}</h1>
<div v-html="b"></div>
</div>
</body>
<script>
var data = {
a: 'hello',
b: 'world'
}
class Compile {
constructor(el, vm) {
this.$el = this.isElementNode(el) ? el : document.querySelector(el);
this.$vm = vm;
if (this.$el) {
this.compileElement(this.$el);
}
}
compileElement(el) {
// 将所有的最小节点拿过来,循环判断是文本节点就看看是不是 {{}} 包裹的字符串,是元素节点就看看是不是v-html喝v-text
var childNodes = Array.from(el.childNodes);
if (childNodes.length > 0) {
childNodes.forEach(child => {
var childArr = Array.from(child.childNodes);
// 匹配{{}}里面的内容
var reg = /\{\{((?:.)+?)\}\}/;
if (childArr.length > 0) {
this.compileElement(child)
}
if (this.isTextNode(child)) {
var text = child.textContent.trim();
var matchTextArr = reg.exec(text);
var matchText;
if (matchTextArr && matchTextArr.length > 1) {
matchText = matchTextArr[1];
this.compileText(child, matchText);
}
} else if (this.isElementNode(child)) {
this.compileNode(child);
}
})
}
}
compileText(node, exp) {
this.bind(node, this.$vm, exp, 'text');
}
compileNode(node) {
var attrs = Array.from(node.attributes);
attrs.forEach(attr => {
if (this.isDirective(attr.name)) {
var directiveName = attr.name.substr(2);
if (directiveName.includes('on')) {
// 绑定事件
node.removeAttribute(attr.name);
var eventName = directiveName.split(':')[1];
this.addEvent(node, eventName, attr.value);
} else {
// v-text v-html 绑定值
node.removeAttribute(attr.name);
this.bind(node, this.$vm, attr.value, directiveName);
}
}
})
}
addEvent(node, eventName, exp) {
node.addEventListener(eventName, this.$vm.$options.methods[exp].bind(this.$vm));
}
bind(node, vm, exp, dir) {
if (dir === 'text') {
node.textContent = vm[exp];
} else if (dir === 'html') {
node.innerHTML = vm[exp];
} else if (dir === 'value') {
node.value = vm[exp];
}
}
// 是否是指令
isDirective(attr) {
if (typeof attr !== 'string') return;
return attr.includes('v-');
}
// 元素节点
isElementNode(node) {
return node.nodeType === 1;
}
// 文本节点
isTextNode(node) {
return node.nodeType === 3;
}
}
new Compile('#root', data);
</script>
</html>
额,感觉还好理解吧,这里只是讲了 Compile 是如何将data中的值渲染到视图上,买了个关子,没有说如何创建 Watcher 的,思考一下,如果要创建 Watcher ,应该在哪个位置创建比较好呢?
答案是渲染值的同时,同时创造一个 Watcher 来监听,上代码:
class Compile {
constructor (el, vm) {
this.$el = this.isElementNode(el) ? el : document.querySelector(el);
this.$vm = vm;
if (this.$el) {
this.$fragment = this.nodeFragment(this.$el);
this.compileElement(this.$fragment);
this.$el.appendChild(this.$fragment);
}
}
nodeFragment (el) {
let fragment = document.createDocumentFragment();
let child;
while (child = el.firstChild) {
fragment.appendChild(child);
}
return fragment;
}
compileElement (el) {
var childNodes = Array.from(el.childNodes);
if (childNodes.length > 0) {
childNodes.forEach(child => {
var childArr = Array.from(child.childNodes);
// 匹配{{}}里面的内容
var reg = /\{\{((?:.)+?)\}\}/;
if (childArr.length > 0) {
this.compileElement(child)
}
if (this.isTextNode(child)) {
var text = child.textContent.trim();
var matchTextArr = reg.exec(text);
var matchText;
if (matchTextArr && matchTextArr.length > 1) {
matchText = matchTextArr[1];
this.compileText(child, matchText);
}
} else if (this.isElementNode(child)) {
this.compileNode(child);
}
})
}
}
compileText(node, exp) {
this.bind(node, this.$vm, exp, 'text');
}
compileNode (node) {
var attrs = Array.from(node.attributes);
attrs.forEach(attr => {
if (this.isDirective(attr.name)) {
var directiveName = attr.name.substr(2);
if (directiveName.includes('on')) {
node.removeAttribute(attr.name);
var eventName = directiveName.split(':')[1];
this.addEvent(node, eventName, attr.value);
} else if (directiveName.includes('model')) {
// v-model
this.bind(node, this.$vm, attr.value, 'value');
node.addEventListener('input', (e) => {
this.$vm[attr.value] = e.target.value;
})
}else{
// v-text v-html
node.removeAttribute(attr.name);
this.bind(node, this.$vm, attr.value, directiveName);
}
}
})
}
addEvent(node, eventName, exp) {
node.addEventListener(eventName, this.$vm.$options.methods[exp].bind(this.$vm));
}
bind (node, vm, exp, dir) {
if (dir === 'text') {
node.textContent = vm[exp];
} else if (dir === 'html') {
node.innerHTML = vm[exp];
} else if (dir === 'value') {
node.value = vm[exp];
}
new Watcher(exp, vm, function () {
if (dir === 'text') {
node.textContent = vm[exp];
} else if (dir === 'html') {
node.innerHTML = vm[exp];
}
})
}
hasChildNode (node) {
return node.children && node.children.length > 0;
}
// 是否是指令
isDirective (attr) {
if (typeof attr !== 'string') return;
return attr.includes('v-');
}
// 元素节点
isElementNode (node) {
return node.nodeType === 1;
}
// 文本节点
isTextNode (node) {
return node.nodeType === 3;
}
}
这里比上面演示的demo多创建一个文档碎片,可以加快解析速度,另外在 80 行创建了 Watcher,当数据变化时,执行回调函数,从而更新视图。
Watcher
期待已久的 Watcher 终于出来了,我们先看看它长什么样:
class Watcher {
constructor (exp, vm, cb) {
this.$vm = vm;
this.$exp = exp;
this.depIds = {};
this.getter = this.parseGetter(exp);
this.value = this.get();
this.cb = cb;
}
update () {
let newVal = this.get();
let oldVal = this.value;
if (oldVal === newVal) return;
this.cb.call(this.vm, newVal);
this.value = newVal;
}
get () {
Dep.target = this;
var value = this.getter.call(this.$vm, this.$vm);
Dep.target = null;
return value;
}
parseGetter (exp) {
if (/[^\w.$]/.test(exp)) return;
return function (obj) {
if (!obj) return;
obj = obj[exp];
return obj;
}
}
addDep (dep) {
if (!this.depIds.hasOwnProperty(dep.id)) {
this.depIds[dep.id] = dep;
dep.subs.push(this);
}
}
}
也不怎么样嘛,只有30多行代码,接下来睁大眼睛啦,看看它是怎么被 Dep 抓过来的。
- 当 Compile 创建 Watcher 出来的时候,也将 Dep.target 指向了 Watcher。同时获取了该节点要渲染的值,触发了 Observer 中的 get 方法,Dep.target 有值了,就执行 self.dep.depend();
- depend 方法里执行 Dep.target.addDep(this); 而现在 Dep.target 指向 Watcher,所以执行的是 Watcher 里的 addDep 方法 同时把 Dep 实例传过去。
- Watcher 里的 addDep 方法是将 Watcher 放在的 Dep实例的 subs 数组里。
- 当vm里的值放生变化时,触发 Observer 的 set 方法,触发所有 subs 里的 Watcher 执行 Watcher 里的 update 方法。
- update 方法里有 Compile 的回调,从而更新视图。
好吧,真想大白了,原来 Watcher 是引诱 Dep 把自己装进小黑屋的。哈哈~
源码已放在我自己的git库里,点击这里获取源码
讲了半天,正主该出来了,mvvm 是如何将上面四个小伙伴给自己打工的呢,其实很简单,上代码
class MVVM {
constructor (options) {
this.$options = options;
var data = this._data = this.$options.data;
observe(data, this);
new Compile(options.el || document.body, this);
}
}
就是实例 MVVM 的时候,调用数据劫持,和 Compile 初始化视图。到此就全部完成了mvvm模式。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。