先看下 Vue 是怎样写的
<div id="app">
<!-- 双向绑定 -->
<input type="type" v-model="message" />
{{message}}
</div>
复制代码
let vm = new Vue({
el: '#app',
data: {
message: 'hello'
}
});
复制代码
根据上面的代码,实现一个简易版的MVVM框架
Vue 中双向绑定的原理
上图总结,Vue 的双向绑定就是
- 模板编译
- 数据劫持
- 观察数据的变化(watcher) (简易版 vue 原理)
从上面两张图可以知道:一个MVVM分为模版编译和数据劫持两个方面,然后整合到 MVVM 中。
MVVM.js
因为使用
vue
是先new
一个实例,所以他是一个构造函数
class MVVM {
constructor(options) {
// 一上来,先把可用的东西挂载到实例上
this.$el = options.el;
this.$data = options.data;
// 如果有要编译的模板,就开始编译
if (this.$el) {
new Compile(this.$el, this);
}
}
}
复制代码
编译 Compile
MVVM 只是作为一个桥梁。下面开始写
compile.js
先在index.html
引入,然后new MVVM
的实例
<body>
<div id="app">
<input type="text" v-model="message" />
{{message}}
</div>
<script src="./compile.js"></script>
<script src="./MVVM.JS"></script>
<script>
let vm = new MVVM({
el: '#app',
data: {
message: 'hello'
}
});
</script>
</body>
复制代码
compile.js
class Compile {
// 两个参数:一个当前元素;一个是当前实例
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 如果有这个元素,才开始编译
if (this.el) {
// 先把真实DOM移入到内存中 fragment==文档碎片;解析模板的过程中为了性能
let fragment = this.nodeToFragment(this.el);
// 然后开始编译==>提取出想要的元素节点:v-model 文本节点:{{}}
this.compile(fragment);
// 最后将编译好的文档碎片塞回页面去
this.el.appendChild(fragment);
}
}
/*一些辅助函数*/
// 判断是否是元素节点
isElementNode(node) {
return node.nodeType === 1;
}
/* 核心函数 */
// 创建文档碎片
nodeToFragment(el) {
// 文档碎片 内存中的DOM节点
let fragment = document.createDocumentFragment();
let firstChild;
while ((firstChild = el.firstChild)) {
fragment.appendChild(firstChild);
}
return fragment;
}
}
复制代码
执行上面的代码之后,页面的元素已经被移到内存中了 根标签中是空的
下面开始执行第二部==>编译
compile()
属于核心方法,需要接收一个文档碎片的参数
- 因为标签是有嵌套的,所以需要递归才能获取所有的标签
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);
}
})
}
复制代码
下面开始实现
compileElement()
和compileText()
这两个核心方法
// 编译元素的方法:检查是否带有v-
compileElement(node){
//获取当前元素的属性:type,v-model。。。
let attrs=node.attributes; ////返回一个类数组
Array.from(attrs).forEach(attr=>{
// attr有name=v-model,和value两个值
let attrName=attr.name;
// 判断是否是v-开头的自定义属性
if (this.isDirective(attrName)) {
// 取到对应的值并放到节点中:在data中取值
let expr = attr.value;
let [, type] = attrName.split('-'); //解构出后面的model
CompileUtil[type](node, this.vm, expr);
}
})
}
// 编译文本方法
compileText(node) {
// 检查是否带有{{message}}
let expr = node.textContent; //取文本中的内容{{message}}
let reg = /\{\{([^}]+)\}\}/g;
if (reg.test(expr)) {
CompileUtil['text'](node, this.vm, expr);
}
}
复制代码
上面用到了
isDirective()
这个辅助函数,以及CompileUtil
这个工具类
isDirective
// 是不是指令
isDirective(name) {
return name.includes('v-');
}
复制代码
CompileUtil
CompileUtil = {
getVal(vm, expr) {
//获取实例上对应的数据
// expr=message.name.age
expr = expr.split('.'); // [message,name,age]
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
},
getTextValue(vm, expr) {
//获取编译文本后的结果
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
return this.getVal(vm, arguments[1]);
});
},
text(node, vm, expr) {
// 文本处理
let updateFn = this.updater['textUpdater'];
// 这里需要抽离获取文本数据的方法
let value = this.getTextValue(vm, expr);
updateFn && updateFn(node, value);
},
model(node, vm, expr) {
// 输入框处理
let updateFn = this.updater['modelUpdater'];
// 问题来了:如果是嵌套数据{message:{a:1}}
// 取到的表达式就是message.a ==> vm.$data['message.a']
updateFn && updateFn(node, this.getVal(vm, expr));
},
updater: {
// 更新数据
textUpdater(node, value) {
node.textContent = value;
},
modelUpdater(node, value) {
node.value = value;
}
}
};
复制代码
写完之后,一切准备就绪,刷新页面!
可以看到,在 data 中的数据成功在页面上显示! Compile完成
数据劫持 Observer
在 MVVM.js
中 new Observer()
实例
class MVVM {
constructor(options) {
// 一上来,先把可用的东西挂载到实例上
this.$el = options.el;
this.$data = options.data;
// 如果有要编译的模板,就开始编译
if (this.$el) {
// 数据劫持就是把对象的所有属性变成get和set方法
new Observer(this.$data);
// 用数据和元素进行编译
new Compile(this.$el, this);
}
}
}
复制代码
observer.js
class Observer {
constructor(data) {
this.observe(data);
}
// 将data的原有数据改成get和set形式
observe(data) {
if (!data || typeof data !== 'object') return;
// 下面开始对数据一一劫持,先拿到data的key和value
// Object.keys(data) 返回的是一个数组 [key1,key2]
Object.keys(data).forEach(key => {
// 劫持
this.defineReactive(data, key, data[key]); //data:哪个对象定义,key:定义谁,data[key]:定义的值
// 如果data[key]是一个对象
this.observe(data[key]); //深度递归劫持
});
}
// 定义响应式
defineReactive(obj, key, value) {
let that = this; //存储this
Object.defineProperty(obj, key, {
get() {
// 取值的时候触发
return value;
},
set(newValue) {
if (newValue !== value) {
// 需要注意,当设置了一个新值是对象
// 这里面的this不是实例
that.observe(newValue); //如果是对象继续劫持
value = newValue;
}
}
});
}
}
复制代码
上面的代码并不难,利用
Object.defineProperty()
对数据进行 get和 set,但是现在页面上并不能实时更新数据,也就是说数据变了,但是没有编译。
那么就需要一个观察者 watcher将两者联系起来,如上面的图示。
观察者watcher
观察者的目的就是给需要变化的那个元素增加一个观察者,当数据变化后执行对应的方法
watcher.js
class Watcher {
// 接收一个实例的数据 ,表达式,还有变化后的回调
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 先获取老值
this.value = this.get();
}
getVal(vm, expr) {
//获取实例上对应的数据
// expr=message.name.age
expr = expr.split('.'); // [message,name,age]
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
}
get() {
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); //变化就执行
}
}
}
复制代码
watcher主要是获取值,并且比较两个值,不同就触发对外暴露的方法
update()
那么问题来了:什么时候需要调用观察者呢?
应该在获取和设置数据的时候,加一个watcher
compile.js
text(node, vm, expr) {
let updateFn = this.updater['textUpdater'];
let value = this.getTextValue(vm, expr);
// 同理,调用watcher,需要注意{{a}} {{b}}这种情况
expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
new Watcher(vm, arguments[1], newValue => {
// 如果数据变化了,文本节点需要重新获取依赖的属性更新文本中的内容
updateFn && updateFn(node, this.getTextValue(vm, expr));
});
});
updateFn && updateFn(node, value);
},
model(node, vm, expr) {
let updateFn = this.updater['modelUpdater'];
// 这里应该加一个监控,数据变化了,应该调用这个watch和cb
new Watcher(vm, expr, newValue => {
// 当值变化后会调用cb,将新的值传过来
updateFn && updateFn(node, this.getVal(vm, expr));
});
updateFn && updateFn(node, this.getVal(vm, expr));
}
复制代码
问题又来了,上面只是new了一个
Watcher
但是并没有调用他的update()
,什么时候调用update()
呢?
我们需要在数据劫持的
set
的时候调用这个方法
结合上面的图,需要一个
Dep
来存放订阅者
observer.js
class Dep {
constructor() {
// 订阅的数组
this.subs = [];
}
addSub(watcher) {
this.subs.push(watcher);
}
// 一调用set就更新
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
复制代码
这个
Dep
实现了调用update()
方法,但是自己还没用到鸭?下面来捋一捋:
- 当我们
new Watcher
的时候,调用了this.get()
这个取值的方法 - 当取值了,就会调用观察者的
get()
- 那么我们就在调用
get()
的时候,把数据放进订阅的数组中
在
watcher
的get()
,在获取值前先把watcher
的实例放过去Dep.target
get() {
Dep.target = this;
let value = this.getVal(this.vm, this.expr);
Dep.target = null; //用完要清空
return value;
}
复制代码
在
observer
的响应式方法中,new Dep()
这个实例
let dep = new Dep(); //每个变化的数据,都会对应一个数组,这个数组时存放所有更新的操作
// ...
get() {
// 取值的时候触发
Dep.target && dep.addSub(Dep.target); //如果有,就放进数组。第一次并没有值,只有当new Watcher才有值
return value;
},
复制代码
下面就是
set()
了
set(newValue) {
if (newValue != value) {
// 需要注意,当设置了一个新值是对象
// 这里面的this不是实例
that.observe(newValue); //如果是对象继续劫持
value = newValue;
dep.notify(); //通知所有人数据更新了
}
}
复制代码
那么在控制台更改data的值,页面上也会变化
给输入框绑定事件
上面已经完成了大部分,只是输入框的值得变化这部分还没有实现
- 只要给输入框绑定
input
事件
setVal(vm, expr, value) {
expr = expr.split('.');
return expr.reduce((prev, next, curIndex) => {
if (curIndex === expr.length - 1) {
// 如果取到数组最后一个时就要设置值, [message,a]
return prev[next] = value;
}
return prev[next];
}, vm.$data);
},
model(node, vm, expr) {
// 输入框处理
let updateFn = this.updater['modelUpdater'];
// 问题来了:如果是嵌套数据{message:{a:1}}
// 取到的表达式就是message.a ==> vm.$data['message.a']
// 这里应该加一个监控,数据变化了,应该调用这个watch和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));
}
复制代码