多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
Vue.js另一个核心思想就是**组件化**。**所谓组件化,就是把页面拆分成多个组件(component),每个组件依赖的css,JavaScript、模板、图片等资源放在一起开发和维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。** 分析Vue组件初始化的过程 ~~~ import Vue from 'vue' import App from './App.vue' var app = new Vue({ el: '#app', // 这里的 h 是 createElement 方法 render: h => h(App) }) ~~~ 也是通过**render函数**去渲染的,不同的这次通过createElement传的参数是一个组件而不是一个原生的标签。 ## createComponent createElement的实现,最终会调用_createElement方法,对tag的判断: * 普通html,**实例化一个普通VNode** * 否则通过createComponent方法创建一个**组件VNode** ~~~ // component vnode = createComponent(Ctor, data, context, children, tag) ~~~ 传入App对象,本质上是一个Component类型,通过createComponent方法来创建vnode。 createComponent核心流程: * 构造**子类构造函数** * 安装**组件钩子函数** * 实例化vnode ### 构造子类构造函数 export的是一个对象,**继承了Vue**。这样就把Vue上的一些**option扩展到了vm.$options上**,我们也就可以通过vm.$options._base拿到Vue这个构造函数了。**mergeOptions的功能是把Vue构造函数的options和用户传入的options做一层合并,到vm.$options上**。 **Vue.extend的作用就是构造一个Vue的子类**,使用一种非常经典的**原型链继承**的方式把一个纯对象转换一个继承于Vue的构造器Sub并返回。然后对Sub这个对象本身扩展了一些属性,如**扩展options**,添加**全局API**;并且对配置中的**props和computed做了初始化**;最后对这个Sub构造函数做了**缓存**,避免多次执行Vue.extend的时候对**同一个子组件重复构造**。 当我们实例化Sub的时候,就会执行this._init逻辑再次做到vue实例的初始化逻辑。 ### 安装组件钩子函数 Virtual DOM参考的是开源库snabbdom,它的一个特点是在**VNode的patch流程中对外暴露了各种时机的钩子函数**,方便额外操作。 合并策略:就是在最终执行的时候,**依次执行这两个钩子函数**即可。 ### 实例化VNode 通过new VNode实例化一个vnode并返回,**需要注意的是和普通元素节点的vnode不同,组件的vnode是没有children的,这是关键。** ### 总结 createComponent的实现,它在渲染一个组件的时候3个关键逻辑:构造子类构造函数,安装组件钩子函数和实例化vnode。 createComponent后返回的是组件的vnode,它也一样做到vm._update方法,进而执行了patch函数。 ## patch 通过createComponent创建子组件VNode,**接下来会走到`vm._update`,执行`__patch__`去把VNode转换成真正的DOM节点。** patch的过程会调用createElm创建元素节点 ~~~ function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index ) { // ... if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return } // ... } ~~~ ### createComponent 创建组件VNode的时候**合并钩子函数中包含init钩子函数**。init钩子函数创建一个Vue的实例,然后调用$mount方法挂载子组件。 **_isComponnet**为true表示它是一个组件,parent表示当前激活的组件实例(如何拿到组件实例?) 子组件的实例化实际上就是在**这个时机**执行的,并且它会执行实例的_init方法。 由于组件初始化的时候是不传el的,因此**组件是自己接管了$mount**的过程。 组件init的过程,在完成实例化_init后,接着会执行**child.$mount(undefined,false)**,它最终会调用mountComponent方法,进而执行`vm._render()`方法。 vm._render生成VNode后,接下来就要执行`vm._update`去渲染VNode了。 **因为实际上JavaScript是一个单线程,Vue整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的Vue实例是什么,并把它作为子组件的父Vue实例**。 **vm.$parent**就是用来保存当前vm的父实例,并且通过**parent.$children.push(vm)**来把当前的vm存储到父实例的$children中。 回到_update,最后就是调用`__patch__`渲染VNode了。 之前分析过负责渲染成 DOM 的函数是`createElm`,注意这里我们只传了 2 个参数,所以对应的`parentElm`是`undefined`。 **先创建一个父节点占位符,然后再遍历所有子VNode递归调用createElm,在遍历过程中,如果遇到子VNode是一个组件的VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整的构建了整个组件树。** **在完成组件的整个patch过程中,最后执行insert(parentElm, vnode.elm, refElm)完成组件的DOM插入,如果组件patch过程中有创建了子组件,那么DOM的插入顺序是先子后父。** ### 总结 一个组件是如何创建,初始化,渲染的过程。 编写一个组件实际上就是编写一个JavaScript对象,对象的描述就是各种配置,_init的最初阶段就是merge options的逻辑。 ## 合并配置 new Vue有的过程通常有2种场景。 * 外部我们的代码主动调用new Vue(options)的方式实例化**一个Vue对象** * 分析组件过程中,内部通过new Vue(options)**实例化子组件**。 都会执行实例的_init(options)方法,首先先会执行一个**merge options的逻辑**。 ### 外部调用场景 当执行new Vue的时候,在执行this._init(options)的时候,如下逻辑去合并options 首先通过Vue.options = Object.create(null)创建一个空对象,然后遍历ASSET_TYPES, ~~~ export const ASSET_TYPES = [ 'component', 'directive', 'filter' ] ~~~ ~~~ Vue.options.components = {} Vue.options.directives = {} Vue.options.filters = {} ~~~ Vue.options._base = Vue 最后通过extend(Vue.options.components,builtInComponents)把一些内置组件扩展到Vue.options.components上(<keep-alive>、<transition>,<transition-group>组件),因此组件中使用<keep-alive>不需要注册。 回到mergeOptions mergeOptionis主要功能就是把parent和child这两个对象根据一些合并策略,合并成一个新对象并返回。 * 先递归把extends和mixins合并到parent上 * 遍历parent,调用mergeField * 再遍历child,如果key不在parent的自身属性上,在mergeFeild mergeField,对不同的key有不同的合并策略 ~~~ export const LIFECYCLE_HOOKS = [ 'beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed', 'activated', 'deactivated', 'errorCaptured' ] ~~~ 对于生命周期函数(钩子函数),合并策略都是mergeHook函数,**一旦parent和child都定义了相同的钩子函数,那么他们会把2个钩子函数合并成一个数组**。 通过执行mergeField函数,**把合并后的结果保存到options对象中**,最终返回它。 合并后,vm.$optons的值 ~~~ vm.$options = { components: { }, created: [ function created() { console.log('parent created') } ], directives: { }, filters: { }, _base: function Vue(options) { //构造函数 // ... }, el: "#app", render: function (h) { //... } } ~~~ ### 组件场景 组件的构造函数是通过**Vue.extend**继承自Vue的,会把extendOptions(前面定义的组件对象)和Vue.options合并到Sub.options中。 `initInternalComponent`方法首先执行`const opts = vm.$options = Object.create(vm.constructor.options)`,这里的`vm.constructor`就是子组件的构造函数`Sub`,相当于`vm.$options = Object.create(Sub.options)`。 接着又把实例化子组件传入的子组件父VNode实例**parentVnode**、子组件的父Vue实例**parent**保存到vm.$options中,还保留了parentVnode配置中如propsData等其他属性。 只是做了简单一层对象赋值,并不涉及到递归,合并策略等复杂逻辑。 vm.$options ~~~ vm.$options = { parent: Vue /*父Vue实例*/, propsData: undefined, _componentTag: undefined, _parentVnode: VNode /*父VNode实例*/, _renderChildren:undefined, __proto__: { components: { }, directives: { }, filters: { }, _base: function Vue(options) { //... }, _Ctor: {}, created: [ function created() { console.log('parent created') }, function created() { console.log('child created') } ], mounted: [ function mounted() { console.log('child mounted') } ], data() { return { msg: 'Hello Vue' } }, template: '<div>{{msg}}</div>' } } ~~~ ### 总结 options的合并有2种方式,子组件初始化过程要比外部初始化Vue通过mergeOptionis的过程要快,合并完的结果保留在vm.$options中。 库、框架的设计:自身定义了一些默认配置,同时又可以在初始化节点传入一些定义配置,然后去merge默认配置,来达到定制不同需求的目的。 ## 生命周期 * **创建vue实例(初始化)钩子函数(create):先父后子(深度遍历)** * **挂载过程钩子函数(mount):先子后父(递归调用)** * **销毁过程钩子函数(destroy):先子后父(递归调用)** * **更新过程钩子函数(update):先父后子** * 编译是发生在调用vm.$mount的时候,**所以编译的顺序是先编译父组件,再编译子组件**。 每个Vue实例在被创建之前都要经过一系列的初始化过程。例如需要设置数据监听、编译模板、挂载实例DOM、在数据变化时更新DOM等。 在这个过程中会运行一些生命周期钩子函数,执行生命周期的函数都是调用callHook方法 ~~~ export function callHook (vm: Component, hook: string) { ~~~ 字符串hook,是vm.$options[hook]对应的回调函数,然后**遍历执行**(子父组件的同种生命周期先后执行),执行的时候把vm作为函数执行的上下文。 vue合并options的过程,各个阶段的生命周期的函数也被合并到vm.$options里,并且是一个数组。**因此callHook函数的功能就是调用某个某个生命周期钩子注册的所有回调函数。** ### beforeCreate & created 都是在实例化Vue的阶段,在_init方中执行, ~~~ Vue.prototype._init = function (options?: Object) { // ... initLifecycle(vm) initEvents(vm) initRender(vm) callHook(vm, 'beforeCreate') initInjections(vm) // resolve injections before data/props initState(vm) initProvide(vm) // resolve provide after data/props callHook(vm, 'created') // ... } ~~~ **initState的作用是初始化props、data、methods、computed、watch等属性**。 beforeCreate中不能获取到props、data中定义的值,也不能调用methods中函数 这两个钩子执行时,**并没有渲染DOM,不能访问DOM**。 如果组件在加载的时候需要和后端有交互,放在这两个钩子函数执行都可以,**如果需要访问props、data等数据,就需要使用created钩子函数**。 ### beforeMount &mounted beforeMount钩子发生在mount,也就是DOM挂载之前,调用实际是在mountComponent函数中。 **在执行vm.render()函数渲染VNode之前,执行beforeMount钩子,在执行完vm._update()把VNode patch到真实DOM后,执行mounted钩子。** mounted钩子函数执行有一个逻辑判断:vm.$vnode如果为null,则表明这不是一次组件的初始时化过程,而是通过外部new Vue初始化的过程。 对于组件:组件的VNode patch到DOM后,执行invokeInsertHook函数,该函数执行insert这个钩子函数,每个组件都是在这个钩子函数中执行mounted函数。 杜宇同步渲染的子组件,**mounted钩子函数的执行顺序也是先子后父**。 ### beforeUpdate & updated 执行时机都应该再数据更新的时候。 beforeUpdate的执行是在渲染Watcher的before函数中。(组件已经mounted之后才会调用这个钩子) 只有满足**当前watcher为vm._watcher**以及**组件已经mounted**这两个条件,才会执行updated钩子函数。 在组件mount的过程中,会实例化一个渲染的Watcher去监听vm上的数据变化重新渲染。 实例化Watcher的过程中,**在它的构造函数里会判断isRenderWatcher,接着把当前watcher实例赋值给vm._watcher**。同时,还把当前watcher实例push到`vm._watchers`中。`vm._watcher`是专门用来监听vm上数据变化然后重新渲染的,所以它是一个**渲染相关的watcher**。只有vm._watcher的回调执行完毕后,才会执行updated钩子函数。 ### beforeDestroy & destroyed 执行时机在组件销毁阶段。组件销毁最终会调用$destroy方法。 beforeDestroy是在$destroy执行最开始的地方,接着执行一系列的销毁动作,包括从parent的$children中删掉自身,删除watcher,当前渲染的VNode执行销毁钩子函数等,执行完毕后再调用destroy钩子函数。 在$destroy的执行过程中,会执行`vm.__patch__(vm.vnode,null)`触发它子组件的销毁钩子函数,这样一层层的递归调用,**所以destroy钩子函数执行顺序是先子后父,和mounted过程一样。** ### activated & deactivated `activated`和`deactivated`钩子函数是专门为`keep-alive`组件定制的钩子,我们会在介绍`keep-alive`组件的时候详细介绍,这里先留个悬念。 ### 总结 created中可以访问数据,在mounted中可以访问DOM,在destroy中可以做一些定时器销毁工作。 ## 组件注册 内置组件:`keep-alive`、`component`、`transition`、`transition-group`等 其他用户自定义自己在使用前必须注册。 全局注册和局部注册: ### 全局注册 ~~~ Vue.component('my-component', { // 选项 }) ~~~ Vue.component函数的定义发生在最开始初始化Vue的全局函数的时候 ~~~ export const ASSET_TYPES = [ 'component', 'directive', 'filter' ] ~~~ Vue是初始化了3个全局函数 Vue.extend把这个对象转换成一个继承于Vue的构造函数。 **最后将全局组件挂载到Vue.options.components上**。 ~~~ Sub.options = mergeOptions( Super.options, extendOptions ) ~~~ Vue.options合并到Sub.options,也就是组件的options上,会把Sub.options.components合并到vm.$options.components上,这样所有子组件都可以使用。 ### 局部注册 在一个组件内部使用components选项做组件的局部注册。 在组件的Vue的实例化阶段有一个合并option的逻辑,就把components合并到vm.$options.components上,拿到这个组件的构造函数,并作为createComponent的钩子的参数。 不同: **局部注册执业该类型的组件才可以访问局部注册的子组件,而全局注册是扩展到Vue.options下,所以所有组件创建的过程中,都会从全局的Vue.options.components扩展到当前组件的vm.$options.components下。**全局组件能被任意使用。 ### 总结 当我们使用到组件库的时候,**往往更通用的基础组件都是全局注册的**,而编写特例场景的业务组件都是局部注册的。 ## 异步组件 开发工作中,为了减少首屏代码体积,**往往会把一些非首屏的组件设计成异步组件,按需加载**。Vue也原生支持了异步组件的能力 ~~~ Vue.component('async-example', function (resolve, reject) { // 这个特殊的 require 语法告诉 webpack // 自动将编译后的代码分割成不同的块, // 这些块将通过 Ajax 请求自动下载。 require(['./my-async-component'], resolve) }) ~~~ ~~~ const Login=resolve=>require(['../components/Login/Login.vue'],resolve) const Cart=resolve=>require(['../components/Cart/Cart.vue'],resolve) ~~~ Vue注册的组件不在是一个对象,**而是一个工厂函数**,函数有两个参数**resolve和reject**,函数内部用**setTimeout模拟了异步**,实际使用可能是通过**动态请求异步组件的JS地址**,最终通过**执行resolve**方法,它的参数就是我们的**异步组件对象**。 由于组件的定义并不是一个普通对象,所以**不会执行Vue.extend的逻辑把它变成一个组件的构造函数**。但是它仍然可以执行到createComponent函数。 3中异步组件的创建方式 * 示例的组件注册方式 ~~~ Vue.component('async-example', function (resolve, reject) { // 这个特殊的 require 语法告诉 webpack // 自动将编译后的代码分割成不同的块, // 这些块将通过 Ajax 请求自动下载。 require(['./my-async-component'], resolve) }) ~~~ * Promise创建组件 ~~~ Vue.component( 'async-webpack-example', // 该 `import` 函数返回一个 `Promise` 对象。 () => import('./my-async-component') ) ~~~ * 高级异步组件 ~~~ const AsyncComp = () => ({ // 需要加载的组件。应当是一个 Promise component: import('./MyComp.vue'), // 加载中应当渲染的组件 loading: LoadingComp, // 出错时渲染的组件 error: ErrorComp, // 渲染加载中组件前的等待时间。默认:200ms。 delay: 200, // 最长等待时间。超出此时间则渲染错误组件。默认:Infinity timeout: 3000 }) Vue.component('async-example', AsyncComp) ~~~ ### 普通函数异步组件 **多个地方同时初始化一个异步组件,那么它的实际加载应该只有一次**。 定义了forceRender、resolve和reject函数,注意resolve和reject函数用once函数做了一层包装。 ~~~ /** * Ensure a function is called only once. */ export function once (fn: Function): Function { let called = false return function () { if (!called) { called = true fn.apply(this, arguments) } } } ~~~ once逻辑:传入一个函数,并返回一个新函数,非常巧妙的**利用闭包和一个标志位**保证了它的保证的函数只会执行一次。也就是resolve和reject函数只执行一次。 接下来执行工厂函数`const res = factory(resolve, reject)`,组件的工厂函数通常会先发送请求去加载我们的异步组件的js文件。拿到组件定义的对象res后,执行resolve(res),如果是一个普通对象,则调用Vue.extend把它转换为一个组件的构造函数。 $forceUpdate:调用渲染watcher的update方法,让渲染watcher对应的回调函数执行,也就是触发了组件的重新渲染:Vue是数据驱动视图重新渲染,但整个异步加载过程中没有数据的变化,所以执行$forceUpdate可以强制组件重新渲染一次。 ### Promise异步组件 webpack2+支持异步加载语法糖:()=>import('./my-async-component'),当执行完`res = factory(resolve, reject)`,返回的就是`import('./my-async-component')`的返回值,它是一个Promise对象。 ### 高级异步组件 由于异步加载组件需要动态加载js,有一定**网络延时**,而且**有加载失败的情况**。所以通常我们在开发异步组件相关逻辑的时候,**需要设计loading组件和error组件**,并在**适当的时机渲染它们**。 Vue.js 2.3+支持了一种高级异步组件的方式,它通过一个简单的对象配置,帮你搞定**loading组件和error组件的渲染时机**。 ~~~ const AsyncComp = ()=>({ //需要加载的组件。应当是一个Promise component:import('./MyComp.vue'), // 加载中应当渲染的组件 loading: LoadingComp, //出错时渲染的组件 error: ErrorComp, // 渲染加载中组件前的等待时间,默认200ms delay:200, //最长等待时间,超出此时间则渲染错误组件。默认Inifinity timeout:3000 }) Vue.component("async-example", AsyncComp); ~~~ 高级异步组件的初始化逻辑和普通异步组件一样。 入股delay配置为0,则这次直接渲染loading组件,否则则延时delay执行forceRender。 #### 异步组件加载失败 执行reject,把factory.error设置为true,同时执行forceRender。再次resolveAsyncComponent,这个时候返回factory.errorCom,直接渲染error组件。 #### 异步加载成功 执行resolve函数,渲染成功加载的组件 #### 异步组件加载中 渲染loading组件 #### 异步加载超时 走到了reject逻辑,之后逻辑和加载失败一样,渲染error组件。 ### 异步组件patch 当执行forceRender的时候,会触发组件的重新渲染,再一次执行resolveAsyncComponent,这时候根据不同情况,可能返回loading、error后成功加载的异步组件。**因此走正常的组件render 、patch过程,与组件的第一次渲染流程不一样,这时候存在新旧vnode的。** ### 总结 3种异步组件的实现方式 高级异步组件的实现非常巧妙,实现了loading、resolve、reject、timeout4种状态。 异步组件实现的本事是**2次渲染**,除了0 delay的高级组件**第一次直接渲染成loading组件外**,其他都是第一次**渲染成一个注释节点**,当异步获取组件成功后,**再通过forceRender强制重新渲染**。