Read the original


MVVM’s past and present lives

MVVM design mode is evolved from MVC (originally from the back end), MVP and other design modes, M-data Model (Model), VM-View Model (ViewModel), V-View layer (View).

In the MVC pattern, except for the Model and View layers, all the other logic is in the Controller, which is responsible for displaying the page, responding to user actions, network requests and interaction with the Model. With the increase of business and product iteration, The processing logic in controllers becomes more and more complex and difficult to maintain. MVVM was born out of the need to slim down controllers in order to manage code better and scale the business more easily, and to more clearly separate user interface (UI) development from the application’s business logic and behavior.

Many MVVM implementations use data binding to separate View logic from other layers, which can be summarized as follows:



There are many front-end frameworks using MVVM design pattern, among which the progressive framework Vue is a typical representative, and is favored by the majority of front-end developers in the development and use. In this article, we will simply simulate a version of MVVM library according to the way Vue implements MVVM.


MVVM process analysis

In Vue MVVM design, we mainly for Compile (template compilation), Observer (data hijacking), Watcher (data monitoring) and Dep (publish and subscribe) several parts to achieve, the core logical process can refer to the following figure:



Similar to this “wheel” code is undoubtedly through object-oriented programming to achieve, and strictly follow the open and closed principle, because ES5 object-oriented programming is more cumbersome, so, in the following code unified use of ES6 class to achieve.


MVVM class implementation

In Vue, only one constructor named Vue is exposed. When used, a new Vue instance is passed, and then an options parameter is passed. The type is an object, including the scope el of the current Vue instance, the data bound to the template, and so on.

When we simulate this MVVM pattern, we also build a class named MVVM, which is similar to the Vue framework. We need to create an instance of MVVM through the new directive and pass in options.

/ / MVVM. Js file
class MVVM {
    constructor(options) {
        // Attach el and data to the MVVM instance
        this.$el = options.el;
        this.$data = options.data;

        // Start compiling if there is a template to compile
        if (this.$el) {
            // Data hijacking is to add get and set to all attributes of an object
            new Observer(this.$data);

            // Proxy the data to the instance
            this.proxyData(this.$data);

            // Compile with data and elements
            new Compile(this.el, this);
        }
    }
    proxyData(data) { // Proxy data method
        Object.keys(data).forEach(key= > {
            Object.defineProperty(this, key, {
                get() {
                    returndata[key]; } set(newVal) { data[key] = newVal; }}); }); }}Copy the code

We can see from the above code that when we create a new MVVM, we pass in the options parameter a Dom root element node and data and hang it on the current MVVM instance.

When the root node exists, the data is hijacked by the Observer class, and the data in the data is attached to the current MVVM instance by the method proxyData of the MVVM instance, and the data is also hijacked. Because we can fetch and modify data directly through this or this.$data, the core method of data hijacking in Vue is Object.defineProperty. We also use this approach to implement data hijacking by adding getters and setters.

Finally, Compile class is used to parse and Compile the template and bound data, and render on the root node. The reason why data hijacking and template parsing are implemented in the way of class is that the code is easy to maintain and expand. In fact, it is not difficult to see that, The MVVM class acts as a bridge between the Compile class and the Observer class.


The template compiles the implementation of the Compile class

Compile class needs to pass in two parameters when creating an instance. The first parameter is the root node of the current MVVM instance, and the second parameter is the MVVM instance. The reason why we pass in the MVVM instance is to obtain the attributes of the MVVM instance more conveniently.

In Compile class, we will try to extract some common logic for maximum reuse, avoid redundant code, improve maintenance and scalability, we Compile class extract instance method is mainly divided into two categories, auxiliary method and core method, marked with annotations in the code.

