亲宝软件园·资讯

展开

Vue的MVVM响应式

DreamYum 人气:0

前言

这些天都在面试,每当我被面试官们问到Vue响应式原理时,回答得都很肤浅。如果您在回答时也只是停留在MVVM框架是model层、view层和viewmodel层这样的双向数据绑定,那么建议您彻底搞定Vue的MVVM响应式原理。
(全文约13900字,阅读时间约25分钟。建议有一定vue基础后再阅读)

怎么来的?

要想清楚的知道某件事物的原理,就该追根溯源,刨根问底。在Vue之前,各框架都是怎么去实现MVVM双向绑定的呢?

大致分为以下几种:

脏值检查,内部其实就是setnterval,当然,为了节约性能,不显的那么low,一般是对特定的事件执行脏值检查:

DOM事件,如输入文本、点击按钮(ng-click)XHR响应事件($http)浏览器locaton 变更事件($location)Timer事件($timeout, $interval)执行$digest()或 $apply()

vue则是采用发布者-订阅者模式,通过Object.defineProperty()来劫持各个属性的getter和setter,在数据变动时发布消息给订阅者,触发相应的监听回调。

Vue的MVVM原理

话不多说,先上图

首先,请尽可能记住这一张图,并能够自己画出来,后面所有原理都是围绕这张图展开。感觉很懵逼对么?不过,相信许多人在Vue官方文档里看过这张图:

其实,这两张图要表达的是一个意思——二者都表示了双向数据绑定的原理流程,官方文档中展示的更为简洁一些。看您更能接受哪种描述,后面自己实现响应式原理后,这两张图都能记得住了。

这里就用第一张图来介绍,在我们创建一个vue实例时,其实vue做了这些事情:

创建了入口函数,分别new了一个数据观察者Observer和一个指令解析器Compile;Compile解析所有DOM节点上的vue指令,提交到更新器Updater(实际上是一个对象);Updater把数据(如{{}},msg,@click)替换,完成页面初始化渲染;Observer使用Object.defineProperty劫持数据,其中的getter和setter通知变化给依赖器Dep;Dep中加入观察者Watcher,当数据发生变化时,通知Watcher更新;Watcher取到旧值和新值,在回调函数中通知Updater更新视图;Compile中每个指令都new了一个Watcher,用于触发Watcher的回调函数进行更新。 简单实现Vue的响应式原理 完整源码:详见

按照前面的思路,下面我们来一步一步实现一个简单的MVVM响应式系统,加深我们对响应式原理的理解。

创建一个html示例

现在我们创建了一个简单的Vue渲染示例,我们要做的就是使用自己的MVue去把里面的data、msg、htmlStr、methods中的数据都渲染到标签上。完成数据驱动视图、视图驱动数据驱动视图。

<!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">  
        <h2>{{person.name}} -- {{person.age}}</h2>
        <h3>{{person.fav}}</h3>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
        </ul>
        <h3>{{msg}}</h3>
        <div v-text="person.fav"></div>
        <div v-text="msg"></div>
        <div v-html="htmlStr"></div>
        <input type="text" v-model="msg">
        <button v-on:click="handleClick">click</button>
        <button @click="handleClick">@click2</button>

    </div>
    <script src="./Observer.js"></script>
    <script src="./MVue.js"></script>
    <script>
     //创建Vue实例,得到 ViewModel
     const vm = new MVue({
        el: '#app',
        data: {
            person: {
                name: "我的vue",
                age: 18,
                fav: "坦克世界"
            },
            msg: "学习MVVM框架原理",
            htmlStr: "<h3>热爱前端,金子总会发光</h3>"
        },
        methods: {
            handleClick() {
                console.log(this);
            }
        }
     });
    </script>
</body>
</html>

在MVue.js中创建MVue入口

class MVue {
  constructor(options) {
    this.$el = options.el;
    this.$data = options.data;
    this.$options = options;

    if (this.$el) {
      // 1、实现一个数据观察者
      // 2、实现一个指令观察者
      new Compile(this.$el, this);
    }
  }

思路:

首先自然是要构建MVue这一个类,MVue类构造函数中需要用到options参数和其中的el、data。
然后需要保证el存在条件下,先实现一个指令解析器Compile,后面再去实现Observer观察者。
显然,Compile应该需要传入MVue实例的el和整个MVue实例,用来解析标签的指令。

创建Compile

思路:在解析标签指令之前,我们首先做的是:

判断el是不是元素节点,如果不是,就要取到el这个标签,然后传入vm实例;递归拿到所有子节点,便于下一步去解析它们。【注意:这一步会频繁触发页面的回流和重绘,所以我们需要把节点先存入文档碎片对象中,就相当于把他们放到了内存中,减少了页面的回流和重绘。】在文档碎片对象中编译好模板;最后再把文档碎片对象追加到根元素上。

class Compile {
  constructor(el, vm) {
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    this.vm = vm;
    // 获取文档碎片对象 放入内存中会减少页面的回流和重绘
    const fragment = this.node2Fragment(this.el);
    // 编译模板
    this.compile(fragment);

    // 追加子元素到根元素
    this.el.appendChild(fragment);
  }

这里我们先自己定义了几个方法:

分别在构造函数之后去实现:

