模拟 Vue 手写一个 MVVM


阅读原文


MVVM 的前世此生

MVVM 设计模式,是由 MVC(最先来源于后端)、MVP 等设计模式进化而来,M - 数据模型(Model),VM - 视图模型(ViewModel),V - 视图层(View)。javascript

在 MVC 模式中,除了 Model 和 View 层之外,其余全部的逻辑都在 Controller 中,Controller 负责显示页面、响应用户操做、网络请求及与 Model 的交互,随着业务的增长和产品的迭代,Controller 中的处理逻辑愈来愈多、愈来愈复杂,难以维护。为了更好的管理代码,为了更方便的扩展业务,必需要为 Controller “瘦身”,须要更清晰的将用户界面(UI)开发从应用程序的业务逻辑与行为中分离,MVVM 为此而生。html

不少 MVVM 的实现都是经过数据绑定来将 View 的逻辑从其余层分离,能够用下图来简略的表示:前端



使用 MVVM 设计模式的前端框架不少,其中渐进式框架 Vue 是典型的表明,并在开发使用中深得广大前端开发者的青睐,咱们这篇就根据 Vue 对于 MVVM 的实现方式来简单模拟一版 MVVM 库。java


MVVM 的流程分析

在 Vue 的 MVVM 设计中,咱们主要针对 Compile(模板编译)、Observer(数据劫持)、Watcher(数据监听)和 Dep(发布订阅)几个部分来实现,核心逻辑流程可参照下图:node



相似这种 “造轮子” 的代码毋庸置疑必定是经过面向对象编程来实现的,并严格遵循开放封闭原则,因为 ES5 的面向对象编程比较繁琐,因此,在接下来的代码中统一使用 ES6 的 class 来实现。正则表达式


MVVM 类的实现

在 Vue 中,对外只暴露了一个名为 Vue 的构造函数,在使用的时候 new 一个 Vue 实例,而后传入了一个 options 参数,类型为一个对象,包括当前 Vue 实例的做用域 el、模板绑定的数据 data 等等。编程

咱们模拟这种 MVVM 模式的时候也构建一个类,名字就叫 MVVM,在使用时同 Vue 框架相似,须要经过 new 指令建立 MVVM 的实例并传入 options后端

// MVVM.js 文件
class MVVM {
    constructor(options) {
        // 先把 el 和 data 挂在 MVVM 实例上
        this.$el = options.el;
        this.$data = options.data;

        // 若是有要编译的模板就开始编译
        if (this.$el) {
            // 数据劫持,就是把对象全部的属性添加 get 和 set
            new Observer(this.$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(newVal) {
                    data[key] = newVal;
                }
            });
        });
    }
}
复制代码

经过上面代码,咱们能够看出,在咱们 new 一个 MVVM 的时候,在参数 options 中传入了一个 Dom 的根元素节点和数据 data 并挂在了当前的 MVVM 实例上。设计模式

当存在根节点的时候,经过 Observer 类对 data 数据进行了劫持,并经过 MVVM 实例的方法 proxyDatadata 中的数据挂在当前 MVVM 实例上,一样对数据进行了劫持,是由于咱们在获取和修改数据的时候能够直接经过 thisthis.$data,在 Vue 中实现数据劫持的核心方法是 Object.defineProperty,咱们也使用这个方式经过添加 gettersetter 来实现数据劫持。数组

最后使用 Compile 类对模板和绑定的数据进行了解析和编译,并渲染在根节点上,之因此数据劫持和模板解析都使用类的方式实现,是由于代码方便维护和扩展,其实不难看出,MVVM 类其实做为了 Compile 类和 Observer 类的一个桥梁。


模板编译 Compile 类的实现

Compile 类在建立实例的时候须要传入两个参数,第一个参数是当前 MVVM 实例做用的根节点,第二个参数就是 MVVM 实例,之因此传入 MVVM 的实例是为了更方便的获取 MVVM 实例上的属性。

Compile 类中,咱们会尽可能的把一些公共的逻辑抽取出来进行最大限度的复用,避免冗余代码,提升维护性和扩展性,咱们把 Compile 类抽取出的实例方法主要分为两大类,辅助方法和核心方法,在代码中用注释标明。

一、解析根节点内的 Dom 结构

// 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.node2fragment(this.el);
        }
    }

    /* 辅助方法 */
    // 判断是不是元素节点
    isElementNode(node) {
        return node.nodeType === 1;
    }

    /* 核心方法 */
    // 将根节点转移至文档碎片
    node2fragment(el) {
        // 建立文档碎片
        let fragment = document.createDocumentFragment();
        // 第一个子节点
        let firstChild;

        // 循环取出根节点中的节点并放入文档碎片中
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
}
复制代码

