JS实现一个简易版的vue

上一篇简析vue实现原理,我们知道在 Vue 的 MVVM 设计中,我们主要针对 Observer(数据劫持)、Dep(发布订阅)、Watcher(数据监听)和 Compile(模板编译)几个部分来实现,下面来实现它们。

// MVVM.js,入口文件,整合上面的几部分

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

        if (this.$el) {
            // $data数据劫持
            new Observer(this.$data);

            // 将数据代理到实例上 vm.message = "hello"
            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;
                }
            });
        });
    }
}

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

class Compile {
    constructor(el, vm) {
        //dom
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;

        // 如过传入的根元素存在,才开始编译
        if (this.el) {
            // 1、把这些真实的 Dom 移动到内存中,即 fragment(文档碎片)
            let fragment = this.node2fragment(this.el);

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

            // 3、把编译好的 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.from(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.textContent;

        // 创建匹配 {{}} 的正则表达式
        //let reg = /\{\{([^}+])\}\}/g;
        //“.”表示任意字符。“+”表示前面表达式一次乃至多次。“?”表示匹配模式是非贪婪的。
        let reg = /\{\{(.+?)\}\}/g;

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

// CompileUtil.js
CompileUtil 中存储着所有的指令方法及指令对应的更新方法,由于 Vue 的指令很多,我们这里只实现典型的 v-model 和 “{{ }}” 对应的方法,考虑到后续更新的情况,我们统一把设置值到 Dom 中的逻辑抽取出对应上面两种情况的方法,存放到 CompileUtil 的 updater 对象中。

CompileUtil = {};

// 更新Dom节点方法
CompileUtil.updater = {
    // 文本更新
    textUpdater(node, value) {
        node.textContent = value;
    },
    // 输入框更新
    modelUpdater(node, value) {
        node.value = value;
    }
};

// 获取 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);
}

// 处理 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(node, value);
};


// 处理文本节点 {{}} 的方法
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(node, value);
};

// Watcher.js
Watcher 类内部要做的事情,获取更改前的值存储起来,并创建一个 update 实例方法,在值被更改时去执行实例的 callback 以达到视图的更新。

class Watcher {
    constructor(vm, exp, callback) {
        this.vm = vm; // MVVM 的实例
        this.exp = 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(newValue);
        }
    }
}

// Dep.js
发布订阅将要执行的函数统一存储在一个数组中管理,当达到某个执行条件时,循环这个数组并执行每一个成员。

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

// Observer.js 数据劫持 Observer 类

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

        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(); // 通知所有人数据更新了
                }
            }
        });
    }
}

来验证下我们写的MVVM。

<!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="./Watcher.js"></script>
<script src="./Observer.js"></script>
<script src="./Compile.js"></script>
<script src="./CompileUtil.js"></script>
<script src="./Dep.js"></script>
<script src="./MVVM.js"></script>
<script>
    let vm = new MVVM({
        el: '#app',
        data: {
            message: 'hello world!'
        }
    })
</script>
</body>
</html>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345