乐趣区

稍微学一下-MVVM-原理

博客原文

介绍

本文通过仿照 Vue,简单实现一个的 MVVM,希望对大家学习和理解 Vue 的原理有所帮助。

前置知识

nodeType

nodeType 为 HTML 原生节点的一个属性,用于表示节点的类型。

Vue 中通过每个节点的 nodeType 属性是 1 还是 3 判断是元素节点还是文本节点,针对不同类型节点做不同的处理。

DocumentFragment

DocumentFragment 是一个可以被 js 操作但不会直接出发渲染的文档对象,Vue 中编译模板时是现将所有节点存到 DocumentFragment 中,操作完后再统一插入到 html 中,这样就避免了多次修改 Dom 出发渲染导致的性能问题。

Object.defineProperty

Object.defineProperty 接收三个参数 Object.defineProperty(obj, prop, descriptor), 可以为一个对象的属性 obj.prop t 通过 descriptor 定义 get 和 set 方法进行拦截,定义之后该属性的取值和修改时会自动触发其 get 和 set 方法。

从零实现一个类 Vue

以下代码的 git 地址:以下代码的 git 地址

目录结构

├── vue
│   ├── index.js
│   ├── obsever.js
│   ├── compile.js
│   └── watcher.js
└── index.html

实现的这个 类 Vue 包含了 4 个主要模块:

  • index.js 为入口文件,提供了一个 Vue 类,并在类的初始化时调用 obsever 与 compile 分别进行数据拦截与模板编译;
  • obsever.js 中提供了一个 Obsever 类及一个 Dep 类,Obsever 对 vue 的 data 属性遍历,给所有数据都添加 getter 与 setter 进行拦截,Dep 用于记录每个数据的依赖;
  • compile.js 中提供了一个 Compile 类,对传入的 html 节点的所有子节点遍历编译,分析 vue 不同的指令并解析 {{}} 的语法;
  • watcher.js 中提供了一个 Watcher 类,用于监听每个数据的变化,当数据变化时调用传入的回调函数;

入口文件

在 index.html 中是通过 new Vue() 来使用的:

<div id="app">
  <input type="text" v-model="msg">
  {{msg}}
  {{user.name}}
</div>
<script>
  const vm = new Vue({
    el: '#app',
    data: {
      msg: 'hello',
      user: {name: 'pan'}
    }
  })
</script>

因此入口文件需提供这个 Vue 的类并进行一些初始化操作:

class Vue {constructor(options) {
    // 参数挂载到实例
    this.$el = document.querySelector(options.el);
    this.$data = options.data;
    if (this.$el) {
      // 数据劫持
      new Observer(this.$data);
      // 编译模板
      new Compile(this.$el, this);
    }
  }
}

Compile

index.js 中调用了 new Compile() 进行模板编译,因此这里需要提供一个 Compile 类:

class Compile {constructor(el, vm) {
    this.el = el;
    this.vm = vm;
    if (this.el) {
      // 将 dom 转入 fragment 内存中
      const fragment = this.node2fragment(this.el);
      // 编译  提取需要的节点并替换为对应数据
      this.compile(fragment);
      // 插回页面中去
      this.el.appendChild(fragment);
    }
  }
  // 编译元素节点  获取 Vue 指令并执行对应的编译函数(取值并更新 dom)compileElement(node) {
    const attrs = node.attributes;
    Array.from(attrs).forEach(attr => {
      const attrName = attr.name;
      if (this.isDirective(attrName)) {
        const expr = attr.value;
        let [, ...type] = attrName.split('-');
        type = type.join('');
        // 调用指令对应的方法更新 dom
        CompileUtil[type](node, this.vm, expr);
      }
    })
  }
  // 编译文本节点  判断文本内容包含 {{}} 则执行文本节点编译函数(取值并更新 dom)compileText(node) {
    const expr = node.textContent;
    const reg = /\{\{\s*([^}\s]+)\s*\}\}/;
    if (reg.test(expr)) {
      // 调用文本节点对应的方法更新 dom
      CompileUtil['text'](node, this.vm, expr);
    }
  }
  // 递归遍历 fragment 中所有节点判断节点类型并编译
  compile(fragment) {
    const childNodes = fragment.childNodes;
    Array.from(childNodes).forEach(node => {if (this.isElementNode(node)) {
        // 元素节点  编译并递归
        this.compileElement(node);
        this.compile(node);
      } else {
        // 文本节点
        this.compileText(node);
      }
    })
  }
  // 循环将 el 中每个节点插入 fragment 中
  node2fragment(el) {const fragment = document.createDocumentFragment();
    let firstChild;
    while (firstChild = el.firstChild) {fragment.appendChild(firstChild);
    }
    return fragment;
  }
  isElementNode(node) {return node.nodeType === 1;}
  isDirective(name) {return name.startsWith('v-');
  }
}

这里利用了 nodeType 区分 元素节点 还是 文本节点,分别调用了 compileElement 和 compileText。

compileElement 及 compileText 中最终调用了 CompileUtil 的方法更新 dom。

