Vue 3.0 进阶之动态组件探秘

开发 前端
本文是 Vue 3.0 进阶系列 的第四篇文章,在这篇文章中,阿宝哥将介绍 Vue 3 中的内置组件 —— component,该组件的作用是渲染一个 “元组件” 为动态组件。

 [[383029]]

本文转载自微信公众号「全栈修仙之路」,作者全栈修仙之路。转载本文请联系全栈修仙之路公众号。 

本文是 Vue 3.0 进阶系列 的第四篇文章,在这篇文章中,阿宝哥将介绍 Vue 3 中的内置组件 —— component,该组件的作用是渲染一个 “元组件” 为动态组件。如果你对动态组件还不了解的话也没关系,文中阿宝哥会通过具体的示例,来介绍动态组件的应用。

由于动态组件内部与组件注册之间有一定的联系,所以为了让大家能够更好地了解动态组件的内部原理,阿宝哥会先介绍组件注册的相关知识。

一、组件注册

1.1 全局注册

在 Vue 3.0 中,通过使用 app 对象的 component 方法,可以很容易地注册或检索全局组件。component 方法支持两个参数:

  • name:组件名称;
  • component:组件定义对象。

接下来,我们来看一个简单的示例:

  1. <div id="app"
  2.    <component-a></component-a> 
  3.    <component-b></component-b> 
  4.    <component-c></component-c> 
  5. </div> 
  6. <script> 
  7.    const { createApp } = Vue 
  8.    const app = createApp({}); // ① 
  9.    app.component('component-a', { // ② 
  10.      template: "<p>我是组件A</p>" 
  11.    }); 
  12.    app.component('component-b', { 
  13.      template: "<p>我是组件B</p>" 
  14.    }); 
  15.    app.component('component-c', { 
  16.      template: "<p>我是组件C</p>" 
  17.    }); 
  18.    app.mount('#app') // ③ 
  19. </script> 

在以上代码中,我们通过 app.component 方法注册了 3 个组件,这些组件都是全局注册的 。也就是说它们在注册之后可以用在任何新创建的组件实例的模板中。

该示例的代码比较简单,主要包含 3 个步骤:创建 App 对象、注册全局组件和应用挂载。其中创建 App 对象的细节,阿宝哥会在后续的文章中单独介绍,下面我们将重点分析其他 2 个步骤,首先我们先来分析注册全局组件的过程。

1.2 注册全局组件的过程

在以上示例中,我们使用 app 对象的 component 方法来注册全局组件:

  1. app.component('component-a', { 
  2.   template: "<p>我是组件A</p>" 
  3. }); 

当然,除了注册全局组件之外,我们也可以注册局部组件,因为组件中也接受一个 components 的选项:

  1. const app = Vue.createApp({ 
  2.   components: { 
  3.     'component-a': ComponentA, 
  4.     'component-b': ComponentB 
  5.   } 
  6. }) 

