模拟一个简单的Vue

Vue是现在前端非常流行的一个前端框架了,了解它的实现原理现在基本已经快成为前端开发一个必备的基本功了,这篇文章将尝试写一个简单的Vue框架。

Vue数据监听架构

Vue主要架构分为三个部分CompileObserverWatcher结构图如下:

Vue数据监听架构

Obserer负责监听Vue中的数据,Compile负责Vue中涉及dom节点的渲染,Compile和Observer通过Watcher关联,当Observer监听到数据变化会通过watcher使Compile更新页面,反之亦然。
下边就一部分一部分拆解Vue数据监听架构。

Vue函数

这里简单模拟Vue函数,el为Vue作用的dom节点钩子,data为Vue主要监听的数据,option为Vue中dom交互事件函数放置的地方。

class Vue {
    constructor(el, data, option) {
        this.$el = el;
        this.$data = data;
        this.$option = option; // 绑定方法放在这里
        if (this.$el) {
            new Observer(this.$data)
            new Compile(this.$el, this);
        }
    }
}

Compile

构造函数

compile负责Vue数据在页面上的渲染,首先看构造函数:

constructor(el, vm) {
        this.vm = vm;
        if (el && el.nodeType === 1) {
            this.$el = el;
        } else {
            this.$el = document.querySelector(el);
        }

        const fragment = this.createFragment(this.$el);
        this.compile(fragment);
        this.$el.appendChild(fragment);
    }

createFragment(el) {
        const fragment = document.createDocumentFragment();
        while (el.firstChild) {
            fragment.appendChild(el.firstChild);
        }
        return fragment;
    }

都是比较简单的功能,首先在Vue构造函数中将el与vue实例通过构造函数传递进来,其他值得一说的就是为了减少dom结构变化造成的重排,使用了fragment,先将el子节点缓存在fragment中,然后compile后一次性插入el子节点中。

compile

compile(fragment) {
        fragment.childNodes.forEach((childNode) => {
            if (childNode && childNode.nodeType === 1) {
                this.compileElement(childNode)
            } else {
                this.compileText(childNode)
            }
            if (childNode && childNode.childNodes.length > 0) {
                this.compile(childNode);
            }
        })
    }

遍历子节点,发现如果是element节点进行子节点的递归调用,这里简单处理为子节点只有element与text类型节点。分别针对element与text节点做编译处理。

编译text与element类型子节点

 compileElement(node) {
        const attributes = Array.from(node.attributes);
        attributes.forEach((attribute) => {
            const {name, value} = attribute;
            if (this.isDirective(name)) {
                const [, directive] = name.split('-');
                const [directiveName, eventName] = directive.split(':');
                CompileUtil[directiveName](node, value, this.vm, eventName);
            }
        })
    }

    compileText(node) {
        if (node.textContent && node.textContent.includes('{{')) {
            CompileUtil['text'](node, node.textContent, this.vm)
        }
    }

    isDirective(name) {
        if (typeof name !== 'string') {
            return false;
        }
        return name.startsWith('v-');
    }
编译element节点

编译element节点首先遍历节点属性,找出v-开头的属性,简单假定这些就是vue框架渲染节点的钩子属性。
然后拆分钩子属性获取到expr(获取data值的属性表达式),绑定的事件名称,然后开始渲染页面。
渲染页面部分是个很独立的一块工作,所以这里封装了一个工具对象。

编译text节点
    compileText(node) {
        if (node.textContent && node.textContent.includes('{{')) {
            CompileUtil['text'](node, node.textContent, this.vm)
        }
    }

文本类型节点主要判断出是否是{{template }}类型的节点,然后将textConten传递给CompileUtil渲染到页面。

CompileUtil
结构图

CompileUtil结构图

首先针对vue的几个常用指令v-text、v-html、v-modal与v-on对应了几个操作方法,update是对应渲染到页面方法的工具对象。
首先从text方法来开始看:

text(node, expr, vm) {
        let value = null;
        if (expr.includes('{{')) {
            value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
                new Watch(args[1], vm, (newValue) => {
                    this.update.textUpdate(node, newValue);
                });
                return this.getValue(args[1], vm);
            })
        } else {
            value = this.getValue(expr, vm);
            new Watch(expr, vm, (newValue) => {
                this.update.textUpdate(node, newValue);
            });
        }
        this.update.textUpdate(node, value);
    },

首先通过expr区分出是模版渲染还是v-text渲染,如果是模版渲染就用replace抽取出表达式,然后通过公用的表达式获取值方法拿到值渲染到页面。
watch类通过表达式关联vm中的对象变化,然后通过回调函数重新渲染页面。
getValue方法很简单,表达式通过‘.’拆分为数组,进行reduce操作,然后将vue实例中的data作为起始值。

getValue(expr, vm) {
        return expr.split('.').reduce((data, attr) => {
            return data[attr];
        }, vm.$data)
    },

Watch与Dep

Dep

Dep类非常简单

class Dep {
    constructor() {
        this.subs = [];
    }

    add(watcher) {
        this.subs.push(watcher);
    }

    notify() {
        this.subs.forEach((sub) => {
            sub.update();
        })
    }

}

Dep对象中负责添加watcher,在需要的时候发起通知,让watcher更新页面

watch

class Watch {
    constructor(expr, vm, callBack) {
        this.expr = expr;
        this.vm = vm;
        this.callBack = callBack;
        this.oldValue = this.getOldValue();
    }

    update() {
        const newValue = CompileUtil.getValue(this.expr, this.vm);
        if (this.oldValue !== newValue) {
            this.callBack(newValue);
            this.oldValue = newValue;
        }
    }

