Vue.js-组件化前端开发新思路
本文章是我最近在公司的一场内部分享的内容。我有个习惯就是每次分享都会先将要分享的内容写成文章。所以这个文集也是用来放这些文章的,顺便也当图床用。
1. 认识Vue.js
Vue.js(读音 /vjuː/,类似于view)是一套构建用户界面的渐进式框架。
如果你有react或者Angular开发经验,你肯定不会对Vue.js感到太过陌生。Vue.js是踩在Angular和React肩膀上的后来者,它充分吸收了二者的优点,是MVVM框架的集大成者。我们只需要花10分钟写一点代码,就能大概窥见Vue的本质。
1.1 数据绑定
所有的MVVM框架要解决的第一件事都是数据绑定。首先要将Model的变化渲染到View中,当有用户输入还需要把用户的修改反映到Model中。所谓的MVVM就是这么来的。
<!DOCTYPE html>
<html>
<head>
<title>Hello Vue</title>
<script src="https://unpkg.com/vue/dist/vue.js"></script>
</head>
<body>
<div id="app">
{{ message }}
</div>
</body>
<script>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>
</html>
在浏览器打开这个HTML文件后,可以看到页面上显示了“Hello Vue”字样。我们在控制台输入app.message = 'hello world'
并回车后,发现页面上的消息也变成了“Hello World”。你会发现这一切都是响应式的!Vue在背后为我们搞定了数据到视图的绑定,然而这一切并没有什么黑魔法,这背后的原理是Object.defineProperty
和对象的存取器属性。
这样看起来,组件化前端开发就像造一辆车,我们将轮子、发动机、悬挂、车身车门等等各部分组装成一辆车,轮子、发动机就是组件,车就是最终产品。我们将页头、侧边栏、页脚、内容区等等组件拼装起来组成了我们的页面。
2.2 组件化的意义
分而治之
在谈到组件化的意义时,很多人的看法都是组件化的目的是复用,但我并不赞同这一看法。
良好地组件化以后的组件,会表现出高内聚低耦合的特征,这会给我们带来好处:
- 组件之间不会相互影响,能有效减少出现问题时定位和解决问题的时间
- 组件化程度高的页面,具有清晰的页面组织和高可读性的HTML结构代码,组件之间的关系一目了然
- 组件化会强迫开发人员划清各个组件的功能边界,使得开发出的功能更加健壮
所以分而治之才是组件化的意义所在,复用只是它的副作用。同时我们还有很多其他方式都可以做到复用,这并不是组件化的专利。
2.3 组件化与模块化
有时候我们可能会分不清组件化和模块化的区别。
模块化是一种处理复杂系统分解成为更好的可管理模块的方式。它可以通过在不同组件设定不同的功能,把一个问题分解成多个小的独立、互相作用的组件,来处理复杂、大型的软件。[^2]
这段话出《Java应用架构设计》,似乎在后端领域,组件化和模块化说的是同一件事。但在我的理解中,前端领域的组件化和模块化是两个概念。先说结论
组件化是从产品功能角度进行分割,模块化是从代码实现角度进行分割,模块化是组件化的前提和基础。
当我们将一段代码写成一个模块的时候,它有可能是一个函数、一个对象或者其他什么做了一件单一事情的东西,我们将它做成模块是因为它完成了一个单一的功能,并且这个功能很多地方都可能用得到。
而当一个组件被从产品中抽象出来,它有时候就只是一个模块,但有时候却有相对复杂的实现,它就可能会有多个模块。
我们说一个日期选择器是一个组件,但实现它的时候,我们分成了计算模块、渲染模块、用户输入响应模块等等模块来实现。一个单一产品功能的实现,可能是由多个模块来实现的。这样理解起来,其实可以说组件化是更粗粒度的模块化,它是在产品功能上的模块化。说到这里,其实不难理解为什么后端领域可以认为组件化与模块化是一件事了,这一点交给大家思考。
2.4 组件化在前端工程中的位置
现在市面上的前端团队的武功等级大概可以用下面的这张图概括:
组件化在前端工程中过的位置今天我们前端领域最先进的工程化水平,在传统的桌面软件开发领域中早就被用烂了,所以这都不是什么新概念。但这也是我今天要分享的原因,既然组件化早就大行其道了,那我们是不是可以探讨一下在组件化过程中要面对的常见问题,以及如何优雅地运用Vue提供的组件系统进行组件化开发?
2.5 前端组件化开发的常见问题
- 组件隔离(模块化):既然要组件化,那么第一件事就是实现组件之间的隔离,否则内聚和低耦合就无从谈起。组件隔离其实就是模块化,这里我们需要实现CSS模块化和JS模块化。
- 组件间通信:高内聚低耦合必然会带来数据流动上的壁垒,所以隔离后的组件就要解决组件之间的通信处理。组件通信分为父子组件通信和非父子组件通信,这就涉及到接口设计、事件处理和状态管理三块内容。
- 内容分发:有时候我们希望抽象的是组件的某种行为模式或交互方式,而组件中包含的内容却是需要使用组件时才能确定,这虽然本质上也是组件间通信,但它的方式更为直观和方便。内容分发涉及到具名/非具名内容分发,子组件向分发内容传递数据等。
- 递归和循环引用:组件本质上也是模块,那么肯定也需要面对模块会面对的问题,递归和循环引用。
- 按需加载:既然已经组件化了,那么更进一步应该实现组件的按需加载,从而提高产品体验
3. Vue中的组件化
Vue在组件化上针对上述问题给出了很完整的解决方案。
3.1 单文件组件系统与CSS局部作用域
之前我们已经看到了Vue中是如何注册和使用一个组件的,然而很多时候一个组件本身的结构和逻辑都远远比这要多和复杂,在这种时候仅仅依靠对象实例这种形式,就会出现诸多不便,同时基本没有什么好的办法来实现CSS隔离。
<style lang="scss" scoped>
.my-component {
color: red;
}
</style>
<template>
<div class="my-component">
{{ message }}
</div>
</template>
<script>
export default {
data () {
return {
message: 'This is my component!'
}
}
}
</script>
Vue给我们提供了单文件组件系统,在这套系统中,我们可以使用一个.vue
后缀的文件来组织组件,这个文件内的结构像极了普通的html
文件:一个表示结构的template
标签,一个编写样式的style
标签,和一个表示逻辑的script
标签。
在script中我们将组件输出为一个模块,利用ES6的Module系统来作为隔离组件的基础。同时我想你已经注意到了style标签中的这个scoped
属性,它意味着当前组件的样式是局部的,不会影响其他组件。至于如何实现的,非常简单:
Webpack的vue-style-load
会在组件的每个元素上添加一个data-v-hash
属性,然后在其对应的CSS选择器上添加这个属性作为选择器:
这样就将组件的样式与其他组件隔离开来。
3.2 Vue组件通信
可以用一张图来表示Vue组件系统中父子组件的数据流动:
父子组件的数据流动使用props
向子组件传递数据,首先要在子组件中定义子组件能接受的props
,然后在父组件中子组件的自定义元素上将数据传递给它:
虽然官方并没有这样的说法,但我仍旧习惯将子组件的props
叫做它的接口,通过组件的接口,我们可以从外部向组件传递数据。但是如果组件需要向外部传递数据,则不能通过props
,这是Vue 2与前一代Vue的区别。Vue 2中强调“单项数据流”。跟React中提倡的“单项数据流”一样,所谓“单向数据流”,即是数据的变动只能由外向内传递,而不能由内向外传递。组件只能将从接口传递进来的数据进行使用,不能对其进行修改:
export default {
props: ['message'],
mounted () {
this.message = 'local message' // Vue will warn you if you try to modify props
}
}
我们唯一能做的,就是在子组件中将props
中传递进来的数据赋值给子组件的本地data
变量,然后在修改了这个本地变量的时候,发送事件通知外部。父组件通过监听子组件发送的这个事件,来决定需要做什么:
<template>
<div>
<input type="text" v-model="localMessage" v-on:change="localMessageChange">
</div>
</template>
<script>
export default {
props: ['message'],
data () {
return {
localMessage: this.message
}
}
methods: {
localMessageChange () {
this.$emit('message-change', localMessage) // notify parent component the change of message
}
}
}
</script>
另外,事件系统也能够解决非父子组件的通信问题,我们使用一个空的Vue实例来作为中央事件总线,就像这样:
let bus = new Vue()
bus.$on('a-custom-event', function () {
// handle the event
})
bus.$emit('a-custom-event', 'some custom event data')
讲到这里就不得不提Vuex。和Redux一样,Vuex是Vue官方提供的状态管理方案。在很多情况下,通过props
和事件系统就基本能满足我们的需求,但当情况复杂到一定阶段(比如咱们的Cube),上述简单的手段就会让状态管理变得不可控,这时应该考虑使用Vuex。
3.3 向子组件分发内容
有时候我们希望将某种“容器”功能抽象出来成为组件,这时它内部的“容纳物”就不确定了。我们当然可以完全通过props
向组件传递大量的HTML字符串来解决问题,但那样的写法相信没几个人会喜欢。HTML是用于表示“结构”的,我们自然希望他们出现在他们该出现的位置上。
Vue提供了slot
(插槽)来解决这个问题。父组件可以通过子组件的slot
向子组件中注入HTML:
<template>
<div class="modal">
<slot></slot>
<slot name="operations"></slot>
</div>
</template>
<modal>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit.</p>
<div slot="operations">
<button>cancel</button>
<button>confirm</button>
</div>
</modal>
在Vue 2.1以前,子组件对于通过slot
传递进来的HTML是没有太多手段去控制的,但在2.1版本后,Vue甚至还提供了一个叫做“作用域插槽”的特性,子组件现在可以向被注入的HTML中传递数据了!这意味着子组件得到了被注入HTML的数据控制权,它可以自定义每一项的展示行为,更可以将列表项中那些特殊项的共同行为和特征也抽象到子组件内部去,不需要额外在子组件外部进行处理了,举个不是很恰当的例子:
<!--with out scope slot-->
<my-list>
<li v-for="item in listItem">{{ item.url || item.text }}</li>
</my-list>
<!--with scope slot-->
<my-list :items="listItem">
<template slot="item" scope="props">
<li v-for="item in listItem">{{ props.text }}</li>
</template>
</my-list>
列表组件可以将“优先显示url”这个特性,通过作用于插槽封装到组件内部进行处理,不再需要外部去处理了:
<!--my-list component-->
<ul>
<slot
name="item"
v-for="item in items"
:text="item.url || item.text"></slot>
</ul>
这个时候每一项展示的数据来源就不是父组件而是子组件了。到这里我们回过头来看一看这三个特性:props
、slot
和scope slot
。
使用props
来传递数据,是将子组件中的结构和数据的控制权完全封闭到子组件中,父组件只管向其提供数据;如果使用了slot
来分发内容,则是将子组件中的某些结构和数据的控制权完全交给父组件,父组件要将这部分结构和数据渲染好了放到子组件指定的位置中;而scope slot
则是二者的中和,它将数据控制权交给了子组件,将结构控制权交给了父组件。
3.4 Vue组件的递归与循环引用
大部分模块系统都会需要处理递归和循环引用这两个问题。Vue组件系统中对这两个问题的处理非常优雅,首先是递归:
<template>
<ul class="admin-menu" :class="isTopLevel ? 'top-level' : ''">
<li v-for="item in localItems">
{{ item.text }}
<admin-menu v-if="item.children && item.children.length" :menu-items="item.children"></admin-menu>
</li>
</ul>
</template>
export default {
name: 'admin-menu',
data () {
return {
localItems: this.menuItems
}
},
props: ['meneItems']
}
这是来自于Admin-UI中的组件admin-menu
中的实现,Vue中的组件只要给定了name
属性,就能够很自然地进行递归调用,只要确保递归有停止条件即可。所以通常递归会与v-if
、v-for
等配合使用。
组件引用自身为递归引用,AB组件互相引用则为循环引用。Vue.component()
方法内部自动处理了这种循环引用,你不仅不需要担心这是个循环引用,你甚至可以将这个特性作为优势进行充分利用。但当使用的是ES2015的模块系统来引入的组件,Webpack就会报循环引用错误了。
为了解释为什么会报错,简单的将上面两个组件称为 A 和 B ,模块系统看到它需要 A ,但是首先 A 需要 B ,但是 B 需要 A, 而 A 需要 B,陷入了一个无限循环,因此不知道到底应该先解决哪个。要解决这个问题,我们需要在其中一个组件中(比如 A )告诉模块化管理系统,“A 虽然需要 B ,但是不需要优先导入 B”
Vue的官方教程上说的非常清楚,只要让两个组件的导入不同时发生,就可以规避这个问题。那么事情就简单了,我们在其中一个组件中注册另一个组件的时候再去引入它就错开了它们的引入时间:
// a.vue
export default {
beforeCreate: function () {
this.$options.components.TreeFolderContents = require('./b.vue')
}
}
3.5 配合Webpack实现组件按需加载
在大型应用中,我们可能需要将应用拆分为多个小模块,按需从服务器下载。为了让事情更简单, Vue.js 允许将组件定义为一个工厂函数,动态地解析组件的定义。Vue.js 只在组件需要渲染时触发工厂函数,并且把结果缓存起来,用于后面的再次渲染。
Vue.component('async-webpack-example', function (resolve) {
// 这个特殊的 require 语法告诉 webpack
// 自动将编译后的代码分割成不同的块,
// 这些块将通过 Ajax 请求自动下载。
require(['./my-async-component'], resolve)
})
3.6 vue-cli实例演示(待定)
使用Node作服务器,制作一个TODO List页面,实现增删改查
4. 其他
4.1 组件层级划分
组件的三个层级依据与业务的耦合程度,由低到高,我们可以将组件分为三个层次:UI组件,应用组件和业务组件。
UI组件主要是大部分由UI库提供的业务无关的纯UI渲染组件,三者中它的粒度最细,每个组件就完成一个UI功能;同时因为无关业务它可以在项目间具有通用性。
应用组件则是与业务有一定耦合的组件,它是基于UI组件进行的封装或组合,粒度与UI组件类似,但带上了一定的业务属性,仅在本项目通用。
业务组件则是完成某个具体业务的组件,它是基于UI组件和应用组件进行的封装或组合,粒度最粗,具有针对性的业务属性,它不需要也不具备通用性。
反映到实现中,可以用一个例子来理解:列表组件 -> 用户列表组件 -> 用户管理组件。基于这种分层,从文件组织,到组件划分,都会有一些最佳实践。
- 适度的组件嵌套:a->b->c->d->e->f...当嵌套层级过多时会带来另一个极端,复杂度不降反升。合适的嵌套规则应该是UI组件尽可能相互独立,不进行嵌套;应用组件是最容易发生过度嵌套的地方,所以它们之间也应该尽可能互相独立,即使嵌套也请不要超过1层,它们应当纯粹由UI组件和业务规则组成;业务组件则仅仅应当由UI组件和应用组件组成,不应该在一个业务组件中嵌套另一个业务组件,这会让业务逻辑显得很奇怪
- 良好的组件命名:UI组件的名称应当反映组件功能,应用组件的名称应当反映业务属性和组件功能,业务组件名称则应当完全体现业务属性,至于英文还是拼音...我只能说随缘吧...
-
统一的组件接口:组件的接口命名应当表达一致的语义,类似
message
、text
、items
这样常用的接口名称代表的语义和功能尽可能要在项目中得到统一 - 清晰的文件组织:UI组件应当来自项目中引入的UI库,或者项目中单独的UI组件文件夹,应用组件应当来自单独的应用组件文件夹,而业务组件则应当每个业务组件一个文件夹,在其中存放该业务组件相关的一切文件
最后,当我们按照上面的划分来组织组件的时候,还会面临一个问题,一个业务组件中,并不完全是由UI组件和应用组件组成的,很多部分其实并不具有任何通用性,那这部分应该如何处理?通常情况下我们会直接将它们写在业务组件中,所以我们一般见到的业务组件多是自定义组件和原生HTML代码混杂在一起的。但更优雅的解决方案,是将这部分内容也拿出来做成组件,它们就放置在业务组件自己的目录中,一旦你这样做,你会发现你的业务组件中不再出现大块的原生HTML代码,取而代之的是语义清晰结构简明的自定义组件。组件化的首要目的是分治而不是复用,所以即使没有复用的需求,你也应该有动力去进行组件化。
4.2 ajax是否需要置于组件内
大量的刚刚开始进行组件化的团队成员们都会对一个问题进行争论:ajax是否需要封装到组件内部?
先说结论:不需要也不应该。原因很简单:解耦。
仅考虑两种情况:
-
一个应用组件在某个业务组件中引用了两次:当这个应用组件内部在
created
钩子中封装了加载数据请求的ajax时,如果参数相同,那么该组件的请求会在同一个业务组件中被发送两次 - 项目需要进行统一的ajax管理和优化:当组件内部存在ajax逻辑的时候,统一的ajax管理和优化会变得麻烦
还有更多的坑我没有列出来,所以出于解耦的目的,尽可能不要将ajax逻辑封装到组件中,组件仅关心渲染逻辑即可。
4.3 为什么选择Vue
安利一波Vue给大家:
-
快速上手,事实上Vue没有改变传统的开发模式,我们在
style
中写样式,我们在template
中写模板,我们在script
中写逻辑,同时文档极其完善,各种语言都有,所以不关你是老鸟还是新手,都能非常快速地上手Vue进行开发 - 全姿势解锁,数据驱动、HTML模板与JSX三者兼得,不喜欢Vue的姿势?没关系,什么姿势都可以,你可以像写React一样去写Vue,也可以像写Angula一样去写Vue
- 强大的项目模板,超好用的项目模板——vue-cli,比create-react-app不知道高到哪里去了
- 性能强悍,基本上Vue的渲染性能是React的差不多两倍,至于Angular...我不说了
- 可爱的开发者,接地气的开发者:尤雨溪活跃在知乎、github、stackoverflow等国内外各大平台,而React和Angular则是facebook和Google团队在维护,你很难接触到他们
- 脑残粉,我喜欢我喜欢我喜欢
4.4 Admin-UI:
最后,再安利一波我们出的Admin-UI库给大家(暂未开源)。
admin-uiAdmin-UI是一套基于Vue,用于PC端的UI库。就像名字那样,这套UI库主要用于PC端的后台管理系统。这一类系统对样式的定制要求比较低,相应地我们希望用于其中的UI库能够带来更快速的开发体验。与BootStrapde的大而全不一样的是,我们对Admin-UI的预期是小而美,借此尽可能降低使用者的学习成本,加速开发。