youngwind / blog

梁少峰的个人博客
4.66k stars 384 forks source link

vue源码学习系列之九:组件化原理探索(静态props) #92

Open youngwind opened 8 years ago

youngwind commented 8 years ago

顾名思义

细心的读者可能已经发现,本篇的标题跟以往相比,去掉了早期两个字,这其实代表着学习方法的转换。 之前之所以要从早期源码开始看起,实在是因为面对庞大而成熟的vue源码无从下手。经过这一段时间以来的学习与探索,我已经渐渐地搞清楚了vue大部分基础功能的实现原理。当我在思考组件化原理的时候忽然发现一个问题: 我花了1个多月的时间,才前进了200多个commit,而目前vue的总commit数几近2000。如果我继续采取这种逐commit、小步伐前进的方法,那么我将花费至少1年的时间才能学习完vue的源码,这样效率实在太低。而且,我们都知道,在编写代码的过程中,为了实现同一个目标,前后我们可能重构过很多次,细究每一次的重构将会降低学习的效率。 所以此时,最佳的学习方法应该是直接跳到成熟版本的vue,直接从那里开始学习。比如,我就是从1.0.26版本开始探索组件化实现的原理。 这就是题目变更的由来。

目标

考虑以下的例子

<div id="app">
    <my-component message="hello liangshaofeng!"></my-component>
    <my-component message="hello Vue!"></my-component>
</div>
import Bue from 'Bue';

var MyComponent = Bue.extend({
    template: '<p>{{message}}</p>'
});

Bue.component('my-component', MyComponent);

const app = new Bue({
    el: '#app'
});

今天我们只考虑最简单的情况:如何将组件正确地解析,渲染,挂载到DOM当中。

思路

仔细观察上面的js代码,我们发现vue实例化一个组件可以分成三步。

  1. 使用extend定义(构造)组件MyComponent
  2. 使用component注册组件
  3. 在初始化app实例的过程中,渲染组件

我们一步步来分析。

定义组件

组件与之前说过的子实例 #90 有一个共同的地方:都应该把它当做是一个vue实例来对待。 但是,组件与子实例的不同之处在于:组件只拥有自己的数据,不能访问父实例的数据,所以对待组件又不能完全等价于子实例。 综上:自然而然我们就能想到这样一个方法: 搞一个组件构造函数VueComponent,继承于Vue,这样VueComponent就能调用到Vue的诸多方法,比如_init等等。

另一个问题,组件自己有options(构造的时候传进来的),Vue本身也有options(主要是一些directive的声明),如何处理两者的关系? → 将组件的options与Vue本身的options合并,重新覆盖组件的options,并且注入到VueComponent的自定义属性当中。 为什么要这么做?VueComponent和Vue都有自己的options,如果不合并过来的话,根据js原型链的查找方式,VueComponent的options会遮住Vue的options,导致组件没法访问到Vue的options。 (为什么组件要访问Vue的options?因为对组件DOM结构进行解析的时候也需要解析里面包含的各种指令,这需要用到Vue的options当中声明的指令) 代码如下:

/**
     * 组件构造器
     * 返回组件构造函数
     * @param extendOptions {Object} 组件参数
     * @returns {BueComponent}
     */
    Bue.extend = function (extendOptions) {
        let Super = this;
        extendOptions = extendOptions || {};
        let Sub = createClass();
        Sub.prototype = Object.create(Super.prototype);
        Sub.prototype.constructor = Sub;
        // 此处的mergeOptions就是简单的Object.assign
        Sub.options = _.mergeOptions(Super.options, extendOptions);
        return Sub;
    };

    /**
     * 构造组件构造函数本身
     * @returns {Function}
     */
    function createClass() {
        return new Function('return function BueComponent(options){ this._init(options)}')(); 
    }

