2种数据绑定的方式

1.单向绑定(v-bind):数据只能从data流向页面。
2.双向绑定(v-model):数据不仅能从data流向页面,还可以从页面流向data。
备注: 1.双向绑定一般都应用在表单类元素上(如:input、select等)
2.v-model:value 可以简写为 v-model,因为v-model默认收集的就是value值。

双向数据绑定

  1. 收集表单数据:
  2. 若:<input type="text"/>,则v-model收集的是value值,用户输入的就是value值。
  3. 若:<input type="radio"/>,则v-model收集的是value值,且要给标签配置value值。
  4. 若:<input type="checkbox"/>
  5. 1.没有配置inputvalue属性,那么收集的就是checked(勾选 or 未勾选,是布尔值)
  6. 2.配置inputvalue属性:
  7. (1)v-model的初始值是非数组,那么收集的就是checked(勾选 or 未勾选,是布尔值)
  8. (2)v-model的初始值是数组,那么收集的的就是value组成的数组
  9. 备注:v-model的三个修饰符:
  10. lazy:失去焦点再收集数据
  11. number:输入字符串转为有效的数字
  12. trim:输入首尾空格过滤

原理

整体实现分为四步:

  1. 实现一个Compile,对指令进行解析,初始化视图,并且订阅数据的变更,绑定好更新函数
  2. 实现一个Observer,对数据进行劫持,通知数据的变化
  3. 实现一个Watcher,将其作为以上两者的一个中介点,在接受数据变更的同时,让Dep添加当前的Watcher,并及时通知视图进行update
  4. 实现MVVM,整合以上三者,作为一个入口函数


下面的讲解主要分为三部分:模板编译(Compiler)数据劫持(Observer)观察者(Watcher)
在使用Vue.js的时候我们都需要 new Vue({}),此时的Vue即为一个类,大(花)括号里面传递的内容即为Vue的属性和方法。要想实现双向数据绑定,需要的最基本的元素即为 el和 data,有了可编译的模板和数据,我们才可以进行接下来的模板编译以及数据劫持,最终通过观察者来实时监测数据的变化进而来不断的更新视图。所以 Vue类的作用可以理解为一个桥梁,将模板编译,数据劫持连接起来。
因为下面的代码以及讲解内容都为笔者自己实现的功能为例,所以Vue类改名为了MVVM,基本功能是一样的(下面的代码不完整,主要目的是为了讲解编写的主要流程和主要功能)。

1.模板编译(Compiler)

  1. class Compile {
  2. //vm-->MVVM中传入的第二个参数就是MVVM的实例,即new MVVM()
  3. constructor(el, vm) {
  4. //传入的可能是 #app或者document.getElementById('app'),所以需要进行判断
  5. this.el = this.isElementNode(el) ? el : document.querySelector(el);
  6. this.vm = vm;
  7. //防止用户输入的既不是“#el”字符串也不是document节点
  8. if (this.el) {
  9. //如果这个元素能够获取到,我们才开始编译
  10. //1.先把真实的DOM移入到内存中(优化性能) -->使用节点碎片 fragment
  11. let fragment = this.nodeToFragment(this.el);
  12. //2.编译=>提取想要的元素节点(v-model)和文本节点{{}}
  13. this.compile(fragment)
  14. //3.把编译好的fragment在放回到页面中
  15. this.el.appendChild(fragment)
  16. }
  17. }

在判断拥有可编译模板之后,接下来就要分别进行下面三步:

1.1 将真实DOM移入到内存中

直接操作DOM节点是非常损耗性能的,更何况对于一个真实的页面或者一个项目,DOM层会有很多节点以及嵌套的节点,所以,如果我们直接操作DOM,可想而知性能会变得很差。在这里我们要借助 fragment 节点碎片,来减少因为直接大量的操作DOM而造成的性能问题。(这个过程可以简单的理解为将DOM节点都移入到内存中,在内存中对DOM节点进行一系列的操作,这样就会提高性能)

  1. nodeToFragment(el) { //需要将el中的内容全部放入到内存中
  2. //文档碎片,不是真正的DOM,是内存中的节点
  3. let fragment = document.createDocumentFragment();
  4. let firstChild;
  5. while (firstChild = el.firstChild) {
  6. //将el中的真实节点一个一个的移入到文档碎片中(el.firstChild指文档中的第一个节点,这一个节点里面可能嵌套很多个节点,但是都没关系,都会一次取走)
  7. fragment.appendChild(firstChild);
  8. }
  9. return fragment; // 内存中的节点
  10. }

上面的一段代码就是将DOM节点移入到内存中的过程,在执行完上面一段代码之后,打开浏览器的控制台你会发现之前的节点都已经消息了(存入到了内存中)。

1.2 编译=>(提取到需要编译的元素节点和文本节点)

  1. compile(fragment) {
  2. //需要递归
  3. let childNodes = fragment.childNodes; //只拿到第一层(父级),拿不到嵌套层的
  4. Array.from(childNodes).forEach(node => {
  5. if (this.isElementNode(node)) {
  6. //这里的需要编译元素
  7. this.compileElement(node);
  8. //是元素节点,还需要继续深入的检查(如果是元素节点,有可能节点里面会嵌套节点,所以要使用递归)
  9. this.compile(node) //因为外层是箭头函数,所以this始终指向Compile实例
  10. } else {
  11. //是文本节点
  12. //这里需要编译文本
  13. this.compileText(node)
  14. }
  15. })
  16. }
  17. compileElement(node) {
  18. //编译带v-model、v-text等的(取节点的属性)
  19. let attrs = node.attributes; //取出当前节点的属性
  20. Array.from(attrs).forEach(attr => {
  21. //判断属性名字是不是包含v-
  22. let attrName = attr.name;
  23. if (this.isDirective(attrName)) {
  24. //取到对应的值(即从data中取到message(示例)),放到节点中
  25. let expr = attr.value;
  26. let [, type] = attrName.split('-') //解构赋值
  27. //node this.vm.$data expr //这里可能有v-model或v-text 还有可能有v-html(这里只处理前两种)
  28. CompileUtil[type](node, this.vm, expr)
  29. }
  30. })
  31. }
  32. compileText(node) {
  33. //编译带{{}}
  34. let expr = node.textContent; //取文本中的内容
  35. let reg = /\{\{([^}]+)\}\}/g;
  36. if (reg.test(expr)) {
  37. //node this.vm.$data expr
  38. CompileUtil['text'](node, this.vm, expr)
  39. }
  40. }

