MVVM的实现原理(手写一个简版Vue)

945 阅读6分钟

先看下 Vue 是怎样写的

<div id="app">
  <!-- 双向绑定 -->
  <input type="type" v-model="message" />
  {{message}}
</div>
let vm = new Vue({
  el: '#app',
  data: {
    message: 'hello'
  }
});

根据上面的代码,实现一个简易版的MVVM框架

Vue 中双向绑定的原理

20191024153914.png

上图总结,Vue 的双向绑定就是

  • 模板编译
  • 数据劫持
  • 观察数据的变化(watcher)
    enter image description here
    (简易版 vue 原理)

从上面两张图可以知道:一个MVVM分为模版编译数据劫持两个方面,然后整合到 MVVM 中。

MVVM.js

因为使用vue是先new一个实例,所以他是一个构造函数

class MVVM {
  constructor(options) {
    // 一上来,先把可用的东西挂载到实例上
    this.$el = options.el;
    this.$data = options.data;

    // 如果有要编译的模板,就开始编译
    if (this.$el) {
      new Compile(this.$el, this);
    }
  }
}

编译 Compile

MVVM 只是作为一个桥梁。下面开始写compile.js 先在index.html引入,然后new MVVM的实例

<body>
  <div id="app">
    <input type="text" v-model="message" />
    {{message}}
  </div>
  <script src="./compile.js"></script>
  <script src="./MVVM.JS"></script>
  <script>
    let vm = new MVVM({
      el: '#app',
      data: {
        message: 'hello'
      }
    });
  </script>
</body>

compile.js

class Compile {
  // 两个参数:一个当前元素;一个是当前实例
  constructor(el, vm) {
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    this.vm = vm;
    // 如果有这个元素,才开始编译
    if (this.el) {
      // 先把真实DOM移入到内存中 fragment==文档碎片;解析模板的过程中为了性能
      let fragment = this.nodeToFragment(this.el);
      // 然后开始编译==>提取出想要的元素节点:v-model  文本节点:{{}}
      this.compile(fragment);
      // 最后将编译好的文档碎片塞回页面去
      this.el.appendChild(fragment);
    }
  }
  /*一些辅助函数*/
  // 判断是否是元素节点
  isElementNode(node) {
    return node.nodeType === 1;
  }

  /*  核心函数 */
  // 创建文档碎片
  nodeToFragment(el) {
    // 文档碎片 内存中的DOM节点
    let fragment = document.createDocumentFragment();
    let firstChild;
    while ((firstChild = el.firstChild)) {
      fragment.appendChild(firstChild);
    }
    return fragment;
  }
}

执行上面的代码之后,页面的元素已经被移到内存中了 根标签中是空的

enter image description here

下面开始执行第二部==>编译

compile()属于核心方法,需要接收一个文档碎片的参数

  • 因为标签是有嵌套的,所以需要递归才能获取所有的标签
compile(fragment){
  // 先拿到所有的子节点
  let childNodes = fragment.childNodes; //返回一个文档集合(类数组)
  Array.from(childNodes).forEach(node=>{
    if (this.isElementNode(node)) {
        //如果是元素节点,那么就要继续深入检查
        // 编译元素
        this.compileElement(node);
        this.compile(node);
      } else {
        //如果是文本节点
        // 编译文本
        this.compileText(node);
      }
  })
}

下面开始实现compileElement()compileText()这两个核心方法

// 编译元素的方法:检查是否带有v-
compileElement(node){
  //获取当前元素的属性:type,v-model。。。
  let attrs=node.attributes; ////返回一个类数组
  Array.from(attrs).forEach(attr=>{
    // attr有name=v-model,和value两个值
    let attrName=attr.name;
    // 判断是否是v-开头的自定义属性
    if (this.isDirective(attrName)) {
        // 取到对应的值并放到节点中:在data中取值
        let expr = attr.value;
        let [, type] = attrName.split('-'); //解构出后面的model
        CompileUtil[type](node, this.vm, expr);
      }
  })
}
// 编译文本方法
  compileText(node) {
    // 检查是否带有{{message}}
    let expr = node.textContent; //取文本中的内容{{message}}
    let reg = /\{\{([^}]+)\}\}/g;
    if (reg.test(expr)) {
      CompileUtil['text'](node, this.vm, expr);
    }
  }

