一起学习、手写MVVM框架
vue中的数据双向绑定,其实一句话就可以说清楚了:利用 Object.defineProperty()
,并且把内部解耦为 Observer
, Dep
, 并使用 Watcher
相连。
那根据这句话我们可以把整一个简单的MVVM框架粗分为以下四个模块:
1.模板编译(Compile)
2.数据劫持(Observer)
3.订阅发布(Dep)
4.观察者(Watcher)
我们就根据这四个模块来分析、手写一个MVVM框架。
想看源码的,请直接下滑到最后。
MVVM类
和Vue类似,我们构建一个MVVM
类,通过new
指令创建一个MVVM
实例,并传入一个类型为对象的参数option
,包含当前实例的作用域el
和模板绑定的数据data
。
class MVVM {
constructor(options) {
// 挂载实例
this.$el = options.el;
this.$data = options.data;
// 编译模板
if(this.$el) {
// 数据劫持 把对象的所有属性 改成带set 和 get 方法的
new Observer(this.$data)
// 将数据代理到实例上,直接操作实例即可,不需要通过vm.$data来进行操作
this.proxyData(this.$data)
// 用数据和元素进行编译
new Compile(this.$el, this)
}
}
proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(newValue) {
data[key] = newValue
}
})
})
}
}
MVVM类
整合了所有的模块,作为连接Compile
和Observer
的桥梁。
模板编译(Compile)
Compile
compile
在编译模板的时候,其实是从指令和文本两个方面来处理的。
class Compile {
constructor(el, vm) {
// 判断是否为DOM,若不是,自己获取
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
// 1. 将真实DOM放进内存中
let fragment = this.node2fragment(this.el);
// 2. 开始编译 提取想要的元素节点 v-model 和 文本节点 {{}}
this.compile(fragment);
// 3. 将编译好的 fragment 重新放回页面
this.el.appendChild(fragment);
}
}
/**
* 辅助方法
* 是否为元素节点
* @isElementNode
* 是否为指令
* @isDirective
*/
isElementNode(node) {
return node.nodeType === 1;
}
isDirective(name) {
return name.includes("v-");
}
/**
* 核心方法
*/
compileElement(node) {
// v-model v-text
let attrs = node.attributes; // 取出当前节点的属性
Array.from(attrs).forEach(attr => {
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 判断属性名是否包含 v-model
// 取到对应的值,放到节点中
let expr = attr.value;
let [, type] = attrName.split("-"); //解构赋值v-model-->model
// 调用对应的编译方法, 编译哪个节点,用数据替换掉表达式
CompileUtil[type](node, this.vm, expr);
}
});
}
compileText(node) {
let expr = node.textContent; // 取出文本中的内容
let reg = /\{\{([^]+)\}\}/g; // {{a}} {{b}} {{c}}
if (reg.test(expr)) {
// 调用编译文本的方法,编辑哪个节点,用数据替换掉表达式
CompileUtil["text"](node, this.vm, expr);
}
}
// 递归
compile(fragment) {
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
// 如果是元素的节点,则继续深入检查
// 编译元素
this.compileElement(node);
this.compile(node);
} else {
// 文本节点
// 编译文本
this.compileText(node);
}
});
// Array.from()方法是将一个类数组对象或者可遍历对象转换成一个真正的数组
}
// 将el中的内容全部放进内存中
node2fragment(el) {
// 文档碎片 内存中的 dom 节点
let fragment = document.createDocumentFragment();
let firstChild;
// 把值赋给变量 取不到后返回null,null作为条件
while ((firstChild = el.firstChild)) {
// 使用appendChild() 方法从一个元素向另一个元素中移动
fragment.appendChild(firstChild);
}
return fragment; // 内存中的节点
}
}
CompileUtil
CompileUtil
是一个对象工具,配合Copmpile
使用。
let CompileUtil = {
model(node, vm, expr) {
let updateFn = this.updater["modelUpdater"];
/**
*
* 这里应该加一个监控,数据变化了 应该调用watch的callback
* (这里只是记录原始的值 watcher的update没有执行,只有属性的set执行的时候,才会执行cb回调,重新进行真实数据绑定)
*
*/
new Watcher(vm, expr, newValue => {
// 当值变化后会调用cb 将新的值传递过来
updateFn && updateFn(node, this.getVal(vm, expr));
});
node.addEventListener("input", e => {
let newValue = e.target.value;
//监听输入事件,将输入的内容设置到对应数据上
this.setVal(vm, expr, newValue);
});
updateFn && updateFn(node, this.getVal(vm, expr));
},
text(node, vm, expr) {
// 文本处理
let updateFn = this.updater["textUpdater"];
let value = this.getTextVal(vm, expr);
expr.replace(/\{\{((?:.|\r?\n)+?)\}\}/g, (...args) => {
new Watcher(vm, args[1], newValue => {
// 如果数据变化了,文本节点需要重新获取依赖的属性,更新文本中的内容
updateFn && updateFn(node, this.getTextVal(vm, expr));
});
});
updateFn && updateFn(node, value);
},
getTextVal(vm, expr) {
// 获取编译文本后的结果
let value = this.parseText(expr);
let result = '';
value.tokens.forEach((item) => {
if(item.hasOwnProperty('@binding')) {
result += this.getVal(vm, item['@binding'])
} else {
result += item
}
})
return result
},
parseText(text) {
const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
if (!tagRE.test(text)) {
return;
}
const tokens = [];
const rawTokens = [];
let lastIndex = (tagRE.lastIndex = 0);
let match, index, tokenValue;
while ((match = tagRE.exec(text))) {
index = match.index;
// push text token
if (index > lastIndex) {
rawTokens.push((tokenValue = text.slice(lastIndex, index)));
tokens.push(JSON.stringify(tokenValue));
}
// tag token
const exp = match[1].trim();
tokens.push(`_s(${exp})`);
rawTokens.push({ "@binding": exp });
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
rawTokens.push((tokenValue = text.slice(lastIndex)));
tokens.push(JSON.stringify(tokenValue));
}
return {
expression: tokens.join("+"),
tokens: rawTokens
};
},
setVal(vm, expr, value) {
expr = expr.split(".");
return expr.reduce((prev, next, currentIndex) => {
if (currentIndex === expr.length - 1) {
return (prev[next] = value);
}
return prev[next];
}, vm.$data);
},
getVal(vm, expr) {
// 获取实例上对应的数据
expr = expr.split("."); // {{message.a}} [message, a]
// vm.$data.message => vm.$data.message.a
return expr.reduce((prev, next) => {
return prev[next.trim()];
}, vm.$data);
/**
* 关于 reduce:
* arr.reduce(callback,[initialValue])
*/
},
updater: {
// 文本更新
textUpdater(node, value) {
node.textContent = value;
},
// 输入框更新
modelUpdater(node, value) {
node.value = value;
}
}
};
再次认识到正则表达式的重要性。
在处理{{}}
模板引擎的时候,遇到一个bug,在一个DOM
节点里,如果有个有多个{{}}{{}}
会显示为undefined
,后来仔细阅读了vueJs
的源码,借鉴其中parseText()
方法,进行处理,得以解决。
数据劫持(Observer)
什么是数据劫持?
在访问或修改对象的某个属性时,通过一段代码拦截这个行为,进行额外的操作,或者修改返回的结果。
数据劫持的作用是什么?
它是双向数据绑定的核心方法,通过劫持对象属性的setter
和getter
操作,监听数据的变化,同时也是后期ES6中很多语法糖底层实现的核心方法。
使用Object.defineProperty()
做数据劫持,有什么弊端?
1、不能监听数组的变化
2、必须遍历对象的每个属性
3、必须深层遍历嵌套的对象
MVVM中的数据劫持
class Observer {
constructor(data) {
this.observe(data)
}
observe(data) {
// 要对这个data数据,将原有的属性改成set和get的形式
// defineProperty针对的是对象
if(!data || typeof data !== 'object') {
return
}
// 将数据一一劫持,先获取到data的key和value
Object.keys(data).forEach(key => {
// 定义响应式变化
this.defineReactive(data, key, data[key])
this.observe(data[key]) //深度递归劫持
})
// 关于Object.keys() 返回一个包含对象的属性名称的数组
}
// 定义响应式
defineReactive(obj, key, value) {
let that = this;
let dep = new Dep(); // 每个变化的数据 都会对应一个数组,这个数组是存放所有更新的操作
Object.defineProperty(obj, key, {
enumerable: true, // 是否能在for...in循环中遍历出来或在Object.keys中列举出来
configurable: true, // false,不可修改、删除目标属性或修改属性性以下特性
get() {
Dep.target && dep.addSub(Dep.target)
return value;
},
set(newValue) {
if(newValue != value) {
that.observe(newValue); // 如果设置的是对象,继续劫持
value = newValue;
dep.notify(); //通知所有人 数据更新了
}
}
})
}
}
订阅发布(Dep)
其实发布订阅说白了就是把要执行的函数统一存储在一个数组subs
中管理,当达到某个执行条件时,循环这个数组并执行每一个成员。
class Dep {
constructor() {
// 订阅数组
this.subs = [];
}
// 添加订阅
addSub(watcher) {
this.subs.push(watcher);
}
// 将消息通知给所有人
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
观察者(Watcher)
Watcher
类的作用是,获取更改前的值存储起来,并创建一个 update
实例方法,当值被更改时,执行实例的 callback
以达到视图的更新。
class Watcher{ // 因为要获取 oldValue,所以需要“数据”和“表达式”
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 先获取 oldValue 保存下来
this.value = this.get();
}
getVal(vm, expr) {
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next.trim()]
}, vm.$data);
}
get() {
// 在取值之前先将 watcher 保存到 Dep 上
Dep.target = this;
let value = this.getVal(this.vm, this.expr);
Dep.target = null;
return value;
}
// 对外暴露的方法,如果值改变就可以调用这个方法来更新
update() {
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if (newValue != oldValue) {
this.cb(newValue);
}
}
}
最后
最后当然是要检测一下,我们的写的代码是不是能正常运行。
<!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>Document</title>
</head>
<body>
<div id="app">
<!-- 双向数据绑定 靠的是表单 -->
<input type="text" v-model="message.a" />
<div>{{ message.a }} 啦啦啦</div>
{{ message.a }}
{{ b }}
</div>
</body>
</html>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script src="compile.js"></script>
<script src="dep.js"></script>
<script src="mvvm.js"></script>
<script>
let vm = new MVVM({
el: "#app",
data: {
message: { a: "wlf" },
b: "biubiubiu"
}
});
</script>
总结
我们根据下图(参考《深入浅出vue.js》),将整个流程再梳理一遍:
流程图.jpg
在 new MVVM()
后, MVVM
会进行初始化即实例化MVVM
,在这个过程中,模板绑定的数据data
通过Observer
数据劫持,转换成了getter/setter
的形式,来监听数据的变化,当被设置的对象被读取的时候会执行getter
函数,当它被赋值的时候会执行setter
函数。
当页面渲染的时候,会读取所需对象的值,这个时候会触发getter
函数从而将Watcher
添加到Dep
中进行依赖收集,添加订阅。
当对象的值发生变化时,会触发对应的setter
函数,setter
会调用dep.notify()
通知之前依赖收集得到的 Dep
中的每一个 Watcher
,也就是遍历subs
这个数组,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher
就会开始调用 update()
来更新视图。