上面编译模板的过程当中,前提条件是必须存在根元素节点,传入的根元素节点容许是一个真实的 Dom 元素,也能够是一个选择器,因此咱们建立了辅助方法 isElementNode 来帮咱们判断传入的元素是不是 Dom,若是是就直接使用,是选择器就获取这个 Dom,最终将这个根节点存入 this.el 属性中。

解析模板的过程当中为了性能,咱们应取出根节点内的子节点存放在文档碎片中(内存),须要注意的是将一个 Dom 节点内的子节点存入文档碎片的过程当中,会在原来的 Dom 容器中删除这个节点,因此在遍历根节点的子节点时,永远是将第一个节点取出存入文档碎片,直到节点不存在为止。

二、编译文档碎片中的结构

在 Vue 中的模板编译的主要就是两部分,也是浏览器没法解析的部分,元素节点中的指令和文本节点中的 Mustache 语法(双大括号)。

// 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.node2fragment(this.el);

            // ********** 如下为新增代码 **********
            // 二、将模板中的指令中的变量和 {{}} 中的变量替换成真实的数据
            this.compile(fragment);

            // 三、把编译好的 fragment 再塞回页面中
            this.el.appendChild(fragment);
            // ********** 以上为新增代码 **********
        }
    }

    /* 辅助方法 */
    // 判断是不是元素节点
    isElementNode(node) {
        return node.nodeType === 1;
    }

    // ********** 如下为新增代码 **********
    // 判断属性是否为指令
    isDirective(name) {
        return name.includes("v-");
    }
    // ********** 以上为新增代码 **********

    /* 核心方法 */
    // 将根节点转移至文档碎片
    node2fragment(el) {
        // 建立文档碎片
        let fragment = document.createDocumentFragment();
        // 第一个子节点
        let firstChild;

        // 循环取出根节点中的节点并放入文档碎片中
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }

    // ********** 如下为新增代码 **********
    // 解析文档碎片
    compile(fragment) {
        // 当前父节点节点的子节点,包含文本节点,类数组对象
        let childNodes = fragment.childNodes;

        // 转换成数组并循环判断每个节点的类型
        Array.from(childNodes).forEach(node => {
            if (this.isElementNode(node)) { // 是元素节点
                // 递归编译子节点
                this.compile(node);

                // 编译元素节点的方法
                this.compileElement(node);
            } else { // 是文本节点
                // 编译文本节点的方法
                this.compileText(node);
            }
        });
    }
    // 编译元素
    compileElement(node) {
        // 取出当前节点的属性,类数组
        let attrs = node.attributes;
        Array.form(attrs).forEach(attr => {
            // 获取属性名,判断属性是否为指令,即含 v-
            let attrName = attr.name;

            if (this.isDirective(attrName)) {
                // 若是是指令,取到该属性值得变量在 data 中对应得值,替换到节点中
                let exp = attr.value;

                // 取出方法名
                let [, type] = attrName.split("-");

                // 调用指令对应得方法
                CompileUtil[type](node, this.vm, exp);
            }
        });

    }
    // 编译文本
    compileText(node) {
        // 获取文本节点的内容
        let exp = node.contentText;

        // 建立匹配 {{}} 的正则表达式
        let reg = /\{\{([^}+])\}\}/g;

        // 若是存在 {{}} 则使用 text 指令的方法
        if (reg.test(exp)) {
            CompileUtil["text"](node, this.vm, exp);
        }
    }
    // ********** 以上为新增代码 **********
}
复制代码

上面代码新增内容得主要逻辑就是作了两件事:

  • 调用 compile 方法对 fragment 文档碎片进行编译,即替换内部指令和 Mustache 语法中变量对应的值;
  • 将编译好的 fragment 文档碎片塞回根节点。

在第一个步骤当中逻辑是比较繁琐的,首先在 compile 方法中获取全部的子节点,循环进行编译,若是是元素节点须要递归 compile,传入当前元素节点。在这个过程中抽取出了两个方法,compileElementcompileText 用来对元素节点的属性和文本节点进行处理。

compileElement 中的核心逻辑就是处理指令,取出元素节点全部的属性判断是不是指令,是指令则调用指令对应的方法。compileText 中的核心逻辑就是取出文本的内容经过正则表达式匹配出被 Mustache 语法的 “{{ }}” 包裹的内容,并调用处理文本的 text 方法。