上面用到了isDirective()这个辅助函数,以及CompileUtil这个工具类

isDirective

// 是不是指令
  isDirective(name) {
    return name.includes('v-');
  }

CompileUtil

CompileUtil = {
  getVal(vm, expr) {
    //获取实例上对应的数据
    // expr=message.name.age
    expr = expr.split('.'); // [message,name,age]
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  },
  getTextValue(vm, expr) {
    //获取编译文本后的结果
    return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
      return this.getVal(vm, arguments[1]);
    });
  },
  text(node, vm, expr) {
    // 文本处理
    let updateFn = this.updater['textUpdater'];
    // 这里需要抽离获取文本数据的方法
    let value = this.getTextValue(vm, expr);
    updateFn && updateFn(node, value);
  },
  model(node, vm, expr) {
    // 输入框处理
    let updateFn = this.updater['modelUpdater'];
    // 问题来了:如果是嵌套数据{message:{a:1}}
    // 取到的表达式就是message.a ==> vm.$data['message.a']
    updateFn && updateFn(node, this.getVal(vm, expr));
  },
  updater: {
    // 更新数据
    textUpdater(node, value) {
      node.textContent = value;
    },
    modelUpdater(node, value) {
      node.value = value;
    }
  }
};

写完之后,一切准备就绪,刷新页面!

enter image description here
enter image description here

可以看到,在 data 中的数据成功在页面上显示! Compile完成

数据劫持 Observer

MVVM.js中 new Observer()实例

class MVVM {
  constructor(options) {
    // 一上来,先把可用的东西挂载到实例上
    this.$el = options.el;
    this.$data = options.data;
    // 如果有要编译的模板,就开始编译
    if (this.$el) {
      // 数据劫持就是把对象的所有属性变成get和set方法
      new Observer(this.$data);
      // 用数据和元素进行编译
      new Compile(this.$el, this);
    }
  }
}

observer.js

class Observer {
  constructor(data) {
    this.observe(data);
  }
  // 将data的原有数据改成get和set形式
  observe(data) {
    if (!data || typeof data !== 'object') return;
    // 下面开始对数据一一劫持,先拿到data的key和value
    // Object.keys(data) 返回的是一个数组 [key1,key2]
    Object.keys(data).forEach(key => {
      // 劫持
      this.defineReactive(data, key, data[key]); //data:哪个对象定义,key:定义谁,data[key]:定义的值

      // 如果data[key]是一个对象
      this.observe(data[key]); //深度递归劫持
    });
  }
  // 定义响应式
  defineReactive(obj, key, value) {
    let that = this; //存储this
    Object.defineProperty(obj, key, {
      get() {
        // 取值的时候触发
        return value;
      },
      set(newValue) {
        if (newValue !== value) {
          // 需要注意,当设置了一个新值是对象
          // 这里面的this不是实例
          that.observe(newValue); //如果是对象继续劫持
          value = newValue;
        }
      }
    });
  }
}

上面的代码并不难,利用Object.defineProperty()对数据进行 getset,但是现在页面上并不能实时更新数据,也就是说数据变了,但是没有编译。

那么就需要一个观察者 watcher将两者联系起来,如上面的图示。

观察者watcher

观察者的目的就是给需要变化的那个元素增加一个观察者,当数据变化后执行对应的方法

watcher.js

class Watcher {
  // 接收一个实例的数据 ,表达式,还有变化后的回调
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 先获取老值
    this.value = this.get();
  }
  getVal(vm, expr) {
    //获取实例上对应的数据
    // expr=message.name.age
    expr = expr.split('.'); // [message,name,age]
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  }
  get() {
    Dep.target = this;
    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); //变化就执行
    }
  }
}

watcher主要是获取值,并且比较两个值,不同就触发对外暴露的方法update()

那么问题来了:什么时候需要调用观察者呢?

应该在获取和设置数据的时候,加一个watcher

compile.js

  text(node, vm, expr) {
      let updateFn = this.updater['textUpdater'];
      let value = this.getTextValue(vm, expr);
      // 同理,调用watcher,需要注意{{a}} {{b}}这种情况
      expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
        new Watcher(vm, arguments[1], newValue => {
          // 如果数据变化了,文本节点需要重新获取依赖的属性更新文本中的内容
          updateFn && updateFn(node, this.getTextValue(vm, expr));
        });
      });
      updateFn && updateFn(node, value);
    },
  model(node, vm, expr) {
    let updateFn = this.updater['modelUpdater'];
    // 这里应该加一个监控,数据变化了,应该调用这个watch和cb
    new Watcher(vm, expr, newValue => {
      // 当值变化后会调用cb,将新的值传过来     
      updateFn && updateFn(node, this.getVal(vm, expr));
    });
    updateFn && updateFn(node, this.getVal(vm, expr));
  }

问题又来了,上面只是new了一个Watcher但是并没有调用他的update(),什么时候调用update()呢?

我们需要在数据劫持的set的时候调用这个方法

结合上面的图,需要一个 Dep来存放订阅者

observer.js

class Dep {
  constructor() {
    // 订阅的数组
    this.subs = [];
  }
  addSub(watcher) {
    this.subs.push(watcher);
  }
  // 一调用set就更新
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

这个Dep实现了调用update()方法,但是自己还没用到鸭?下面来捋一捋:

  • 当我们new Watcher的时候,调用了this.get()这个取值的方法
  • 当取值了,就会调用观察者的get()
  • 那么我们就在调用get()的时候,把数据放进订阅的数组中

watcherget(),在获取值前先把watcher的实例放过去Dep.target

get() {
    Dep.target = this;
    let value = this.getVal(this.vm, this.expr);
    Dep.target = null; //用完要清空
    return value;
  }

observer的响应式方法中,new Dep()这个实例

let dep = new Dep(); //每个变化的数据,都会对应一个数组,这个数组时存放所有更新的操作
// ...
    get() {
        // 取值的时候触发
        Dep.target && dep.addSub(Dep.target); //如果有,就放进数组。第一次并没有值,只有当new Watcher才有值
        return value;
      },

下面就是set()

set(newValue) {
        if (newValue != value) {
          // 需要注意,当设置了一个新值是对象
          // 这里面的this不是实例
          that.observe(newValue); //如果是对象继续劫持
          value = newValue;
          dep.notify(); //通知所有人数据更新了
        }
      }

那么在控制台更改data的值,页面上也会变化

enter image description here

给输入框绑定事件

上面已经完成了大部分,只是输入框的值得变化这部分还没有实现

  • 只要给输入框绑定input事件
setVal(vm, expr, value) {
    expr = expr.split('.');
    return expr.reduce((prev, next, curIndex) => {
      if (curIndex === expr.length - 1) {
        // 如果取到数组最后一个时就要设置值, [message,a]
        return prev[next] = value;
      }
      return prev[next];
    }, vm.$data);
  },
  model(node, vm, expr) {
    // 输入框处理
    let updateFn = this.updater['modelUpdater'];
    // 问题来了:如果是嵌套数据{message:{a:1}}
    // 取到的表达式就是message.a ==> vm.$data['message.a']
    // 这里应该加一个监控,数据变化了,应该调用这个watch和cb
    new Watcher(vm, expr, newValue => {
      // 当值变化后会调用cb,将新的值传过来
      updateFn && updateFn(node, this.getVal(vm, expr));
    });
    node.addEventListener('input', e => {
      let newValue = e.target.value;
      this.setVal(vm, expr, newValue);
    });
    updateFn && updateFn(node, this.getVal(vm, expr));
  }

简易版的MVVM框架就完成了