需要注意的是,局部注册的组件在其子组件中是不可用的。接下来,我们来继续介绍注册全局组件的过程。对于前面的示例来说,我们使用的 app.component 方法被定义在 runtime-core/src/apiCreateApp.ts 文件中:

  1. export function createAppAPI<HostElement>( 
  2.   render: RootRenderFunction, 
  3.   hydrate?: RootHydrateFunction 
  4. ): CreateAppFunction<HostElement> { 
  5.   return function createApp(rootComponent, rootProps = null) { 
  6.     const context = createAppContext() 
  7.     const installedPlugins = new Set() 
  8.     let isMounted = false 
  9.  
  10.     const app: App = (context.app = { 
  11.       // 省略部分代码 
  12.       _context: context, 
  13.  
  14.       // 注册或检索全局组件 
  15.       component(name: string, component?: Component): any { 
  16.         if (__DEV__) { 
  17.           validateComponentName(name, context.config) 
  18.         } 
  19.         if (!component) { // 获取name对应的组件 
  20.           return context.components[name
  21.         } 
  22.         if (__DEV__ && context.components[name]) { // 重复注册提示 
  23.           warn(`Component "${name}" has already been registered in target app.`) 
  24.         } 
  25.         context.components[name] = component // 注册全局组件 
  26.         return app 
  27.       }, 
  28.     }) 
  29.  
  30.     return app 
  31.   } 

当所有的组件都注册成功之后,它们会被保存到 context 对象的 components 属性中,具体如下图所示:

顾名思义 context 是表示应用的上下文对象,那么该对象是如何创建的呢?其实,该对象是通过 createAppContext 函数来创建的:

  1. const context = createAppContext() 

而 createAppContext 函数被定义在 runtime-core/src/apiCreateApp.ts 文件中:

  1. // packages/runtime-core/src/apiCreateApp.ts 
  2. export function createAppContext(): AppContext { 
  3.   return { 
  4.     app: null as any
  5.     config: { // 应用的配置对象 
  6.       isNativeTag: NO
  7.       performance: false
  8.       globalProperties: {}, 
  9.       optionMergeStrategies: {}, 
  10.       isCustomElement: NO
  11.       errorHandler: undefined, 
  12.       warnHandler: undefined 
  13.     }, 
  14.     mixins: [], // 保存应用内的混入 
  15.     components: {}, // 保存全局组件的信息 
  16.     directives: {}, // 保存全局指令的信息 
  17.     provides: Object.create(null
  18.   } 

分析完 app.component 方法之后,是不是觉得组件注册的过程还是挺简单的。那么对于已注册的组件,何时会被使用呢?要回答这个问题,我们就需要分析另一个步骤 —— 应用挂载。

1.3 应用挂载的过程

为了更加直观地了解应用挂载的过程,阿宝哥利用 Chrome 开发者工具的 Performance 标签栏,记录了应用挂载的主要过程:

在上图中我们发现了一个与组件相关的函数 resolveComponent。很明显,该函数用于解析组件,且该函数在 render 方法中会被调用。在源码中,我们找到了该函数的定义:

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. const COMPONENTS = 'components' 
  3.  
  4. export function resolveComponent(name: string): ConcreteComponent | string { 
  5.   return resolveAsset(COMPONENTS, name) || name 

由以上代码可知,在 resolveComponent 函数内部,会继续调用 resolveAsset 函数来执行具体的解析操作。在分析 resolveAsset 函数的具体实现之前,我们在 resolveComponent 函数内部加个断点,来一睹 render 方法的 “芳容”:

在上图中,我们看到了解析组件的操作,比如 _resolveComponent("component-a")。前面我们已经知道在 resolveComponent 函数内部会继续调用 resolveAsset 函数,该函数的具体实现如下:

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. function resolveAsset( 
  3.   type: typeof COMPONENTS | typeof DIRECTIVES, 
  4.   name: string, 
  5.   warnMissing = true 
  6. ) { 
  7.   const instance = currentRenderingInstance || currentInstance 
  8.   if (instance) { 
  9.     const Component = instance.type 
  10.     // 省略大部分处理逻辑 
  11.     const res = 
  12.       // 局部注册 
  13.       // check instance[type] first for components with mixin or extends. 
  14.       resolve(instance[type] || (Component as ComponentOptions)[type], name) || 
  15.       // 全局注册 
  16.       resolve(instance.appContext[type], name
  17.     return res 
  18.   } else if (__DEV__) { 
  19.     warn( 
  20.       `resolve${capitalize(type.slice(0, -1))} ` + 
  21.         `can only be used in render() or setup().` 
  22.     ) 
  23.   } 

因为注册组件时,使用的是全局注册的方式,所以解析的过程会执行 resolve(instance.appContext[type], name) 该语句,其中 resolve 方法的定义如下:

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. function resolve(registry: Record<string, any> | undefined, name: string) { 
  3.   return ( 
  4.     registry && 
  5.     (registry[name] || 
  6.       registry[camelize(name)] || 
  7.       registry[capitalize(camelize(name))]) 
  8.   ) 

分析完以上的处理流程,我们在解析全局注册的组件时,会通过 resolve 函数从应用的上下文对象中获取已注册的组件对象。

  1. (function anonymous() { 
  2.     const _Vue = Vue 
  3.  
  4.     return function render(_ctx, _cache) { 
  5.         with (_ctx) { 
  6.           const {resolveComponent: _resolveComponent, createVNode: _createVNode,  
  7.             Fragment: _Fragment, openBlock: _openBlock, createBlock: _createBlock} = _Vue 
  8.  
  9.             const _component_component_a = _resolveComponent("component-a"
  10.             const _component_component_b = _resolveComponent("component-b"
  11.             const _component_component_c = _resolveComponent("component-c"
  12.  
  13.             return (_openBlock(), 
  14.             _createBlock(_Fragment, null, [ 
  15.               _createVNode(_component_component_a),  
  16.               _createVNode(_component_component_b),  
  17.               _createVNode(_component_component_c)], 64)) 
  18.         } 
  19.     } 
  20. }) 

在获取到组件之后,会通过 _createVNode 函数创建 VNode 节点。然而,关于 VNode 是如何被渲染成真实的 DOM 元素这个过程,阿宝哥就不继续往下介绍了,后续会写专门的文章来单独介绍这块的内容,接下来我们将介绍动态组件的相关内容。

二、动态组件

在 Vue 3 中为我们提供了一个 component 内置组件,该组件可以渲染一个 “元组件” 为动态组件。根据 is 的值,来决定哪个组件被渲染。如果 is 的值是一个字符串,它既可以是 HTML 标签名称也可以是组件名称。对应的使用示例如下:

  1. <!--  动态组件由 vm 实例的 `componentId` property 控制 --> 
  2. <component :is="componentId"></component> 
  3.  
  4. <!-- 也能够渲染注册过的组件或 prop 传入的组件--> 
  5. <component :is="$options.components.child"></component> 
  6.  
  7. <!-- 可以通过字符串引用组件 --> 
  8. <component :is="condition ? 'FooComponent' : 'BarComponent'"></component> 
  9.  
  10. <!-- 可以用来渲染原生 HTML 元素 --> 
  11. <component :is="href ? 'a' : 'span'"></component> 

2.1 绑定字符串类型

介绍完 component 内置组件,我们来举个简单的示例:

  1. <div id="app"
  2.    <button 
  3.       v-for="tab in tabs" 
  4.       :key="tab" 
  5.       @click="currentTab = 'tab-' + tab.toLowerCase()"
  6.       {{ tab }} 
  7.    </button> 
  8.    <component :is="currentTab"></component> 
  9. </div> 
  10. <script> 
  11.    const { createApp } = Vue 
  12.    const tabs = ['Home''My'
  13.    const app = createApp({ 
  14.      data() { 
  15.        return { 
  16.          tabs, 
  17.          currentTab: 'tab-' + tabs[0].toLowerCase() 
  18.        } 
  19.      }, 
  20.    }); 
  21.    app.component('tab-home', { 
  22.      template: `<div style="border: 1px solid;">Home component</div>` 
  23.    }) 
  24.    app.component('tab-my', { 
  25.      template: `<div style="border: 1px solid;">My component</div>` 
  26.    }) 
  27.    app.mount('#app'
  28. </script> 

在以上代码中,我们通过 app.component 方法全局注册了 tab-home 和 tab-my 2 个组件。此外,在模板中,我们使用了 component 内置组件,该组件的 is 属性绑定了 data 对象的 currentTab 属性,该属性的类型是字符串。当用户点击 Tab 按钮时,会动态更新 currentTab 的值,从而实现动态切换组件的功能。以上示例成功运行后的结果如下图所示:

看到这里你会不会觉得 component 内置组件挺神奇的,感兴趣的小伙伴继续跟阿宝哥一起,来揭开它背后的秘密。下面我们利用 Vue 3 Template Explorer 在线工具,看一下 模板编译的结果:

  1. const _Vue = Vue 
  2.  
  3. return function render(_ctx, _cache, $props, $setup, $data, $options) { 
  4.   with (_ctx) { 
  5.     const { resolveDynamicComponent: _resolveDynamicComponent, openBlock: _openBlock,  
  6.       createBlock: _createBlock } = _Vue 
  7.     return (_openBlock(), _createBlock(_resolveDynamicComponent(currentTab))) 
  8.   } 

通过观察生成的渲染函数,我们发现了一个 resolveDynamicComponent 的函数,根据该函数的名称,我们可以知道它用于解析动态组件,它被定义在 runtime-core/src/helpers/resolveAssets.ts 文件中,具体实现如下所示:

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. export function resolveDynamicComponent(component: unknown): VNodeTypes { 
  3.   if (isString(component)) { 
  4.     return resolveAsset(COMPONENTS, component, false) || component 
  5.   } else { 
  6.     // invalid types will fallthrough to createVNode and raise warning 
  7.     return (component || NULL_DYNAMIC_COMPONENT) as any 
  8.   } 

在 resolveDynamicComponent 函数内部,若 component 参数是字符串类型,则会调用前面介绍的 resolveAsset 方法来解析组件:

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. function resolveAsset( 
  3.   type: typeof COMPONENTS | typeof DIRECTIVES, 
  4.   name: string, 
  5.   warnMissing = true 
  6. ) { 
  7.   const instance = currentRenderingInstance || currentInstance 
  8.   if (instance) { 
  9.     const Component = instance.type 
  10.     // 省略大部分处理逻辑 
  11.     const res = 
  12.       // 局部注册 
  13.       // check instance[type] first for components with mixin or extends. 
  14.       resolve(instance[type] || (Component as ComponentOptions)[type], name) || 
  15.       // 全局注册 
  16.       resolve(instance.appContext[type], name
  17.     return res 
  18.   } 

对于前面的示例来说,组件是全局注册的,所以解析过程中会从 app.context 上下文对象的 components 属性中获取对应的组件。当 currentTab 发生变化时,resolveAsset 函数就会返回不同的组件,从而实现动态组件的功能。

此外,如果 resolveAsset 函数获取不到对应的组件,则会返回当前 component 参数的值。比如 resolveDynamicComponent('div') 将返回 'div' 字符串。

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. export const NULL_DYNAMIC_COMPONENT = Symbol() 
  3.  
  4. export function resolveDynamicComponent(component: unknown): VNodeTypes { 
  5.   if (isString(component)) { 
  6.     return resolveAsset(COMPONENTS, component, false) || component 
  7.   } else { 
  8.     return (component || NULL_DYNAMIC_COMPONENT) as any 
  9.   } 

细心的小伙伴可能也注意到了,在 resolveDynamicComponent 函数内部,如果 component 参数非字符串类型,则会返回 component || NULL_DYNAMIC_COMPONENT 这行语句的执行结果,其中 NULL_DYNAMIC_COMPONENT 的值是一个 Symbol 对象。

2.2 绑定对象类型

了解完上述的内容之后,我们来重新实现一下前面动态 Tab 的功能:

  1. <div id="app"
  2.    <button 
  3.       v-for="tab in tabs" 
  4.       :key="tab" 
  5.       @click="currentTab = tab"
  6.      {{ tab.name }} 
  7.    </button> 
  8.    <component :is="currentTab.component"></component> 
  9. </div> 
  10. <script> 
  11.    const { createApp } = Vue 
  12.    const tabs = [ 
  13.      { 
  14.        name'Home'
  15.        component: { 
  16.          template: `<div style="border: 1px solid;">Home component</div>` 
  17.        } 
  18.      }, 
  19.      { 
  20.        name'My'
  21.        component: { 
  22.          template: `<div style="border: 1px solid;">My component</div>` 
  23.        } 
  24.    }] 
  25.    const app = createApp({ 
  26.      data() { 
  27.        return { 
  28.          tabs, 
  29.          currentTab: tabs[0] 
  30.        } 
  31.      }, 
  32.    }); 
  33.    app.mount('#app'
  34. </script> 

在以上示例中,component 内置组件的 is 属性绑定了 currentTab 对象的 component 属性,该属性的值是一个对象。当用户点击 Tab 按钮时,会动态更新 currentTab 的值,导致 currentTab.component 的值也发生变化,从而实现动态切换组件的功能。需要注意的是,每次切换的时候,都会重新创建动态组件。但在某些场景下,你会希望保持这些组件的状态,以避免反复重渲染导致的性能问题。

对于这个问题,我们可以使用 Vue 3 的另一个内置组件 —— keep-alive,将动态组件包裹起来。比如:

  1. <keep-alive> 
  2.    <component :is="currentTab"></component> 
  3. </keep-alive>   

keep-alive 内置组件的主要作用是用于保留组件状态或避免重新渲染,使用它包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。关于 keep-alive 组件的内部工作原理,阿宝哥后面会写专门的文章来分析它,对它感兴趣的小伙伴记得关注 Vue 3.0 进阶 系列哟。

三、阿宝哥有话说

3.1 除了 component 内置组件外,还有哪些内置组件?

在 Vue 3 中除了本文介绍的 component 和 keep-alive 内置组件之外,还提供了 transition、transition-group 、slot 和 teleport 内置组件。

3.2 注册全局组件与局部组件有什么区别?

注册全局组件

  1. const { createApp, h } = Vue 
  2. const app = createApp({}); 
  3. app.component('component-a', { 
  4.   template: "<p>我是组件A</p>" 
  5. }); 

使用 app.component 方法注册的全局的组件,被保存到 app 应用对象的上下文对象中。而通过组件对象 components 属性注册的局部组件是保存在组件实例中。

注册局部组件

  1. const { createApp, h } = Vue 
  2. const app = createApp({}); 
  3. const componentA = () => h('div''我是组件A'); 
  4. app.component('component-b', { 
  5.   components: { 
  6.     'component-a': componentA 
  7.   }, 
  8.   template: `<div> 
  9.     我是组件B,内部使用了组件A 
  10.     <component-a></component-a>     
  11.   </div>` 
  12. }) 

解析全局注册和局部注册的组件

  1. // packages/runtime-core/src/helpers/resolveAssets.ts 
  2. function resolveAsset( 
  3.   type: typeof COMPONENTS | typeof DIRECTIVES, 
  4.   name: string, 
  5.   warnMissing = true 
  6. ) { 
  7.   const instance = currentRenderingInstance || currentInstance 
  8.   if (instance) { 
  9.     const Component = instance.type 
  10.     // 省略大部分处理逻辑 
  11.     const res = 
  12.       // 局部注册 
  13.       // check instance[type] first for components with mixin or extends. 
  14.       resolve(instance[type] || (Component as ComponentOptions)[type], name) || 
  15.       // 全局注册 
  16.       resolve(instance.appContext[type], name
  17.     return res 
  18.   } 

3.3 动态组件能否绑定其他属性?

component 内置组件除了支持 is 绑定之外,也支持其他属性绑定和事件绑定:

  1. <component :is="currentTab.component" :name="name" @click="sayHi"></component> 

这里阿宝哥使用 Vue 3 Template Explorer 这个在线工具,来编译上述的模板:

  1. const _Vue = Vue 
  2. return function render(_ctx, _cache, $props, $setup, $data, $options) { 
  3.   with (_ctx) { 
  4.     const { resolveDynamicComponent: _resolveDynamicComponent,  
  5.       openBlock: _openBlock, createBlock: _createBlock } = _Vue 
  6.  
  7.     return (_openBlock(), _createBlock(_resolveDynamicComponent(currentTab.component), { 
  8.       namename
  9.       onClick: sayHi 
  10.     }, null, 8 /* PROPS */, ["name""onClick"])) 
  11.   } 

观察以上的渲染函数可知,除了 is 绑定会被转换为 _resolveDynamicComponent 函数调用之外,其他的属性绑定都会被正常解析为 props 对象。

四、参考资源

Vue 3 官网 - 应用 API

Vue 3 官网 - 内置组件

 

责任编辑:武晓燕 来源: 全栈修仙之路
相关推荐

2021-02-16 16:41:45

Vue项目指令

2021-02-26 05:19:20

Vue 3.0 VNode虚拟

2021-02-19 23:07:02

Vue绑定组件

2021-02-28 20:41:18

Vue注入Angular

2021-02-18 08:19:21

Vue自定义Vue 3.0

2021-03-04 22:31:02

Vue进阶函数

2021-03-09 22:29:46

Vue 响应式API

2021-03-08 00:08:29

Vue应用挂载

2020-09-16 06:12:30

Vue.js 3.0Suspense组件前端

2020-09-28 15:48:37

开源技术 软件

2021-09-05 07:35:58

lifecycleAndroid组件原理

2010-05-11 16:22:40

2020-10-13 08:24:31

Vue3.0系列

2009-07-27 10:08:48

Java 7动态语言JVM

2020-04-22 14:15:32

Vue 3.0语法前端

2010-06-28 09:26:15

JDK 7Swing组件Java

2022-04-25 07:36:21

组件数据函数

2023-04-18 09:17:40

父子组件Vue

2011-05-20 09:43:23

JDK7

2011-05-20 09:35:22

JDK7
点赞
收藏

51CTO技术栈公众号