文本节点的内容有可能存在 “{{ }} {{ }} {{ }}”,正则匹配默认是贪婪的,为了防止第一个 “{” 和最后一个 “}” 进行匹配,因此在正则表达式中应使用非贪婪匹配。

在调用指令的方法时都是调用的 CompileUtil 下对应的方法,咱们之因此单独把这些指令对应的方法抽离出来存储在 CompileUtil 对象下的目的是为了解耦,由于后面其余的类还要使用。

三、CompileUtil 对象中指令方法的实现

CompileUtil 中存储着全部的指令方法及指令对应的更新方法,因为 Vue 的指令不少,咱们这里只实现比较典型的 v-model 和 “{{ }}” 对应的方法,考虑到后续更新的状况,咱们统一把设置值到 Dom 中的逻辑抽取出对应上面两种状况的方法,存放到 CompileUtilupdater 对象中。

// CompileUtil.js 文件
CompileUtil = {};

// 更新节点数据的方法
CompileUti.updater = {
    // 文本更新
    textUpdater(node, value) {
        node.textContent = value;
    },
    // 输入框更新
    modelUpdater(node, value) {
        node.value = value;
    }
};
复制代码

这部分的整个思路就是在 Compile 编译模板后处理 v-model 和 “{{ }}” 时,其实都是用 data 中的数据替换掉 fragment 文档碎片中对应的节点中的变量。所以会常常性的获取 data 中的值,在更新节点时又会从新设置 data 中的值,因此咱们抽离出了三个方法 getValgetTextValsetVal 挂在了 CompileUtil 对象下。

// CompileUtil.js 文件
// 获取 data 值的方法
CompileUtil.getVal = function (vm, exp) {
    // 将匹配的值用 . 分割开,如 vm.data.a.b
    exp = exp.split(".");

    // 归并取值
    return exp.reduce((prev, next) => {
        return prev[next];
    }, vm.$data);
};

// 获取文本 {{}} 中变量在 data 对应的值
CompileUtil.getTextVal = function (vm, exp) {
    // 使用正则匹配出 {{ }} 间的变量名,再调用 getVal 获取值
    return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
        return this.getVal(vm, args[1]);
    });
};

// 设置 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {
    exp = exp.split(".");
    return exp.reduce((prev, next, currentIndex) => {
        // 若是当前归并的为数组的最后一项,则将新值设置到该属性
        if(currentIndex === exp.length - 1) {
            return prev[next] = newVal;
        }

        // 继续归并
        return prev[next];
    }, vm.$data);
}
复制代码

获取和设置 data 的值两个方法 getValsetVal 思路类似,因为获取的变量层级不定,多是 data.a,也多是 data.obj.a.b,因此都是使用归并的思路,借用 reduce 方法实现的,区别在于 setVal 方法在归并过程当中须要判断是否是归并到最后一级,若是是则设置新值,而 getTextVal 就是在 getVal 外包了一层处理 “{{ }}” 的逻辑。

在这些准备工做就绪之后就能够实现咱们的主逻辑,即对 Compile 类中解析的文本节点和元素节点指令中的变量用 data 值进行替换,还记得前面说针对 v-model 和 “{{ }}” 进行处理,所以设计了 modeltext 两个核心方法。

CompileUtil.model 方法的实现:

// CompileUtil.js 文件
// 处理 v-model 指令的方法
CompileUtil.model = function (node, vm, exp) {
    // 获取赋值的方法
    let updateFn = this.updater["modelUpdater"];

    // 获取 data 中对应的变量的值
    let value = this.getVal(vm, exp);

    // 添加观察者,做用与 text 方法相同
    new Watcher(vm, exp, newValue => {
        updateFn && updateFn(node, newValue);
    });

    // v-model 双向数据绑定,对 input 添加事件监听
    node.addEventListener('input', e => {
        // 获取输入的新值
        let newValue = e.target.value;

        // 更新到节点
        this.setVal(vm, exp, newValue);
    });

    // 第一次设置值
    updateFn && updateFn(vm, value);
};
复制代码

CompileUtil.text 方法的实现:

// CompileUtil.js 文件
// 处理文本节点 {{}} 的方法
CompileUtil.text = function (node, vm, exp) {
    // 获取赋值的方法
    let updateFn = this.updater["textUpdater"];

    // 获取 data 中对应的变量的值
    let value = this.getTextVal(vm, exp);

    // 经过正则替换,将取到数据中的值替换掉 {{ }}
    exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
        // 解析时遇到了模板中须要替换为数据值的变量时,应该添加一个观察者
        // 当变量从新赋值时,调用更新值节点到 Dom 的方法
        new Watcher(vm, args[1], newValue => {
            // 若是数据发生变化,从新获取新值
            updateFn && updateFn(node, newValue);
        });
    });

    // 第一次设置值
    updateFn && updateFn(vm, value);
};
复制代码