上面的三个函数最终的结果是拿到了最终需要编译的元素节点,最后就是要将传入的 data中对应的数据显示在模板上。

  1. //文本更新
  2. textUpdater(node, value) {
  3. node.textContent = value
  4. },
  5. //输入框更新
  6. modelUpdater(node, value) {
  7. node.value = value
  8. }

1.3 编译好的fragment在放回到页面中

将数据显示在节点上之后,我们发现页面上并没有显示任何数据,而且元素节点也不存在,那是因为上面的一系列操作都是我们在内存中进行的,最后我们需要将编译好的 fragment放回到页面中。

  1. this.el.appendChild(fragment)

至此,模板编译部分就结束了了,这时候我们就会发现我们在data中定义的数据已经完全渲染在页面上了。

2.数据劫持(Observer)

顾名思义,数据劫持就是对 data中的每一个属性值进行监测,只要数据变化了,就要做出相应的事情(这里就是更新视图)。话不多说,先贴代码,在说明其中的几个注意点

  1. class Observer {
  2. constructor(data) {
  3. this.observer(data)
  4. }
  5. observer(data) {
  6. //要对这个data数据原有的属性改成set和get的形式
  7. if (!data || typeof data !== 'object') { //如果数据不存在或者不是对象
  8. return;
  9. }
  10. //要将数据一一劫持,先获取到data的key和value
  11. Object.keys(data).forEach(key => { //该方法是将对象先转换成数组,再循环
  12. //劫持(定义一个函数,数据响应式)
  13. this.defineReactive(data, key, data[key]);
  14. //深度递归劫持,这里的递归只会为初始的data中的数据进行劫持(添加set和get方法),如果在defineReactive函数中使用set新增加则不会进行劫持
  15. this.observer(data[key]);
  16. })
  17. }
  18. //定义响应式
  19. defineReactive(obj, key, value) {
  20. //在获取某个值的时候,可以在获取或更改值的时候,做一些处理
  21. let that = this;
  22. Object.defineProperty(obj, key, {
  23. enumerable: true,
  24. configurable: true,
  25. get() { //当取值时,调用的方法
  26. return value;
  27. },
  28. set(newValue) { //当给data属性中设置值的时候,更改获取的属性的值
  29. if (newValue !== value) {
  30. console.log(this, 'this'); //这个this指向的是被修改的值
  31. //但是这里的this不是Observer的实例,所以需要在最初保存一下当前this指向
  32. that.observer(newValue); //如果是对象继续劫持
  33. value = newValue;
  34. }
  35. }
  36. })
  37. }
  38. /**
  39. * 以上就实现了数据劫持
  40. */
  41. }

数据劫持部分比较简单,主要使用了 Object.defineProperty(),下面列出一个需要注意的地方:

  • 在对data数据中的原有属性改为 get和 set之前,需要对data进行判断,排除不是对象和数据不存在的情况
  • 因为 data中的数据可能是多层嵌套的对象,所以要进行深层递归,但是这里的递归只会为data中初始的数据进行劫持,对于新添加的则不会。
  • 基于上面一条的缺陷,所以我们需要在为数据添加set方法时,对数据也进行劫持(因为此时的this指向的是被修改的值,所以需要在方法最初保存一下当前的this值)

核心的模板编译和数据劫持已经完成,两个部分也都可以实现自己的职能,但是如何将两者关联起来,达到最终双向绑定的效果呢?
下面就是结合两者的 Watcher的主场了!!!

3.观察者 (Watcher)