 node2Fragment(el) {
    // 创建文档碎片对象
    const f = document.createDocumentFragment();
    // 递归放入
    let firstChild;
    while ((firstChild = el.firstChild)) {
      f.appendChild(firstChild);
    }
    return f;
  }
  isElementNode(node) {
    return node.nodeType === 1;
  }

编译模板compile(fragment)实现思路:递归获取所有子节点,判断节点是元素节点还是文本节点,再分别定义两个方法compileElement(child)compileText(child)去处理这两种节点。

compile(fragment) {
    // 获取子节点
    const childNodes = fragment.childNodes;
    [...childNodes].forEach((child) => {
      if (this.isElementNode(child)) {
        // 是元素节点
        // 编译元素节点
        // console.log("元素节点",child);
        this.compileElement(child);
      } else {
        // 是文本节点
        // 编译文本节点
        // console.log("文本节点", child);
        this.compileText(child);
      }

      // 一层一层递归遍历
      if (child.childNodes && child.childNodes.length) {
        this.compile(child);
      }
    });
  }

好了,现在Compile的一个基本框架已经搭好了。希望看到这里的您还没有犯困,打起精神来!现在,我们继续往下淦元素节点和文本节点的处理。

1.处理元素节点compileElement(child)

思路:

拿到标签里的每个vue指令,如v-text v-html v-model v-on:click,显然它们都是以v-开头的,当然还有@开头的指令也不要忘记把节点、节点值、vm实例、(on的事件名)传入compileUtil对象,后面用它处理每个指令,属性对应指令方法;别忘了,最后的视图标签上是没有vue指令的,所以我们要把它们从节点属性中删去。

compileElement(node) {
    const attributes = node.attributes;
    [...attributes].forEach((attr) => {
      const { name, value } = attr;
      if (this.isDirective(name)) {
        // 是一个指令 v-text v-html v-model v-on:click
        const [, directive] = name.split("-"); // text html model on:click
        const [dirName, eventName] = directive.split(":"); // text html model on
        // 更新数据 数据驱动视图
        compileUtil[dirName](node, value, this.vm, eventName);

        // 删除有指令标签上的属性
        node.removeAttribute("v-" + directive);
      } else if (this.isEventName(name)) {
        // @click='handleClick'
        let [, eventName] = name.split('@');
        compileUtil["on"](node, value, this.vm, eventName);
      }
    });
  }

判断是否是指令,以v-开头

isDirective(attrName) {
    return attrName.startsWith("v-");
  }

2.处理文本节点compileText(child)

主要使用正则匹配双大括号即可:

compileText(node) {
    // {{}} v-text
    const content = node.textContent;

    if (/\{\{(.+?)\}\}/.test(content)) {
      compileUtil["text"](node, content, this.vm);
    }
  }

3.实现compileUtil指令处理

思路:

每个指令对应各自方法,除了on需要额外传入事件名称,其他的指令处理函数只需要传节点、值(或表达式expr)、vm

实例:

const compileUtil = {
  text(node, expr, vm) {

  },
  html(node, expr, vm) {
   
  },
  model(node, expr, vm) {
   
  },
  on(node, expr, vm, eventName) {
  
  }
};

没有一下子放出代码来的话,骨架原来这么简单啊,继续逐个击破它们!
v-html指令处理,思路:拿到值,把值传给updater更新器,更新,完事儿。

html(node, expr, vm) {
    const value = this.getVal(expr, vm);
    this.updater.htmlUpdater(node, value);
  },

v-model指令处理,同上。先实现数据=>视图这条线,双向绑定最后实现。

model(node, expr, vm) {
    const value = this.getVal(expr, vm);
    this.updater.modelUpdater(node, value);
  },

比较复杂的,v-on,思路:获取事件名,从methods中取到对应的函数,添加到事件中,注意this要绑定给vm实例,false默认事件冒泡。

on(node, expr, vm, eventName) {
    // 获取事件名, 从method里面取函数
    let fn = vm.$options.methods && vm.$options.methods[expr];
    node.addEventListener(eventName, fn.bind(vm), false)
  },

v-text指令处理:

text(node, expr, vm) {
    // expr:msg: "学习MVVM框架原理"
    // 对传入不同的字符串不同操作 <div v-text="person.name"></div>
    // {{}}
    let value;
    if (expr.indexOf('{{') !== -1) {
        // {{person.name}} -- {{person.age}}
        value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(args[1], vm)
        })
    } else {
        value = this.getVal(expr, vm);
    }
    this.updater.textUpdater(node, value);
  },

