Principle and implementation of bidirectional binding of vue

Posted by crzyman on Thu, 13 Jan 2022 21:35:19 +0100

Principle: vue data bidirectional binding is realized through data hijacking combined with publisher subscriber mode

Let's first take a look at how to output an object structure defined on vue initialization data through the console

var vm = new Vue({
    data: {
        obj: {
            a: 1
        }
    },
    created: function () {
        console.log(this.obj);
    }
});

result:

 

We can see that attribute a has two corresponding get and set methods. Why are there more than these two methods? Because vue is through object Defineproperty() to implement data hijacking.

Object. What is defineproperty () used for? It can control some special operations of an object attribute, such as read-write rights and whether enumeration can be performed. Here, we mainly study its corresponding two description attributes get and set

Object. The syntax of defineproperty() is as follows:

 

In normal times, we can easily print out the attribute data of an object:

var Book = {
  name: 'vue Authoritative guide'
};
console.log(Book.name);  // vue authoritative guide

If you want to execute console Log (Book. Name) and directly add a Book name to the Book name. What should I do? Or the attribute value of the object Book to be monitored through. At this time, object Defineproperty () comes in handy. The code is as follows:

var Book = {}
var name = '';
Object.defineProperty(Book, 'name', {
  set: function (value) {
    name = value;
    console.log('You took the title of a book called' + value);
  },
  get: function () {
    return '<' + name + '>'
  }
})
 
Book.name = 'vue Authoritative guide';  // You took a book called vue authority guide
console.log(Book.name);  // vue authoritative guide

We passed object Defineproperty () sets the name property of the object Book and rewrites its get and set. As the name suggests, get is the function triggered when reading the value of the name property, and set is the function triggered when setting the value of the name property. Therefore, when executing {Book When the statement name = 'vue authoritative guide', the console will print "you have taken a Book name called vue authoritative guide". Then, when reading this attribute, it will output "vue authoritative guide", because we processed the value in the get function. If we execute the following statement at this time, what will the console output?

console.log(Book);

result:

At first glance, does it look a little similar to the vue data printed on it, indicating that vue does hijack data through this method. Next, we implement a simple version of mvvm bidirectional binding code through its principle.

Implementation process

The implementation of mvvm mainly includes two aspects: data change updates the view, and view change updates the data:

The key point is how data updates the view, because the view can update data through event monitoring. For example, the input tag can monitor the 'input' event. So we focus on analyzing how to update the view when the data changes.

The focus of the data update view is how to know that the data has changed. As long as you know that the data has changed, the next things are easy to handle. How to know that the data has changed? In fact, we have given the answer above, that is, through object Defineproperty () sets a set function for the property. This function will be triggered when the data changes, so we can update the view by putting some methods that need to be updated here.

Implementation process

We already know that to realize the two-way binding of data, we must first hijack and listen to the data, so we need to set a listener Observer to listen to all properties. If the attribute changes, you need to tell the subscriber Watcher to see if it needs to be updated. Because there are many subscribers, we need a message subscriber Dep to collect these subscribers, and then manage them uniformly between the listener Observer and the subscriber Watcher. Then, we also need an instruction parser Compile to scan and parse each node element, initialize the relevant instructions into a subscriber Watcher, replace the template data or bind the corresponding function. At this time, when the subscriber Watcher receives the corresponding attribute change, it will execute the corresponding update function to update the view. Therefore, next, we perform the following three steps to realize the two-way binding of data:

1. Implement a listener Observer, which is used to hijack and listen to all properties. If there is any change, it will notify the subscriber.

2. Implement a subscriber Watcher, which can receive the change notification of the attribute and execute the corresponding function to update the view.

3. Implement a parser Compile, which can scan and parse the relevant instructions of each node, and initialize the corresponding subscriber according to the initialization template data.

The flow chart is as follows:

1. Implement an Observer

Observer is a data listener, and its core implementation method is the object mentioned above defineProperty( ). If you want to listen to all attributes, you can traverse all attribute values through recursive methods and object them Defineproperty() process. The following code implements an observer.

function defineReactive(data, key, val) {
    observe(val); // Recursively traverses all child attributes
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            return val;
        },
        set: function(newVal) {
            val = newVal;
            console.log('attribute' + key + 'It has been monitored. The current value is:“' + newVal.toString() + '"');
        }
    });
}
 
