vue.js dynamic data binding learning

Posted by mark s on Sat, 29 Jun 2019 23:23:42 +0200

For the dynamic data binding of vue.js, after reading the source code and blog explanations repeatedly, we can finally understand its implementation, and share the learning results, which is also a record. Full code GitHub address: https://github.com/hanrenguang/Dynamic-data-binding . You can also read this article at README in the warehouse.

Overall thinking

I don't know if there are any classmates like me. Looking at the source code of vue, I don't know where to start. It's really a big headache. Looking at the source code of observer, watcher and compile, I just feel confused. Ultimately, from Here Inspired by this, the author writes well and is worth reading.

As for dynamic data binding, Dep, Observer, Watcher and Compile are the classes that need to be worked out. They have all kinds of connections. If you want to understand the source code, you must first understand their connections. Here's a reason:

  • What Observer does is hijack all attributes and notify Dep when there is a change.

  • Watcher adds subscriptions to Dep, and when attributes change, Observer notifies Dep and Dep notifies Watcher.

  • When Watcher is notified, the callback function is called to update the view

  • Compile parses the DOM structure of the bound elements, adding Watcher subscriptions to all properties that need to be bound

As can be seen from this, when the attributes change, it is Observer - > Dep - > Watcher - > update view, and Compile successfully retires after initially parsing DOM and adding Watcher subscriptions.

From the order of program execution, that is, after new Vue({}), it should be like this: first hijack all attributes through Observer, then Compile parses DOM structure, and adds Watcher subscription, then attribute change - > Observer - > Dep - > Watcher - > update view, and then talk about the specific implementation.

Start with a new example

Many source code interpretations on the Internet start with Observer, and I'll start with a new MVVM instance, which may be easier to understand in the order of program execution. Let's start with a simple example:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>
<body>
    <div class="test">
        <p>{{user.name}}</p>
        <p>{{user.age}}</p>
    </div>

    <script type="text/javascript" src="hue.js"></script>
    <script type="text/javascript">
        let vm = new Hue({
            el: '.test',
            data: {
                user: {
                    name: 'Jack',
                    age: '18'
                }
            }
        });
    </script>
</body>
</html>

Next, we will take it as an example to analyze. Let's look at a brief implementation of MVVM, which is named hue here. For convenience, a proxy is set for data attributes. Accessing data attributes through vm._data is cumbersome and redundant. This problem can be solved by proxy, which is also explained in the annotations. After adding attribute proxy, an observe function is called. What this step does is that Observer's attribute is hijacked. How to implement this step is not expanded for the time being. First remember that he added getter and setter to the properties of data.

function Hue(options) {
    this.$options = options || {};
    let data = this._data = this.$options.data,
        self = this;

    Object.keys(data).forEach(function(key) {
        self._proxyData(key);
    });

    observe(data);

    self.$compile = new Compile(self, options.el || document.body);
}

// A proxy for data is made.
// Accessing vm.xxx triggers the getter of vm. _data [xxxx] to get the value of vm. _data [xxxx].
// Assigning a value to vm.xxx triggers the setter of vm._data[xxx]
Hue.prototype._proxyData = function(key) {
    let self = this;
    Object.defineProperty(self, key, {
        configurable: false,
        enumerable: true,
        get: function proxyGetter() {
            return self._data[key];
        },
        set: function proxySetter(newVal) {
            self._data[key] = newVal;
        }
    });
};

Looking further down, the last step is a new Compile. Now let's talk about Compile.

Compile

In the new Compile(self, options.el || document.body) line of code, the first parameter is the current Hue instance, and the second parameter is the bound element, in the above example is the div of class. test.

With respect to Compile, only the simplest textContent binding is implemented here. Compile's code is easy to read. All it does is parse the DOM and add Watcher subscriptions. For DOM parsing, the root node el is transformed into document fragment for parsing and compiling. After parsing, the fragment is added back to the original real DOM node. Take a look at this part of the code:

function Compile(vm, el) {
    this.$vm = vm;
    this.$el = this.isElementNode(el)
        ? el
        : document.querySelector(el);

    if (this.$el) {
        this.$fragment = this.node2Fragment(this.$el);
        this.init();
        this.$el.appendChild(this.$fragment);
    }
}

Compile.prototype.node2Fragment = function(el) {
    let fragment = document.createDocumentFragment(),
        child;

    // Maybe some of you don't understand this step very well. You might as well write a small example to observe his behavior.
    while (child = el.firstChild) {
        fragment.appendChild(child);
    }

    return fragment;
};