用到args这个数组,console.log一下args,发现args[1]就有我们要找的具体属性:

例如,取到person.name后,传入到this.getVal('person.name',vm),最后能取到vm.$data.person.name

怎么拿到它们对应的值呢?

显然,不论是htmlStr、msg、person,它们都在实例vm的data内,在自定义方法getVal中,可以使用split分割小圆点“.”得到数组,再用高逼格的reduce方法去遍历找到data每个属性(对象)下的每个属性的值,像这样:

getVal(expr, vm) {
    return expr.split(".").reduce((data, currentVal) => {
      return data[currentVal];
    }, vm.$data);
  },

(不记得怎么用reduce?请在右上角新建标签页,去CSDN上补一补。)进阶拿到双大括号内对应的属性的值:

getContentVal(expr, vm) {
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      console.log(args);
      return this.getVal(args[1], vm);
    });
  },

更新器Updater更新数据

在指令方法的后面接着创建一个updater属性,实则是一个类,我们把它亲切地称作更新器,长得还很一目了然,您马上就能记住它的样子:

// 更新的函数
  updater: {
    textUpdater(node, value) {
      node.textContent = value;
    },
    htmlUpdater(node, value) {
      node.innerHTML = value;
    },
    modelUpdater(node, value){
      node.value = value;
    }
  },

在每个指令方法取到值后,更新到node节点上。

至此,我们已经完成了原理图上的MVVM到Compile到Updater这一条线:

实现数据观察者Observer

class MVue {
  constructor(options) {
    this.$el = options.el;
    this.$data = options.data;
    this.$options = options;

    if (this.$el) {
      // 1、实现一个数据观察者
      new Observer(this.$data);
      // 2、实现一个指令观察者
      new Compile(this.$el, this);
    }
  }

Observer类构造函数应该传什么给它?对,Observer要监听所有数据,所以我们将vm实例的data作为参数传入。

4、5、6这最后三点可以说是MVVM实现中最关键、最巧妙的3步,正是这画龙点睛的三笔,把整个系统桥梁成功架起来,注意它们各自放置在代码中位置。

class Observer {
  constructor(data) {
    this.observer(data);
  }
  observer(data) {
    /**
        {
            person:{
                name:'张三',
                fav: {
                    a: '爱好1',
                    b: '爱好2'
                }
            }
        }
         */
    if (data && typeof data === "object") {
      Object.keys(data).forEach((key) => {
        this.defineReactive(data, key, data[key]);
      });
    }
  }
  defineReactive(obj, key, value) {
    // 递归遍历
    this.observer(value);
    const dep = new Dep();
    // 劫持数据
    Object.defineProperty(obj, key, {
      // 是否可遍历
      enumerable: true,
      // 是否可以更改编写
      configurable: false,
      // 编译之前,初始化的时候
      get() {
        // 订阅数据变化时,往Dep中添加观察者
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 外界修改数据的时候
      set: (newVal) => {
        // 新值也要劫持
        this.observer(newVal); // 这里的this要指向当前的实例,所以改用箭头函数向上查找
        // 判断新值是否有变化
        if (newVal !== value) {
          value = newVal;
        }
        // 告诉Dep通知变化
        dep.notify();
      },
    });
  }
}

数据依赖器Dep

主要作用:

// 数据依赖器
class Dep {
    constructor() {
        this.subs = [];
    }
    // 收集观察者
    addSub(watcher) {
        this.subs.push(watcher);
    }
    // 通知观察者去更新
    notify() {
        console.log("通知了观察者", this.subs);
        this.subs.forEach(w =>w.update())
    }
}

观察者Watcher

注意Dep.target = this;这一步,是为了把观察者挂载到Dep实例上,关联起来。所以当观察者Watcher获取旧值后,应该解除关联,否则会重复地添加观察者,以下是未取消关联的错误示范:

最后,使用callback回调函数传递要处理的新值给Updater即可。

class Watcher {
  constructor(vm, expr, callback) {
    // 把新值通过cb传出去
    this.vm = vm;
    this.expr = expr;
    this.callback = callback;
    // 先把旧值保存起来
    this.oldVal = this.getOldVal();
  }
  getOldVal() {
    // 把观察者挂载到Dep实例上,关联起来
    Dep.target = this;
    const oldVal = compileUtil.getVal(this.expr, this.vm);
    // 获取旧值后,取消关联,就不会重复添加
    Dep.target = null;
    return oldVal;
  }
  update() {
    // 更新,要取旧值和新值
    const newVal = compileUtil.getVal(this.expr, this.vm);
    if (newVal !== this.oldVal) {
        this.callback(newVal);
    }
  }
}

如何Updater如何接收从Watcher传来的新值做回调处理呢?

只需要在刚刚写好的compileUtil对象的每个指令处理方法内都new(添加)一个Watcher实例即可。注意text指令方法下new Watcher实例的value参数,可以用args[1]传入,重新处理newVal。

const compileUtil = {
  getVal(expr, vm) {
    return expr.split(".").reduce((data, currentVal) => {
      return data[currentVal];
    }, vm.$data);
  },
  setVal(expr, vm, inputVal) {
    return expr.split(".").reduce((data, currentVal) => {
      data[currentVal] = inputVal; // 把当前新值复制给旧值
    }, vm.$data);
  },
  getContentVal(expr, vm) {
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      console.log(args);
      return this.getVal(args[1], vm);
    });
  },
  text(node, expr, vm) {
    // expr:msg: "学习MVVM框架原理"
    // 对传入不同的字符串不同操作 <div v-text="person.name"></div>
    // {{}}
    let value;
    if (expr.indexOf('{{') !== -1) {
        // {{person.name}} -- {{person.age}}
        value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            new Watcher(vm, args[1], () => {
              // 额外处理expr: {{person.name}} -- {{person.age}}
              // 还要重新处理newVal
              this.updater.textUpdater(node, this.getContentVal(expr, vm));
            });
            return this.getVal(args[1], vm)
        })
    } else {
        value = this.getVal(expr, vm);
    }
    this.updater.textUpdater(node, value);
  },
  html(node, expr, vm) {
    const value = this.getVal(expr, vm);
    // 绑定观察者,将来数据发生变化 出发这里的回调 进行更新
    new Watcher(vm, expr, newVal => {
        this.updater.htmlUpdater(node, newVal);
    })
    this.updater.htmlUpdater(node, value);
  },
  model(node, expr, vm) {
    const value = this.getVal(expr, vm);
    // 绑定更新函数 数据=>驱动视图
    new Watcher(vm, expr, (newVal) => {
      this.updater.modelUpdater(node, newVal);
    });
    // 视图 => 数据 => 视图
    node.addEventListener('input', e => {
        // 设置值
        this.setVal(expr, vm, e.target.value);
    })
    this.updater.modelUpdater(node, value);
  },
  on(node, expr, vm, eventName) {
    // 获取事件名, 从method里面取函数
    let fn = vm.$options.methods && vm.$options.methods[expr];
    node.addEventListener(eventName, fn.bind(vm), false)
  },
  bind(node, expr, vm, attrName) {
      // 类似on。。。
  },
  // 更新的函数
  updater: {
    textUpdater(node, value) {
      node.textContent = value;
    },
    htmlUpdater(node, value) {
      node.innerHTML = value;
    },
    modelUpdater(node, value){
      node.value = value;
    }
  },
};

