Open youngwind opened 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实例化一个组件可以分成三步。
我们一步步来分析。
组件与之前说过的子实例 #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; };
注册结果如下图所示。
这一步比较复杂,让我们将它细分为以下三个步骤。
在初始化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; } };
下面上图证明组件真的被当成了指令来处理。
既然把组件当成是一个组件指令,那么,剩下的就是如何编写指令的bind方法了。我们将在bind方法中完成组件的渲染与挂载。
要想渲染组件,有两个关键点。
<p>{{message}}</p>
message="hello, liangshaofeng!"
message="hello, Vue!"
组件options当中的template是一个字符串,代表着一个DOM结构。如何将这个字符串“
{{message}}
// 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传递给它,像这样:<my-component message="hello liangshaofeng!"></my-component>。这是实现组件化的通用手法,React也是如此。 所以,我们需要把message解析出来,并且将message存储到组件实例的$data当中,这样组件里面的{{message}}才能解析成"hello liangshaofeng!"。 这一部分的代码如下所示:
<my-component message="hello liangshaofeng!"></my-component>
/** * 初始化组件的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; }); };
在处理完模板解析和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组件化了,完整的代码在这里,效果如下图所示。
本篇所实现的只是最为简单的组件化。还有许多问题没有考虑到,比如:
====End====
顾名思义
细心的读者可能已经发现,本篇的标题跟以往相比,去掉了
早期
两个字,这其实代表着学习方法的转换。 之前之所以要从早期源码开始看起,实在是因为面对庞大而成熟的vue源码无从下手。经过这一段时间以来的学习与探索,我已经渐渐地搞清楚了vue大部分基础功能的实现原理。当我在思考组件化原理的时候忽然发现一个问题: 我花了1个多月的时间,才前进了200多个commit,而目前vue的总commit数几近2000。如果我继续采取这种逐commit、小步伐前进的方法,那么我将花费至少1年的时间才能学习完vue的源码,这样效率实在太低。而且,我们都知道,在编写代码的过程中,为了实现同一个目标,前后我们可能重构过很多次,细究每一次的重构将会降低学习的效率。 所以此时,最佳的学习方法应该是直接跳到成熟版本的vue,直接从那里开始学习。比如,我就是从1.0.26版本开始探索组件化实现的原理。 这就是题目变更的由来。目标
考虑以下的例子
今天我们只考虑最简单的情况:如何将组件正确地解析,渲染,挂载到DOM当中。
思路
仔细观察上面的js代码,我们发现vue实例化一个组件可以分成三步。
我们一步步来分析。
定义组件
组件与之前说过的子实例 #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当中声明的指令) 代码如下:
这里有个值得注意的地方: 为什么需要createClass函数new Function?为什么不能直接只定义一个BueComponent构造函数,然后每次构造组件的时候都用它呢?就像只有一个Vue构造函数一样。 答案:因为我们将options放在了BueComponent的自定义属性当中,如果我们只用一个BueComponent的话,后面声明的组件的options将会覆盖前面声明组件的options。这显然不是我们想要的。
为了更好地理解组件的构造结果,可以看下图。 注:代码经过babel处理,所以看起来有点凌乱。
注册组件
上面讲完了构造组件,现在我们来看看注册组件。 注册组件其实就是声明组件与自定义标签的对应关系,比如声明MyComponent组件对应于标签,这样程序解析到才知道:“哦,原来它就是MyComponent组件。”
为什么要有注册组件这一步呢?
如果之前一直用React的人应该跟我有同样的疑问。因为在React中构造完组件之后,就可以直接在jsx中使用了,并没有注册这一个步骤。如下所示。
个人热为可能是基于以下的考虑: 与React相比,Vue的侵入性要小得多。Vue需要直接应用在普通的DOM结构上,然而,在这些普通的DOM结构当中,可能之前就已经存在自定义标签了,Vue提供的注册功能正好可以解决这个命名冲突的问题。 也就是说,假如没有注册功能,直接把组件MyComponent对应成标签,要是万一之前的DOM结构里面已经有这样一个自定义的标签,也叫mycomponent,这不就懵逼了吗?
所以,注册功能只需要完成组件与标签名的映射就可以了。相关的代码如下所示:
注册结果如下图所示。
渲染组件
这一步比较复杂,让我们将它细分为以下三个步骤。
识别组件
在初始化app这个Vue实例的过程中,当DOM遍历解析到 的时候,由于我们在上面已经进行了组件注册,所以我们知道那是一个组件,需要特殊处理。
相关代码如下:
组件指令化
在我们识别出标签是一个组件之后,该如何对待这个组件呢?
文章开头就讲到过,组件与子实例是类似的,我们当初处理“v-if”条件渲染的时候,就是检查到“v-if”是一个特殊的指令,然后就将“v-if”里面的DOM结构当成Vue实例来处理。
这里,我们可以采用类似的方法,引入当做一个组件指令。
相关代码如下。
组件指令
的概念,把下面上图证明组件真的被当成了指令来处理。
既然把组件当成是一个组件指令,那么,剩下的就是如何编写指令的bind方法了。我们将在bind方法中完成组件的渲染与挂载。
渲染、挂载组件
要想渲染组件,有两个关键点。
<p>{{message}}</p>
message="hello, liangshaofeng!"
和message="hello, Vue!"
模板处理
组件options当中的template是一个字符串,代表着一个DOM结构。如何将这个字符串“
{{message}}
”转化成对应的DOM结构呢?在不考虑兼容性的情况下,我们直接采用DOMParser,代码如下:props处理
组件是有自己的数据属性的,这跟子实例不同。子实例不仅能访问自己的数据,还能访问父实例的数据。但是组件只能访问自己的数据,不能访问父实例/父组件的数据,组件想要访问的数据必须显式地通过props传递给它,像这样:
<my-component message="hello liangshaofeng!"></my-component>
。这是实现组件化的通用手法,React也是如此。 所以,我们需要把message解析出来,并且将message存储到组件实例的$data当中,这样组件里面的{{message}}才能解析成"hello liangshaofeng!"。 这一部分的代码如下所示:bind方法
在处理完模板解析和props解析之后,我们终于来到了最后一步,编写组件指令的bind方法,真正地初始化组件实例。代码如下。
实现效果
至此,我们已经实现了最简单的vue组件化了,完整的代码在这里,效果如下图所示。
遗留问题
本篇所实现的只是最为简单的组件化。还有许多问题没有考虑到,比如:
====End====