function observe(data) {
    if (!data || typeof data !== 'object') {
        return;
    }
    Object.keys(data).forEach(function(key) {
        defineReactive(data, key, data[key]);
    });
};
 
var library = {
    book1: {
        name: ''
    },
    book2: ''
};
observe(library);
library.book1.name = 'vue Authoritative guide'; // The attribute name has been monitored, and now the value is: "vue authoritative guide"
library.book2 = 'There is no such book';  // The property book2 has been monitored, and now the value is: "there is no such book"

In the thought analysis, it is necessary to create a message subscriber Dep that can accommodate subscribers. The subscriber Dep is mainly responsible for collecting subscribers, and then executes the update function of the corresponding subscriber when the attribute changes. Therefore, it is obvious that the subscriber needs to have a container, which is list. Slightly transform the above Observer and implant it into the message subscriber:

function defineReactive(data, key, val) {
    observe(val); // Recursively traverses all child attributes
    var dep = new Dep(); 
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            if (Do you need to add subscribers) {
                dep.addSub(watcher); // Add a subscriber here
            }
            return val;
        },
        set: function(newVal) {
            if (val === newVal) {
                return;
            }
            val = newVal;
            console.log('attribute' + key + 'It has been monitored. The current value is:“' + newVal.toString() + '"');
            dep.notify(); // Notify all subscribers if the data changes
        }
    });
}
 
function Dep () {
    this.subs = [];
}
Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};

From the code point of view, we add a subscriber to the subscriber Dep and design it in the getter to trigger the Watcher initialization. Therefore, we need to judge whether to add a subscriber. As for the specific design scheme, it will be described in detail below. In the setter function, if the data changes, all subscribers will be notified, and the subscribers will execute the corresponding updated function. So far, a relatively complete Observer has been implemented. Next, we begin to design Watcher.

2. Implement Watcher

The subscriber Watcher needs to add itself to the subscriber Dep during initialization. How to add it? We already know that the listener Observer performs the operation of adding subscriber Wather in the get function, so we just need to trigger the corresponding get function to perform the operation of adding subscriber when the subscriber Watcher is initialized. How to trigger the get function can be as simple as obtaining the corresponding attribute value, The core reason is that we use object Defineproperty() to listen for data. There is another fine node to deal with. We only need to add subscribers when the subscriber Watcher is initialized, so we need to make a judgment operation. Therefore, we can do something on the subscriber: cache the subscribers on Dep.target, and remove them after adding them successfully. The subscriber Watcher is implemented as follows:

function Watcher(vm, exp, cb) {
    this.cb = cb;
    this.vm = vm;
    this.exp = exp;
    this.value = this.get();  // Add yourself to the subscriber
}
 
Watcher.prototype = {
    update: function() {
        this.run();
    },
    run: function() {
        var value = this.vm.data[this.exp];
        var oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value, oldVal);
        }
    },
    get: function() {
        Dep.target = this;  // Cache yourself
        var value = this.vm.data[this.exp]  // Enforce the get function in the listener
        Dep.target = null;  // Release yourself
        return value;
    }
};

At this time, we need to make a slight adjustment to the listener Observer, mainly corresponding to the get function on the Watcher class prototype. What needs to be adjusted is the defineReactive function:

function defineReactive(data, key, val) {
    observe(val); // Recursively traverses all child attributes
    var dep = new Dep(); 
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            if (Dep.target) {.  // Determine whether to add subscribers
                dep.addSub(Dep.target); // Add a subscriber here
            }
            return val;
        },
        set: function(newVal) {
            if (val === newVal) {
                return;
            }
            val = newVal;
            console.log('attribute' + key + 'It has been monitored. The current value is:“' + newVal.toString() + '"');
            dep.notify(); // Notify all subscribers if the data changes
        }
    });
}
Dep.target = null;

So far, the simple version of Watcher has been designed. At this time, we can realize a simple two-way data binding by associating Observer and Watcher. Since the parser Compile has not been designed here, we all write the template data. Suppose there is another node on the template with the id number of 'name', and the bound variable of two-way binding is also 'name', and it is wrapped in two curly braces (this is just for demonstration and is not useful for the time being). The template is as follows:

