MVVM
concept
MVVM represents Model-View-ViewModel.
- Model: Model layer, responsible for handling business logic and interacting with the server side
- View: View layer: responsible for converting data models into UI displays, which can be easily understood as HTML pages
- ViewModel: The View Model layer that connects Models to Views and serves as a communication bridge between Models and Views
The View layer and the Model layer are not directly related, but interact through the ViewModel layer.The ViewModel layer connects the View layer to the Model layer through two-way data binding, making synchronization between the View layer and the Model layer completely automatic.
How data binding is implemented and represents:
- Backbone
- Data hijacking or proxy (VueJS, AvalonJS) through Object.defineProperty or Proxy, the former cannot listen for array changes, must traverse every property of the object, the nested object must traverse deeply; the latter can listen for array changes, still needs to traverse nested objects deeply, and is less compatible than the former.
- Data Dirty Checks (RegularJS) perform dirty checks, such as DOM events, XHR response events, timers, and so on, when UI changes may be triggered.
Realization
Two-way data binding requires three classes:
- Observer listener: Used to monitor property changes and notify subscribers
- Watcher Subscriber: Accept notifications of property changes and update the view
- Compile parser: parse instructions, initialize templates, bind subscribers
Next, we implement a simple MVVM framework as Vue implements.
- Implement listener Observer
Using Obeject.defineProperty() to listen for property changes, data objects that require observe are recursively traversed, including the properties of child property objects, plus setters and getter s.When a value of this object is assigned, the setter is triggered and the data changes can be monitored.
We need to notify Watcher subscribers to perform an update function to update the view when we hear that the properties have changed. We may have many subscribers in the process, so we need to create a container Dep to do a unified management.
function observer(data) { if(!data || typeof data !== 'object'){ return; } Object.keys(data).forEach(key=>{ defineReactive(data, key, data[key]); }) } function defineReactive(obj,key,val){ observer(val); //Recursive listener child properties var dep = new Dep(); //Subscriber's Dependent Collector Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function getter(){ if (Dep.target) { dep.addSub(Dep.target); //Add each property to the listener in the getter } return val; }, set: function setter(newVal){ if (newVal === val) { return; } val = newVal; //Data Re-assignment console.log('Monitoring value changed ', val, ' --> ', newVal); dep.notify(); //Notification Subscriber } }) } function Dep() { this.subs = []; } Dep.prototype.addSub = function (sub) { this.subs.push(sub); } Dep.prototype.notify = function () { console.log('Attribute Change, notice Watcher Perform functions to update views'); this.subs.forEach(sub => { sub.update(); //Call Watcher's View Update function here }) } Dep.target = null; //Global target Copy Code
A listener Observer was created above, and now we can add listeners to an object and change the properties to see what happens.
var person = { name: 'ben' } observer(person); person.name = 'bob'; Copy Code
Printed out through the console: Attribute changes notify Watcher to perform the function of updating the view, proving that the listener Observer is in effect.
- Implement Subscriber Watcher
Now that you have listened for property changes, you need Watcher to perform the update.Watcher primarily accepts notifications of property changes and then executes an update function to update the view.
function Watcher(vm, prop, callback) { this.vm = vm; this.prop = prop; this.callback = callback; this.value = this.getValue();//After new Watcher, automatically add to listening } Watcher.prototype.update = function(){ const value = this.vm.$data[this.prop]; const oldVal = this.value; if (value !== oldVal) { this.value = value; this.callback(value); } } Watcher.prototype.getValue = function(){ Dep.target = this; //Store Subscriber const value = this.vm.$data[this.prop]; //Because the property is being listened on, this step executes the get method in the listener Dep.target = null; return value; } Copy Code
The above two steps have achieved a simple two-way binding, we will combine the two to see the effect.
function MVVM(options){ this.$options = options || {}; this.$data = this.$options.data; this.$el = document.querySelector(this.$options.el); this.init(); } MVVM.prototype.init = function(){ var prop = 'name' //Temporarily Write Property name observer(this.$data) this.$el.innerText = this.$data[prop] new Watcher(this, prop, value => { this.$el.innerText = value }) } Copy Code
Verify:
<!DOCTYPE html> <html lang="en"> <head></head> <body> <div id="app">{{name}}</div> </body> <script src="test.js"></script> <script> const vm = new MVVM({ el: "#app", data: { name: "ben" } }) </script> </html> Copy Code
ben appears on the page, and when we output vm. $data.name ='jack'in the browsing console, Jack appears instantaneously on the page.So a simple two-way data binding is done.But the property name is dead and el.innerText is not scalable, so let's implement a template parser next.
- Implement compiler Compile
The main purpose of Compile is to parse the instruction initialization template, add subscribers, and bind update functions.Because DOM is frequently manipulated during DOM node parsing, we use Document Fragments to help us parse DOM to optimize performance.The entire node and instructions are processed and compiled, different rendering functions are invoked according to different nodes, update functions are bound, and DOM fragments are added to the page after compilation is complete.
function Compile(vm) { this.vm = vm; this.el = vm.$el; this.fragment = null; this.init(); } Compile.prototype = { init: function () { this.fragment = this.nodeFragment(this.el); }, nodeFragment: function (el) { const fragment = document.createDocumentFragment(); let child = el.firstChild; //Move all child nodes within the document fragment while (child) { fragment.appendChild(child); child = el.firstChild; } return fragment; }, compileNode: function (fragment) { let childNodes = fragment.childNodes; [...childNodes].forEach(node => { let reg = /\{\{(.*)\}\}/; let text = node.textContent; if (this.isElementNode(node)) { this.compile(node); //Rendering Instruction Template } else if (this.isTextNode(node) && reg.test(text)) { let prop = RegExp.$1; this.compileText(node, prop); //Render {{}} Template } //Recursive Compile Child Node if (node.childNodes && node.childNodes.length) { this.compileNode(node); } }); }, compile: function (node) { let nodeAttrs = node.attributes; [...nodeAttrs].forEach(attr => { let name = attr.name; if (this.isDirective(name)) { let value = attr.value; if (name === "v-model") { this.compileModel(node, value); } node.removeAttribute(name); } }); }, //Omit... } Copy Code
The MVVM function becomes this way
function MVVM(options){ this.$options = options || {}; this.$data = this.$options.data; this.$el = document.querySelector(this.$options.el); this.init(); } MVVM.prototype.init = function(){ observer(this.$data) new Compile(this); } Copy Code
- Add Data Agent
Currently, when we modify data, we can only modify it by vm. $data.name ='jack', not directly by vm.name ='jack'. Is there any way to do that?The answer is to add a layer of data proxies.
function MVVM(options){ this.$options = options || {}; this.$data = this.$options.data; this.$el = document.querySelector(this.$options.el); //Data Proxy Object.keys(this.$data).forEach(key => { this.proxyData(key); }); this.init(); } MVVM.prototype.init = function(){ observer(this.$data) new Compile(this); } MVVM.prototype.proxyData = function (key) { Object.defineProperty(this, key, { get: function () { return this.$data[key] }, set: function (value) { this.$data[key] = value; } }); } Copy Code
Vue Summary
- Any Vue Component has a corresponding Watcher instance.
- Attributes on Vue's data are added getter and setter attributes.
- When the Vue Component render function is executed, the data is touched, that is, read, and the getter method is called, and the Vue records all the data on which the Vue component depends.(This process is called dependent collection)
- When the data is changed (mainly user action), that is, written, the setter method is called, and Vue notifies all components that depend on the data to call their render function for updates.
- Vue cannot detect the addition or removal of property.Since Vue performs a getter/setter transformation on property when the instance is initialized, property must exist on the data object for Vue to convert it to responsive.
var vm = new Vue({ data:{ a:1 } }) // `vm.a` is responsive vm.b = 2 // `vm.b` is non-responsive Vue.set(vm.someObject, 'b', 2) //Responsive property can be added to nested objects via the Vue.set(object, propertyName, value) method Copy Code
- Vue cannot detect changes in the following arrays:
- When you use an index to set an array item directly, for example: vm.items[indexOfItem] = newValue
- When you modify the length of an array, for example, vm.items.length = newLength
var vm = new Vue({ data: { items: ['a', 'b', 'c'] } }) vm.items[1] = 'x' // Not responsive vm.items.length = 2 // Not responsive Vue.set(vm.items, indexOfItem, newValue) //Responsive status updates can be triggered by Vue.set(vm.items, indexOfItem, newValue) Copy Code
- Vue is asynchronous when updating DOM.As long as you listen for data changes, Vue opens a queue and buffers all data changes that occur in the same event loop.If the same watcher is triggered multiple times, it will only be pushed into the queue once.This removal of duplicate data while buffering is important to avoid unnecessary calculations and DOM operations.Then, in the next event loop, tick, Vue refreshes the queue and performs the actual (weighted) work.