这里有个值得注意的地方: 为什么需要createClass函数new Function?为什么不能直接只定义一个BueComponent构造函数,然后每次构造组件的时候都用它呢?就像只有一个Vue构造函数一样。 答案:因为我们将options放在了BueComponent的自定义属性当中,如果我们只用一个BueComponent的话,后面声明的组件的options将会覆盖前面声明组件的options。这显然不是我们想要的。

为了更好地理解组件的构造结果,可以看下图。 组件构造 注:代码经过babel处理,所以看起来有点凌乱。

注册组件

上面讲完了构造组件,现在我们来看看注册组件。 注册组件其实就是声明组件与自定义标签的对应关系,比如声明MyComponent组件对应于标签,这样程序解析到才知道:“哦,原来它就是MyComponent组件。” 为什么要有注册组件这一步呢? 如果之前一直用React的人应该跟我有同样的疑问。因为在React中构造完组件之后,就可以直接在jsx中使用了,并没有注册这一个步骤。如下所示。

var HelloMessage = React.createClass({
  render: function() {
    return <div>Hello {this.props.name}</div>;
  }
});
// 你看,React不需要将HellMessage注册成<hello-message>
ReactDOM.render(<HelloMessage name="John" />, mountNode);

个人热为可能是基于以下的考虑: 与React相比,Vue的侵入性要小得多。Vue需要直接应用在普通的DOM结构上,然而,在这些普通的DOM结构当中,可能之前就已经存在自定义标签了,Vue提供的注册功能正好可以解决这个命名冲突的问题。 也就是说,假如没有注册功能,直接把组件MyComponent对应成标签,要是万一之前的DOM结构里面已经有这样一个自定义的标签,也叫mycomponent,这不就懵逼了吗?

所以,注册功能只需要完成组件与标签名的映射就可以了。相关的代码如下所示:

/**
     * 注册组件
     * vue的组件使用方式与React不同。React构建出来的组件名可以直接在jsx中使用
     * 但是vue不是。vue的组件在构建之后还需要注册与之相对应的DOM标签
     * @param id {String}, 比如 'my-component'
     * @param definition {BueComponent} 比如 MyComponent
     * @returns {*}
     */
    Bue.component = function (id, definition) {
        this.options.components[id] = definition;
        return definition;
    };

注册结果如下图所示。 组件注册

渲染组件

这一步比较复杂,让我们将它细分为以下三个步骤。

  1. 识别组件
  2. 组件指令化
  3. 渲染、挂载组件

    识别组件

在初始化app这个Vue实例的过程中,当DOM遍历解析到的时候,由于我们在上面已经进行了组件注册,所以我们知道那是一个组件,需要特殊处理。 相关代码如下:

/**
 * 渲染节点
 * @param node {Element}
 * @private
 */
exports._compileElement = function (node) {
    // 判断节点是否是组件
    // 这个函数具体做什么,下面会讲到
    if (this._checkComponentDirs(node)) {
        return;
    }
    // ....
};

组件指令化

在我们识别出标签是一个组件之后,该如何对待这个组件呢? 文章开头就讲到过,组件与子实例是类似的,我们当初处理“v-if”条件渲染的时候,就是检查到“v-if”是一个特殊的指令,然后就将“v-if”里面的DOM结构当成Vue实例来处理。 这里,我们可以采用类似的方法,引入组件指令的概念,把当做一个组件指令。 相关代码如下。

/**
 * 判断节点是否是组件指令,如 <my-component></my-component>
 * 如果是,则构建组件指令
 * @param node {Element}
 * @returns {boolean}
 * @private
 */
exports._checkComponentDirs = function (node) {
    let tagName = node.tagName.toLowerCase();
    if (this.$options.components[tagName]) {
        let dirs = this._directives;
        dirs.push(
            new Directive('component', node, this, {
                expression: tagName
            })
        );
        return true;
    }
};

下面上图证明组件真的被当成了指令来处理。 component-directive

既然把组件当成是一个组件指令,那么,剩下的就是如何编写指令的bind方法了。我们将在bind方法中完成组件的渲染与挂载。

渲染、挂载组件