<body>
    <h1 id="name">{{name}}</h1>
</body>

At this time, we need to associate Observer with Watcher:

function SelfVue (data, el, exp) {
    this.data = data;
    observe(data);
    el.innerHTML = this.data[exp];  // Initialize the value of the template data
    new Watcher(this, exp, function (value) {
        el.innerHTML = value;
    });
    return this;
}

Then, create the following SelfVue classes on the page to realize two-way data binding:

<body>
    <h1 id="name">{{name}}</h1>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
    var ele = document.querySelector('#name');
    var selfVue = new SelfVue({
        name: 'hello world'
    }, ele, 'name');
 
    window.setTimeout(function () {
        console.log('name The value has changed');
        selfVue.data.name = 'canfoo';
    }, 2000);
 
</script>

When you open the page at this time, you can see that the page displays' hello world 'at the beginning, and it will become' canfoo 'after 2s. Here, we're halfway there, but there's another detail. When we assign values, it's in the form of 'selfVue data. Name = 'canfoo' 'and our ideal form is' selfVue Name = 'canfoo' 'in order to implement this form, we need to do an agent processing when new SelfVue is accessed, so that the agent accessing the properties of selfVue can access selfVue The implementation principle of data attribute is object Defineproperty() wraps the property value one more layer:

function SelfVue (data, el, exp) {
    var self = this;
    this.data = data;
 
    Object.keys(data).forEach(function(key) {
        self.proxyKeys(key);  // Bind proxy properties
    });
 
    observe(data);
    el.innerHTML = this.data[exp];  // Initialize the value of the template data
    new Watcher(this, exp, function (value) {
        el.innerHTML = value;
    });
    return this;
}
 
SelfVue.prototype = {
    proxyKeys: function (key) {
        var self = this;
        Object.defineProperty(this, key, {
            enumerable: false,
            configurable: true,
            get: function proxyGetter() {
                return self.data[key];
            },
            set: function proxySetter(newVal) {
                self.data[key] = newVal;
            }
        });
    }
}

Now we can go directly through 'selfvue Name = 'canfoo' 'to change the template data. If you want children's shoes that urgently see the phenomenon, come quickly Get code!

3. Implement Compile

Although an example of two-way data binding has been implemented above, the whole process does not resolve dom nodes, but directly fix a node to replace data. Therefore, next, we need to implement a parser Compile to do parsing and binding. Implementation steps of parser Compile:

1. Parse the template instruction, replace the template data, and initialize the view

2. Bind the node corresponding to the template instruction to the corresponding update function and initialize the corresponding subscriber

In order to parse the template, you first need to obtain the dom element, and then process the nodes containing instructions on the dom element. Therefore, dom operations need to be frequent in this link. You can first create a fragment segment and store the dom nodes to be parsed in the fragment segment for processing:

function nodeToFragment (el) {
    var fragment = document.createDocumentFragment();
    var child = el.firstChild;
    while (child) {
        // Move Dom element into fragment
        fragment.appendChild(child);
        child = el.firstChild
    }
    return fragment;
}

Next, we need to traverse each node and carry out special processing on the nodes with relevant specified. Here, we first deal with the simplest case, and only deal with the instructions in the form of '{variable}}'. It is difficult to simplify the way first, and then consider more instructions:

function compileElement (el) {
    var childNodes = el.childNodes;
    var self = this;
    [].slice.call(childNodes).forEach(function(node) {
        var reg = /\{\{(.*)\}\}/;
        var text = node.textContent;
 
        if (self.isTextNode(node) && reg.test(text)) {  // Determine whether it is an instruction in this form {}}
            self.compileText(node, reg.exec(text)[1]);
        }
 
        if (node.childNodes && node.childNodes.length) {
            self.compileElement(node);  // Continue to recursively traverse the child nodes
        }
    });
},
function compileText (node, exp) {
    var self = this;
    var initText = this.vm[exp];
    updateText(node, initText);  // Initializes the initialized data into the view
    new Watcher(this.vm, exp, function (value) {  // Generate subscribers and bind update functions
        self.updateText(node, value);
    });
},
function updateText (node, value) {
    node.textContent = typeof value == 'undefined' ? '' : value;
}

