学习mvvm思想:实现一个简单的Vue

388 阅读6分钟
花了两天时间模拟了Vue的一些核心功能,包括模板解析、双向数据绑定。 借鉴了掘金上一些大佬的文章,我写这个的主要目的就是想吃透Vue的核心思想,所以这边文章是总结性的,可以帮助我回顾与深入理解mvvm的实现过程。我会对其中一些重要部分的思路做记录,毕竟好记性不如烂笔头,当然有些地方的实现比较简陋,毕竟主要目的是理解思想,理解了就好。

Vue类

// Vue.js
class Vue{
    constructor(options) {
        this.$options = options
        this.$el = document.querySelector(options.el)
        this.$data = options.data
        this.$methods = options.methods

        // 如果存在模板就开始编译
        if(this.$el) {
            // 挂载属性和方法到实例上
            this.handleMethods()
            this.handleProxy(this.$data)

            // 数据劫持
            new Observer(this, this.$data)

            // 模板编译
            new Compile(this.$el, this)
        }
    }

    // methods上的方法挂载到实例上
    handleMethods() {
        Object.keys(this.$methods).forEach(key => {
            this[key] = this.$methods[key]
        })
    }

    // data上的属性挂载到实例上
    handleProxy(data) {
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                    return data[key]
                },
                set(val) {
                    data[key] = val
                }
            })
        })
    }
}

Vue类用于创建vue实例,并且创建的时候会将data和methods中的方法挂载到实例上,挂载使用的是 handleProxy , handleMethods方法实现的。 在构造函数中将传入的参数挂载到实例上,包括options、DOM节点、datamethods等,这样做是让我们可以通过 this.获取属性和方法。 同时对data进行数据双向绑定的处理(主要由Observer类实现)。然后对模板进行编译,解析其中的指令并作相关处理,这一部分由Compile类实现。

Compile类

Compile即模板编译,负责解析模板,主要由两个文件组成: Compile.js 、 compileUtil.js
Compile.js
// Compile.js

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

        if(this.el) {
            // 创建文档碎片
            let fragment = this.moveToFragment(this.el)

            // 模板编译核心方法
            this.compile(fragment)

            // 编译好的文档碎片再放回DOM中
            this.el.appendChild(fragment)
        }
    }

    // 是否元素节点
    isElementNode(node) {
        return node.nodeType === 1
    }

    moveToFragment(el) {
        let fragment = document.createDocumentFragment()

        while(el.firstChild) {
            fragment.appendChild(el.firstChild)
        }

        return fragment
    }

    compile(el) {
        let childNodes = Array.from(el.childNodes)

        childNodes.forEach(node => {
            if(this.isElementNode(node)) {
                // 是元素节点
                this.compile(node) // 递归调用子节点

                // 编译元素节点
                this.compileElement(node)
            } else {
                // 是文本节点
                this.compileTextElement(node)
            }
        })
    }

    compileElement(node) {
        let attrs = Array.from(node.attributes)

        attrs.forEach(attr => {
            let attrName = attr.name
            if(this.isMethod(attrName)) {
                // 是方法属性(@click, v-on)
                let methodName
                if(attrName.indexOf('@') === 0) {
                    methodName = attrName.replace('@', '')
                } else {
                    methodName = attrName.replace('v-on:', '')
                }

                compileUtil.on(this.vm, node, methodName, attr.value)
            } else if(this.isDirective(attrName)) {
                // 是指令属性(v-bind,v-model..)
                let val = attr.value
                let directiveType = attrName.split('-')[1]

                compileUtil[directiveType](node, this.vm, val)
            }
        })
    }

    compileTextElement(node) {
        node.textContent = this.getTextBind(node)
    }

    getTextBind(node) {  
        const originText = node.textContent
        return node.textContent.replace(/\{\{([^}]+)\}\}/g, (...args) => {
            new Watcher(this.vm, args[1], (value) => {
                compileUtil.updateValue(node, value, originText)
            })
            return compileUtil.getValue(this.vm, args[1])
        })
    }


    isDirective(name) {
        return name.indexOf('v-') !== -1
    }

    isMethod(name) {
        return name.indexOf('@') === 0 || name.indexOf('v-on:') !== -1
    }
}

Compile类通过递归调用compile方法完成所有DOM节点的编译,需要注意的是会先将DOM复制到文档碎片中,再对文档碎片进行编译,再一次性放入DOM,这样做的好处是可以有效减少回流重绘。 compile方法的作用是对元素的子节点进行遍历,对不同类型的子节点(文本节点、元素节点)使用不同的函数进行处理,对于元素节点还要进行递归,直到所有元素节点都解析完成。

compileTextElement: 若元素是文本节点,则调用这个函数进行处理,这个函数会调用getTextBind并取得返回值为元素重新赋值。

getTextBind: 若文本节点中存在{{}}形式的指令绑定,则getTextBind会对其进行处理,解析出其中的指令,并未这个节点创建一个观察者(new Watcher()), 并将观察者添加到对应属性的依赖队列中(这一步会在后续的Watcher类和Dep类中实现), 观察者会在每次属性发生更新时触发自己的update方法,完成视图更新。

compileElement: 若元素是元素节点,则会触发这个函数。这个函数会遍历节点的所有属性,对其中的指令属性进行处理(这里我实现了以v-开头的部分属性和@关键字),并调用compileUtil对象中的对应方法进行处理。

moveToFragment: 将DOM树复制到文档碎片中。

compileUtil.js
// compileUtil.js

const compileUtil = {}

// v-model
compileUtil.model = function(node, vm, val) {
    if(node.tagName === 'INPUT') {
        node.value = this.getValue(vm, val)
        node.oninput = (e) => {
           this.setValue(vm, val, e.target.value)
        }
        new Watcher(vm, val, (value) => {
            node.value = value
        })
    }
}

// v-bind
compileUtil.bind = function(node, vm, val) {
    node.textContent = this.getValue(vm, val)
    new Watcher(vm, val, (value) => {
        this.updateBindValue(node, value)
    })
}

// v-on
compileUtil.on = function(vm, node, event, method) {
    let temp = method.match(/\((.+?)\)/)
    let args = []
    if(temp)
    args = temp[0].replace(/\(|\)/g,'')

    let func = vm.$methods[method.replace(/\((.+?)\)/,'')]

    node.addEventListener(event, (e) => {
        func.call(vm, ...args, event = e)
    })
}

compileUtil.getValue = function(vm, exp) {
    exp = exp.split('.')

    return exp.reduce((pre, cur) => pre[cur], vm.$data)
}

compileUtil.setValue = function(vm, exp, value) {
    exp = exp.split('.')
    // 归并处理嵌套的属性
    return exp.reduce((pre, cur, index) => {
        if(index === exp.length - 1) {
            return pre[cur] = value
        }
        return pre[cur]
    }, vm.$data)
}

compileUtil.updateValue = function(node, value, originText) {
    if(!originText) return
    let text = originText.replace(/\{\{([^}]+)\}\}/g, value)
    node.textContent = text
}

compileUtil.updateBindValue = function(node, value) {
    node.textContent = value
}

compileUtil中集成了模板编译需要的一些方法,对这些方法的作用和原理进行解释:

model: 用于处理v-model指令,这个指令只对input标签生效, 他会为节点绑定一个oninput事件,每当输入框输入文本的时候都将对应的值赋给其绑定的data元素, 同时添加了一个 watcher,每当data元素更新时会更新自己的值,这样就完成了双向绑定

bind: 处理v-bind指令,为绑定节点赋值同时添加一个watcher, 绑定元素更新触发视图更新。

on: 处理v-on@指令,为节点绑定监听事件,用于触发methods中对应的函数。由于在Vue类中已经挂载了datamethods到实例上,所以可以通过this去访问到。

PS: setValue和getValue使用了归并的思想,用于处理嵌套对象。

Watcher类

Watcher类即大名鼎鼎的观察者模式,同时还有发布订阅模式的思想,每个观察者会被Dep类收集到一起,每当元素更新后会通知发布者Dep再由Dep通知所有watcher,所以说观察者模式和发布订阅模式的唯一区别其实就是有没有这个发布者做中间人(即Dep

class Watcher{
    constructor(vm, exp, callback) {
        this.vm = vm
        this.exp = exp
        this.callback = callback
        this.value = this.get()
    }

    get() {
        Dep.target = this

        let value = compileUtil.getValue(this.vm, this.exp)

        Dep.target = null
        return value
    }

    update(newVal) {
        if(newVal !== this.value) {
            this.value = newVal
            this.callback(this.value)
        }
    }

}

观察者带有一个update方法,update中会调用传入的回调函数,,用于触发视图更新。同时在实例化一个watcher时会调用其get方法,这个方法会触发对应属性(exp)的get方法,将这个watcher放入属性的Dep实例的的依赖数组中。

Dep类

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

    addSub(watcher) {
        this.subs.push(watcher)
    }

    notify(val) {
        this.subs.forEach(watcher => watcher.update(val))
    }

    static target = null
}

observe方法会在为每一个data属性添加一个Dep实例,用于收集依赖于他的节点,并且他有一个notify方法用于触发更新,notify会通知依赖数组中的所有观察者,并触发其update方法。这个notify方法会在对应属性的set触发时执行。

Observer类

// Observer.js
class Observer{
    constructor(vm, data) {
        this.data = data
        this.vm = vm
        this.observe(data)
    }

    observe(data) {
        if(!data || typeof data !== 'object' || data._observer) return
        data._observer = true
        Object.keys(data).forEach(key => {
            let value = data[key]
            this.defineReactive(data, key, value)
            this.observe(value)
        })
    }

    defineReactive(obj, key, value) {
        let dep = new Dep()
        let _this = this

        Object.defineProperty(obj, key, {
            get() {
                Dep.target && dep.addSub(Dep.target)
                return value
            },
            set(newVal) {
                // data属性更新触发 set 事件
                if(newVal !== value) {
                    value = newVal
                    _this.observe(value)
                    dep.notify(value)
                }    
            },
            enumerable: true,
            configurable: true
        })
    }
}

Observer会为递归调用observe方法为data对象以及其子对象添加数据劫持,通过defineReactive方法为其添加Dep依赖收集者,并且在数据劫持的set触发后通知收集者触发更新事件。