Compile.prototype.init = function() {
    // Analytical fragment
    this.compileElement(this.$fragment);
};

In the example above, if fragment is printed out at this time, it can be observed that it contains two p elements:

<p>{{user.name}}</p>
<p>{{user.age}}</p>

The next step is to parse fragment s and look directly at the code and comments.

Compile.prototype.compileElement = function(el) {
    let childNodes = Array.from(el.childNodes),
        self = this;

    childNodes.forEach(function(node) {
        let text = node.textContent,
            reg = /\{\{(.*)\}\}/;

        // If it is a textNode element and matches the reg rule
        // In the previous example,'{user.name}'and'{user.age}' are matched.
        if (self.isTextNode(node) && reg.test(text)) {
            // Resolve textContent, RegExp.  is the matched content, in the previous example,'user.name'and'user.age'
            self.compileText(node, RegExp.$1);
        }

        // recursion
        if (node.childNodes && node.childNodes.length) {
            self.compileElement(node);
        }
    });
};

Compile.prototype.compileText = function(node, exp) {
    // this.$vm is the Hue instance, exp is the regular matching content, that is,'user.name'or'user.age'
    compileUtil.text(node, this.$vm, exp);
};

let compileUtil = {
    text: function(node, vm, exp) {
        this.bind(node, vm, exp, 'text');
    },

    bind: function(node, vm, exp, dir) {
        // Callback function to get updated view
        let updaterFn = updater[dir + 'Updater'];

        // First call updaterFn to update the view
        updaterFn && updaterFn(node, this._getVMVal(vm, exp));

        // Add Watcher subscriptions
        new Watcher(vm, exp, function(value, oldValue) {
            updaterFn && updaterFn(node, value, oldValue);
        });
    },

    // According to exp, get its value, in the previous example,'vm.user.name'or'vm.user.age'
    _getVMVal: function(vm, exp) {
        let val = vm;
        exp = exp.trim().split('.');
        exp.forEach(function(k) {
            val = val[k];
        });
        return val;
    }
};

let updater = {
    // Callback functions for Watcher subscriptions
    // Here, update node.textContent, or update view
    textUpdater: function(node, value) {
        node.textContent = typeof value === 'undefined'
            ? ''
            : value;
    }
};

As you can see in the code, Compile adds a subscription to the XXX attribute after parsing to {{xxx}, namely new Watcher(vm, exp, callback). Once you understand this step, you need to understand how to implement subscriptions to related attributes. Start with Observer.

Observer

In the simplest case, the change of array elements is not considered. For the time being, the relationship between Dep and Observer is not considered. First look at the Observer constructor:

function Observer(data) {
    this.data = data;
    this.walk(data);
}

Observer.prototype.walk = function(data) {
    const keys = Object.keys(data);
    // Traverse all attributes of data
    for (let i = 0; i < keys.length; i++) {
        // Call defineReactive to add getter and setter
        defineReactive(data, keys[i], data[keys[i]]);
    }
};

Next, we add getter and setter to all attributes through Object.defineProperty method, which achieves our goal. Attributes may also be objects, so the value of attributes needs to be called recursively.

function defineReactive(obj, key, val) {
    // For attribute value recursion, the corresponding attribute value is the case of the object.
    let childObj = observe(val);

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            // Return attribute values directly
            return val;
        },
        set: function(newVal) {
            if (newVal === val) {
                return;
            }
            // Modify val in closure when the value changes.
            // Ensure that the correct value is returned when the getter is triggered
            val = newVal;

            // Recursion of newly assigned values to prevent the assignment of values to the object
            childObj = observe(newVal);
        }
    });
}

Finally, the observation function, which is called in Hue constructor, is added.

function observe(val) {
    // If val is an object and not an array, then new an Observer instance, val as a parameter
    // Simply put: the object continues.
    if (!Array.isArray(val) && typeof val === "object") {
        return new Observer(val);
    }
}

In this way, all the descendant attributes of data (I wonder if there is such a statement.) All of them were hijacked. Obviously, so far, it's not helpful, or if you just do it here, it's no different from doing nothing. So Dep came on. I think it's important to understand the relationship between Dep and Observer and Watcher. Let's talk about what Dep did in Observer first.

Observer & Dep

After each defineReactive function is called, a new Dep instance, let dep = new Dep(), is created in the closure. Dep provides some methods. First, let's talk about notify. What does it do? If Dep is notified when the attribute value changes, then our code can be added as follows:

function defineReactive(obj, key, val) {
    let childObj = observe(val);
    const dep = new Dep();

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            return val;
        },
        set: function(newVal) {
            if (newVal === val) {
                return;
            }

            val = newVal;
            childObj = observe(newVal);

            // Change
            dep.notify();
        }
    });
}

If we only consider the connection between Observer and Dep, i.e. notifying Dep when there is a change, then we're done here. However, in the source code of vue.js, we can also see a code added to getter:

// ...
get: function() {
    if (Dep.target) {
        dep.depend();
    }
    return val;
}
// ...

What about this depend method? What does it do? The answer is to add a Watcher subscription to the Dep instance in the closure, and what is Dep.target? He's actually a Watcher example. With a blurred face, just remember. First, look at some of the Depp source code.

// Identifiers, which are useful in Watcher, should not be ignored
let uid = 0;

function Dep() {
    this.id = uid++;
    this.subs = [];
}

Dep.prototype.depend = function() {
    // This step is equivalent to doing one thing: this.subs.push(Dep.target)
    // That is to say, Watcher subscription is added, and addDep is Watcher's method.
    Dep.target.addDep(this);
};

// Notification updates
Dep.prototype.notify = function() {
    // Each of this.subs is a Watcher instance
    this.subs.forEach(function(sub) {
        // Update is a method for Watcher to update views
        // Yes, in fact, this method will eventually call updaterFn in Compile.
        // Callback in new Watcher(vm, exp, callback)
        sub.update();
    });
};

// Called in Watcher
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub);
};

// Initially quoted as null
Dep.target = null;

Maybe it's still a muddled face, that's all right, then go on. Perhaps some students wonder why we should add Watcher subscriptions to getter. Next, let's talk about the story of Watcher and Dep.

Watcher & Dep

Let's first review what Compile does, parse fragment s, and then add subscriptions to the corresponding properties: new Watcher(vm, exp, cb). After the new Watcher, what about Watcher? There's a dialogue like this:

Watcher: hey Dep, I need to subscribe to changes in exp properties.

dep: I can't do that. You have to look for dep in the exp attribute. He can do it.

Watcher: But he's in the closure. I can't get in touch with him.

Dep: You get the whole Hue instance vm, and you know the property exp. You can trigger his getter. You can't just do something in it.

Watcher: That makes sense, but I have to let dep know that I subscribed to it, otherwise he won't notify me.

Dep: That's easy. Let me help you. Before you trigger a getter, just tell Dep.target your reference. Remember to leave Dep.target blank when you're done.

So there's the code in the getter above:

// ...
get: function() {
    // Was it triggered by Watcher?
    if (Dep.target) {
        // Yes, add it in.
        dep.depend();
    }
    return val;
}
// ...

Now look back at the Dep code and see if it's easier to understand. In this way, what Watcher needs to do is simple and clear:

function Watcher(vm, exp, cb) {
    this.$vm = vm;
    this.cb = cb;
    this.exp = exp;
    this.depIds = new Set();

    // Returns a function to get the corresponding attribute value
    this.getter = parseGetter(exp.trim());

    // Call get method to trigger getter
    this.value = this.get();
}

Watcher.prototype.get = function() {
    const vm = this.$vm;
    // Point Dep.target to the current Watcher instance
    Dep.target = this;
    // Trigger getter
    let value = this.getter.call(vm, vm);
    // Dep.target empty
    Dep.target = null;
    return value;
};

Watcher.prototype.addDep = function(dep) {
    const id = dep.id;
    if (!this.depIds.has(id)) {
        // Add a subscription, equivalent to dep.subs.push(this)
        dep.addSub(this);
        this.depIds.add(id);
    }
};

function parseGetter(exp) {
    if (/[^\w.$]/.test(exp)) {
        return;
    }

    let exps = exp.split(".");

    return function(obj) {
        for (let i = 0; i < exps.length; i++) {
            if (!obj)
                return;
            obj = obj[exps[i]];
        }
        return obj;
    };
}

Last but not least, after Dep notification changes, Watcher's processing, the specific function call process is as follows: dep. notify () - > sub. update (), directly on the code:

Watcher.prototype.update = function() {
    this.run();
};

Watcher.prototype.run = function() {
    let value = this.get();
    let oldVal = this.value;

    if (value !== oldVal) {
        this.value = value;
        // Callback function to update view
        this.cb.call(this.$vm, value, oldVal);
    }
};

epilogue

Up to now, even if it is finished, my level is limited. If there are any shortcomings, you are welcome to point out and discuss them together.

Reference material

https://github.com/DMQ/mvvm

Topics: Javascript Fragment Attribute Vue github