Web前端之路前端Vue专辑vue集锦

一起学习、手写MVVM框架

2019-07-17  本文已影响2人  你看到我的小熊了吗

vue中的数据双向绑定,其实一句话就可以说清楚了:利用 Object.defineProperty(),并且把内部解耦为 Observer, Dep, 并使用 Watcher 相连。
那根据这句话我们可以把整一个简单的MVVM框架粗分为以下四个模块:
1.模板编译(Compile)
2.数据劫持(Observer)
3.订阅发布(Dep)
4.观察者(Watcher)
我们就根据这四个模块来分析、手写一个MVVM框架。
想看源码的,请直接下滑到最后。

MVVM类

和Vue类似,我们构建一个MVVM类,通过new指令创建一个MVVM实例,并传入一个类型为对象的参数option,包含当前实例的作用域el和模板绑定的数据data

class MVVM {
  constructor(options) {
    // 挂载实例
    this.$el = options.el;
    this.$data = options.data;

    // 编译模板
    if(this.$el) {
      // 数据劫持 把对象的所有属性 改成带set 和 get 方法的
      new Observer(this.$data)
      
      // 将数据代理到实例上,直接操作实例即可,不需要通过vm.$data来进行操作
      this.proxyData(this.$data)
      // 用数据和元素进行编译
      new Compile(this.$el, this)
    }
  }

  proxyData(data) {
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, {
        get() {
          return data[key]
        },
        set(newValue) {
          data[key] = newValue
        }
      })
    })
  }
} 

MVVM类整合了所有的模块,作为连接CompileObserver的桥梁。

模板编译(Compile)

Compile

compile在编译模板的时候,其实是从指令和文本两个方面来处理的。

class Compile {
  constructor(el, vm) {
    // 判断是否为DOM,若不是,自己获取
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    this.vm = vm;
    if (this.el) {
      // 1. 将真实DOM放进内存中
      let fragment = this.node2fragment(this.el);
      // 2. 开始编译 提取想要的元素节点 v-model 和 文本节点 {{}}
      this.compile(fragment);
      // 3. 将编译好的 fragment 重新放回页面
      this.el.appendChild(fragment);
    }
  }

  /**
   * 辅助方法
   * 是否为元素节点
   * @isElementNode
   * 是否为指令
   * @isDirective
   */
  isElementNode(node) {
    return node.nodeType === 1;
  }
  isDirective(name) {
    return name.includes("v-");
  }

  /**
   * 核心方法
   */
  compileElement(node) {
    // v-model  v-text
    let attrs = node.attributes; // 取出当前节点的属性
    Array.from(attrs).forEach(attr => {
      let attrName = attr.name;
      if (this.isDirective(attrName)) {
        // 判断属性名是否包含 v-model

        // 取到对应的值,放到节点中
        let expr = attr.value;
        let [, type] = attrName.split("-");  //解构赋值v-model-->model

        // 调用对应的编译方法, 编译哪个节点,用数据替换掉表达式
        CompileUtil[type](node, this.vm, expr);
      }
    });
  }

  compileText(node) {
    let expr = node.textContent; // 取出文本中的内容
    let reg = /\{\{([^]+)\}\}/g; // {{a}} {{b}} {{c}}
    if (reg.test(expr)) {
      // 调用编译文本的方法,编辑哪个节点,用数据替换掉表达式
      CompileUtil["text"](node, this.vm, expr);
    }
  }

  // 递归
  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);
      }
    });

    // Array.from()方法是将一个类数组对象或者可遍历对象转换成一个真正的数组
  }

  // 将el中的内容全部放进内存中
  node2fragment(el) {
    // 文档碎片 内存中的 dom 节点
    let fragment = document.createDocumentFragment();
    let firstChild;
    // 把值赋给变量 取不到后返回null,null作为条件
    while ((firstChild = el.firstChild)) {
      // 使用appendChild() 方法从一个元素向另一个元素中移动
      fragment.appendChild(firstChild);
    }

    return fragment; // 内存中的节点
  }
}