上面两个方法逻辑类似,都获取了各自的 updater 中的方法,对值进行设置,而且在设置的同时为了后续 data 中的数据修改,视图的更新,建立了 Watcher 的实例,并在内部用新值从新更新节点,不一样的是 Vue 的 v-model 指令在表单中实现了双向数据绑定,只要表单元素的 value 值发生变化,就须要将新值更新到 data 中,并响应到页面上。

因此咱们的实现方式是给这个绑定了 v-model 的表单元素监听了 input 事件,并在事件中实时的将新的 value 值更新到 data 中,至于 data 中的改变后响应到页面中须要另外三个类 WatcherObserverDep 共同实现,咱们下面就来实现 Watcher 类。


观察者 Watcher 类的实现

CompileUtil 对象的方法中建立 Watcher 实例的时候传入了三个参数,即 MVVM 的实例、模板绑定数据的变量名 exp 和一个 callback,这个 callback 内部逻辑是为了更新数据到 Dom,因此咱们的 Watcher 类内部要作的事情就清晰了,获取更改前的值存储起来,并建立一个 update 实例方法,在值被更改时去执行实例的 callback 以达到视图的更新。

// Watcher.js 文件
class Watcher {
    constructor(vm, exp, callback) {
        this.vm = vm;
        this.exp = exp;
        this.callback = callback;

        // 更改前的值
        this.value = this.get();
    }
    get() {
        // 将当前的 watcher 添加到 Dep 类的静态属性上
        Dep.target = this;

        // 获取值触发数据劫持
        let value = CompileUtil.getVal(this.vm, this.exp);

        // 清空 Dep 上的 Watcher,防止重复添加
        Dep.target = null;
        return value;
    }
    update() {
        // 获取新值
        let newValue = CompileUtil.getVal(this.vm, this.exp);
        // 获取旧值
        let oldValue = this.value;

        // 若是新值和旧值不相等,就执行 callback 对 dom 进行更新
        if(newValue !== oldValue) {
            this.callback();
        }
    }
}
复制代码

看到上面代码必定有两个疑问:

  • 使用 get 方法获取旧值得时候为何要将当前的实例挂在 Dep 上,在获取值后为何又清空了;
  • update 方法内部执行了 callback 函数,可是 update 在何时执行。

这就是后面两个类 Depobserver 要作的事情,咱们首先来介绍 Dep,再介绍 Observer 最后把他们之间的关系整个串联起来。


发布订阅 Dep 类的实现

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

// Dep.js 文件
class Dep {
    constructor() {
        this.subs = [];
    }
    // 添加订阅
    addSub(watcher) {
        this.subs.push(watcher);
    }
    // 通知
    notify() {
        this.subs.forEach(watcher => watcher.update());
    }
}
复制代码

Dep 类中只有一个属性,就是一个名为 subs 的数组,用来管理每个 watcher,即 Watcher 类的实例,而 addSub 就是用来将 watcher 添加到 subs 数组中的,咱们看到 notify 方法就解决了上面的一个疑问,Watcher 类的 update 方法是怎么执行的,就是这样循环执行的。

接下来咱们整合一下盲点:

  • Dep 实例在哪里建立声明,又是在哪里将 watcher 添加进 subs 数组的;
  • Depnotify 方法应该在哪里调用;
  • Watcher 内容中,使用 get 方法获取旧值得时候为何要将当前的实例挂在 Dep 上,在获取值后为何又清空了。

这些问题在最后一个类 Observer 实现的时候都将清晰,下面咱们重点来看最后一部分核心逻辑。


数据劫持 Observer 类的实现

还记得实现 MVVM 类的时候就建立了这个类的实例,当时传入的参数是 MVVM 实例的 data 属性,在 MVVM 中把数据经过 Object.defineProperty 挂到了实例上,并添加了 gettersetter,其实 Observer 类主要目的就是给 data 内的全部层级的数据都进行这样的操做。

