190行代码实现mvvm模式

前言

网上讲 vue 原理,mvvm 模式的实现,数据双向绑定的文章一搜一大堆,不管写的谁好谁坏,都是写的自己的理解,我也发一篇文章记录自己的理解,如果对看官有帮助,那也是我莫大的荣幸,不过看完之后,你们以后如果再被面试官问到 vue 的原理的时候,千万不要只用一句【通过 javascrit 的 Object.defineProperty 将 data 进行劫持,发生改变的时候改变对应节点的值】这么笼统的话来应付了。如果有不懂的,可以问我。话不多说,上效果图:

效果

190行代码实现mvvm模式

以及代码

<body>
    <div id="root">
       <h1>{{a}}</h1>
       <button v-on:click="changeA">changeA</button>
       <h2 v-html="b"></h2>
       <input type="text" v-model="b">
    </div>
</body>
<script src="./Watcher.js"></script>
<script src="./Compile.js"></script>
<script src="./Dep.js"></script>
<script src="./Observe.js"></script>
<script src="./MVVM.js"></script>
<script>
var vue = new MVVM({
    el: '#root',
    data: {
        a: 'hello',
        b: 'world'
    },
    methods: {
        changeA () {
            this.a = 'hi'
        }
    }
})
</script>

怎么样,是不是跟vue的写法很像,跟着我的思路,你们也可以的。

原理

talk is cheap, show you the picture
190行代码实现mvvm模式

如图,实现一个mvvm,需要几个辅助工具,分别是 Observer, Compile, Dep, Watcher。每个工具各司其职,再由 MVVM 统一掉配从而实现数据的双向绑定,下面我分别介绍下接下来出场的几位菇凉

  1. Compile 能够将页面中的页面初始化,对指令进行解析,把 data 对应的值渲染上去的同时,new 一个 Watcher,并告诉它,当渲染的这个数据发生改变时告诉我,我好更新视图。
  2. Observer 能够实现将 data 中的数据通过Object.defineProperty进行劫持,当获取 data 中的值的时候,触发get里方法,把 Compile 新建的 Watcher 抓过来,关到 Dep(发布订阅者模式)的小黑屋里狂...,当值修改的时候,触发 set 里的方法,通知小黑屋(Dep)里所有 Watcher 菇凉们,你们解放啦。
  3. Dep 就是传说中的小黑屋了,其内在原理是发布订阅者模式,不了解发布订阅者模式的话可以看我 这篇文章
  4. Watcher 们从小黑屋里逃出来之后就赶紧跑到对应的 Compile 那,告诉他开始更新视图吧,看,我是爱你的。

哈哈,通过我很(lao)幽(si)默(ji)的讲解。你们是不是都想下车了?
190行代码实现mvvm模式

嗯,知道大概是怎么回事之后,我分别讲他们的功能。不过话说前面,mvvm 模式之前有千丝万缕的联系,必须要全部看完,才能真正理解 mvvm 的原理。

Observe

我的 mvvm 模式中 Observe 的功能有两个。1.对将data中的数据绑定到上下文环境上,2.对数据进行劫持,当数据变化的时候通知 Dep。下面用一个 demo 来看看,如何将数据绑定到环境中,并劫持数据

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
</body>
<script>
  var data = {
    a: 'hello',
    b: 'world'
  }
  class Observer {
    constructor(obj, vm) {
      this.walk(obj, vm);
    }
    walk(obj, vm) {
      Object.keys(obj).forEach(key => {
        Object.defineProperty(vm, key, {
          configurable: true,
          enumerable: true,
          get () {
            console.log('获取obj的值' + obj[key])
            return obj[key];
          },
          set(newVal) {
            var val = obj[key];
            if (val === newVal) return;
            console.log(`值更新啦`);
            obj[key] = newVal;
          }
        })
      })
    }
  }
  new Observer(data, window);
  console.log(window.a);
  window.a = 'hi';
</script>

</html>

