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移入到内存中(优化性能) -->使用节点碎片 fragmentlet 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 exprCompileUtil['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和valueObject.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的callbacknew 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、目标对象与观察者之间的抽象耦合关系能够单独扩展以及重用。
注意事项:
监听要在触发之前。