// Observer.js 文件
class Observer {
    constructor (data) {
        this.observe(data);
    }
    // 添加数据监听
    observe(data) {
        // 验证 data
        if(!data || typeof data !== 'object') {
            return;
        }

        // 要对这个 data 数据将原有的属性改为 set 和 get 的形式
        // 要将数据一一劫持,先获取到 data 的 key 和 value
        Object.keys(data).forEach(key => {
            // 劫持(实现数据响应式)
            this.defineReactive(data, key, data[key]);
            this.observe(data[key]); // 深度劫持
        });
    }
    // 数据响应式
    defineReactive (object, key, value) {
        let _this = this;
        // 每一个变化的数据都会对应一个数组,这个数组是存放全部更新的操做
        let dep = new Dep();

        // 获取某个值被监听到
        Object.defineProperty(object, key, {
            enumerable: true,
            configurable: true,
            get () { // 当取值时调用的方法
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set (newValue) { // 当给 data 属性中设置的值适合,更改获取的属性的值
                if(newValue !== value) {
                    _this.observe(newValue); // 从新赋值若是是对象进行深度劫持
                    value = newValue;
                    dep.notify(); // 通知全部人数据更新了
                }
            }
        });
    }
}
复制代码

在的代码中 observe 的目的是遍历对象,在内部对数据进行劫持,即添加 gettersetter,咱们把劫持的逻辑单独抽取成 defineReactive 方法,须要注意的是 observe 方法在执行最初就对当前的数据进行了数据类型验证,而后再循环对象每个属性进行劫持,目的是给同为 Object 类型的子属性递归调用 observe 进行深度劫持。

defineReactive 方法中,建立了 Dep 的实例,并对 data 的数据使用 getset 进行劫持,还记得在模板编译的过程当中,遇到模板中绑定的变量,就会解析,并建立 watcher,会在 Watcher 类的内部获取旧值,即当前的值,这样就触发了 get,在 get 中就能够将这个 watcher 添加到 Depsubs 数组中进行统一管理,由于在代码中获取 data 中的值操做比较多,会常常触发 get,咱们又要保证 watcher 不会被重复添加,因此在 Watcher 类中,获取旧值并保存后,当即将 Dep.target 赋值为 null,而且在触发 get 时对 Dep.target 进行了短路操做,存在才调用 DepaddSub 进行添加。

data 中的值被更改时,会触发 set,在 set 中作了性能优化,即判断从新赋的值与旧值是否相等,若是相等就不从新渲染页面,不等的状况有两种,若是原来这个被改变的值是基本数据类型没什么影响,若是是引用类型,咱们须要对这个引用类型内部的数据进行劫持,所以递归调用了 observe,最后调用 Depnotify 方法进行通知,执行 notify 就会执行 subs 中全部被管理的 watcherupdate,就会执行建立 watcher 时的传入的 callback,就会更新页面。

MVVM 类将 data 的属性挂在 MVVM 实例上并劫持与经过 Observer 类对 data 的劫持还有一层联系,由于整个发布订阅的逻辑都是在 datagetset 上,只要触发了 MVVM 中的 getset 内部会自动返回或设置 data 对应的值,就会触发 datagetset,就会执行发布订阅的逻辑。

经过上面长篇大论的叙述后,这个 MVVM 模式用到的几个类的关系应该彻底叙述清晰了,虽然比较抽象,可是细心琢磨仍是会明白之间的关系和逻辑,下面咱们就来对咱们本身实现的这个 MVVM 进行验证。


验证 MVVM

咱们按照 Vue 的方式根据本身的 MVVM 实现的内容简单的写了一个模板以下:

<!-- index.html 文件 -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>MVVM</title>
</head>
<body>
    <div id="app">
        <!-- 双向数据绑定 靠的是表单 -->
        <input type="text" v-model="message">
        <div>{{message}}</div>
        <ul>
            <li>{{message}}</li>
        </ul>
        {{message}}
    </div>

    <!-- 引入依赖的 js 文件 -->
    <script src="./js/Watcher.js"></script>
    <script src="./js/Observer.js"></script>
    <script src="./js/Compile.js"></script>
    <script src="./js/CompileUtil.js"></script>
    <script src="./js/Dep.js"></script>
    <script src="./js/MVVM.js"></script>
    <script> let vm = new MVVM({ el: '#app', data: { message: 'hello world!' } }); </script>
</body>
</html>
复制代码

打开 Chrom 浏览器的控制台,在上面经过下面操做来验证:

  • 输入 vm.message = "hello" 看页面是否更新;
  • 输入 vm.$data.message = "hello" 看页面是否更新;
  • 改变文本输入框内的值,看页面的其余元素是否更新。

总结

经过上面的测试,相信应该理解了 MVVM 模式对于前端开发重大的意义,实现了双向数据绑定,实时保证 View 层与 Model 层的数据同步,并可让咱们在开发时基于数据编程,而最少的操做 Dom,这样大大提升了页面渲染的性能,也可使咱们把更多的精力用于业务逻辑的开发上。

相关文章
相关标签/搜索