注意:这里对 Vue 的源码做了删减,有需要请看源码。

从一个🌰开始,代码如下:

<div id="app"></div>
<script>
    var vm = new Vue({
        el: '#app',
        template: 
            '<div>' +
                '{{message}}' +
                '<input v-model="message">' +
                '<i v-on:click="getMessage" v-show="message">{{message + "s"}}</i>' +
            '</div>',
        data: function () {
            return {
                message: 'a message yo',
            }
        },
        methods: {
            getMessage: function () {
                console.info(this.message);
            }
        }
    });
</script>

this.$mount(options.el); 以一个 DOM 对象为参数开始编译过程,然后调用 Vue.prototype._compile 具体代码如下:

Vue.prototype._compile = function (el) {
    var options = this.$options;
    // 缓存 DOM Tree 中的 DOM 对象,最后一次性替换,为什么?
    var original = el;
    /**
     * 把 template 转换成 DOM 对象并赋值给 el
     * 后续的操作都是针对该转置的 DOM 对象(没有 append 到 DOM Tree 中)
     */
    el = transclude(el, options);

    // 编译根节点返回 linkFn
    var rootLinker = compileRoot(el, options, contextOptions);
    // 执行根节点的 linkFn
    var rootUnlinkFn = rootLinker(this, el, this._scope);
    /**
     * 对根节点的操作可以先跳过,
     * 先讲解对其内容节点的操作,这也是编译过程的入口,
     * 这里有两个函数的连续调用,compile 和 compile 返回的 linkFn
     */
    var contentUnlinkFn = compile(el, options)(this, el);

    // finally replace original 输出到 DOM Tree 中
    if (options.replace) {
        replace(original, el);
    }
    this._isCompiled = true;
    this._callHook('compiled');
};

_compile 描述了编译的总的流程:缓存 DOM Tree 中的 DOM 对象,把转置 template 得到 DOM 对象赋值给 el,先对 el 进行 compile 和 link,然后对 el 的内容节点进行 compile 返回 linkFn,以 vm 对象为参数调用 linkFn,到这里响应的数据绑定(双向绑定)就已经完成了,最后把 el replace 到 DOM Tree 中。
这里我们把总流程分解成 transclude、compile、link 三个阶段来具体讲解。

转置 transclude

transclude 是转置的意思,主要是用来把模板转换成 DOM 对象并返回,后面 el 指的都是该 DOM 对象,具体代码如下:

function transclude(el, options) {
    // ......
    if (options) {
        if (options._asComponent && !options.template) {
            options.template = '<slot></slot>';
        }
        if (options.template) {
            options._content = extractContent(el);
            // 直接到这里
            el = transcludeTemplate(el, options);
        }
    }
    return el;
}

function transcludeTemplate(el, options) {
    var template = options.template;
    var frag = parseTemplate(template, true);
    if (frag) {
        // 这里使用的是 frag 的第一个子节点
        var replacer = frag.firstChild;
        // 默认情况下 replace 为 true
        if (options.replace) {
            if (
                frag.childNodes.length > 1 ||
                /**
                 * 如果有多个子节点,直接返回 frag,el 上的属性就会丢失
                 * 注意使用不当会导致 fragment instance 错误
                 * 还有很多条件这里先忽略
                 */
                ) {
                return frag;
            } else {
                options._replacerAttrs = extractAttrs(replacer);
                // merge el 的属性到 replacer 上
                mergeAttrs(el, replacer);
                return replacer;
            }
        }
    }
}

function parseTemplate(template, shouldClone, raw) {
    var node, frag;
    if (typeof template === 'string') {
        frag = stringToFragment(template, raw);
    }
    return frag;
}

/**
 * 把 template 字符串通过 innerHTML 方法转换成 DOM 对象
 * 并 append 到 frag 对象
 */
function stringToFragment(templateString, raw) {
    var frag = document.createDocumentFragment();
    var node = document.createElement('div');

    node.innerHTML = prefix + templateString + suffix;
    var child;
    while (child = node.firstChild) {
        frag.appendChild(child);
    }
    return frag;
}

转置的过程比较简单,这部分完了之后得到了编译的 DOM 对象(el),我们切回主流程。从 compile(el, options)(this, el); 开始编译阶段的主要逻辑。

编译阶段 compile

编译阶段的入口函数是 compile 代码如下:

function compile(el, options, partial) {
    var nodeLinkFn = compileNode(el, options) : null;
    var childLinkFn = compileNodeList(el.childNodes, options) : null;

    return function compositeLinkFn(vm, el, host, scope, frag) {
        var dirs = linkAndCapture(function compositeLinkCapturer() {
            if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag);
            if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag);
        }, vm);
        return makeUnlinkFn(vm, dirs);
    };
}