CompileUtil

CompileUtil是一个对象工具,配合Copmpile使用。

let CompileUtil = {
  model(node, vm, expr) {
    let updateFn = this.updater["modelUpdater"];

    /**
     *
     * 这里应该加一个监控,数据变化了 应该调用watch的callback
     * (这里只是记录原始的值 watcher的update没有执行,只有属性的set执行的时候,才会执行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));
  },
  text(node, vm, expr) {
    // 文本处理
    let updateFn = this.updater["textUpdater"];
    let value = this.getTextVal(vm, expr);

    expr.replace(/\{\{((?:.|\r?\n)+?)\}\}/g, (...args) => {
      new Watcher(vm, args[1], newValue => {
        // 如果数据变化了,文本节点需要重新获取依赖的属性,更新文本中的内容
        updateFn && updateFn(node, this.getTextVal(vm, expr));
      });
    });
    updateFn && updateFn(node, value);
  },
  getTextVal(vm, expr) {
    // 获取编译文本后的结果
    let value = this.parseText(expr);
    let result = '';
    value.tokens.forEach((item) => {
      if(item.hasOwnProperty('@binding')) {
        result += this.getVal(vm, item['@binding'])
      } else {
        result += item
      }
    })
    return result
  },
  parseText(text) {
    const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
    if (!tagRE.test(text)) {
      return;
    }
    const tokens = [];
    const rawTokens = [];
    let lastIndex = (tagRE.lastIndex = 0);
    let match, index, tokenValue;
    while ((match = tagRE.exec(text))) {
      index = match.index;
      // push text token
      if (index > lastIndex) {
        rawTokens.push((tokenValue = text.slice(lastIndex, index)));
        tokens.push(JSON.stringify(tokenValue));
      }
      // tag token 
      const exp = match[1].trim();
      tokens.push(`_s(${exp})`);
      rawTokens.push({ "@binding": exp });
      lastIndex = index + match[0].length;
    }

    if (lastIndex < text.length) {
      rawTokens.push((tokenValue = text.slice(lastIndex)));
      tokens.push(JSON.stringify(tokenValue));
    }
    return {
      expression: tokens.join("+"),
      tokens: rawTokens
    };
  },
  setVal(vm, expr, value) {
    expr = expr.split(".");
    return expr.reduce((prev, next, currentIndex) => {
      if (currentIndex === expr.length - 1) {
        return (prev[next] = value);
      }
      return prev[next];
    }, vm.$data);
  },
  getVal(vm, expr) {
    // 获取实例上对应的数据
    expr = expr.split("."); // {{message.a}} [message, a]

    // vm.$data.message => vm.$data.message.a
    return expr.reduce((prev, next) => {
      return prev[next.trim()];
    }, vm.$data);

    /**
     *  关于 reduce:
     * arr.reduce(callback,[initialValue])
     */
  },
  updater: {
    // 文本更新
    textUpdater(node, value) {
      node.textContent = value;
    },
    // 输入框更新
    modelUpdater(node, value) {
      node.value = value;
    }
  }
};

再次认识到正则表达式的重要性。
在处理{{}}模板引擎的时候,遇到一个bug,在一个DOM节点里,如果有个有多个{{}}{{}}会显示为undefined,后来仔细阅读了vueJs的源码,借鉴其中parseText()方法,进行处理,得以解决。

数据劫持(Observer)

什么是数据劫持?

在访问或修改对象的某个属性时,通过一段代码拦截这个行为,进行额外的操作,或者修改返回的结果。

数据劫持的作用是什么?

它是双向数据绑定的核心方法,通过劫持对象属性的settergetter操作,监听数据的变化,同时也是后期ES6中很多语法糖底层实现的核心方法。

使用Object.defineProperty()做数据劫持,有什么弊端?