1. Parse the Dom structure inside the root node

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

        // Start compiling if the passed root element exists
        if (this.el) {
            // Move these real Dom fragments into memory.
            let fragment = this.node2fragment(this.el); }}/* Helper method */
    // Check if it is an element node
    isElementNode(node) {
        return node.nodeType === 1;
    }

    /* Core method */
    // Move the root node to the document shard
    node2fragment(el) {
        // Create a document fragment
        let fragment = document.createDocumentFragment();
        // First child node
        let firstChild;

        // Loop out the nodes in the root node and put them into the document shard
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        returnfragment; }}Copy the code

The root element node passed in can be either a real Dom element or a selector, so we created the helper method isElementNode to help us determine if the element passed in is a Dom. The selector retrieves the Dom and eventually stores the root node into the this.el property.

In the process of parsing the template, we should take out the child nodes in the root node and store them in the document fragment (memory) for the sake of performance. It should be noted that the process of storing the child nodes in a Dom node into the document fragment will delete the node in the original Dom container. Therefore, when traversing the child nodes of the root node, Always take the first node out and store it in the document fragment until it no longer exists.

2. Compile the structure in the document fragment

Templates in Vue compile in two main parts that the browser can’t parse: instructions in element nodes and Mustache syntax (double braces) in text nodes.

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

        // Start compiling if the passed root element exists
        if (this.el) {
            // Move these real Dom fragments into memory.
            let fragment = this.node2fragment(this.el);

            // ********** here is the new code **********
            // 2. Replace the variables in the directive and {{}} with real data
            this.compile(fragment);

            // 3. Insert the compiled fragment back into the page
            this.el.appendChild(fragment);
            // ********** this is the new code **********}}/* Helper method */
    // Check if it is an element node
    isElementNode(node) {
        return node.nodeType === 1;
    }

    // ********** here is the new code **********
    // Determine if the attribute is an instruction
    isDirective(name) {
        return name.includes("v-");
    }
    // ********** this is the new code **********

    /* Core method */
    // Move the root node to the document shard
    node2fragment(el) {
        // Create a document fragment
        let fragment = document.createDocumentFragment();
        // First child node
        let firstChild;

        // Loop out the nodes in the root node and put them into the document shard
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }

    // ********** here is the new code **********
    // Parse document fragments
    compile(fragment) {
        // Children of the current parent node, containing text nodes, array like objects
        let childNodes = fragment.childNodes;

        // Convert to an array and loop through the type of each node
        Array.from(childNodes).forEach(node= > {
            if (this.isElementNode(node)) { // is the element node
                // Recursively compile child nodes
                this.compile(node);

                // The method to compile the element node
                this.compileElement(node);
            } else { // is a text node
                // The method to compile the text node
                this.compileText(node); }}); }// Compile the element
    compileElement(node) {
        // Get the attributes of the current node, class array
        let attrs = node.attributes;
        Array.form(attrs).forEach(attr= > {
            // Get the attribute name to determine whether the attribute is an instruction, that is, contain v-
            let attrName = attr.name;

            if (this.isDirective(attrName)) {
                // If it is an instruction, take the value of the attribute value variable in data and replace it with the node
                let exp = attr.value;

                // Retrieve the method name
                let [, type] = attrName.split("-");

                // Call the instruction to the due method
                CompileUtil[type](node, this.vm, exp); }}); }// Compile text
    compileText(node) {
        // Get the content of the text node
        let exp = node.contentText;

        // Create a regular expression that matches {{}}
        let reg = /\{\{([^}+])\}\}/g;

        // If {{}} exists, use the method of the text directive
        if (reg.test(exp)) {
            CompileUtil["text"](node, this.vm, exp); }}// ********** this is the new code **********
}
Copy the code

The main logic for adding content to the code above does two things:

  • callcompileThe method offragmentDocument fragments are compiled to replace values corresponding to variables in internal instructions and Mustache syntax;
  • Will compilefragmentThe document fragment is plugged back into the root node.

In the first step, the logic is more complicated. First, compile all the child nodes in the compile method, and compile the cycle. If it is an element node, compile recursively and pass in the current element node. Two methods are extracted from this process, compileElement and compileText, to handle element node attributes and text nodes.