实现视图驱动数据驱动视图

还是借着上面这个代码块,我们只需要在model指令方法下,为input标签绑定事件,并自定义setVal方法为node赋值即可。

到这里,我们已经基本完整实现了Vue的MVVM双向数据绑定

小改进:

在MVue实例中,我们一开始使用的是$data获取到数据,这里可以做一层代理proxy,便于我们省略$data

methods: {
            handleClick() {
                // console.log(this);
                this.person.name = "这是做了一层代理" 
                // 把this.$data 代理成 this
                this.$data.person.name = "数据更改了"
            }
        }

还是使用Object.defineProperty数据劫持,遍历data下的每个key,让getter返回data[key],setter设置data[key]直接等于newVal即可。

class MVue {
  constructor(options) {
    this.$el = options.el;
    this.$data = options.data;
    this.$options = options;

    if (this.$el) {
      // 1、实现一个数据观察者
      new Observer(this.$data);
      // 2、实现一个指令观察者
      new Compile(this.$el, this);
      this.proxyData(this.$data);
    }
  }
  proxyData(data) {
      for(const key in data) {
          Object.defineProperty(this, key, {
              get() {
                  return data[key]
              },
              set(newVal) {
                data[key] = newVal;
              }
          })
      }
  }
}

总结

再次体会官方文档对响应式原理的描述:

当我们把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。

这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property
被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装
vue-devtools 来获取对检查数据更加友好的用户界面。

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的
setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

以及开头时我自己总结的原理描述:

在我们创建一个vue实例时,其实vue做了这些事情:

创建了入口函数,分别new了一个数据观察者

在实现代码的过程中,我们能深刻地体会到Vue的数据驱动视图,视图驱动数据驱动视图 这一核心的巧妙,也知道了Object.defineProperty具体应用场景。

加载全部内容

相关教程
猜你喜欢
用户评论