After obtaining the outermost node, the compileElement function is called to judge all the sub nodes. If the node is a text node and matches, the node will start to Compile and process. The compilation process first needs to initialize the view data. Corresponding to step 1 above, we need to generate a subscriber that binds the update function. Corresponding to step 2 above. In this way, the three processes of instruction parsing, initialization and compilation are completed, and a parser Compile can work normally. In order to associate the parser Compile with the listener Observer and subscriber Watcher, we need to modify the SelfVue function of the class:

function SelfVue (options) {
    var self = this;
    this.vm = this;
    this.data = options;
 
    Object.keys(this.data).forEach(function(key) {
        self.proxyKeys(key);
    });
 
    observe(this.data);
    new Compile(options, this.vm);
    return this;
}

After the change, we don't need to pass in fixed element values for two-way binding as before. We can name various variables for two-way binding:

<body>
    <div id="app">
        <h2>{{title}}</h2>
        <h1>{{name}}</h1>
    </div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
 
    var selfVue = new SelfVue({
        el: '#app',
        data: {
            title: 'hello world',
            name: ''
        }
    });
 
    window.setTimeout(function () {
        selfVue.title = 'Hello';
    }, 2000);
 
    window.setTimeout(function () {
        selfVue.name = 'canfoo';
    }, 2500);
 
</script>

According to the above code, it can be observed on the page that at the beginning, the title and name are initialized to 'hello world' and empty respectively. After 2s, the title is replaced with 'hello' and after 3s, the name is replaced with 'canfoo'. No more nonsense. I'll give you this version of code (v2), Get code!

Here, a two-way data binding function has been basically completed. The next step is to improve the parsing and compilation of more instructions. Where can more instructions be processed? The answer is obvious. Just judge other instruction nodes in the compileElement function mentioned above, and then traverse all its attributes to see if there are matching instruction attributes. If so, parse and compile them. Here, we add another parsing and compilation of v-model instructions and event instructions. For these nodes, we use the function compile for parsing and processing:

function compile (node) {
    var nodeAttrs = node.attributes;
    var self = this;
    Array.prototype.forEach.call(nodeAttrs, function(attr) {
        var attrName = attr.name;
        if (self.isDirective(attrName)) {
            var exp = attr.value;
            var dir = attrName.substring(2);
            if (self.isEventDirective(dir)) {  // Event instruction
                self.compileEvent(node, self.vm, exp, dir);
            } else {  // v-model instruction
                self.compileModel(node, self.vm, exp, dir);
            }
            node.removeAttribute(attrName);
        }
    });
}

The above compile function is mounted on the compile prototype. It first traverses all node attributes, and then determines whether the attribute is an instruction attribute. If so, it can distinguish which instruction is, and then carry out corresponding processing. The processing method is relatively simple. It is not listed here. Students who want to read the code can do it immediately Click here to get.

Finally, we slightly modify the class SelfVue to make it more like the usage of vue:

function SelfVue (options) {
    var self = this;
    this.data = options.data;
    this.methods = options.methods;
 
    Object.keys(this.data).forEach(function(key) {
        self.proxyKeys(key);
    });
 
    observe(this.data);
    new Compile(options.el, this);
    options.mounted.call(this); // When everything is done, execute the mounted function
}

At this time, we can really test it. Set the following things on the page:

<body>
    <div id="app">
        <h2>{{title}}</h2>
        <input v-model="name">
        <h1>{{name}}</h1>
        <button v-on:click="clickMe">click me!</button>
    </div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
 
     new SelfVue({
        el: '#app',
        data: {
            title: 'hello world',
            name: 'canfoo'
        },
        methods: {
            clickMe: function () {
                this.title = 'hello world';
            }
        },
        mounted: function () {
            window.setTimeout(() => {
                this.title = 'Hello';
            }, 1000);
        }
    });
 
</script>

be accomplished!

Article reprinted from: https://www.cnblogs.com/canfoo/p/6891868.html

Topics: Front-end Vue