可以看到将 data 数据绑定到 window 上,当数据变化时候,会打印 '值更新啦',那么 data 变化 是如何通知 Dep 的呢?首先我们要明白,observe 只执行一遍,将数据绑定到 mvvm 实例上,Dep也只有一个,之前说把所有的 Watcher 抓过来,全放在这个 Dep 里,还是看代码说话把。

function observe (obj, vm) {
    if (!obj || typeof obj !== 'object') return;
    return new Observer(obj, vm)
}
class Observer {
    constructor(obj, vm) {
        // vm 代表上下文环境,也是指向 mvvm 的实例 (调用的时候会传入)
        this.walk(obj, vm);
        // 实例化一个 Dep;
        this.dep = new Dep();
    }
    walk (obj, vm) {
        var self = this;
        Object.keys(obj).forEach(key => {
            Object.defineProperty(vm, key, {
                configurable: true,
                enumerable: true,
                get () {
                    // 当获取 vm 的值的时候,如果 Dep 有 target 时执行,目的是将 Watcher 抓过来,后面还会说明
                    if (Dep.target) {
                        self.dep.depend();
                    }
                    return obj[key];
                },
                set (newVal) {
                    var val = obj.key;
                    if (val === newVal) return;
                    obj[key] = newVal;
                    // 当 劫持的值发生变化时候触发,通知 Dep
                    self.dep.notify();
                }
            })
        })
    }
}

Dep

接下来讲讲 Dep 的实现,Dep 功能很简单,难点是如何将 watcher 联系起来,先看代码吧。

class Dep {
  constructor (props) {
    this.subs = [];
    this.uid = 0;
  }
  addSub (sub) {
    this.subs.push(sub);
    this.uid++;
  }
  notify () {
    this.subs.forEach(sub => {
      sub.update();
    })
  }
  depend (sub) {
    Dep.target.addDep(this, sub);
  }
}
Dep.target = null;

subs 是一个数组,用来存储 Watcher 的,当数据更新时候(由Observer告知),会触发 Dep 的 notify 方法,调用 subs 里所有 Watcher 的 update 方法。
接下来是不是迫不及待的想知道 Dep 是如何将 Watcher 抓过来的吧(污污污),别着急我们先看看 Watcher 是如何诞生的。

Compile

我觉得 Compile 是 mvvm 中最劳苦功高的一个了,它的任务是页面过来时候,初始化视图,将页面中的{{.*}}解析成对应的值,还有指令解析,如绑定值的 v-text、v-html 还有绑定的事件 v-on,还有创造 Watcher 去监听值的变化,当值变化的时候又要更新节点的视图。
我们先看看 Compile 是如何初始化视图的

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="root">
    <h1>{{a}}</h1>
    <div v-html="b"></div>
  </div>