The core logic in compileElement is to process directives by fetching all the attributes of the element node to determine if they are directives and calling the corresponding method if they are. The core logic in compileText is to take the contents of the text and use regular expressions to match the contents wrapped by the “{{}}” of Mustache syntax and call the text method that handles the text.

There may be “{{}} {{}} {{}}” in the content of text nodes. Regular matching is greedy by default. In order to prevent the first “{” and the last”} “from matching, non-greedy matching should be used in regular expressions.

All methods are called to CompileUtil. The reason we separate these methods into CompileUtil is for decoupling, since other classes will need to use them later.

Implementation of instruction methods in CompileUtil

CompileUtil stores all instruction methods and their corresponding update methods. Since Vue has a lot of instructions, we only implement the typical V-model and {{}} methods. We extract the methods for both cases from the Dom logic and store them in the updater object CompileUtil.

/ / CompileUtil. Js file
CompileUtil = {};

// A method to update node data
CompileUti.updater = {
    // Text update
    textUpdater(node, value) {
        node.textContent = value;
    },
    // Input box updatedmodelUpdater(node, value) { node.value = value; }};Copy the code

The whole idea of this part is that when v-model and “{{}}” are processed after Compile the template, the variables in the corresponding nodes in the fragment document are replaced with the data in data. So we get values from data frequently and reset them when we update nodes, so we separate getVal, getTextVal and setVal from CompileUtil.

/ / CompileUtil. Js file
// The method to get the data value
CompileUtil.getVal = function (vm, exp) {
    // Split matched values with., for example, vm.data.a.b
    exp = exp.split(".");

    // Merge values
    return exp.reduce((prev, next) = > {
        return prev[next];
    }, vm.$data);
};

// Get the value of the variable in the text {{}} corresponding to data
CompileUtil.getTextVal = function (vm, exp) {
    // Use the re to match the variable name between {{}} and call getVal to get the value
    return exp.replace(/\{\{([^}]+)\}\}/g, (... args) => {return this.getVal(vm, args[1]);
    });
};

// Set the data method
CompileUtil.setVal = function (vm, exp, newVal) {
    exp = exp.split(".");
    return exp.reduce((prev, next, currentIndex) = > {
        // If the currently merged item is the last item in the array, the new value is set to that property
        if(currentIndex === exp.length - 1) {
            return prev[next] = newVal;
        }

        // continue merging
        return prev[next];
    }, vm.$data);
}
Copy the code

The ideas of getVal and setVal are similar. Since the hierarchy of the variables to be obtained is variable, which may be data.a or data.obj.a.b, they are both realized by merging and borrowing the reduce method. The difference is that the setVal method needs to determine whether to merge to the last level, and if so, sets a new value, whereas getTextVal outlays a layer of logic to handle “{{}}” in getVal.

With this in place, we can implement our main logic, which is to replace the variables in the text nodes and element node instructions parsed in the Compile class with data values. Remember that the v-model and “{{}}” are processed. Therefore, two core methods, Model and text, are designed.

Implementation of compileutil. model:

/ / CompileUtil. Js file
// How to handle v-model directives
CompileUtil.model = function (node, vm, exp) {
    // Get the assignment method
    let updateFn = this.updater["modelUpdater"];

    // Get the value of the corresponding variable in data
    let value = this.getVal(vm, exp);

    // Add observer, same as text method
    new Watcher(vm, exp, newValue => {
        updateFn && updateFn(node, newValue);
    });

    // V-model two-way data binding, add event listener for input
    node.addEventListener('input', e => {
        // Get the new input value
        let newValue = e.target.value;

        // Update to node
        this.setVal(vm, exp, newValue);
    });

    // Set the value for the first time
    updateFn && updateFn(vm, value);
};
Copy the code

Implementation of compileutil. text:

/ / CompileUtil. Js file
// The method to handle the text node {{}}
CompileUtil.text = function (node, vm, exp) {
    // Get the assignment method
    let updateFn = this.updater["textUpdater"];

    // Get the value of the corresponding variable in data
    let value = this.getTextVal(vm, exp);

    // Replace {{}} with {{}}
    exp.replace(/\{\{([^}]+)\}\}/g, (... args) => {// Add an observer when you encounter a variable in the template that needs to be replaced with a data value
        // When a variable is reassigned, the method that updates the value node to the Dom is called
        new Watcher(vm, args[1], newValue => {
            // If the data changes, get the new value again
            updateFn && updateFn(node, newValue);
        });
    });

    // Set the value for the first time
    updateFn && updateFn(vm, value);
};
Copy the code

The above two methods have similar logic. They both obtain the methods in their respective updater, set the values, and at the same time create an instance of Watcher for subsequent data modification and view update, and internally update the node with the new values. The difference is that Vue’s V-Model directive implements two-way data binding in the form. Whenever the value of a form element changes, the new value needs to be updated to data and responded to the page.

So what we’re doing is we’re listening for the input event for this v-Model bound form element and updating the new value to data in real time. As for the three other classes Watcher, Observer, and Dep that need to be implemented in response to changes in Data to the page, we will implement the Watcher class below.


An implementation of the Watcher class

The Watcher instance to CompileUtil takes three arguments: the MVVM instance, the template bound data variable name exp, and a callback. The internal logic for this callback is to update the data to the Dom. So it’s clear what our Watcher class needs to do internally: get the value before the change and store it, and create an Update instance method that executes the instance callback to update the view when the value changes.

/ / Watcher. Js file
class Watcher {
    constructor(vm, exp, callback) {
        this.vm = vm;
        this.exp = exp;
        this.callback = callback;

        // Change the previous value
        this.value = this.get();
    }
    get() {
        // Add the current watcher to the static property of the Dep class
        Dep.target = this;

        // Fetching values triggers data hijacking
        let value = CompileUtil.getVal(this.vm, this.exp);

        // Empty the Watcher on Dep to prevent repeated additions
        Dep.target = null;
        return value;
    }
    update() {
        // Get a new value
        let newValue = CompileUtil.getVal(this.vm, this.exp);
        // Get the old value
        let oldValue = this.value;

        // If the new value is not equal to the old value, the DOM is updated with a callback
        if(newValue ! == oldValue) {this.callback(); }}}Copy the code

See the above code must have two questions:

  • usegetWhy should the current instance be hung when the method gets the old valueDep, why it was cleared after the value was obtained;
  • updateMethod executed internallycallbackDelta function, butupdateWhen to execute.

This is what the next two classes, Dep and Observer, do. We will introduce the Dep first, then the Observer, and finally tie the relationship between them together.


Publish and subscribe the implementation of the Dep class

Publish-subscribe basically stores all the functions to be executed in an array, and when certain execution conditions are met, loops through the array and executes each member.

/ / Dep. Js file
class Dep {
    constructor() {
        this.subs = [];
    }
    // Add a subscription
    addSub(watcher) {
        this.subs.push(watcher);
    }
    / / notice
    notify() {
        this.subs.forEach(watcher= >watcher.update()); }}Copy the code

There is only one property in the Dep class, which is an array called subs, which is used to manage each watcher, which is an instance of the Watcher class. AddSub is used to add the Watcher to the subs array. We see the notify method solves the above question, Watcher of the class is how to perform the update method, that is executed in cycles.

Let’s integrate the blind spots:

  • DepWhere does the instance create the declaration, and where does the declaration gowatcherAdded to thesubsOf the array;
  • DepnotifyWhere methods should be called;
  • WatcherContent, usegetWhy should the current instance be hung when the method gets the old valueDepOn, why it was cleared after the value was retrieved.

These issues will become clear when the final class Observer is implemented, but let’s focus on the last piece of core logic.


Implementation of the data hijacking Observer class

Remember that we created an instance of this class when we implemented the MVVM class, passing in the data property of the MVVM instance, attaching the data to the instance via Object.defineProperty, and adding the getter and setter. The primary purpose of the Observer class is to do this for all levels of data within data.

