Simple implementation of MVVM framework

As we all know, the current era is the prevalence of MVVM, from the early Angular to the current React and Vue, and from the initial three-point world to the current two tigers competing.

It undoubtedly brings an unprecedented new experience to our development. We say goodbye to the thinking of operating DOM and replace it with the thinking of data-driven pages. Sure enough, the progress of the times has changed us a lot.

It’s not good to talk too much. Let’s get into today’s topic

Highlights

MVVM two-way data binding is handled through dirty value detection in the Angular1.x version.

But now, whether it is React, Vue or the latest Angular, the implementation methods are actually more similar.

That is throughdata hijacking + publish-subscribe model

The actual implementation actually relies on Object.defineProperty provided in ES5. Of course, this is incompatible, so Vue and others only support IE8 +

Why it

Object.defineProperty() To be honest, we don’t use it much in development. Most of it is to modify internal properties, but is it just to define the properties and values on the object? Why do you have to do it so hard (Purely my personal thoughts)

But it is of great use when implementing frameworks or libraries. I won’t go into details about this. It’s just a piece of cake and it has not yet reached the strength of writing a library.

To know why, let’s see how to use it

let obj = {};
let song = 'hair as snow';
obj.singer = 'Jay Chou';

Object.defineProperty(obj, 'music', { // 1. value: 'Qilixiang', configurable: true, // 2. You can configure the object and delete attributes // writable: true, // 3. You can Modify the object enumerable: true, // 4. Can be enumerated // ☆ Writable and value cannot be set when setting get and set. They replace the two and are mutually exclusive get() { // 5. Get obj.music The get method will be called return song; }, set(val) { // 6. Reassign the modified value to song song = val; } }); // The parts printed below are the corresponding code writing order execution console.log(obj); // {singer: 'Jay Chou', music: 'Qilixiang'} // 1 delete obj.music; // If you want to delete the attributes in obj, set configurable to true 2 console.log(obj); // This is {singer: 'Jay Chou'} obj.music = 'Listen to mom's words'; // If you want to modify the properties of obj, set writable to true 3 console.log(obj); // {singer: 'Jay Chou', music: "Listen to mommy"} for (let key in obj) { // By default, properties defined through defineProperty cannot To be enumerated (traversed) // you need to set enumerable to true // otherwise you will not be able to get the music attribute, you can only get the singer console.log(key); // singer, music 4 } console .log(obj.music); // 'Fa like snow' 5 obj.music = 'Nocturne'; // Call set to set a new value console.log(obj.music); // 'Nocturne ' 6 Copy code

The above is about the usage of Object.defineProperty

Let’s write an example below. Here we use Vue as a reference to implement how to write MVVM.

// index.html
<body>
    <div id="app">
        <h1>{<!-- -->{song}}</h1>
        <p>"{<!-- -->{album.name}}" is an album released by {<!-- -->{singer}} in November 2005</p>
        <p>The title song is {<!-- -->{album.theme}}</p>
        <p>The lyrics are written by {<!-- -->{singer}} and others.</p>
        Play Chopin's {<!-- -->{album.theme}} for you
    </div>
    <!--implemented mvvm-->
    <script src="mvvm.js"></script>
    <script>
        // The writing method is the same as Vue
        let mvvm = new Mvvm({
            el: '#app',
            data: { // Object.defineProperty(obj, 'song', 'hair like snow'); song: 'hair like snow', album: { name: 'Chopin in November' , theme: 'Nocturne' }, singer: 'Jay Chou' } }); </script> </body> Copy code 

The above is how it is written in html. I believe it is familiar to students who have used Vue.

So start implementing your own MVVM now

Build MVVM

//Create a Mvvm constructor
// Here, the es6 method is used to assign an initial value to options to prevent them from being passed, which is equivalent to options || {}function Mvvm(options = {}) {
    // vm.$options Vue mounts all properties to it
    // So we implement the same thing and mount all properties to $options
    this.$options = options;
    // this._data This is also the same as Vue
    let data = this._data = this.$options.data; // Data hijacking observe(data); } Copy code
Data Hijacking

Why do data hijacking?

  • Observe the object and add Object.defineProperty to the object
  • The characteristic of Vue is that it cannot add properties that do not exist. Properties that do not exist do not have get and set.
  • Deep response because every time a new object is assigned, defineProperty (data hijacking) will be added to the new object.

It’s useless to say more, let’s look at the code together

//Create an Observe constructor
//Write the main logic function of data hijacking Observe(data) {
    // The so-called data hijacking is to add get and set to the object
    // Let’s traverse the object first and then talk about it
    for (let key in data) { // Define the data attribute through defineProperty let val = data[key]; observe(val); // Continue searching recursively to achieve deep data hijacking Object.defineProperty(data , key, { configurable: true, get() { return val; }, set(newVal) { // When changing the value if (val === newVal) { // The set value is the same as the previous value, ignore it return; } val = newVal; // If you get the value again (get) in the future, return the value you just set to observe(newVal); // When it is set to a new value, you also need to define the new value again. into attribute} }); } } // Write another function outside // No need to write new every time // It is also convenient to call function observe(data) recursively { // If it is not an object, just return it directly // Prevent Recursive overflow if (!data || typeof data !== 'object') return; return new Observe(data); } Copy code

The above code implements data hijacking, but there may be some confusion, such as: recursion

Let’s talk about why recursion in detail, look at this chestnut

let mvvm = new Mvvm({
        el: '#app',
        data: {
            a: {
                b: 1
            },
            c: 2
        }
    });
Copy code

Let’s take a look at the console

The marked place is passed

Recursion observe(val) performs data hijacking and adds get and set. Recursion continues to define attributes for the objects in a. After personal testing, you can eat with confidence.

Next, let’s talk about why observe(newVal) is also recursive here.

Still on the lovely console, type this piece of code mvvm._data.a = {b:’ok’}

Then continue to look at the pictures and talk

added by observe(newVal)

Now I roughly understand why we need to recursively observe the new value set, haha, so easy

Data hijacking is complete, let’s make another data proxy

Data Agent

The data agent allows us to get the data in the data without having to write a long string every time, such as mvvm._data.a.b. We can actually write it directly as mvvm.a.b in an obvious way.

Continue reading below, the + sign indicates the implementation part

function Mvvm(options = {}) {
    //data hijacking
    observe(data);
    // this proxies this._data
 + for (let key in data) {
        Object.defineProperty(this, key, {
            configurable: true, get() { return this._data[key]; // For example, this.a = {b: 1} }, set(newVal) { this._data[key] = newVal; } }); + } } // Now you can simplify the writing: console.log(mvvm.a.b); // 1 mvvm.a.b = 'ok'; console.log(mvvm.a.b); // 'ok' Copy code 

At this point, data hijacking and data proxy have been implemented, then you need to compile it and parse out the content in {{}}

Data compilation
function Mvvm(options = {}) {
    // observe(data);
        
    // compile
 + new Compile(options.el, this);
}

//Create Compile constructor
function Compile(el, vm) {
    // Mount el to the instance for easy calling
    vm.$el = document.querySelector(el);
    // Get all the contents in the el range, of course not one by one
    // You can choose to move it to memory and put it into document fragments to save overhead.
    let fragment = document.createDocumentFragment();
    
    while (child = vm.$el.firstChild) { fragment.appendChild(child); // At this time, put the content in el into memory} // Replace the content in el function replace(frag) { Array. from(frag.childNodes).forEach(node => { let txt = node.textContent; let reg = /\{\{(.*?)\}\}/g; // Regular matching {< !-- -->{}} if (node.nodeType === 3 & amp; & amp; reg.test(txt)) { // Even if it is a text node and there are braces {<!-- - ->{}} console.log(RegExp.$1); // The first matched group is: a.b, c let arr = RegExp.$1.split('.'); let val = vm; arr .forEach(key => { val = val[key]; // Such as this.a.b }); // Use the trim method to remove the leading and trailing spaces node.textContent = txt.replace(reg, val).trim(); } // If there are child nodes, continue to replace recursively if (node.childNodes & amp; & amp; node.childNodes.length) { replace(node); } }); } replace(fragment); // Replace content vm. $el.appendChild(fragment); //Put the document fragment into el} Copy code

Seeing that you can already show your talents in the interview, then work hard and do everything in one go.

Now the data can be compiled, but the data we manually modified has not changed on the page.

Let’s take a look at how to deal with it. In fact, a particularly common design pattern is used here, the publish-subscribe pattern.

Publish and subscribe

Publish and subscribe mainly rely on array relationships. To subscribe is to put in a function, and to publish is to let the function in the array be executed.

//Publish and subscribe mode subscription and publishing such as [fn1, fn2, fn3]function Dep() {
    // An array (event pool to store functions)
    this.subs = [];
}
Dep.prototype = {
    addSub(sub) {
        this.subs.push(sub);
    },
    notify() { // The bound method has an update method this.subs.forEach(sub => sub.update()); } }; // Listening function // Instances created through the Watcher class all Has update method function Watcher(fn) { this.fn = fn; // Put fn on the instance} Watcher.prototype.update = function() { this.fn(); }; let watcher = new Watcher(() => console.log(111)); // let dep = new Dep(); dep.addSub(watcher); // Put watcher into the array, watcher has its own update method, => [watcher] dep.addSub (watcher); dep.notify(); // 111, 111 Copy code
Data update view
  • Now we need to subscribe to an event. When the data changes, the view needs to be refreshed. This needs to be handled in the replacement logic.
  • Subscribe to the data through new Watcher, and perform the operation of changing the content as soon as the data changes.
function replace(frag) {
    // Omit...
    // Replacement logic
    node.textContent = txt.replace(reg, val).trim();
    //Listen for changes
    //Add two more parameters to Watcher to get the new value (newVal) and pass parameters to the callback function
 + new Watcher(vm, RegExp.$1, newVal => {
        node.textContent = txt.replace(reg, newVal).trim();
 + });
}

// Rewrite the Watcher constructor
function Watcher(vm, exp, fn) {
    this.fn = fn;
 + this.vm = vm;
 + this.exp = exp;
    //Add an event
    //Here we first define a property
 + Dep.target = this;
 + let arr = exp.split('.'); + let val = vm; + arr.forEach(key => { // Get value + val = val[key]; // Get this.a.b, By default, the get method will be called + }); + Dep.target = null; } Copy code

When the value is obtained, the get method will be automatically called, so let’s look for the get method in the data hijacking

function Observe(data) {
 + let dep = new Dep();
    // Omit...
    Object.defineProperty(data, key, {
        get() {
 + Dep.target & amp; & amp; dep.addSub(Dep.target); // Add watcher to the subscription event [watcher]
            return val; }, set(newVal) { if (val === newVal) { return; } val = newVal; observe(newVal); + dep.notify(); // Just let all watcher update methods execute} }) } Copy code

When the set modifies the value, the dep.notify method is executed. This method is to execute the update method of the watcher. Then we will modify the update.

Watcher.prototype.update =function() {
    //The value has changed when notifying
    // Then use vm, exp to get the new value
 + let arr = this.exp.split('.');
 + let val = this.vm; + arr.forEach(key => { + val = val[key]; // Get the new value through get + }); this.fn(val); // Change each time Just replace the content of {<!-- -->{}} with the new value you get}; Copy code

Now our data changes can modify the view, which is very good. There is one last thing left. Let’s take a look at the two-way data binding that is often tested in interviews.

Two-way data binding
// html structure
    <input v-model="c" type="text">
    
    //data part
    data: {
        a: {
            b: 1
        },
        c: 2
    }
    
    function replace(frag) {
        // Omit...
 + if (node.nodeType === 1) { // Element node let nodeAttr = node.attributes; // Get all the attributes on the dom, which is a class array Array.from(nodeAttr).forEach(attr => { let name = attr.name; // v-model type let exp = attr.value; // c text if (name.includes('v-')){ node.value = vm[exp]; // this. c is 2 } // Monitor changes new Watcher(vm, exp, function(newVal) { node.value = newVal; // When the watcher is triggered, it will automatically put the content into the input box}); node.addEventListener(' input', e => { let newVal = e.target.value; // Equivalent to assigning a new value to this.c // The change of the value will call set, and notify will be called in set, and notify will be called. The update method of watcher implements the update vm[exp] = newVal; }); }); + } if (node.childNodes & amp; & amp; node.childNodes.length) { replace(node); } } Copy code< /pre>
   <p>It’s done. The only things I asked about Vue in the interview were this, how to implement two-way data binding, and I didn’t ask any questions at all. Bad review! ! !</p>
   <p><strong>Officials, please stay.</strong> I should have stopped, but on a temporary basis (my hands are itchy), let’s write some more functions, and add computed (computed attributes) and mounted (hook functions)</p>
   <h5 class="heading">computed(computed attribute) & amp; & amp; mounted(hook function)</h5>
   <pre>// html structure
    <p>The summed value is {<!-- -->{sum}}</p>
    
    data: { a: 1, b: 9 },
    computed: {<!-- -->sum() {
            return this.a + this.b;
        },
        noop() {} }, mounted() { setTimeout(() => { console.log('Everything is done'); }, 1000); } function Mvvm(options = {}) { // Initialize computed, point this to the instance + initComputed.call(this); // Compile new Compile(options.el, this); // After everything is processed, execute the mounted hook function + options.mounted.call(this); / / This implements the mounted hook function} function initComputed() { let vm = this; let computed = this.$options.computed; // Get the computed attribute from options {sum: ?, noop: ?} // get The keys of objects can be converted into arrays through Object.keys Object.keys(computed).forEach(key => { // key is sum,noop Object.defineProperty(vm, key, { // The judgment here is in computed Is the key an object or a function // If it is a function, the get method will be adjusted directly // If it is an object, just manually adjust the get method // For example: sum() {<!-- -->return this.a + this.b;}, they will call the get method when getting the values of a and b // So there is no need for new Watcher to monitor changes get: typeof computed[key] === 'function' ? computed[key] : computed[key].get, set() {} }); }); } Copy code

It’s not too much to write these contents. Let’s make a formal summary at the end.

Summary

The mvvm implemented by myself includes the following things in total:

  1. Data hijacking through get and set of Object.defineProperty
  2. Proxy data to this by traversing data data
  3. Compile data via {{}}
  4. Synchronize data and views through publish-subscribe model
  5. Passed, passed, accepted, thanks to the official for staying.

Supplement

There are still some small bugs when compiling the above code. After further research and expert advice, the compilation has been improved. Please see the modified code below.

Fixed: Two adjacent {{}} regular matches, the latter one cannot be correctly compiled into the corresponding text, such as {{album. name}} {{singer}}

function Compile(el, vm) {
    // Omit...
    function replace(frag) {
        // Omit...
        if (node.nodeType === 3 & amp; & reg.test(txt)) {
            function replaceTxt() { node.textContent = txt.replace(reg, (matched, placeholder) => { console.log(placeholder); // Matched groups such as: song, album.name, singer... new Watcher (vm, placeholder, replaceTxt); // Monitor changes, match and replace content return placeholder.split('.').reduce((val, key) => { return val[key]; }, vm); }); }; // Replace replaceTxt(); } } } Copy code

The above code mainly relies on the reduce method. reduce executes the callback function in sequence for each element in the array.

If there is still something unclear, let’s take a look at the reduce part separately.

// Split each matched value
    // Such as:'song'.split('.') => ['song'] => ['song'].reduce((val, key) => val[key] )
    //In fact, vm is passed to val as the initial value, and reduce executes a callback to return a value.
    // vm['song'] => 'Jay Chou' // The above is not in-depth enough, let's look at another // Another example: 'album.name'.split('.') => ['album', 'name'] => ['album', 'name'].reduce((val, key) => val[key]) // Here vm is still used as Pass the initial value to val, make the first call, and return vm['album'] // Then pass the returned vm['album'] object to the next call of val // Finally Becomes vm['album']['name'] => 'Chopin in November' return placeholder.split('.').reduce((val, key) => { return val[key]; }, vm); Copy code

Reduce has many uses. For example, it is a relatively common method to calculate the sum of arrays. Another useful advantage is that it can flatten two-dimensional arrays. You may wish to take a last look at it.

let arr = [
  [1, 2],
  [3, 4],
  [5, 6]
];

let flatten = arr.reduce((previous, current) => {
  return previous.concat(current);
});

console.log(flatten); // [1, 2, 3, 4, 5, 6]

// In ES6, it can also be implemented using the... expansion operator. The implementation idea is the same, but the writing method is more streamlined.
flatten = arr.reduce((a, b) => [...a, ...b]);
console.log(flatten); // [1, 2, 3, 4, 5, 6]
Copy code

Thanks again to my fellow countrymen, brothers and sisters for watching! This time it’s really the last look, it’s the end!

Author: chenhongdong

Link: https://juejin.im/post/5abdd6f6f265da23793c4458

Source: Nuggets

Copyright belongs to the author. For commercial reprinting, please contact the author for authorization. For non-commercial reprinting, please indicate the source.