创建Watcher观察者,用新值和老值进行比对,如果发生变化了,就调用更新方法,进行视图的更新。

  1. class Watcher {
  2. constructor(vm, expr, cb) {
  3. this.vm = vm;
  4. this.expr = expr;
  5. this.cb = cb;
  6. //先获取一下老的值
  7. this.value = this.get();
  8. }
  9. getVal(vm, expr) { //获取实例上对应的数据
  10. expr = expr.split('.');
  11. return expr.reduce((prev, next) => { //vm.$data.a....
  12. return prev[next];
  13. }, vm.$data)
  14. }
  15. get() {
  16. Dep.target = this; //将当前watcher实例放入到tartget中
  17. let value = this.getVal(this.vm, this.expr);
  18. Dep.target = null;
  19. return value;
  20. }
  21. //对外暴露的方法
  22. update() {
  23. let newValue = this.getVal(this.vm, this.expr);
  24. let oldValue = this.value;
  25. if (newValue !== oldValue) {
  26. this.cb(newValue); //对应watch的callback
  27. }
  28. }
  29. }

在这里还要插入一个知识,发布-订阅模式:

  1. //observer.js
  2. /**
  3. * 发布订阅
  4. */
  5. class Dep {
  6. constructor() {
  7. //订阅的数组
  8. this.subs = [];
  9. }
  10. //添加订阅者
  11. addSub(watcher) {
  12. this.subs.push(watcher);
  13. }
  14. //通知
  15. notify() {
  16. this.subs.forEach(watcher => {
  17. watcher.update()
  18. })
  19. }
  20. }

发布订阅在这里的作用:因为 Watcher是来观察数据变化的,即订阅者。因为一个数据可能在模板的多处使用,所以一个数据会有多个监测者。即可以理解为对于一个数据有多个订阅者,那么当一个数据变化时,就可以一次通知便可实现所有订阅者都知道这个消息的结果。(也就是一个数据变化,模板中使用这个数据的值都发生了改变)

当在模板编译中 创建 Watcher实例时,这行代码 Dep.target = this; //将当前watcher实例放入到tartget中 就会将监听这个数据变化的订阅者放到订阅者数组中,注意,因为Dep中没有target这个属性,所以在使用完之后,记得释放该没有必要的内存空间 Dep.target = null;,通过这一步,我们就先将所有订阅者都放入到了订阅者的数组中。

  1. // compile.js
  2. //这里应该加一个监控,数据变化了,应该调用这个watch的callback
  3. new Watcher(vm, expr, (newValue) => {
  4. //当值变化后,会调用cb将新值传递过来()
  5. updateFn && updateFn(node, this.getVal(vm, expr))
  6. })

生成响应式

  1. //observer.js
  2. //定义响应式
  3. defineReactive(obj, key, value) {
  4. //在获取某个值的时候,可以在获取或更改值的时候,做一些处理
  5. let that = this;
  6. console.log(that, this);
  7. let dep = new Dep(); //每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
  8. Object.defineProperty(obj, key, {
  9. enumerable: true,
  10. configurable: true,
  11. get() { //当取值时,调用的方法
  12. Dep.target && dep.addSub(Dep.target);
  13. return value;
  14. },
  15. set(newValue) { //当给data属性中设置值的时候,更改获取的属性的值
  16. if (newValue !== value) {
  17. console.log(this, 'this'); //这个this指向的是被修改的值
  18. //但是这里的this不是Observer的实例,所以需要在最初保存一下当前this指向
  19. that.observer(newValue); //如果是对象继续劫持
  20. value = newValue;
  21. dep.notify(); //通知所有人数据更新了
  22. }
  23. }
  24. })
  25. }


在数据劫持的部分定义一个数组Dep.target && dep.addSub(Dep.target);,存放需要更新的订阅者。
在获取值的时候,将这些订阅者都放到上面定义的数组中,Dep.target && dep.addSub(Dep.target);
在改变值的时候,就会调用 dep.notify(); //通知所有人数据更新了,间接调用 watcher.update()来更新数据。
到此为止,双向数据绑定已经基本实现,下面还有两点简单的内容。
为输入框添加点击事件

  1. //为节点添加点击事件
  2. node.addEventListener('input', e => {
  3. let newValue = e.target.value;
  4. this.setVal(vm, expr, newValue);
  5. })

添加代理
当我们访问实例上的数据时,我们都要通过 this.$data.message才能访问到,因为我们的数据是 $data里面的,如果我们想要实现 this.message就能访问到数据,这时候就需要使用一层代理。

  1. proxyData(data) {
  2. Object.keys(data).forEach(key => {
  3. Object.defineProperty(this, key, {
  4. get() {
  5. return data[key]
  6. },
  7. set(newValue) {
  8. data[key] = newValue
  9. }
  10. })
  11. })
  12. }


发布-订阅模式

观察者模式又叫发布订阅模式(Publish/Subscribe),它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。
模式作用:

  • 1、支持简单的广播通信,自动通知所有已经订阅过的对象。
  • 2、页面载入后目标对象很容易与观察者存在一种动态关联,增加了灵活性
  • 3、目标对象与观察者之间的抽象耦合关系能够单独扩展以及重用。

注意事项:
监听要在触发之前。