Skip to main content

Create a Vue

Free2017-02-26#JS#vue源码分析#vue diff#vue数据绑定原理#vue数据绑定性能

Build a Vue... core, from Compiler & Directive to Watcher, implement two-way data binding

I. Core Structure

Vue's data binding mechanism:

setter + dirty checking + publish-subscribe management

It has been like this since 0.x, dep.js, watcher.js, observer.js:

Subject: dep.js
Observer: watcher.js (built-in dirty checking)
Setter: observer.js (trigger Subject.notify when set)

By defining setter to listen for data changes, there's a very important question: for deep data structures, do we define setter one by one?

Indeed it is:

If it's an array, observe one by one to define setter, recursively listen to all Object keys
Data that has been touched has __ob__ on it

It seems like there might be a memory explosion problem. If you pass in a super large data object, just the getter/setter would take up a lot of space, but in actual scenarios it's hard to encounter a whole page being a big lump of data. For data-heavy scenarios, generally the data layer is extracted, and a dedicated state management mechanism splits the data, making it hard for memory explosion to occur

The most critical parts are these, to make it run we also need Compiler & Directive to compile and transform source code, structure as follows:

       Extract relationships
       Create View             Listen for changes
tpl —— Compiler —— Subject & Observer & Manager
           |
       Directive

Input tpl & data, output view, and establish data-view connection

II. Framework

Code that speaks for itself:

// Extract data-view relationships from template
var Compiler = function(tpl) {
    // Template compilation, transform to dom operations
};
var Directive = function(directive) {
    // Work with compiler, handle more complex DOM operations (repeat, on, bind), establish data-view association
};

// Listen for data changes, implement data-view binding relationships
var Subject = function() {
    // Subject
    this.obs = [];
};
var Observer = function(updateFn) {
    // Observer
};
var Manager = function(data) {
    // Define setter, manage Subject and Observer
};

Input looks like this:

<!-- Template -->
<div id="demo" v-cloak>
    <h1 v-bind:style="{ items.length ? 'border-bottom: 1px solid #ddd' : 'border: none' }">
    {{title}}
    </h1>
    <p v-if="!items.length">empty</p>
    <ul v-for="item in items">
        <li v-on:click="item.a[1].a[1].a.a++" style="background-color: #2b80b6">
            {{item.a[1].a[1].a.a}}
        </li>
    </ul>
</div>

// Data
var data = {
    title: 'list',
    items: [{a: [0, {a: [1, {a: {a: 1}}]}]}]
};
var v = new V({
    el: '#demo',
    data: data,
    created: function() {
      console.log(data);
    }
});

It seems there's still a long way to go, endless at first glance, let's refine it a bit

Compiler

// Extract data-view relationships from template
var Compiler = function(tpl) {
    // Template compilation, transform to dom operations
};

Compiler parses template, should output structure information, so define NodeMeta:

Compiler.NodeMeta = function(tag) {
    // Compiler's output DOM structure meta format
    // {
    //   tag: 'ol',
    //   children: [NodeMeta, NodeMeta...],
    //   props: [{key: 'id', value: 'ol'}...],
    //   directives: [{name: 'v-if', value: '!items.length'}],
    //   // Text node situation is more complex, not considering non-data-bound text and text not wrapped separately here
    //   textContent: 'item.text',
    //   // for directive will extend child scope
    //   extraScope: {'item': Object}
    // }
};

Core task is to parse template:

Compiler.prototype.parse = function() {
    // Extract tag name, create meta tree
    this.nodeMeta = this.matchTag();
};
Compiler.prototype.matchTag = function() {
    var rootMeta;
    //...Create structure tree
    return rootMeta;
};

After getting structure meta tree, it's time to create View:

Compiler.prototype.render = function(vm) {
    this.vm = vm;
    var render = function(nodeMeta) {
        //...Parse directives
        //...Create nodes
        //...Set attr, bind event handler, implement view-data response
    };
    var node = render(this.nodeMeta);
    // Use created node to replace template element
    vm.el.parentNode.replaceChild(node, vm.el);
};

Directive

Directives assist the compiler, responsible for handling some complex things:

var Directive = function(directive) {
    // Work with compiler, handle more complex DOM operations (repeat, on, bind), establish data-view association
};

Since it assists the compiler, it should also be responsible for creating View:

Directive.prototype.render = function(vm, node, nodeMeta, compilerRender) {
    switch (dir) {
        case 'for':
            //...Create multiple groups of nodes
            break;
        case 'if':
            //...Conditional creation
            break;
        case 'on':
            //...Bind event handler, establish view-data association
            break;
        case 'bind':
            //...Simple expression evaluation
            break;
        case 'cloak':
            //...No need to handle
            break;
        default:
            console.error('unknown directive: ' + key);
    }
};

All directives by default need to establish data-view association, events are special because handler might change data, which is equivalent to view change, data needs to follow, so event directives also need to complete view-data association

Compared to compiler, the complex part of directives is that they need to do expression evaluation and create handler:

Directive.getParams = function(vm, extraScope) {
};
Directive.createFn = function(vm, fnStr, extraScope) {
    var handler = function() {
        var args = [].slice.call(arguments);
        var param = Directive.getParams(vm, extraScope);
        var fnBody;
        //...Fill in all parts of function definition
        var fn = eval(fnBody);
        return fn.apply(vm, args.concat(param[1]));
    };
    return handler;
};

Subject & Observer

Subject (newspaper) in publish-subscribe pattern (observer pattern):

// Listen for data changes, implement data-view binding relationships
var Subject = function() {
    // Subject
    this.obs = [];
};

Only need to implement a few basic interfaces:

Subject.prototype.add = function(ob) {
    this.obs.push(ob);
};
Subject.prototype.remove = function(ob) {
    var index = this.obs.indexOf(ob);
    if (index !== -1) this.obs.splice(index, 1);
};
Subject.prototype.notify = function(lastValue, newValue) {
    this.obs.length && this.obs.forEach(function(ob) {
        ob.update();
    });
};

Parameter ob is the observer (person subscribing to newspaper) Observer instance, defined as follows:

var Observer = function(updateFn) {
    // Observer
    if (typeof updateFn === 'function') {
        this.update = updateFn;
    }
};
Observer.prototype.update = function() {};

When newspaper publishes information, notify all subscribers. Here observer pattern is used to maintain data-view one-to-many relationship

Manager

Manager is what connects generic observer pattern with actual scenarios, mainly responsible for defining setter:

var Manager = function(data) {
    this.data = data;
    this.dep = new Subject();
    // Define setter, manage Subject and Observer
    this.observe(data);
};
Manager.prototype.observe = function(obj) {
};
Manager.prototype.observeArray = function(arr) {
};

Recursively define setter, embed data change hook

III. Specific Implementation

Roughly divided into 3 parts:

  • Listen for data changes

  • Parse template, find connection between view and data

  • Create View and establish data-view, view-data relationships

  • Entry point

Listening for data changes is very easy, done in minutes; parsing template is important but not critical part, complexity is average; creating View and establishing data binding is the most critical part, also most complex; of course, finally we need to open an entry point

Listen for Data Changes

Subject & Observer part is right above, doesn't need to be changed, generic part is easy to handle

So mainly Manager defining setter part:

var Manager = function(data) {
    this.data = data;
    this.dep = new Subject();
    // Define setter, manage Subject and Observer
    this.observe(data);
};

Hold a Subject instance, subsequently add Observer to it, nothing much to say. Deeply defining setter is relatively easy to implement:

Manager.prototype.observe = function(obj) {
    for (var key in obj) {
        if (obj.hasOwnProperty(key)) {
            // Listen to children first
            if (typeof obj[key] === 'object') {
                if (Array.isArray(obj[key])) {
                    self.observeArray(obj[key]);
                }
                else {
                    self.observe(obj[key]);
                }
            }
        }
        // Define setter
        void function() {
            // Isolate a value
            var value = obj[key];
            Object.defineProperty(obj, key, {
                set: function(newValue) {
                    if (typeof newValue === 'object' || newValue !== value) {
                        console.log('data changed', value, newValue);
                        value = newValue;
                        // setter notifies change
                        obj.__ob__.dep.notify(value, newValue);
                    }
                    return newValue;
                }
            });
        }();
    }
};

For arrays, iterate and observe:

Manager.prototype.observeArray = function(arr) {
    arr.forEach(function(data) {
        if (typeof data === 'object') {
            if (Array.isArray(data)) {
                self.observeArray(data);
            }
            else {
                self.observe(data);
            }
        }
    });
};

P.S. Implementation here is not quite the same as Vue, for simplicity

Parse Template

Read template, find relationship between data and view

Use n regular expressions to extract needed parts:

var Compiler = function(tpl) {
    // Template compilation, transform to dom operations
    this.tpl = tpl.trim();

    this.REGEX = {
        tag: /<([^>/\s]+)[^<>]*>/gm,
        attrTag: /<([^>/\s]+)\s+([^>]+)>/gm,
        text: /<([^>\s]+).*>\s*{{([^}]*)}}\s*<\/\1>/gm,
        attr: /(?:([^="\s]+)(?:="([^"]+)")?)/gm
    };
};

P.S. Honestly, this part is still quite strenuous (master said it needs 1 year... haha)

Only simple template parsing is implemented here, lazily not supporting bare text nodes, source code is relatively long, only key parts given here:

Compiler.prototype.matchTag = function() {
    var openTagStack = [], peak;
    while ((tmp = this.REGEX.tag.exec(str)) !== null) {
        newMeta = new NodeMeta(tag);
        if (lastEndIndex > 0) {
            // Construct nodeMeta tree
            peak = openTagStack[openTagStack.length - 1];
            closeTagRegex = new RegExp('</' + peak + '>', 'm');
            skipMatch = str.substring(lastEndIndex, tmp.index);
            newMeta.parent = meta;
            // Default enter next level
            if (meta.children.length > 0){
                meta = meta.children[meta.children.length - 1];
            }
            // Pop one level per match
            if (closeTagRegex.test(skipMatch)) {
                openTagStack.pop();
                meta = meta.parent;
            }
            meta.children.push(newMeta);
        }
        openTagStack.push(tag);

        // Fill props & directives
        attrs = this.matchAttr(thisMatch);

        // Fill textContent
    }

    return rootMeta;
};

Now we have structure meta tree, next we take this configuration data to create View:

Create View and Establish Data Binding

Source code is too long, simple process as follows:

Compiler.prototype.render = function(vm) {
    var render = function(nodeMeta) {
        // tag
        var node = document.createElement(nodeMeta.tag);
        // props
        node.setAttribute(prop.key, prop.value);
        // textContent
        var fn = function() {
            var exp = Directive.createFn(vm, nodeMeta.textContent, nodeMeta.extraScope);
            var text = exp();
            node.innerText = text || "";
        }
        //!!! Implement data-view binding
        vm.data.__ob__.dep.add(new Observer(fn));
        // directives
        var directive = nodeMeta.directives[i];
        var d = new Directive(directive);
        // Directive render returns false means no need to render node & children
        // Returns true means children are already rendered
        var renderOrNot = d.render(vm, node, nodeMeta, render);
        // children
        var childNode = render(meta);
        childNode && node.appendChild(childNode);

        return node;
    };
    // Create View, replace template element
    var node = render(this.nodeMeta);
    vm.el.parentNode.replaceChild(node, vm.el);
};

Add observer to data during first render, subsequent data changes can be captured, thus implementing data-view binding

Important auxiliary Directive is as follows, too long, here taking on directive as example:

Directive.prototype.render = function(vm, node, nodeMeta, compilerRender) {
    var dir = this.REGEX.directive.exec(key)[1];
    var event, handler, exp, prop, propValue;
    switch (dir) {
        case 'on':
            event = this.REGEX.key.exec(key);
            if (event) {
                event = event[1];
                handler = Directive.createFn(vm, value, nodeMeta.extraScope);
                node.addEventListener(event, function() {
                    handler();
                });
            }
            break;
    }
};

Create handler, and addEventListener, under the condition of defining setter to implement data change listening, view-data binding is natural, no extra handling needed, because as long as handler execution changes data, it will trigger setter, then notify the data-view relationship established when creating View, updating View

P.S. The part creating handler is quite rough,拼 a new Function() definition, then eval to get it out, performance explosion, but this step can be done during compilation phase, no problem

Entry Point

At this point it's almost complete, open entry point, connect the flow:

// Entry point
var V = function(config) {
    // Basic configuration data (view, data)
    this.el = el;
    this.data = config.data;
    this.methods = config.methods;
    // Lifecycle hook
    var LIFE_CYCLES = ['created'];

    this._init();
};
V.prototype._init = function() {
    // Listen for data changes
    this._observe();
    console.log(this.data);
    // Parse relationships, transform to DOM operations
    this.compiler = this._render();
};
V.prototype._render = function() {
    var c = new Compiler(this.el.outerHTML);
    c.parse();
    c.render(this);
    return c;
};
V.prototype._observe = function() {
    this.__ob__ = new Manager(this.data);
};

IV. Online Demo

Demo address: http://ayqy.net/temp/data-binding/vue/index.html

P.S. All source code is in the source code, comments are very clear

Final Words

Life always has gray parts, can't see light, can't find direction. But fortunately the road is under our feet, no original intention, no future, it doesn't matter, just being on the road is fine, keep up

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment