Fork me on GitHub

从动态组件谈一谈 Angular和Vue

又是半个月一个字都没写,眼看 2018 年又要到头了,时间过得飞快。今年年初定的目标,看起来好像又要一梦黄粱去了,唉,只能勉强安慰自己,至少一直在路上,且行且珍惜吧。

今天这篇博客,我们就来聊一聊 AngularVue 两个前端框架的动态组件特性,同时通过它们各自实现方式的对比,体会一下两个框架从基于模式取舍和设计思想考量的不同,最终延伸出的各种差异和得失。

OK,不多说废话,开始正文。

动态组件,是前端组件化开发中比较重要的一个组成。

从技术角度来说,动态组件提供了一种方式来让我们将一部分视图和视图对应的逻辑抽离出去,只留下一个类似接口的插入点,从而更好的将某些基础的视图逻辑模块化和服务化,实现更高的可复用性和可维护性。举例来说,我们可以将常见的模态框抽离出去,作为一个动态组件,在需要模态框时直接动态载入,在其它组件的逻辑中,我们不再需要提前载入它或者在视图中为它预留位置。

从业务角度来说,动态组件能够更好的应对业务和需求变化,通过将组件的实现和它最终存在的位置进行解耦,我们可以更聚焦其业务实现,而不必考虑它实际的构造生成和依赖环境等问题。举例来说,我们可以实现很多独立的广告组件,在广告需要出现的位置,通过配置规则来动态的加载广告组件,当我们需要增删广告,修改广告频率等等时,只需要简单的修改动态组件的加载规则即可,从而将广告和页面中其它的业务逻辑隔离开来,更容易更改和变动。

各个前端框架基本都提供了动态组件的实现,今天主要讨论一下AngualrVue在动态组件特性上实现的一些不同,首先我们来看一下Vue的动态组件。

Vue的动态组件

Vue动态组件的使用相当简单,只需要使用<component>元素,并通过此元素的is属性来指定要加载的组件即可。如下:

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 声明一个组件选项对象,当然,也可以提前将组件注册到Vue实例中
const dynamicComponentOption = {
name: 'DynamicComponent'
component: {
template: `<h1>this is dynamic-component</h1>`
}
}

// 通过<component>元素和is属性即可加载组件
new Vue({
el: '#dynamci-banner'
data: {
currentComp: dynamicComponentOption
}
})

动态组件宿主的模板如下:

1
2
3
4
<div id="dynamic-banner">
<h1>dynamicComponent:</h1>
<component :is="currentComp.component"></component>
</div>

从上面可以看出,Vue的动态组件相当的简单易懂,通过<component>元素来定义动态组件的载入点,通过is属性来定义要动态加载的组件即可。

当然,通常我们会有多个组件,需要根据特定情况加载特定的组件或者循环轮流加载。从上面的代码,我们很自然的就想到了解决思路,我们只需要根据需求切换is属性的值即可。例如下面这个例子,它有三个组件,每隔三秒来切换一次展示的组件:

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 这次我们换一种方式,不再直接定义组件的选项对象,而是提前将组件注册到 Vue 示例中。
// 另外多说一句,一般我们开发时都是使用单文件模式,那种情况下直接导入对应的动态组件的类即可
Vue.component('dynamic-comp-one', {
template: '<div>dynamic component one</div>'
})
Vue.component('dynamic-comp-two', {
template: '<div>dynamic component two</div>'
})
Vue.component('dynamic-comp-three', {
template: '<div>dynamic component three</div>'
})

new Vue({
el: '#dynamic-banner',
data: {
currentIndex: 0,
intervalId: null,
comps: ['one', 'two', 'three']
},
computed: {
currentComp() {
return 'dynamic-comp-'+ this.comps[this.currentIndex]
}
},

created() {
this.intervalId = setInterval(_ => {
this.currentIndex === 2 ?
this.currentIndex++ :
this.currentIndex = 0
}, 3000)
},

beforeDestory() {
clearInterval(this.intervalId)
}
})

动态组件宿主的模板如下:

1
2
3
4
<div id="dynamic-banner">
<h1>dynamicComponent:</h1>
<component :is="currentComp"></component>
</div>

值得一提的是,Vue提供了一个<keep-alive>标签用于缓存组件状态,从而使得动态组件只会创建一次,后续再动态加载同一组件时,使用的还是上次创建的组件,避免了反复创建和渲染造成的性能消耗。

我们来回顾以下Vue的动态组件这个概念,首先值得称道的是,除了动态组件本身这个概念外,Vue没有引入任何新概念,单纯的通过一个标签和属性,就可以顺畅的使用动态组件这个特性。这也是Vue易上手易学习特点的一个显著体现,也是Vue的一大优点。

