2种数据绑定的方式
1.单向绑定(v-bind):数据只能从data流向页面。
2.双向绑定(v-model):数据不仅能从data流向页面,还可以从页面流向data。
备注: 1.双向绑定一般都应用在表单类元素上(如:input、select等)
2.v-model:value 可以简写为 v-model,因为v-model默认收集的就是value值。
双向数据绑定
收集表单数据:
若:<input type="text"/>,则v-model收集的是value值,用户输入的就是value值。
若:<input type="radio"/>,则v-model收集的是value值,且要给标签配置value值。
若:<input type="checkbox"/>
1.没有配置input的value属性,那么收集的就是checked(勾选 or 未勾选,是布尔值)
2.配置input的value属性:
(1)v-model的初始值是非数组,那么收集的就是checked(勾选 or 未勾选,是布尔值)
(2)v-model的初始值是数组,那么收集的的就是value组成的数组
备注:v-model的三个修饰符:
lazy:失去焦点再收集数据
number:输入字符串转为有效的数字
trim:输入首尾空格过滤
原理
整体实现分为四步:
- 实现一个Compile,对指令进行解析,初始化视图,并且订阅数据的变更,绑定好更新函数
- 实现一个Observer,对数据进行劫持,通知数据的变化
- 实现一个Watcher,将其作为以上两者的一个中介点,在接受数据变更的同时,让Dep添加当前的Watcher,并及时通知视图进行update
- 实现MVVM,整合以上三者,作为一个入口函数
下面的讲解主要分为三部分:模板编译(Compiler)、 数据劫持(Observer)、观察者(Watcher)。
在使用Vue.js的时候我们都需要 new Vue({}),此时的Vue即为一个类,大(花)括号里面传递的内容即为Vue的属性和方法。要想实现双向数据绑定,需要的最基本的元素即为 el和 data,有了可编译的模板和数据,我们才可以进行接下来的模板编译以及数据劫持,最终通过观察者来实时监测数据的变化进而来不断的更新视图。所以 Vue类的作用可以理解为一个桥梁,将模板编译,数据劫持连接起来。
因为下面的代码以及讲解内容都为笔者自己实现的功能为例,所以Vue类改名为了MVVM,基本功能是一样的(下面的代码不完整,主要目的是为了讲解编写的主要流程和主要功能)。
1.模板编译(Compiler)
class Compile {
//vm-->MVVM中传入的第二个参数就是MVVM的实例,即new MVVM()
constructor(el, vm) {
//传入的可能是 #app或者document.getElementById('app'),所以需要进行判断
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
//防止用户输入的既不是“#el”字符串也不是document节点
if (this.el) {
//如果这个元素能够获取到,我们才开始编译
//1.先把真实的DOM移入到内存中(优化性能) -->使用节点碎片 fragment
let fragment = this.nodeToFragment(this.el);
//2.编译=>提取想要的元素节点(v-model)和文本节点{{}}
this.compile(fragment)
//3.把编译好的fragment在放回到页面中
this.el.appendChild(fragment)
}
}
1.1 将真实DOM移入到内存中
直接操作DOM节点是非常损耗性能的,更何况对于一个真实的页面或者一个项目,DOM层会有很多节点以及嵌套的节点,所以,如果我们直接操作DOM,可想而知性能会变得很差。在这里我们要借助 fragment 节点碎片,来减少因为直接大量的操作DOM而造成的性能问题。(这个过程可以简单的理解为将DOM节点都移入到内存中,在内存中对DOM节点进行一系列的操作,这样就会提高性能)
nodeToFragment(el) { //需要将el中的内容全部放入到内存中
//文档碎片,不是真正的DOM,是内存中的节点
let fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = el.firstChild) {
//将el中的真实节点一个一个的移入到文档碎片中(el.firstChild指文档中的第一个节点,这一个节点里面可能嵌套很多个节点,但是都没关系,都会一次取走)
fragment.appendChild(firstChild);
}
return fragment; // 内存中的节点
}
上面的一段代码就是将DOM节点移入到内存中的过程,在执行完上面一段代码之后,打开浏览器的控制台你会发现之前的节点都已经消息了(存入到了内存中)。
1.2 编译=>(提取到需要编译的元素节点和文本节点)
compile(fragment) {
//需要递归
let childNodes = fragment.childNodes; //只拿到第一层(父级),拿不到嵌套层的
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
//这里的需要编译元素
this.compileElement(node);
//是元素节点,还需要继续深入的检查(如果是元素节点,有可能节点里面会嵌套节点,所以要使用递归)
this.compile(node) //因为外层是箭头函数,所以this始终指向Compile实例
} else {
//是文本节点
//这里需要编译文本
this.compileText(node)
}
})
}
compileElement(node) {
//编译带v-model、v-text等的(取节点的属性)
let attrs = node.attributes; //取出当前节点的属性
Array.from(attrs).forEach(attr => {
//判断属性名字是不是包含v-
let attrName = attr.name;
if (this.isDirective(attrName)) {
//取到对应的值(即从data中取到message(示例)),放到节点中
let expr = attr.value;
let [, type] = attrName.split('-') //解构赋值
//node this.vm.$data expr //这里可能有v-model或v-text 还有可能有v-html(这里只处理前两种)
CompileUtil[type](node, this.vm, expr)
}
})
}
compileText(node) {
//编译带{{}}
let expr = node.textContent; //取文本中的内容
let reg = /\{\{([^}]+)\}\}/g;
if (reg.test(expr)) {
//node this.vm.$data expr
CompileUtil['text'](node, this.vm, expr)
}
}
上面的三个函数最终的结果是拿到了最终需要编译的元素节点,最后就是要将传入的 data中对应的数据显示在模板上。
//文本更新
textUpdater(node, value) {
node.textContent = value
},
//输入框更新
modelUpdater(node, value) {
node.value = value
}
1.3 编译好的fragment在放回到页面中
将数据显示在节点上之后,我们发现页面上并没有显示任何数据,而且元素节点也不存在,那是因为上面的一系列操作都是我们在内存中进行的,最后我们需要将编译好的 fragment放回到页面中。
this.el.appendChild(fragment)
至此,模板编译部分就结束了了,这时候我们就会发现我们在data中定义的数据已经完全渲染在页面上了。
2.数据劫持(Observer)
顾名思义,数据劫持就是对 data中的每一个属性值进行监测,只要数据变化了,就要做出相应的事情(这里就是更新视图)。话不多说,先贴代码,在说明其中的几个注意点
class Observer {
constructor(data) {
this.observer(data)
}
observer(data) {
//要对这个data数据原有的属性改成set和get的形式
if (!data || typeof data !== 'object') { //如果数据不存在或者不是对象
return;
}
//要将数据一一劫持,先获取到data的key和value
Object.keys(data).forEach(key => { //该方法是将对象先转换成数组,再循环
//劫持(定义一个函数,数据响应式)
this.defineReactive(data, key, data[key]);
//深度递归劫持,这里的递归只会为初始的data中的数据进行劫持(添加set和get方法),如果在defineReactive函数中使用set新增加则不会进行劫持
this.observer(data[key]);
})
}
//定义响应式
defineReactive(obj, key, value) {
//在获取某个值的时候,可以在获取或更改值的时候,做一些处理
let that = this;
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() { //当取值时,调用的方法
return value;
},
set(newValue) { //当给data属性中设置值的时候,更改获取的属性的值
if (newValue !== value) {
console.log(this, 'this'); //这个this指向的是被修改的值
//但是这里的this不是Observer的实例,所以需要在最初保存一下当前this指向
that.observer(newValue); //如果是对象继续劫持
value = newValue;
}
}
})
}
/**
* 以上就实现了数据劫持
*/
}
数据劫持部分比较简单,主要使用了 Object.defineProperty(),下面列出一个需要注意的地方:
- 在对data数据中的原有属性改为 get和 set之前,需要对data进行判断,排除不是对象和数据不存在的情况
- 因为 data中的数据可能是多层嵌套的对象,所以要进行深层递归,但是这里的递归只会为data中初始的数据进行劫持,对于新添加的则不会。
- 基于上面一条的缺陷,所以我们需要在为数据添加set方法时,对数据也进行劫持(因为此时的this指向的是被修改的值,所以需要在方法最初保存一下当前的this值)
核心的模板编译和数据劫持已经完成,两个部分也都可以实现自己的职能,但是如何将两者关联起来,达到最终双向绑定的效果呢?
下面就是结合两者的 Watcher的主场了!!!
3.观察者 (Watcher)
创建Watcher观察者,用新值和老值进行比对,如果发生变化了,就调用更新方法,进行视图的更新。
class Watcher {
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
//先获取一下老的值
this.value = this.get();
}
getVal(vm, expr) { //获取实例上对应的数据
expr = expr.split('.');
return expr.reduce((prev, next) => { //vm.$data.a....
return prev[next];
}, vm.$data)
}
get() {
Dep.target = this; //将当前watcher实例放入到tartget中
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); //对应watch的callback
}
}
}
在这里还要插入一个知识,发布-订阅模式:
//observer.js
/**
* 发布订阅
*/
class Dep {
constructor() {
//订阅的数组
this.subs = [];
}
//添加订阅者
addSub(watcher) {
this.subs.push(watcher);
}
//通知
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
发布订阅在这里的作用:因为 Watcher是来观察数据变化的,即订阅者。因为一个数据可能在模板的多处使用,所以一个数据会有多个监测者。即可以理解为对于一个数据有多个订阅者,那么当一个数据变化时,就可以一次通知便可实现所有订阅者都知道这个消息的结果。(也就是一个数据变化,模板中使用这个数据的值都发生了改变)
当在模板编译中 创建 Watcher实例时,这行代码 Dep.target = this; //将当前watcher实例放入到tartget中 就会将监听这个数据变化的订阅者放到订阅者数组中,注意,因为Dep中没有target这个属性,所以在使用完之后,记得释放该没有必要的内存空间 Dep.target = null;,通过这一步,我们就先将所有订阅者都放入到了订阅者的数组中。
// compile.js
//这里应该加一个监控,数据变化了,应该调用这个watch的callback
new Watcher(vm, expr, (newValue) => {
//当值变化后,会调用cb将新值传递过来()
updateFn && updateFn(node, this.getVal(vm, expr))
})
生成响应式
//observer.js
//定义响应式
defineReactive(obj, key, value) {
//在获取某个值的时候,可以在获取或更改值的时候,做一些处理
let that = this;
console.log(that, this);
let dep = new Dep(); //每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() { //当取值时,调用的方法
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newValue) { //当给data属性中设置值的时候,更改获取的属性的值
if (newValue !== value) {
console.log(this, 'this'); //这个this指向的是被修改的值
//但是这里的this不是Observer的实例,所以需要在最初保存一下当前this指向
that.observer(newValue); //如果是对象继续劫持
value = newValue;
dep.notify(); //通知所有人数据更新了
}
}
})
}
在数据劫持的部分定义一个数组Dep.target && dep.addSub(Dep.target);,存放需要更新的订阅者。
在获取值的时候,将这些订阅者都放到上面定义的数组中,Dep.target && dep.addSub(Dep.target);
在改变值的时候,就会调用 dep.notify(); //通知所有人数据更新了,间接调用 watcher.update()来更新数据。
到此为止,双向数据绑定已经基本实现,下面还有两点简单的内容。
为输入框添加点击事件
//为节点添加点击事件
node.addEventListener('input', e => {
let newValue = e.target.value;
this.setVal(vm, expr, newValue);
})
添加代理
当我们访问实例上的数据时,我们都要通过 this.$data.message才能访问到,因为我们的数据是 $data里面的,如果我们想要实现 this.message就能访问到数据,这时候就需要使用一层代理。
proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(newValue) {
data[key] = newValue
}
})
})
}
发布-订阅模式
观察者模式又叫发布订阅模式(Publish/Subscribe),它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。
模式作用:
- 1、支持简单的广播通信,自动通知所有已经订阅过的对象。
- 2、页面载入后目标对象很容易与观察者存在一种动态关联,增加了灵活性
- 3、目标对象与观察者之间的抽象耦合关系能够单独扩展以及重用。
注意事项:
监听要在触发之前。