    getOldValue() {
        Dep.target = this; // 用这种方式就不能Dep类与Watch类分在两个文件,webpack打包target值会丢掉
        const oldValue = CompileUtil.getValue(this.expr, this.vm); // 获取data中的值,在get中添加Watch入Dep
        Dep.target = null;
        return oldValue;
    }
}

watch类中在构造函数中传递expr vm 与跟新的回调函数,最重要的是getOldValue函数,在这里边在Dep类中添加了target属性,属性值存了Watch实例对象,这里的关键思想是在这里通过CompileUtil.getValue获取Vue中data值,并在Dep中上存了一个watch,获取data属性值的时候会调用这个属性的get方法,如果Dep对象上target有值,就在Dep对象上添加一个watch。
update方法通过CompileUtils.getValue获取watch中表达式值如果新值不等于老值就调用callback跟新页面

Observer

Observer类是核心对象,这里通过构造函数传递Vue需要监听的对象

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

    observe(data) {
        if (data && typeof data === 'object') {
            for (const key of Object.keys(data)) {
                this.defineReactive(data, key, data[key]);
            }
        }
    }

    defineReactive(data, key, value) {
        this.observe(value);
        const dep = new Dep();
        Object.defineProperty(data, key, {
            configurable: false,
            enumerable: true,
            get: () => {
                Dep.target && dep.add(Dep.target)
                return value;
            },
            set: (v) => {
                this.observe(v);
                if (v !== value) {
                    value = v;
                    dep.notify();
                }
            }
        })
    }
}

在observe方法中遍历data对象,然后调用核心方法defineReactive,这里注意的是在方法中首先回调了observe方法,因为对象的属性值可能也是个对象,所以回调了一下observe方法进行深度监听,这里遍历对象的每个属性值,然后添加get 与set方法,get方法中与watch对象中的getOldValue进行联动,在set方法中因为新设置的值可能也是一个对象,所以也要回调一此observe方法,如果属性设置的值与老值不同就调用dep进行广播所有watch进行页面更新。
这里set方法有个小技巧,set方法构成一个闭包,v关联了data的属性值所以每次更新值都可以和data中的属性值进行比较。

测试

下边简单测试一下功能
html部分的代码

<input type="text" id="input">
  <p v-text="text.value">
    </p>
    {{text.value}}

js部分的代码

var vue = new Vue(
    '#box',
    {
        text: {
            value: '文本'
        },
        html: '<h1>html</h1>',
        inputValue: 'input'
    },
    {
        clickButton() {
            alert(this.$data.text.value);
        }
    }
)

const input = document.getElementById('input');
input.addEventListener('input', (e) => {
    vue.$data.text.value = e.target.value;
})
为了测试效果给input绑定时间修改input值修改文本绑定的变量

测试结果

初始效果

改变input值后效果


改变变量后值

v-html效果

v-html比较简单,首先看CompileUtil部分代码:

html(node, expr, vm) {
        const value = this.getValue(expr, vm);
        this.update.htmlUpdate(node, value);
        new Watch(expr, vm, (newValue) => {
            this.update.htmlUpdate(node, newValue);
        })
    },
...
     htmlUpdate(node, value) {
            node.innerHTML = value;
        },
...

思路很简单通过expr获取变量值然后渲染到页面,watch监听到变化后重新调用update

测试

html部分代码:

    <button id="changeHtmlBtn">修改html</button>
    <div v-html="html">
        html
    </div>

js部分代码


const htmlBtn = document.getElementById('changeHtmlBtn');
htmlBtn.addEventListener('click', (e) => {
    vue.$data.html = '<h2>changeHtml</h2>'
})

当点击button后修改div下的html


初始效果
点击button后效果

修改后的html

v-modal

v-modal就是我们常说的双向绑定
一样我们先看CompileUtil部分代码

...
  setValue(expr, vm, inputValue) {
        expr.split('.').reduce((data, currentValue, currentIndex, array) => {
            if (currentIndex === array.length - 1) {
                // 最后一个属性值赋值input输入的值
                data[currentValue] = inputValue;
            }
            return data[currentValue];
        }, vm.$data)
    },
...
modal(node, expr, vm) {
        node.addEventListener('input', (e) => {
            const value = e.target.value;
            this.setValue(expr, vm, value);
        }, false);
        new Watch(expr, vm, (newValue) => {
            this.update.modalUpdate(node, newValue);
        });
        this.update.modalUpdate(node, this.getValue(expr, vm));
    },
 update: {
...
               modalUpdate(node, value) {
            node.value = value;
        }
...
    }

其实也很简单给节点绑定一个input事件,事件回调函数给vue中的data赋值,watch监听框架中的变量变化后更新节点的value值,赋值操作封装一个setValue方法,setValue方法和getValue方法一样使用reduce方法,在最后一个属性赋值inputValue

测试

html代码
<input type="text" v-modal = 'inputValue'>
<div>{{inputValue}}</div>
inputValue初始值赋值为input

效果

初始效果

input初始值赋值为input

修改input输入框值后,页面动态发生变化


修改输入框值

结语

这里只是简单模拟vue框架,有很多地方存在缺陷,大家有选择的阅读思考就好,感谢阅读。


image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,710评论 4 376
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,839评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,295评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,776评论 0 223
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,198评论 3 297
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,074评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,200评论 2 322
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,986评论 0 214
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,733评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,877评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,348评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,675评论 3 265
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,393评论 3 246
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,209评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,996评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,212评论 2 287
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 36,003评论 2 280

推荐阅读更多精彩内容