CompileUtil = {
  // 获取实例上对应数据
  getVal(vm, expr) {expr = expr.split('.');
    return expr.reduce((prev, next) => {return prev[next];
    }, vm.$data);
  },
  // 文本节点需先去除 {{}} 并利用正则匹配多组
  getTextVal(vm, expr) {return expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {return this.getVal(vm, arguments[1]);
    })
  },
  // 从 vm.$data 上取值并更新节点的文本内容
  text(node, vm, expr) {expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      // 添加数据监听,数据变化时调用回调函数
      new Watcher(vm, arguments[1], () => {this.updater.textUpdater(node, this.getTextVal(vm, expr));
      })
    })
    this.updater.textUpdater(node, this.getTextVal(vm, expr));
  },
  // 从 vm.$data 上取值并更新输入框内容
  model(node, vm, expr) {
    // 添加数据监听,数据变化时调用回调函数
    new Watcher(vm, expr, () => {this.updater.modelUpdater(node, this.getVal(vm, expr));
    })
    // 输入框输入时修改 data 中对应数据
    node.addEventListener('input', e => {
      const newValue = e.target.value;
      this.setVal(vm, expr, newValue);
    })
    this.updater.modelUpdater(node, this.getVal(vm, expr));
  },
  updater: {textUpdater(node, value) {node.textContent = value;},
    modelUpdater(node, value) {node.value = value;}
  }
}

getVal 方法用于处理嵌套对象的属性,如传入表达式 expr 为 user.name 的情况,利用 reduce 从 vm.$data 上拿到。

Observer

index.js 中调用了 new Observer() 进行数据劫持,Vue 实例 data 属性的每项数据都通过 defineProperty 方法添加 getter setter 拦截数据操作将其定义为响应式数据,因此这里首先需要提供一个 Observer 类:

class Observer {constructor(data) {
    // 遍历 data 将每个属性定义为响应式
    this.observer(data);
  }
  observer(data) {if (!data || typeof data !== 'object') {return;}
    for (const [key, value] of Object.entries(data)) {this.defineReactive(data, key, value);
      // 当属性为对象则需递归遍历
      this.observer(value);
    }
  }
  // 定义响应式属性
  defineReactive(obj, key, value) {
    const that = this;
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: false,
      // 获取数据时调用
      get() {
        // 将 Watcher 实例存入依赖
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 设置数据时调用
      set(newVal) {if (newVal !== value) {
          // 当新值为对象时,需遍历并定义对象内属性为响应式
          that.observer(newVal);
          value = newVal;
          // 通知依赖更新
          dep.notify();}
      }
    })
  }
}

定义为响应式数据后再对其取值和修改是会触发对应的 get 和 set 方法。
取值时将改值本身返回,并先判断是否有依赖目标 Dep.target,如果有则保存起来。
修改值时先手动将原值修改并通知保存的所有依赖目标进行更新操作。

这里对每项数据都通过创建一个 Dep 类实例进行保存依赖和通知更新的操作,因此需要写一个 Dep 类:

class Dep {constructor() {this.subs = [];
  }
  addSub(watcher) {this.subs.push(watcher);
  }
  notify() {this.subs.forEach(watcher => watcher.update());
  }
}

Dep 中有一个数组,用于保存数据的依赖目标(watcher),notify 遍历所有依赖并调用其 update 方法进行更新。

Watcher

通过上面的 Observer 可以知道,每项数据在被调用时可能会有依赖目标,依赖目标需要被保存并在取值时调用 notify 通知更新,且通过 Dep 可以知道依赖目标是一个有 update 方法的对象实例。

因此需要创建一个 Watcher 类:

class Watcher {constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 记录旧值
    this.value = this.get();}
  getVal(vm, expr) {expr = expr.split('.');
    return expr.reduce((prev, next) => {return prev[next];
    }, vm.$data);
  }
  get() {
    Dep.target = this;
    // 获取 data 会触发对应数据的 get 方法,get 方法中从 Dep.target 拿到 Watcher 实例
    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 方法,调用 update 时会重新根据表达式 expr 取值与老值对比并调用回调函数。
这里的回调函数就是对应的更新 dom 的方法,在 compile.js 中的 model 及 text 方法中有执行 new Watcher(),在模板解析时就为每项数据添加了监听:

model(node, vm, expr) {
  // 添加数据监听,数据变化时调用回调函数
  new Watcher(vm, expr, () => {this.updater.modelUpdater(node, this.getVal(vm, expr));
  })
  this.updater.modelUpdater(node, this.getVal(vm, expr));
},

Watcher 中很巧妙的一点就是,模板编译之前已经将所有添加了数据拦截,在 Watcher 的 get 方法中调用 getVal 取值时会触发该数据的 getter 方法,因此这里在取值前通过 Dep.target = this; 将该 Watcher 实例暂存,对应数据的 getter 方法中又将该实例作为依赖目标保存到了自身对应的 Dep 实例中。

总结

这样就实现了一个简易的 MVVM 原理,里面的一些思路还是非常值得反复体会学习的。

退出移动版