调用 compileNode 和 compileNodeList 得到两个 link 函数,返回 compositeLinkFn,在链接阶段调用该 compositeLinkFn 时可以调用得到的两个 link 函数,该函数描述了编译阶段的总流程:返回两个链接函数供下一个阶段(链接阶段)调用。
compileNodeList 其实是一个迭代函数,迭代调用 compileNode,所以该阶段(编译阶段)的关键是 compileNode 函数,compileNode 根据 nodeType 的值(1 和 3)分别调用 compileElement 和 compileTextNode,其他情况返回 null,所以处理的只有两种类型的 node。
因为这两个方法的意图(主要来获取指令的 descriptor)一样,这里就只分析 compileElement 方法了,代码如下:

function compileElement(el, options) {
    var linkFn;
    var hasAttrs = el.hasAttributes();
    var attrs = hasAttrs && toArray(el.attributes);
    // ....
    linkFn = compileDirectives(attrs, options);
    return linkFn;
}    

function compileDirectives(attrs, options) {
    var i = attrs.length;
    // 注意这个数组
    var dirs = [];
    var attr, name, value, rawName, rawValue, 
         dirName, arg, modifiers, dirDef, tokens, matched;

   // 遍历 DOM 属性,匹配 Vue 的内置指令
   while (i--) {
        attr = attrs[i];
        name = rawName = attr.name;
        value = rawValue = attr.value;
        tokens = parseText(value);
        //......
        // 这里用 v-on 举例
        if (onRE.test(name)) {
            arg = name.replace(onRE, '');
            // 匹配到 Vue 的内置指令后执行 pushDir
            pushDir('on', directives.on);
        } else
        //......
    }

    function pushDir(dirName, def, interpTokens) {
        // def 是内置指令的引用
        // dirs 中 push 一个能引用到内置指令的对象(指令 descriptor)
        dirs.push({
            name: dirName,
            attr: rawName,
            raw: rawValue,
            def: def,
            // ....
        });
    }
    if (dirs.length) {
        return makeNodeLinkFn(dirs);
    }
}

function makeNodeLinkFn(directives) {
    return function nodeLinkFn(vm, el, host, scope, frag) {
        var i = directives.length;
        while (i--) {
            vm._bindDir(directives[i], el, host, scope, frag);
        }
    };
}

删减了代码,这样跟下来就知道了 compile 阶段其实就是根据 DOM 对象的属性匹配 Vue 的内置指令(指令单例),匹配到就创建一个能引用到指令单例的对象(指令 descriptor)并 push 到 dirs 数组中,返回 nodeLinkFn(compileTextNode 会创建 token 的数组返回 textNodeLinkFn)即返回的这些 link 函数可以访问到指令 descriptor。编译是一个迭代的过程,最终返回的 linkFn 是一个树形结构,如图: enter image description here

链接

compile(el, options)(this, el) 编译阶段完成后接着调用返回的 linkFn,把 Vue 对象实例传递进去进行链接阶段。 首先调用 compositeLinkFn,通过 linkAndCapture 调用 nodeLinkFn 和 childLinkFn 获取指令 descriptor,然后通过 Vue.prototype. _bindDir 往 vm 的 _directives 数组中 push 通过 new Directive(descriptor, this, .....) 创建的指令对象。在 linkAndCapture 函数中接着获取到 vm._directives 中的指令对象,按优先级 sort 后执行指令对象的 _bind 方法,代码如下:

Directive.prototype._bind = function() {
    var descriptor = this.descriptor;
    // 指令单例
    var def = descriptor.def;
    if (typeof def === 'function') {
        this.update = def;
    } else {
        // extend 指令单例对象的属性
        extend(this, def);
    }

    if (this.bind) {
        // 执行指令单例的 bind 方法
        this.bind();
    }
    this._bound = true;

    var dir = this;
    if (this.update) {
        this._update = function(val, oldVal) {
            if (!dir._locked) {
                dir.update(val, oldVal);
            }
        };
    } else {
        this._update = noop$1;
    }

    var watcher = this._watcher = new Watcher(this.vm, this.expression, 
        this._update,
        {
            filters: this.filters,
            twoWay: this.twoWay,
            deep: this.deep,
            preProcess: preProcess,
            postProcess: postProcess,
            scope: this._scope
        });

    if (this.afterBind) {
        this.afterBind();
    } else if (this.update) {
        this.update(watcher.value);
    }
};