但是,如果我们不想将元素插入在<component>上而是想通过选择器选择一个DOM元素然后插入将其作为动态组件的宿主,该怎么做?

我们再发散的思考一下,如果我们的组件在被载入时才能注入某些依赖,传入相关数据,确定相关的属性呢,我们要怎么做?如果我们要一次插入多个组件呢?如果这多个动态组件有依赖关系,需要按顺序进行动态加载呢?如果我们想针对不同组件设置不同的<keep-alive>策略呢?

再进一步,如果我们想让动态组件的加载可以在后台页面配置,就像我们开头提到过的广告动态组件加载,在后台通过配置确定展示哪些广告,然后前端再通过接口获取配置再根据配置调整组件内容并加载,我们该怎么做呢?

这些问题,Vue作为一个鼓励自行扩展或使用第三方插件的渐进式增强框架,并没有也不会告诉我们该怎么办,我们需要去学习别人是如何实现的或者自己去实现。

软件开发领域有一个戏称叫做复杂度守恒理论,是说软件开发的复杂度不会增加也不会减少,只会从一个地方转移到另一个地方。用在这里还是非常贴切的,Vue将动态组件特性暴露为一个非常简单的实现,那复杂当然就留给了我们自己,当我们需要这些复杂的时候,我们就只能自己去拓展和实现了。问题是,在实现过程中,如何保证你的实现稳固可靠,可测试可复用,符合工程化的一些通用模式和标准?如果要引入第三方插件,如何评估插件的功能和我们需要的功能的契合度?它是否能应对后续业务变化?我们怎样保证系统边界的清晰,不被第三方插件绑定或强耦合?这些问题,对于小型项目来说,可能并不需要过多考虑,但是对于一个长期的大型项目来说,那就必须是在系统开始架构时就要慎重考量了。

这也是我认为Vue的一个缺点,它较低的上手门槛无法保证一些复杂实现上的工程化模式,用通俗的话来讲,就是小白看一遍文档就能写,但写出来一定不好维护,它的低门槛是一把双刃剑,更友好也意味着更容易混乱。

我们再来看看Angular的动态组件。

Angular的动态组件

在详细介绍之前,我们先来考虑一下,如果我们自己用工程化的思维来设计一个动态组件的实现,该怎么做?

首先,一个动态组件,首先一定是一个组件(废话!),那么它肯定就有组件的数据,方法,模板等属性,以及创建,编译,初始化,销毁等行为。

我们要把组件动态载入,当然也需要一个方法来创建它。

我们还需要确定它加载的位置,这个位置可能是一个DOM元素,也可能是其它组件的视图,一个插入点也应该可以插入多个组件。

这样动态组件的思路就有了,我们定义一个插入点作为宿主元素,然后创建组件,再将组件插入到宿主元素呈现即可。

我们用伪代码来实现一下这个思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 设置组件插入点
<div id="insertion"></div>

// 获取组件的宿主元素(假装获取到了)
const insertion = $('#insertion')

// 定义一个可以创建组件的方法
createOption = (option) => {
return new Comp(option)
}

// 定义宿主元素可以包含多个组件
insertion.container = []

// 定义宿主元素的一个方法,用来创建组件并插入到自身
insertion.createViewByComp = (comp, option)=>{
const comp = createOption(option)
comp.option = option
this.container.push(comp)

// 将组件插入到宿主元素中
for (comp in container) {
this.innerHTML += comp
}
}

// 获取将被动态载入的组件
const dynamicComp = import('./dynamic')

// 插入
insertion.createViewByComp(dynamicComp)

如果你看过Angular的源码的话,你会发现我们的思路已经与Angular的实现思路非常相近了。

不同的是,Angular通过TypeScript的类和类型约束以及框架自身的一些特性,将上面这个流程实现的严谨又优雅。

具体的,Angular的动态组件的创建分为了两个步骤,一步是编译,一步是实例化,编译这一步被Angular分配给了一个底层的API,实例化组件的方法被定义在了container容器上,这也是比较容易理解的,因为上面我们提到过,我们要插入的元素可能是DOM元素,还可能是另一个组件的元素,不管从设计模式还是具体实现上,相比交给宿主元素,将实例化动态组件的任务交给拥有全部组件的容器都是更好的方式。

Angular的实现中,这个容器叫做ViewContainerRef,通过元素上的这个容器,我们可以创建和管理当前元素的模板和组件视图。我们可以简单的认为每个元素或组件上都有一个这样的容器。