要想渲染组件,有两个关键点。

  1. 如何处理组件的模板?也就是template参数:<p>{{message}}</p>
  2. 如何处理组件的props?也就是message="hello, liangshaofeng!"message="hello, Vue!"

    模板处理

组件options当中的template是一个字符串,代表着一个DOM结构。如何将这个字符串“

{{message}}

”转化成对应的DOM结构呢?在不考虑兼容性的情况下,我们直接采用DOMParser,代码如下:

// compiler/transclude.js
/**
 * 将template模板转化成DOM结构,
 * 举例: '<p>{{user.name}}</p>'  -> 对应的DOM结构
 * @param el {Element} 原有的DOM结构
 * @param options {Object}
 * @returns {DOM}
 */
module.exports = function (el, options) {
    let tpl = options.template;
    if (tpl) {
        var parser = new DOMParser();
        var doc = parser.parseFromString(tpl, 'text/html');
        // 此处生成的doc是一个包含html和body标签的HTMLDocument
        // 想要的DOM结构被包在body标签里面
        // 所以需要进去body标签找出来
        return doc.querySelector('body').firstChild;
    } else {
        return el;
    }
};

props处理

组件是有自己的数据属性的,这跟子实例不同。子实例不仅能访问自己的数据,还能访问父实例的数据。但是组件只能访问自己的数据,不能访问父实例/父组件的数据,组件想要访问的数据必须显式地通过props传递给它,像这样:<my-component message="hello liangshaofeng!"></my-component>。这是实现组件化的通用手法,React也是如此。 所以,我们需要把message解析出来,并且将message存储到组件实例的$data当中,这样组件里面的{{message}}才能解析成"hello liangshaofeng!"。 这一部分的代码如下所示:

/**
 * 初始化组件的props,将props解析并且填充到$data中去
 * @private
 */
exports._initProps = function () {
    let isComponent = this.$options.isComponent;
    if (!isComponent) return;
    let el = this.$options.el;
    let attrs = Array.from(el.attributes);
    attrs.forEach((attr) => {
        let attrName = attr.name;
        let attrValue = attr.value;
        this.$data[attrName] = attrValue;
    });
};

bind方法

在处理完模板解析和props解析之后,我们终于来到了最后一步,编写组件指令的bind方法,真正地初始化组件实例。代码如下。

// component.js
module.exports = {
    bind: function () {
        // 判断该组件是否已经被挂载
        if (!this.el.__vue__) {
            // 这里的anchor作为锚点,是之前常用的方法了
            this.anchor = document.createComment(`${config.prefix}component`);
            _.replace(this.el, this.anchor);
            this.setComponent(this.expression);
        }
    },

    update: function () {
        // update方法暂时不做任何事情
    },

    /**
     * @param value {String} 组件标签名, 如 "my-component"
     */
    setComponent: function (value) {
        if (value) {
            // 这里的Component就是那个带有options自定义属性的BueComponent构造函数啊!
            this.Component = this.vm.$options.components[value];
            this.ComponentName = value;
            this.mountComponent();
        }
    },

    /**
     * 构建、挂载组件实例
     */
    mountComponent: function () {
        let newComponent = this.build();
        // 就是在这里将组件生成的DOM结构插入到真实DOM中
        newComponent.$before(this.anchor);
    },

    /**
     * 构建组件实例
     * @returns {BueComponent}
     */
    build: function () {
        if (this.Component) {
            let options = {
                name: this.ComponentName,   // "my-component"
                el: this.el.cloneNode(), 
                parent: this.vm,
                isComponent: true
            };
            // 实例化组件
            let child = new this.Component(options);
            return child;
        }
    }
};

实现效果

至此,我们已经实现了最简单的vue组件化了,完整的代码在这里,效果如下图所示。 demo

遗留问题

本篇所实现的只是最为简单的组件化。还有许多问题没有考虑到,比如:

  1. 局部注册与全局注册的区别。
  2. 动态props的传递
  3. 父子组件之间的嵌套与通信
  4. ......

====End====