I spent two days simulating some of Vue’s core features, including template parsing and two-way data binding. Referring to the nuggets on some of the leaders of the article, I write this main purpose is to understand the core idea of Vue, so this article is a summary, can help me review and in-depth understanding of the implementation process of MVVM. I will make notes of some important parts of the ideas, after all, a good memory is better than a bad pen, of course, some parts of the implementation is relatively simple, after all, the main purpose is to understand the idea, understanding is good.

Vue class

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

        // Start compiling if a template exists
        if(this.$el) {
            // Mount properties and methods to the instance
            this.handleMethods()
            this.handleProxy(this.$data)

            // Data hijacking
            new Observer(this.this.$data)

            // Template compilation
            new Compile(this.$el, this)}}// methods are mounted to the instance
    handleMethods() {
        Object.keys(this.$methods).forEach(key= > {
            this[key] = this.$methods[key]
        })
    }

    // Attributes on data are mounted to the instance
    handleProxy(data) {
        Object.keys(data).forEach(key= > {
            Object.defineProperty(this, key, {
                get() {
                    return data[key]
                },
                set(val) {
                    data[key] = val
                }
            })
        })
    }
}

Copy the code

The Vue class is used to create Vue instances, and the methods in Data and Methods will be mounted to the instance during the creation, using handleProxy, which is implemented by handleMethods. Mount the parameters passed to the instance in the constructor, including options, DOM nodes, data, methods, etc., so that we can pass this. Gets properties and methods. Two-way data binding is also performed on data (mainly implemented by the Observer class). Then Compile the template, parse the instructions and do related processing, this part is implemented by the Compile class.

The Compile class

Compile, which parses templates, consists of two files compile. js and 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) {
            // Create a document fragment
            let fragment = this.moveToFragment(this.el)

            // Template compiles core methods
            this.compile(fragment)

            // The compiled document fragments are put back into the DOM
            this.el.appendChild(fragment)
        }
    }

    // Is there an element node
    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)) {
                // is the element node
                this.compile(node) // Recursively call the child node

                // Compile the element node
                this.compileElement(node)
            } else {
                // is a text node
                this.compileTextElement(node)
            }
        })
    }

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

        attrs.forEach(attr= > {
            let attrName = attr.name
            if(this.isMethod(attrName)) {
                // Is a method attribute (@click, V-on)
                let methodName
                if(attrName.indexOf(The '@') = = =0) {
                    methodName = attrName.replace(The '@'.' ')}else {
                    methodName = attrName.replace('v-on:'.' ')
                }

                compileUtil.on(this.vm, node, methodName, attr.value)
            } else if(this.isDirective(attrName)) {
                // Is the instruction attribute (v-bind, V-model..)
                let val = attr.value
                let directiveType = attrName.split(The '-') [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(The '@') = = =0 || name.indexOf('v-on:')! = = -1}}Copy the code

Compile class completes the compilation of all DOM nodes by recursively calling Compile method. Note that the DOM is first copied into the document fragment, then compiled into the document fragment, and then put into the DOM at one time. The advantage of this method is to effectively reduce backflow and redraw. Compile method is used to iterate over the child nodes of elements. Different types of child nodes (text nodes, element nodes) are processed using different functions. For element nodes, we also recurse until all element nodes are parsed.

CompileTextElement: If the element is a text node, call this function. This function calls getTextBind and gets the return value to reassign the element.

getTextBind: If there is an instruction binding of the form {{}} in the text node, getTextBind will process it and parse out the instruction in it. Instead of creating an observer (new Watcher()) for this node and adding the observer to the dependency queue for the corresponding property (this will be done in subsequent Watcher classes and Dep classes), the observer will trigger its own update method every time the property is updated, completing the view update.

CompileElement: This function will be fired if the element is an element node. This function will walk through the node’s properties, handle the instruction properties (here I’ve implemented some properties starting with V – and the @ keyword) and call the corresponding methods on compileUtil.

MoveToFragment: Copies the DOM tree into the document fragment.

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('. ')
    // merge to handle nested attributes
    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
}
Copy the code

CompileUtil contains methods for template compilation and explains how they work:

model: This handles the V-model directive, which applies only to the input tag. It binds the node to an onInput event that assigns the value to its bound data element every time the input field enters text. It also adds a watcher that updates its value every time the data element is updated. This completes bidirectional binding.

Bind: Handles the V-bind directive, assigns a binding node and adds a watcher, and binding element updates trigger view updates.

On: Handles v-on and @ directives, listens for events for node bindings, and triggers corresponding functions in methods. Since data and methods are already mounted to the instance in the Vue class, they can be accessed through this.

PS: setValue and getValue use the idea of merging to handle nested objects.

Watcher class

The Watcher class is known as the observer model, but also the idea of a published-subscribe model, where each observer is collected by the Dep class, and every time an element is updated, the publisher is notified by the Dep and the Dep notifes all the Watcher, So the only difference between the observer model and the published-subscribe model is that there is a publisher in the middle.

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)
        }
    }

}
Copy the code

The observer has an update method that calls the callback function passed in to trigger the view update. The get method is called when a watcher is instantiated. This method fires the get method of the property (exp) and places the Watcher in the dependency array of the Dep instance of the property.

Dep class

// 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
}
Copy the code

The Observe method adds an instance of Dep for each data property to collect the nodes that depend on it, and it has a notify method to trigger updates that notify all observers in the dependent array and trigger their update method. The notify method is executed when the set of the corresponding property fires.

The Observer class

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

    observe(data) {
        if(! data ||typeofdata ! = ='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 updates trigger the set event
                if(newVal ! == value) { value = newVal _this.observe(value) dep.notify(value) } },enumerable: true.configurable: true}}})Copy the code

The Observer adds data hijacking to the data object and its children for recursive invocation of the Observe method, adds a Dep dependent collector to it via defineReactive, and notifes the collector to trigger an update event when the set of the data hijacking is triggered.