/ / Observer. Js file
class Observer {
    constructor (data) {
        this.observe(data);
    }
    // Add data listener
    observe(data) {
        / / validation data
        if(! data ||typeofdata ! = ='object') {
            return;
        }

        // Change the attributes of the data to set and get
        // To hijack data one by one, obtain the key and value of data first
        Object.keys(data).forEach(key= > {
            // hijacking (implementing data responsiveness)
            this.defineReactive(data, key, data[key]);
            this.observe(data[key]); // Deep hijacking
        });
    }
    // Data responsive
    defineReactive (object, key, value) {
        let _this = this;
        // Each change corresponds to an array that holds all the updated operations
        let dep = new Dep();

        // Get a value that is listened on
        Object.defineProperty(object, key, {
            enumerable: true.configurable: true,
            get () { // The method to call when the value is specified
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set (newValue) { // Change the value of the acquired property when the value set to the data property is appropriate
                if(newValue ! == value) { _this.observe(newValue);// reassign if the object is deep hijacking
                    value = newValue;
                    dep.notify(); // Notify everyone that the data is updated}}}); }}Copy the code

The purpose of Observe is to walk through the object and hijack the data internally by adding getters and setters. We separately extract the hijack logic into the defineReactive method. Observe that the Observe method initially performs data type validation for the current data, and then loops through each property of the Object to perform a deep hijacking of the recursive call to Observe for a subproperty of the same type.

In the defineReactive method, we created an instance of Dep and hijacked the data of data using get and set. Remember that during template compilation, variables bound in the template were parsed and watcher was created. Get = Dep subs = Dep subs = Dep Subs = Dep Subs = Dep SubS = get We also want to make sure that the watcher is not added repeatedly, so in the Watcher class, after fetching the old value and saving it, we immediately assign the dep. target value to null and short-circuit the dep. target when get is triggered. If it exists, call Dep addSub to add it.

When the value in data is changed, the set is triggered, and performance optimization is done in the set, that is, determine whether the reassigned value is equal to the old value, if it is equal, do not re-render the page, there are two different cases, if the original changed value is a basic data type, it does not matter, if it is a reference type, We need to hijack the data inside this reference type, so we recursively call observe, and finally call notify of Dep. Notify will perform update of all managed watcher in subS. The callback passed in when the Watcher was created will be executed and the page will be updated.

The hijacking of a data attribute on an MVVM instance by an MVVM class is also related to the hijacking of data through an Observer class, since the entire publish-subscribe logic is on data’s GET and set, As long as the get and set in MVVM are triggered, the corresponding values of data will be automatically returned or set internally. The get and set of Data will be triggered, and the logic of publish and subscribe will be executed.

After a long description of the above, the relationship between several classes used by the MVVM pattern should be completely clear, although relatively abstract, but careful thinking will still understand the relationship and logic, we will come to our own implementation of the MVVM to verify.


Verify the MVVM

We simply wrote a template according to the content of our own MVVM implementation in the way of Vue as follows:

<! -- index.html file -->

      
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>MVVM</title>
</head>
<body>
    <div id="app">
        <! Two-way data binding relies on forms -->
        <input type="text" v-model="message">
        <div>{{message}}</div>
        <ul>
            <li>{{message}}</li>
        </ul>
        {{message}}
    </div>

    <! -- Importing dependent JS files -->
    <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>
Copy the code

Open the Console of the Chrom browser and verify from above by doing the following:

  • The inputvm.message = "hello"See if the page is updated;
  • The inputvm.$data.message = "hello"See if the page is updated;
  • Change the value in the text input field to see if other elements of the page are updated.


conclusion

Through the above test, I believe I should understand the MVVM mode for the front-end development of great significance, the realization of two-way data binding, real-time guarantee of View layer and Model layer data synchronization, and can let us in the development of data-based programming, and the least Dom operation, which greatly improves the performance of page rendering. It also allows us to focus more on the development of business logic.