</body>
<script>
  var data = {
    a: 'hello',
    b: 'world'
  }
  class Compile {
      constructor(el, vm) {
        this.$el = this.isElementNode(el) ? el : document.querySelector(el);
        this.$vm = vm;
        if (this.$el) {
          this.compileElement(this.$el);
        }
      }
      compileElement(el) {
        // 将所有的最小节点拿过来,循环判断是文本节点就看看是不是 {{}} 包裹的字符串,是元素节点就看看是不是v-html喝v-text
        var childNodes = Array.from(el.childNodes);
        if (childNodes.length > 0) {
          childNodes.forEach(child => {
            var childArr = Array.from(child.childNodes);
            // 匹配{{}}里面的内容
            var reg = /\{\{((?:.)+?)\}\}/;
            if (childArr.length > 0) {
              this.compileElement(child)
            }
            if (this.isTextNode(child)) {
              var text = child.textContent.trim();
              var matchTextArr = reg.exec(text);
              var matchText;
              if (matchTextArr && matchTextArr.length > 1) {
                matchText = matchTextArr[1];
                this.compileText(child, matchText);
              }
            } else if (this.isElementNode(child)) {
              this.compileNode(child);
            }
          })
        }

      }
      compileText(node, exp) {
        this.bind(node, this.$vm, exp, 'text');
      }
      compileNode(node) {
        var attrs = Array.from(node.attributes);
        attrs.forEach(attr => {
          if (this.isDirective(attr.name)) {
            var directiveName = attr.name.substr(2);
            if (directiveName.includes('on')) {
              // 绑定事件
              node.removeAttribute(attr.name);
              var eventName = directiveName.split(':')[1];
              this.addEvent(node, eventName, attr.value);
            } else {
              // v-text v-html 绑定值
              node.removeAttribute(attr.name);
              this.bind(node, this.$vm, attr.value, directiveName);
            }
          }
        })
      }
      addEvent(node, eventName, exp) {
        node.addEventListener(eventName, this.$vm.$options.methods[exp].bind(this.$vm));
      }
      bind(node, vm, exp, dir) {
        if (dir === 'text') {
          node.textContent = vm[exp];
        } else if (dir === 'html') {
          node.innerHTML = vm[exp];
        } else if (dir === 'value') {
          node.value = vm[exp];
        }
      }
      // 是否是指令
      isDirective(attr) {
        if (typeof attr !== 'string') return;
        return attr.includes('v-');
      }
      // 元素节点
      isElementNode(node) {
        return node.nodeType === 1;
      }
      // 文本节点
      isTextNode(node) {
        return node.nodeType === 3;
      }
    }
    new Compile('#root', data);
</script>
</html>

额,感觉还好理解吧,这里只是讲了 Compile 是如何将data中的值渲染到视图上,买了个关子,没有说如何创建 Watcher 的,思考一下,如果要创建 Watcher ,应该在哪个位置创建比较好呢?
答案是渲染值的同时,同时创造一个 Watcher 来监听,上代码:

class Compile {
  constructor (el, vm) {
    this.$el = this.isElementNode(el) ? el : document.querySelector(el);
    this.$vm = vm;
    if (this.$el) {
      this.$fragment = this.nodeFragment(this.$el);
      this.compileElement(this.$fragment);
      this.$el.appendChild(this.$fragment);
    }
  }
  nodeFragment (el) {
    let fragment = document.createDocumentFragment();
    let child;
    while (child = el.firstChild) {
      fragment.appendChild(child);
    }
    return fragment;
  }
  compileElement (el) {
    var childNodes = Array.from(el.childNodes);
    if (childNodes.length > 0) {
      childNodes.forEach(child => {
        var childArr = Array.from(child.childNodes);
        // 匹配{{}}里面的内容
        var reg = /\{\{((?:.)+?)\}\}/;
        if (childArr.length > 0) {
          this.compileElement(child)
        } 
        if (this.isTextNode(child)) {
          var text = child.textContent.trim();
          var matchTextArr = reg.exec(text);
          var matchText;
          if (matchTextArr && matchTextArr.length > 1) {
            matchText = matchTextArr[1];
            this.compileText(child, matchText);
          }
        } else if (this.isElementNode(child)) {
          this.compileNode(child);
        }
      })
    }

  }
  compileText(node, exp) {
    this.bind(node, this.$vm, exp, 'text');
  }
  compileNode (node) {
    var attrs = Array.from(node.attributes);
    attrs.forEach(attr => {
      if (this.isDirective(attr.name)) {
        var directiveName = attr.name.substr(2);
        if (directiveName.includes('on')) {
          node.removeAttribute(attr.name);
          var eventName = directiveName.split(':')[1];
          this.addEvent(node, eventName, attr.value);
        } else if (directiveName.includes('model')) {
          // v-model
          this.bind(node, this.$vm, attr.value, 'value');
          node.addEventListener('input', (e) => {
            this.$vm[attr.value] = e.target.value;
          })
        }else{
          // v-text v-html
          node.removeAttribute(attr.name);
          this.bind(node, this.$vm, attr.value, directiveName);
        }
      }
    })
  }
  addEvent(node, eventName, exp) {
    node.addEventListener(eventName, this.$vm.$options.methods[exp].bind(this.$vm));
  }
  bind (node, vm, exp, dir) {
    if (dir === 'text') {
      node.textContent = vm[exp];
    } else if (dir === 'html') {
      node.innerHTML = vm[exp];
    } else if (dir === 'value') {
      node.value = vm[exp];
    }
    new Watcher(exp, vm, function () {
      if (dir === 'text') {
        node.textContent = vm[exp];
      } else if (dir === 'html') {
        node.innerHTML = vm[exp];
      }
    })
  }
  hasChildNode (node) {
    return node.children && node.children.length > 0;
  }
  // 是否是指令
  isDirective (attr) {
    if (typeof attr !== 'string') return;
    return attr.includes('v-');
  }
  // 元素节点
  isElementNode (node) {
    return node.nodeType === 1;
  }
  // 文本节点
  isTextNode (node) {
    return node.nodeType === 3;
  }
}