在 _bind 方法中完善了指令对象的属性(bind、update),执行指令单例对象的 bind(绑定 DOM 事件),创建 watcher 对象,执行 this.update(watcher.value) watcher.value 映射 vm 对象的属性值,如{{message}}可以映射到 message = 'a message yo',执行指令单例的 update 更新 DOM。
这里如果直接把数据通过指令单例输出的 DOM 就不会有双向绑定的效果了,这里有个 watcher 承担了 vm 对象和指令对象的通信工作,在 vm 变化时通知指令对象,指令对象的 DOM 事件侦听器通过 watcher 通知 vm。Watcher 的代码如下:

function Watcher(vm, expOrFn, cb, options) {
    this.vm = vm;
    vm._watchers.push(this);
    // watch 的访问器属性名
    this.expression = expOrFn;
    // 访问器属性变化通知到 watcher 时会调用
    this.cb = cb;
    /**
     * Parse an expression into re-written getter/setters.
     * 映射访问器属性的 get 和 set 方法
     */
    var res = parseExpression(expOrFn, this.twoWay);
    this.getter = res.get;
    this.setter = res.set;

    /**
     * 该方法调用完后 watcher 对象就 push 到了 dep.subs 中 
     */
    this.value = this.get();
    this.queued = this.shallow = false;
}

/**
 * Build a getter function. Requires eval.
 * 如 body = 'scope.message'
 * getter.call(vm, vm) 返回 vm.message,
 * 因为 message 是访问器属性,会调用 get 方法
 */
function makeGetterFn(body) {
    return new Function('scope', 'return ' + body + ';');
}

来看下 Watcher.prototype.get 是如何把 watcher 对象添加到消息队列(dep 的 subs)中的,代码如下:

Watcher.prototype.get = function() {
    // beforeGet 方法的代码
    // 把 Dep.target 执行 watcher 对象
    Dep.target = this;
    var scope = this.scope || this.vm;
    var value;
    // 映射到 vm 的访问器属性并调 get 方法
    value = this.getter.call(scope, scope);

    this.afterGet();
    // Dep.target = null;
    return value;
};

这段代码做的就是把 watcher 赋值给 Dep.target 并掉起 vm 相应的访问器属性的 get 方法。后续怎么 push 到 dep.subs 中就很简单了可以看下 function defineReactive(obj, key, val) 方法的代码,这里略过。该方法还获取到了 vm 对象的属性值并返回,将作为指令对象的 update 方法的参数去更新 DOM。
到这里只是完成了 vm 对象到 DOM 的过程是单向的绑定过程(单向绑定),那如何通过 DOM 如何改变 vm 对象?其实就在单向绑定的基础上给可输入元素(input、textare等)添加了change(input)事件,来动态修改 vm,具体如何绑定事件侦听器可以看下 v-model 实现这里略过。
通过指令的事件侦听器调用 watcher 的 setter 映射 vm 的访问器属性的 set 方法,执行 dep.notify() 遍历调用 dep.subs 中的 watcher 对象的 update 方法把 watcher push 到一个队列,在 nextTick 时调用 Watcher.prototype.run 方法,该方法中会执行 this.cb.call(this.vm, value, oldValue);this.cb就是

this._update = function (val, oldVal) {
    if (!dir._locked) {
        dir.update(val, oldVal);
    }
};

会调用指令的 update 方法去更新 DOM(nextTick 的本质就是 setTimeout)。

编译阶段的意图比较简单,就是遍历 DOM 属性匹配指令单例并创建指令 descriptor,然后返回可以访问到指令 descriptor 的 linkFn,注意 linkFn 的结构比较复杂,是一个树形的结构。
与编译阶段相比,链接阶段的意图则复杂的多的多,先是遍历执行编译阶段得到的 linkFn,通过指令 descriptor 创建指令对象(全部 push 到 vm._directives 数组中),然后取到 _directives 数组把指令对象按优先级排序后执行每个指令对象的 _bind 方法,该 _bind 方法中做了主要做了三件事:

  • extend 指令单例,执行 extend 得到的 bind 方法,进行 DOM 事件绑定;
  • 创建 watcher,watcher 对象包含 getter 和 setter 方法映射对应的访问器属性的 get 和 set,同时 watcher 也被 push 到 dep 的消息队列中;
  • 把数据(vm 的属性)通过指令对象的 update 方法(extend 指令单例得来)绑定到 DOM。

编译阶段的主要流程见下图: enter image description here 到这里就完整的实现了双向绑定。