但实际上,Angular实现的相当巧妙:只有我们主动去获取元素上的这个容器时,这个容器才是存在的。十分的唯心,可以说是薛定谔的容器了。当然实际上,Angular只会在我们主动去获取元素的这个容器时才为元素创建这个属性。

犹记得我当初看 Angular的动态组件实现,折服于它为何如此暴力,为每个元素和组件都挂载了这个容器,但实际我去查看又发现所有元素上都没有这个容器,摸不着头脑了好一会。

我们来看下面的一个简单的Angular动态组件的例子:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Component, OnInit, 
ViewContainerRef,
ComponentFactoryResolver } from '@angular/core'

@Component({
selector: 'ad-place',
template: `
<div>
<h1>Dynamic Component Place</h1>
</div>`
})
export class DynaPlaceComponent implements OnInit {

constructor( private cfr: ComponentFactoryResolver,
private vcr: ViewContainerRef
) {}

ngOnInit() {
const componentFactory = this.cfr.resolveComponentFactory(DynamicComponent)
this.vcr.createComponent(componentFactory)
}
}

// 动态组件
@Component({
template: `<div> i am dynamic component </div>`
})
export class DynamicComponent {}

来看上面这个例子,我们将ViewContainerRef注入到了组件类中,与此同时我们还注入了ComponentFactoryResolver,随后我们通过ComponentFactoryResolverresolveComponentFactory得到组件的ComponentFactory,并将其作为参数传递给ViewContainerRefcreateComponent方法,完成了动态组件的插入。

同时,我们需要在模块的元数据使用entryComponent来声明动态组件,Angular在编译时会为每个动态组件创建一个ComponentFactory

不要被名字迷惑,ComponentFactory其实并不是组件的工厂函数,而是编译后的组件。想一下,当我们使用Angular的预编译AOT模式时,编译是在Angular构建时就已经完成的,打包之后的应用发送到前端,是不会包含编译器的,那么动态组件要怎么编译呢?所以我们必须提前保存一份编译后的动态组件。

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { 
dynaPlaceComponent,
dynamicComponent } from './dyna.component'

@NgModule({
imports: [ BrowserModule ],
declarations: [
AppComponent,
dynaPlaceComponent,
dynamicComponent
],
bootstrap: [ AppComponent ],
entryComponents: [
dynamicComponent
]
})
export class AppModule {}

在这个例子中,我们直接将ViewContainerRef 注入组件类,使用了组件自身作为宿主元素。

最后组件的视图如下:

Dynamic Component Place
i am dynamic component

但通常来说,更常见的情况是我们不会直接以组件自身作为宿主元素插入动态组件的视图,而是以组件中的某个元素来作为宿主元素,这种情况下我们可以通过@ViewChild来获取元素的ViewContainerRef

@ViewChild 是一个装饰器,我们可以通过它来进行视图查询,从而获取当前组件视图上的DOM元素,子组件,指令等。

来看下面这个例子,我们会将组件插入到模板引用变量#dync中,同时我们定义了两个动态组件,这两个动态组件会每三秒循环一次,另外我们还向组件內传递了一个其被创建时间的时间戳:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { Component, OnInit, Input, ViewChild, ComponentFactoryResolver, ViewContainerRef, ComponentRef } from '@angular/core'

@Component({
selector: 'ad-place',
template: `
<div>
<h1>Dynamic Component Place:</h1>
<div #dync></div>
<h2>Footer</h2>
</div>`
})
export class DynamicPlaceComponent implements OnInit {
// read 选项表示我们不获取视图的元素引用,而是获取元素的视图容器
@ViewChild('dync', { read: ViewContainerRef }) vcf: ViewContainerRef

private dyncCompArr: any[] = [DynamicComponentOne, DynamicComponentTwo]
private currentIndex: number = 0
private intervalId: number

constructor(private cfr: ComponentFactoryResolver) { }

ngOnInit() {
this.loadComponent()

this.intervalId = setInterval(_ => {
this.loadComponent()
}, 3000)
}

private loadComponent() {
this.currentIndex === 1 ? this.currentIndex = 0 : this.currentIndex++
const comp = this.dyncCompArr[this.currentIndex]

const adFactory = this.cfr.resolveComponentFactory(comp)

// 清空视图容器中的所有动态组件,如果不加这一步的话,就可以插入多个动态组件
this.vcf.clear()
const componentRef: ComponentRef<any> = this.vcf.createComponent(adFactory)
componentRef.instance.nowDate = new Date()
}

ngOnDestroy() {
clearInterval(this.intervalId)
}
}


@Component({
template: `<div> i am dynamic component one </div>
<div> it is {{ nowDate }} </div>
`
})
export class DynamicComponentOne {
@Input() nowDate: string
}

@Component({
template: `<div> i am dynamic component two </div>
<div> it is {{ nowDate }} </div>`
})
export class DynamicComponentTwo {
@Input() nowDate: string
}

戳这里可以查看效果

在上面这个例子中,我们做了这些事情:

  1. 通过@ViewChild得到想绑定动态组件插入位置的视图元素的ViewContainerRef
  2. 通过ComponentFactoryResolver来得到动态组件
  3. 通过ViewContainerRef创建组件实例,并向组件实例传递绑定数据。

通过这样的一个思路和实现,我们将动态组件的绑定,编译,创建渲染,数据传递这些动作,完美的进行了解耦。

在后续的优化中,我们至少可以做以下几件事情来让这个动态组件的模组更加强壮:

  1. #dync模块引用变量换成一个属性型指令,通过向指令中注入ViewContainerRef来让它帮我们获取插入元素的位置。这样我们可以在组件中多处插入动态组件,不再局限于一个元素上。
  2. 将盛放动态组件数组的dyncCompArr 的获取以及组件输入属性的获取委托给服务,而不是写死在组件里。
  3. 扩充ViewContainerRef 容器管理组件的逻辑(我们现在只是简单的使用了它的createComponet方法来创建组件,但其实它可以做很多事情),使其可以同时加载多个组件或者按需进行组件的增删。
  4. 将动态组件类型约束为一个interface 或者class ,从而保证动态组件高自由度下的健壮

在经过这样的优化之后,我们可以通过后台我们的动态组件的基础功能已经具有了相当高的可维护性和可扩展性了。我们可以在服务中从任意位置(例如后台接口)获取需要动态加载的组件的列表和绑定数据,我们可以管理(例如替换,清空,删除)当前已经加载的动态组件,我们还可以通过一个简单的元素属性来变更或者增加动态组件的插入位置。

回头看一下,通过引入几个类似ViewContainerRef的概念并遵循特定的实现方式,我们实现了一个简单又健壮的动态组件的展示脚手架,它足以应对后续繁多的变化。

这也是Angular的特点体现之处了: 更工程化和模式化的开发方式,也意味着更高的门槛,更长的开发周期,当然也意味着更容易的维护。

从框架的角度来讲,Angular几乎接管了你前端开发涉及到的所有东西,从表单,路由,请求,动画,i18n 到构建,测试,发布,以及AOT,移动端,SSRNative等等等等,相比Vue,它不再像一个Framework型的前端框架,而是越来越接近一个Platform型的前端开发平台。

还记得我们在Vue中提到的<keep-alive>标签吗 ?在Angular中,如果想让动态组件实现<keep-alive>效果,我们就需要自己去实现组件缓存策略,在实现之后,和动态组件一样,我们会拥有相比<keep-alive>标签更大的自由,我们可以决定哪些组件会被缓存,在什么情况下才缓存,我们可以控制它的缓存次数,可以在取出缓存组件时对其进行修改,替换,可以在后台配置好缓存条件再通过接口发送给前端。

现在问题来了,类似下面这三行Vue代码,如果用Angular实现的话,需要引入若干个类,定义若干个interface,创建若干个service,简而言之,就是需要更长时间的学习,写更多的代码。但好消息是,在完成了所有工作之后,我们就拥有了自由定制与这三行代码有关的所有过程的能力和可能,同时还能保证它在广义意义上的可维护性。

1
2
3
<keep-alive>
<dynamic-comp></dynamic-comp>
</keep-alive>

这样是值得的吗?这个问题,其实已经不单纯的是一个技术问题了,它会涉及到项目规模,开发周期,人员组成,资金成本,技术成本,甚至公司的开发氛围,大环境的趋势等等等等,已经难以一言蔽之了。

当然,很有一些人喜欢说一些不应该拘泥于技术,技术只是工具这种车轱辘话来掩盖自己其实也不知道答案,就只好随便选一选的事实。问题是,对于程序员来说,技术如果只是一种工具的话,和一个咸鱼有什么区别?剑客以剑为生,却不执着于剑,反倒说剑只是一种工具,只要能杀敌就行,我是总觉得不太合适的。就算飞花摘叶,皆可伤人,那也是追求剑道到极致才能做到的啊。

那,你可能会说了,你说的一套一套的,那你的答案呢,你的答案是什么?其实,有句讲句,我也整不太明白,不过为了让我自己不再纠结这个问题,我打算再去看看React(逃。。。

本篇博客到此结束,感谢阅读(^_^)a

----本文结束感谢阅读----