这里比上面演示的demo多创建一个文档碎片,可以加快解析速度,另外在 80 行创建了 Watcher,当数据变化时,执行回调函数,从而更新视图。

Watcher

期待已久的 Watcher 终于出来了,我们先看看它长什么样:

class Watcher {
  constructor (exp, vm, cb) {
    this.$vm = vm;
    this.$exp = exp;
    this.depIds = {};
    this.getter = this.parseGetter(exp);
    this.value = this.get();
    this.cb = cb;
  }
  update () {
    let newVal = this.get();
    let oldVal = this.value;
    if (oldVal === newVal) return;
    this.cb.call(this.vm, newVal);
    this.value = newVal;
  }
  get () {
    Dep.target = this;
    var value = this.getter.call(this.$vm, this.$vm);
    Dep.target = null;
    return value;
  }
  parseGetter (exp) {
    if (/[^\w.$]/.test(exp)) return;
    return function (obj) {
      if (!obj) return;
      obj = obj[exp];
      return obj;
    }
  }
  addDep (dep) {
    if (!this.depIds.hasOwnProperty(dep.id)) {
      this.depIds[dep.id] = dep;
      dep.subs.push(this);
    }
  }
}

也不怎么样嘛,只有30多行代码,接下来睁大眼睛啦,看看它是怎么被 Dep 抓过来的。

  1. 当 Compile 创建 Watcher 出来的时候,也将 Dep.target 指向了 Watcher。同时获取了该节点要渲染的值,触发了 Observer 中的 get 方法,Dep.target 有值了,就执行 self.dep.depend();
  2. depend 方法里执行 Dep.target.addDep(this); 而现在 Dep.target 指向 Watcher,所以执行的是 Watcher 里的 addDep 方法 同时把 Dep 实例传过去。
  3. Watcher 里的 addDep 方法是将 Watcher 放在的 Dep实例的 subs 数组里。
  4. 当vm里的值放生变化时,触发 Observer 的 set 方法,触发所有 subs 里的 Watcher 执行 Watcher 里的 update 方法。
  5. update 方法里有 Compile 的回调,从而更新视图。

好吧,真想大白了,原来 Watcher 是引诱 Dep 把自己装进小黑屋的。哈哈~
源码已放在我自己的git库里,点击这里获取源码
讲了半天,正主该出来了,mvvm 是如何将上面四个小伙伴给自己打工的呢,其实很简单,上代码

class MVVM {
  constructor (options) {
    this.$options = options;
    var data = this._data = this.$options.data;
    observe(data, this);
    new Compile(options.el || document.body, this);
  }
}

就是实例 MVVM 的时候,调用数据劫持,和 Compile 初始化视图。到此就全部完成了mvvm模式。

190行代码实现mvvm模式

参考

  1. 合格前端系列第三弹-实现一个属于我们自己的简易MVVM库
  2. vue.js 权威指南

相关推荐