1、不能监听数组的变化
2、必须遍历对象的每个属性
3、必须深层遍历嵌套的对象

MVVM中的数据劫持

class Observer {
  constructor(data) {
    this.observe(data)
  }

  observe(data) {
    // 要对这个data数据,将原有的属性改成set和get的形式

    // defineProperty针对的是对象
    if(!data || typeof data !== 'object') {
      return
    }

    // 将数据一一劫持,先获取到data的key和value
    Object.keys(data).forEach(key => {
      // 定义响应式变化
      this.defineReactive(data, key, data[key])
      this.observe(data[key]) //深度递归劫持
    })

    // 关于Object.keys() 返回一个包含对象的属性名称的数组
  }

  // 定义响应式
  defineReactive(obj, key, value) {
    let that = this;
    let dep = new Dep(); // 每个变化的数据 都会对应一个数组,这个数组是存放所有更新的操作

    Object.defineProperty(obj, key, {
      enumerable: true,     // 是否能在for...in循环中遍历出来或在Object.keys中列举出来
      configurable: true,   // false,不可修改、删除目标属性或修改属性性以下特性
      get() {
        Dep.target && dep.addSub(Dep.target)
        return value;
      },
      set(newValue) {
        if(newValue != value) {
          that.observe(newValue);  // 如果设置的是对象,继续劫持
          value = newValue;
          dep.notify(); //通知所有人 数据更新了
        }
      }
    })
  }
}

订阅发布(Dep)

其实发布订阅说白了就是把要执行的函数统一存储在一个数组subs中管理,当达到某个执行条件时,循环这个数组并执行每一个成员。

class Dep {
  constructor() {
    // 订阅数组
    this.subs = [];
  }

  // 添加订阅
  addSub(watcher) {
    this.subs.push(watcher);
  }

  // 将消息通知给所有人
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

观察者(Watcher)

Watcher 类的作用是,获取更改前的值存储起来,并创建一个 update 实例方法,当值被更改时,执行实例的 callback 以达到视图的更新。

class Watcher{  // 因为要获取 oldValue,所以需要“数据”和“表达式”
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;

    // 先获取 oldValue 保存下来
    this.value = this.get();
  }

  getVal(vm, expr) {
    expr = expr.split('.');

    return expr.reduce((prev, next) => {
      return prev[next.trim()]
    }, vm.$data);
  }

  get() {
    // 在取值之前先将 watcher 保存到 Dep 上
    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);
    }
  }
}

最后

最后当然是要检测一下,我们的写的代码是不是能正常运行。

<!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">
      <!-- 双向数据绑定 靠的是表单 -->
      <input type="text" v-model="message.a" />
      <div>{{ message.a }} 啦啦啦</div>
      {{ message.a }}
      {{ b }}
    </div>
  </body>
</html>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script src="compile.js"></script>
<script src="dep.js"></script>
<script src="mvvm.js"></script>
<script>
  let vm = new MVVM({
    el: "#app",
    data: {
      message: { a: "wlf" },
      b: "biubiubiu"
    }
  });
</script>

总结

我们根据下图(参考《深入浅出vue.js》),将整个流程再梳理一遍:


流程图.jpg

new MVVM() 后, MVVM 会进行初始化即实例化MVVM,在这个过程中,模板绑定的数据data通过Observer数据劫持,转换成了getter/setter的形式,来监听数据的变化,当被设置的对象被读取的时候会执行getter函数,当它被赋值的时候会执行setter函数。

当页面渲染的时候,会读取所需对象的值,这个时候会触发getter函数从而将Watcher添加到Dep中进行依赖收集,添加订阅。

当对象的值发生变化时,会触发对应的setter函数,setter会调用dep.notify()通知之前依赖收集得到的 Dep 中的每一个 Watcher,也就是遍历subs这个数组,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update() 来更新视图。

源码地址:
https://github.com/lostimever/MVVM

上一篇下一篇

猜你喜欢

热点阅读