1.为什么要实施前端组件化?
在项目开发中,页面和功能大都拆分为多文件来实现,多文件管理逐渐暴露出以下问题:
- 相似的业务代码无法复用:X同事实现了一遍A页面,Y同事要实现一个和A页面类似的B页面,发现X同事的代码无法有效复用,只好重新再写一遍。
- 多人重复实现同一功能:X同事完成了A功能,Y同事开发时要做同样的功能,但是并不知道X同事已经实现了,又重新写了一遍。
随着项目的不断迭代,以上问题便会导致:
- 代码体积不断增加,冗余越来越大;
- 业务逻辑复杂度不断增加,逻辑的可扩展性、可维护性、健壮性越来越差;
而产生以上问题的原因主要体现在:
- 相似代码重复开发;
- 复用功能代码的方式是简单粗暴的复制粘贴;
- 团队内协作开发导致代码耦合度高,后期维护难;
随着项目的迭代,从长期维护的稳定性和可操作性方面来看,大多数人都想过无数次重构和优化,却总是不敢轻易“动”。所以在前端项目工程化的前提下,引入了前端组件化,从功能模块的复用及多人协作层面进行解耦。
2.组件化:前端解耦的有效利器
2.1 什么是前端组件化?
前端的组件化,其实是对项目进行自上而下的拆分,把通用的、可复用的功能中的模型(Model)、视图(View)和视图模型(ViewModel)以黑盒的形式封装到一个组件中,然后暴露一些开箱即用的函数和属性配置供外部组件调用,实现与业务逻辑的解耦,来达到代码间的高内聚、低耦合,实现功能模块的可配置、可复用、可扩展。除此之外,还可以再由这些组件组合更复杂的组件、页面。
上面提到了要对项目进行拆分,那经常听到的模块化与组件化都涉及了对项目的模块拆分,指的是一回事吗?
组件化≠模块化。模块化是从文件层面上,对代码或资源进行拆分;而组件化是从设计层面上,对用户界面进行拆分。前端组件化更偏向UI层面,更多把逻辑放到页面中,使得UI元素复用性更高。
换句话说,页面上所有的东西都可以是组件,可以把页面看作大型业务组件,它又能拆分为多个中型业务组件,然后可以再拆分成多个复合组件,复合组件再拆成多个基础组件,直到拆成Dom元素为止。实际项目开发中,只需要应用这些组件,像搭积木一样完成页面的搭建就可以了。
图1 页面与组件间的关系
2.2 组件化思维
组件化思维的精髓是独立、完整、自由组合。以此为目标,尽可能把设计和开发中的元素独立化,使其具备完整的功能,通过自由组合来构成整个页面/产品。
2.3 组件分类
一般常见的组件可以划分为这四种:基础组件、业务组件、区块、页面。
图2 组件分类与关系图
基础组件:不包含业务,具备独立具体的功能,比如button、input、select等组件。项目中不需要关心也无法修改组件内部的代码,通过组件定义的props配置来实现不同的功能;
业务组件:由基础组件组合而成,含有业务逻辑的组件;
区块:由基础组件和业务组件组合而成;项目中对于区块代码可以进行任何改动,这是区块和业务组件的最大区别;
页面:呈现给用户的页面,也可以看作是业务组件。
2.4 组件化的特点
网上对于组件化并没有一个明确的定义,但是在提到组件化的时候都会提到高内聚、低耦合。也就是希望每个组件对内做到各个元素紧密结合,互相依赖;对外和其他组件的联系最少且接口简单,可复用可组合。组件化的意义在于提效,希望可以交付可用的、直观的、可组合的业务形态。
如果项目实现了组件化,那么写代码就具有了更高的灵活性,可扩展性和可维护性。
图3 组件化特点
2.5 组件化开发后的新面貌
组件化以后,一个页面可能是这个样子的:
图4 页面与组件的构成
项目可能是这个样子的:项目内部对应多个页面,页面内部对应多个组件,组件内部又可能对应了多个不同的库。
图5 项目、页面与组件的关系分布
3.实际业务开发:具体问题具体分析
3.1 组件设计原则
无论是基础组件还是业务组件,在设计时都应遵循一定的原则:
单一性:一个组件只专注做一件事,且把这件事做好。一个功能如果可以拆分为多个功能点,就将每个功能点封装为一个组件,但并不是组件的颗粒度越小越好,只要将一个组件内的功能和逻辑控制好即可。
可配置:明确组件的输入和输出分别是什么。比如组件内的文本、按钮、字体颜色、按钮位置等,都是可配置的,最基本的配置方式就是通过属性想组件传递配置的值。
粒度适中:划分的粒度大小要根据实际情况权衡,太小会提升维护成本,太大又不够灵活。组件的抽象粒度并不是越细越好,拆分是为了分层、复用,在基本原则不变的前提下,我们更应该关注如何适配不同的业务场景和需求,合适才是最重要的。
3.2 如何在项目中实施前端组件化?
实际项目中基础组件并不能满足我们的业务需求,往往会包含大量重复的业务场景。而这些业务场景又大同小异,有些是具有一定业务逻辑的组件,另一些是由基础组件和业务组件组合成的模块等。
思考 | |
① | 那么这些重复的工作能否用机器来解决? |
② | 如何更好的复用这些业务组件来降低开发成本呢? |
③ | 能否在前端资源紧缺的情况下,直接使用现有工具搭建出想要的页面和项目? |
带着这些问题,中台技术部的前端团队开发了Elsa插件,提供项目模板以及高质量的业务组件来解决这些问题。之所以使用插件,就是在避免现有可视化工具痛点的基础上,让开发更加高效轻松。Elsa把现有项目中可共用的业务组件都发布为一个jnpm包,以组件库列表呈现给用户,方便开发人员安装使用,还可下载对应组件后在其基础上进行二次开发,再发布jnpm,来满足更加多样的业务场景。同时,还提供了自定义组件,赋予用户开发的自由度,发布自己业务组件到jnpm后同样呈现在列表,从而丰富和沉淀出更多的业务组件。
图6 Elsa-组件库列表页面
业务组件的开发离不开基础组件,Elsa也提供了可视化拼装表单功能,提供表单页面的基础组件元素,通过拖拽轻松完成表单页面的搭建。
图7 Elsa-拼装组件页面
并且针对不同的业务场景和页面模式,该插件还提供了多版项目模板。开发人员可以直接使用现有项目模板进行开发,极大地降低了项目研发和维护的成本。
图8 Elsa-创建应用页面
关于Elsa中目前沉淀出的业务组件是从哪里来?又是如何从项目拆分、设计的?目前Elsa列表中提供的业务组件是从产品中心中抽取封装发布的,在重构中提供了极大的便利,团队别的同事可以很方便的下载使用。所以下文组件拆分就以产品中心为例说明。
3.3 组件拆分
面对项目中一个功能几百行上千行,都堆积在一个js文件中,尤其在刚接手别人项目得时候遇到这种情形会极其崩溃。这个现象就像盖房子时,地基不稳,还在上面一直摞砖,这样的房子试问有人敢住吗?那同样的,这样的代码运行起来随时都可能会出现意想不到的问题,可能下一秒出现问题,可能增加了某个功能后出现问题......OMG,项目要上线,BUG改不完,问题还没有定位到,怕是要完蛋了......
目前业内对于组件拆分也没有统一的标准。因为每个人对组件化的理解不同,拆分边界、程度都要结合具体的业务场景来思考。拆分思路也可以借鉴Vue官网的一个图来说明:
图9 vue中组件系统的划分
中台技术部的前端团队在拆分组件时,除按照布局和人员分工拆分之外,主要从这两个角度来考虑的:
项目内是否可复用:即同页面或者多页面都用到的功能模块(比如产品中心的表格、分页、日志组件)。
页面内出现频率:如果某部分出现频率很高,但总有一部分差异,考虑插槽方式封装组件。
重构项目前先做了项目整体业务逻辑和代码架构的梳理,制定出组件化方案。对比以下几个页面:
图10 产品中心页面A
图11 产品中心页面B
图12 产品中心页面C
现象描述
相同点:从页面结构来看,自上而下都是由过滤框条件、操作按钮、表格、分页模块组成。
不同点:过滤条件不同,操作按钮不同,表格列不同。
拆分思路
将每个页面都划分成过滤条件组件、表格组件和分页组件来实现组件间的独立、再组合完成复用。
封装设计
- 过滤条件:关于过滤条件组件,如果差异不大,都是由固定属性集合中的内容来过滤,可以v-if控制显示。如果各页面的过滤条件基本不同,按照最小粒度划分那就是select、input类型,那就以最小粒度的视角来拆分,实现更高程度的复用。根据业务场景和页面来看,如果都涉及到了产品分类,产品线等同样的内容,就不需要更小粒度的划分了,可以将数据直接绑定上去封装在组件内部。
- 表格:其实哪个位置不同,哪就可配置。很明显,表格组件应该包含两部分,由操作按钮+表格组成。既然页面都有操作按钮,但总有一些不同,参考上面的原则2来用slot插槽实现。表格列和对应的数据通过传参绑定,表格列中的操作按钮同样也采取slot插槽根据页面差异自定义。
- 分页:分页组件UI是统一的,那么它只是根据数据来显示对应的内容,只要绑定数据即可。
业务发展前期,可能这样抽取的组件通用性很强了。当日积月累业务中增加了新的需求,同一项目中的不同页面中类似的模块又有别的差异无法用现有的组件逻辑来满足,需要不断新增参数。此时组件内部出现了大量的判断逻辑。尽管组件的通用性再好,可以应对各种页面的逻辑,但此时组件本身已经变得更难以维护。这个时候就应该思考:抽离组件应该做业务层和视图层的分离,视图层负责页面的样式和交互,业务层处理业务逻辑(接口调用、数据结构调整等)。这种情形在常见的新增、编辑页面,就可以避免组件中的耦合判断,共用一个视图,并在各自的业务层实现不同的业务逻辑。
table组件主要部分:
<el-table
ref="multipleTable"
v-loading="loading"
:stripe="ifStripe"
:border="border"
:height="`calc(100% - ${pageHeight}px)`"
:max-height="maxHeight"
:style="{ width: '100%' }"
:data="dataSource"
v-bind="options"
v-on="tableEvents"
@selection-change="handleSelectionChange"
>
<!-- 复选框 -->
<el-table-column
v-if="options && options.selection && (!options.isShow || options.isShow())"
type="selection"
width="55"
:align="alignDirestion"
></el-table-column>
<el-table-column
v-if="operates && operates.length > 0"
:fixed="operatesDir"
label="操作"
width="200"
v-bind="options && options.props"
:align="alignDirestion"
>
<template slot-scope="scope">
<div class="operate-group">
<template v-for="(btn, key) in operatesOut">
<span v-if="!btn.isShow || (btn.isShow && btn.isShow(scope.row, scope.$index))" :key="key">
<template v-if="btn.render">
<render :column="scope.row" :row="scope.row" :render="btn.render" :index="scope.$index"></render>
</template>
<template v-if="!btn.render">
<!-- v-show="!btn.disabled || (btn.show && btn.show(scope.row, scope.$index))" -->
<el-button
:size="btn.size || 'small'"
:type="btn.type || `text`"
:icon="btn.icon"
:plain="btn.plain"
:style="btn.setStyle && btn.setStyle(scope.row, scope.$index)"
v-bind="btn.props"
:disabled="btn.disabled && btn.disabled(scope.row, scope.$index)"
@click.native.prevent="btn.method(scope.row, scope.$index)"
>
{{ typeof btn.label === 'string' ? btn.label : btn.label(scope.row)
}}{{ operates.length >= 2 ? ' ' : '' }}
</el-button>
</template>
</span>
</template>
<template>
<span v-if="operatesInner && operatesInner.length > 0">
<el-dropdown class="dropdown-btn">
<span class="el-dropdown-link">
更多
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu>
<!-- @click="arrange(scope.row)" command="arrange" @click="getLog(scope.row)" command="getLog" -->
<el-dropdown-item
:disabled="btn.disabled && btn.disabled(scope.row, scope.$index)"
v-for="(btn, key) in operatesInner"
:key="key"
@click.native.prevent="btn.method(scope.row, scope.$index)"
>
{{ btn.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</span>
</template>
</div>
</template>
</el-table-column>
<!-- 表格数据 -->
<template v-for="(column, index) in columns">
<slot v-if="column.slot" :name="column.slot"></slot>
<!-- :align="column.align" 表头字体样式无需自定义直接居中即可 -->
<template v-else>
<el-table-column
v-if="!column.isShow || (column.isShow && column.isShow())"
:key="index"
v-bind="column.props"
:prop="column.prop"
:label="column.label"
:width="column.width"
:fixed="column.fixed"
show-overflow-tooltip
:align="alignDirestion"
>
<!-- 每一个值 -->
<template slot-scope="scope">
<template v-if="!column.render">
<template v-if="column.formatter">
<span v-if="!column.time" @click="column.click && column.click(scope.row, scope.$index)">
{{ column.formatter(scope.row, column, scope.$index) }}
</span>
<span v-if="column.time">
{{ column.formatter(scope.row, column, scope.$index) | dateFormat }}
</span>
</template>
<template v-else-if="column.newjump">
<router-link
class="newjump"
type="primary"
:underline="false"
v-bind="{ target: '_blank', ...column.target }"
:to="column.newjump(scope.row, column, scope.$index)"
>
{{ scope.row[column.prop] || column.content }}
</router-link>
</template>
<template v-else>
<span
:style="column.click ? 'color: #409EFF; cursor: pointer;' : null"
@click="column.click && column.click(scope.row, scope.$index)"
>
{{ scope.row[column.prop] || column.content ? scope.row[column.prop] || column.content : '-' }}
{{ `${scope.row[column.prop] && column.unit ? column.unit : ''}` }}
</span>
</template>
</template>
<template v-else>
<render :column="column" :row="scope.row" :render="column.render" :index="index"></render>
</template>
</template>
</el-table-column>
</template>
</template>
<!-- slot插槽按钮 -->
<el-table-column v-if="options && options.slotcontent" label="操作" :align="alignDirestion">
<template slot-scope="scope">
<slot :data="scope"></slot>
</template>
</el-table-column>
</el-
左右滑动查看完成代码
页面调用:
<table-list
alignDirestion="left"
:operates="operates(this)"
operatesDir="right"
:pageHeight="48"
:export-but="exportBut(this)"
class="table-list"
:columns="columns"
:data-source="tableData"
:isDoLayout="true"
></table-list>
3.4 组件化面临的挑战
不可描述需求的开发:现实开发中需求不可能都特别明确,对于不定性的,不可描述的需求开发,组件化挑战比较大,设计的组件可能需要不断变化来适应业务场景。可用性和可复用冲突:当前阶段,组件完全满足功能,但是对于未来需求变化能否满足就需要开发者有一定的前瞻性和预留扩展,以达到适应和复用目的。组件维护:组件内部互相独立,要对组件可能出现的意外准确定位,设立相关容错机制。
持续增长的挑战:组件化开发是为了更好减轻开发中的各种负担,但是组件持续增长中本身也容易成为一种负担,是否有相应的处理手段来应对。
组件开发规范:团队开发中开发风格各有差异,如何合作和理解彼此开发的组件需要制定统一的规范。
健壮性和可维护性:健壮性和可维护是保证组件运行乃至整个页面、系统运行的关键。
4.总结
随着前端领域的发展和开发探索,组件化思想已经在前端开发中广泛使用,比如流行的Vue、React框架等,都是基于组件化思想的产物。组件化并非一蹴而就,而是一个持续的过程。在沉淀业务组件的同时还需考虑组件包的大小,不能因为组件包的体积大而导致页面加载过慢,以及组件发布前的测试等。挑战也是一直存在的,但可以通过一些方法和规范去解决挑战,让组件化设计更好的服务于系统。所以,理解组件化可以帮助开发者更好的使用框架进行工作内容的拆分和维护,才能在实际开发中结合具体的业务场景,设计出合理的组件,实现真正的前端组件化。