最初,前端没有组件化的概念,随着工程复杂度的上升,需要考虑代码的复用、维护等问题,组件化才引起前端工程师的重视。组件化是一种非常优雅的提高效率、保障质量的解决方案,对于研发人员而言,可以实现功能复用,降低代码重复率,提高研发效率;对于设计师而言,可以快速构建UI稿,保证风格的一致性;对于用户而言,可以带来视觉和交互上的一致性。
组件建设是前端质量工程体系架构中的重要一环,它在很大程度上决定了前端工程代码的复用率,通过增加复用性、灵活性,可以有效解决很多代码层面的问题,帮助开发人员提升研发质量和效率。
1. 组件规范
1.1 设计语言规范
设计语言规范主要用于明确组件的表达方式、偏好和风格,例如颜色、布局、字体等。明确设计语言规范有两大好处:
- 对内而言,可以避免业务中出现各种个性化设计,保证了页面风格的一致性,使页面井然有序
- 对外而言,可以帮助企业建立统一的品牌符号、品牌特征,以及提升体验,为产品加分
设计语言主要包括7个方面:
- 设计价值观:指导设计师进行设计的原则,它确定了一个设计语言的基调。
- 色彩体系:包括品牌色、辅助色、不可用颜色、成功失败状态颜色等。设计师一般会维护一套主色盘和辅助色盘用于后续的设计工作。从研发的角度来看,在实现组件时通常会采用变量存储关键色彩数值,便于统一维护和替换主题色。
- 图形:包括图标、背景图、插画等。通过将某个概念转换成清晰易读的图形,可以降低用户的理解成本,提升界面的美观度。
- 布局:它决定了页面中内容的区域划分,合理的布局方案能够让页面的内容展示更加友好。常见的布局模式包括网格布局、流式布局等。
- 字体:包括字体种类、字间距、行间距、字重、字体颜色等。科学合理的字体系统可以大大提升用户的阅读体验和效率。
- 阴影:阴影可以用于创建深度感和层次感,使组件在界面中更加突出,以及在交互中可以用于增强用户的交互反馈。
- 图文关系:定义图文之间的协同关系,保证两者之间不出现冲突。
业界比较有名的设计语言有谷歌的Material Design、微软的Metro,以及蚂蚁金服的Ant Design等。从零到一搭建一套设计语言是一件繁琐、困难且成本极高的事情,建议选择一套成熟的设计语言作为基准语言,在它的基础上进行个性化改造。
1.2 研发设计规范
组件是一种编程抽象化、代码组织化的方式,可以增加代码的复用性、灵活性,从而提升开发效率。开发人员通过对一段可复用的代码进行抽象封装,使得这部分代码可以在相同或类似的功能场景下,通过引用的方式实现复用。
组件在设计时需要遵守一定的原则。组件过度抽象会导致使用复杂度高,可能需要更多的定制化才能满足具体场景;组件抽象程度不够又会导致复用性差。这两种情况都会导致组件难用而很少被用到甚至被弃用,因此设计人员需要根据具体的功能场景进行合理权衡,在两者之间找到一个平衡点。
按照组件的功能颗粒度可以将组件划分为原子组件和分子组件。
- 原子组件:不可拆分的、或者没必要再拆分的组件就是原子组件。例如,一个简单的按钮组件就是原子组件。这种组件的抽象程度较高,一般不包含业务逻辑相关的功能,只提供基本的功能。原子组件的设计重点在于通用性和一致性。
- 分子组件:由原子组件构成,同时添加了功能扩展的组件就是分子组件。比如一个表单组件,它可能包含输入框、标签、按钮等多个原子组件,这些组件协同工作来实现数据的收集和提交功能。复合组件的抽象程度适中,它既具有一定的通用性,又包含了特定的业务逻辑。在设计复合组件时,需要考虑组件内部各个子组件之间的交互和数据传递。
明确组件的功能颗粒度可以帮助开发人员避免重复开发组件,最大程度地实现组件的复用。然后还需要制定原子组件的研发设计规范,它包含以下几个原则:
-
KISS(Keep It Simple, Stupid)原则:保持简单直接,不要夹杂过多与主要功能无关的复杂逻辑。判定组件是否符合KISS原则的关键不是其代码量,而是可读性。
-
YAGNI(You Aren't Gonna Need It)原则:不要过度设计,只实现当前需要的功能。在组件研发阶段,避免为了可能的未来需求而添加大量当前无用的代码。代码可以根据业务情况预留扩展点,但不需要提前实现这些功能。
-
DRY(Don't Repeat Yourself)原则:避免代码重复,同样功能的代码,只应该被实现一次。公共部分应该将其抽象出来,形成可复用的函数、组件或者模块。一般来说,分子组件中的原子组件可以作为公共部分进行抽象,从而提高组件复用性。
-
LOD(Law of Demeter)原则(最少知识原则):一个对象应该对其他对象有尽可能少的了解。这意味着组件之间应该保持低耦合,不要依赖其他组件内部的细节,如果要依赖就尽可能依赖抽象部分。这样当依赖部分变更时可以将影响降到最低,即使是不兼容改动,也能以最低成本迁移。
-
SRP(Single Responsibility Principle)原则(单一职责原则):每个组件应该只有一个单一的职责。如果违反这个原则,就会导致组件内部出现大量的逻辑分支,从而变得逻辑混乱,难以维护和扩展。
通常情况下,开发人员第一次实现功能时,不需要投入太多精力去考虑复用性(YAGNI原则)。如果遇到复用场景,则需要对代码进行重构,从而使其能被复用(DRY原则)。
2. 目录结构
清晰可靠的组件目结构可以有效提高开发效率。一般来说组件目录下需要包含组件入口、组件实现、组件单测、组件样式等。确定目录结构后,还可以通过脚本在components目录下快速创建组件,从而提高开发效率。
3. 样式主题
样式主题指组件的UI风格,包括全局样式(主色、圆角、边框)和指定组件的视觉定制。
定制样式主题通常会借助CSS扩展语言的变量能力。比如Ant Design使用Less语言定义了一系列的全局/组件样式变量,开发人员在进行主题样式定制时,可以通过覆盖变量来实现自定义样式。具体实现上,可以在Less代码中进行覆盖,也可以在webpack构建工具的less-loader配置项中进行覆盖。
4. 国际化
国际化(Internationalization)可以帮助产品快速适应不同国家和区域的要求。它规定开发人员应该从产品中抽离所有与地域语言、国家/地区和文化相关的元素,并通过配置的方式来快速替换这些元素。通过国际化方案可以很好地解决多种语言切换的问题。
目前开源社区已经有很多成熟的库可以用于设计国际化方案,通过“i18n”关键词就能找到相关的库。组件库也需要提供全局设置国际化方案的方法,比如Ant Design可以在顶层通过 Context 的形式设置locale进行配置。
国际化方案的地测过都是通过维护多套配置信息来实现的,可以根据实际情况自行选择合适的方案实践。
5. 组件测试
组件是抽象的基础公共模块,因此对其进行单元测试是非常必要的。一方面,单元测试可以覆盖一些端到端测试覆盖不到的点;另一方面,它也能提高组件代码的可维护性,保证代码质量。尤其是在组件重构的时候,单元测试可以确保重构后的代码可以完全覆盖以前的测试用例,从而将影响降到最低。
组件测试推荐使用Jest
框架。它是Facebook开源的一个前端测试框架,自带断言库,配置简易,提供了mock系统、快照测试、异步代码测试、静态分析结果等功能。
在组件测试中,用的最多的通用是快照测试。快照测试会在测试文件目录下生成快照文件目录,每次执行测试命令时,Jest都会先执行测试用例,然后将结果与该目录下的快照文件进行比对,如果快照内容不匹配,测试用例就不通过。如果开发人员确认该差异为有效变更,那么可以通过npm test -- -u
来更新快照。
6. 文档管理
组件编写完成是组件建设的第一步,只有当组件被实际运用到业务中,能够取得实质性研发提效成果,组件建设才有意义。组件使用的文档是否清晰完善,对于组件能否被有效运用起着关键作用。好的组件文档必须满足两个条件:
- 组件属性描述齐全:需要包含组件的属性名、参数类型、功能描述、默认值、是否必填等信息。开发人员可以通过组件文档快速了解组件等所有功能,而不需要花费大量时间阅读源码
- 主要使用场景全覆盖:丰富的功能示例可以帮助开发人员快速找到对应的使用场景,提高其使用组件的积极性和正确性。
前端进入工程化阶段之后,开源社区诞生了许多优秀的开源文档管理工具,开发人员可以使用它们快速生成界面美观、交互友好、可提供在线实例的组件文档。Storybook
就是一个开发UI组件的开源工具,可以展示UI组件的文档。它可以覆盖组件文档的绝大部分功能,包括国际化、主题切换、在线实例、界面缩放、视窗模拟等。开发人员可以专注于组件的开发,所有与组件文档相关的工作都可以借助Storybook
完成。
7. 构建打包
组件开发完成后,需要构建打包并输出编译后的文件,便于作为第三方依赖被其他模块使用。在一般情况下,开发人员会将打包结果输出在工程根目录下的build
或lib
文件夹下,并把该目录添加到.gitignore
文件中。
7.1 模块规范
构建打包时需要考虑使用哪种模块规范。常见的模块规范有CommonJS、AMD、CMD、UMD和 ES6 Module。
-
CommonJS:服务器端的模块规范。它的核心思想是通过
require
函数来引入模块,通过module.exports
来导出模块。- 模块在运行时加载,动态加载的模块会被缓存,这样在多次引用同一个模块时,不会重复加载。
- 同步加载模块,适合服务器环境,因为服务器端的模块加载通常是在启动阶段进行,同步加载可以更好地处理模块之间的依赖关系。
- 在 Node.js 生态系统中,大量的库和模块都是基于 CommonJS 规范编写的,所以在开发 Node.js 应用时,这是最常用的模块规范。
// math.js function add(a, b) { return a + b; } module.exports = { add: add }; // app.js const math = require('./math.js'); const result1 = math.add(5, 3);
-
AMD(Asynchronous Module Definition):AMD 主要用于浏览器端,它的设计目的是为了解决浏览器环境下模块的异步加载问题。通过
define
函数来定义模块,通过require
函数来加载模块。RequireJS
的模块规范- 模块的加载是异步的,这样可以避免阻塞浏览器的渲染。
- 依赖前置,在定义模块时需要先声明依赖的模块,这使得模块之间的依赖关系比较明确。
// math.js define(function () { function add(a, b) { return a + b; } return { add: add }; }); // main.js require(['math.js'], function (math) { const result1 = math.add(5, 3); });
-
CMD(Common Module Definition):CMD 也是一种用于浏览器端的模块规范,和 AMD 类似,但是它采用了不同的加载策略。它的特点是就近依赖,即只有在使用模块时才会去加载它,更加灵活。适合于一些小型的、模块之间依赖关系不是特别复杂的浏览器端应用,尤其是使用
SeaJS
构建的项目。// math.js define(function (require, exports, module) { function add(a, b) { return a + b; } exports.add = add; }); // main.js define(function (require) { var math = require('./math.js'); var result1 = math.add(5, 3); });
-
UMD(Universal Module Definition):通用模块定义方式,用于兼容多种模块规范,包括 CommonJS、AMD 和浏览器全局变量(script 标签直接引用)。它通过检测当前环境支持的模块加载方式,来决定如何定义和加载模块。具有很强的兼容性,适合编写一些需要在多种环境下使用的库。
// UMD模块定义
(function (global, factory) {
if (typeof define === 'function' && define.amd) {
// AMD环境
define(factory);
} else if (typeof module === 'object' && typeof exports === 'object') {
// CommonJS环境
module.exports = factory();
} else {
// 浏览器全局变量环境
global.umdModule = factory();
}
})(this, function () {
function add(a, b) {
return a + b;
}
return {
add: add,
};
});
-
ES6 Module:ES6 Module 是 ECMAScript 6 标准中定义的模块规范。它使用
import
语句来引入模块,export
语句来导出模块。- 模块是静态的,即在编译时就可以确定模块的依赖关系,这有助于进行模块的优化,如代码打包和摇树(Tree-Shaking)。
- 逐渐成为主流的前端模块规范,被现代浏览器原生支持,并且在大多数的前端框架中被广泛使用。
// math.js export function add(a, b) { return a + b; } // app.js import { add } from './math.js'; const result1 = add(5, 3);
以上就是常用的模块规范,一般是不能混用的。
在 ES6 模块中引入 CommonJS 模块(理论上可行)
- 基本原理:在 ES6 模块(使用
import
语句)中可以引入 CommonJS 模块,但由于两种模块规范的差异,这个过程需要一些额外的处理。CommonJS 模块是在运行时加载,通过require
函数动态加载模块,并且模块的exports
对象是可以动态修改的。而 ES6 模块是静态的,在编译阶段就确定了模块的依赖关系,import
和export
语句是不可变的。
7.2 按需引入
组件的构建打包还需要考虑组件的实际使用场景。组件的使用场景包括全量引入和按需引入两种。
- 全量引入:将所有组件都引入工程,此时打包模块是一个整体,不支持按需加载。
- 优点:实现起来非常简便。只需要将组件入口配置为
entry
,即可输出整个bundle
。 - 缺点:会增加项目的打包体积,即使只使用了组件库中的几个组件,整个组件库的代码都会被打包到项目中。
- 优点:实现起来非常简便。只需要将组件入口配置为
- 按需引入:对每个组件单独编译,当引入某个特定组件时,只打包用到的组件
- 优点:有效减小打包体积,优化页面加载性能。
- 缺点:需要对每个组件的引入进行单独配置,相对全量引入来说,开发过程稍微复杂一些。
按需引入可以通过显式指明组件的引用路径来实现。比如:
import Button from 'ui-components/lib/Button';
但是这样的方式不够优雅,它需要开发人员关注组件库内部的目录结构,增加负担。可以通过编译插件对每个文件进行单独处理:
- JavaScript组件:通过babel进行编译,可以使用
babel-plugin-import
插件 - TypeScript组件:通过tsc进行编译,可以使用
ui-component-loader
插件
此时就可以通过正常的方式引入组件:
import { Button } from 'ui-components';
// 编译后
// import Button from 'ui-components/lib/Button';
按需加载还可以通过Tree Shaking来实现,它是基于ES6 模块的静态模块解析来实现的。实现方式:
- 指定使用 ES6 模块:在
package.json
中设置"type": "module"
- 指定组件的 ES6 模块入口点:在
package.json
中设置module
字段为组件的输出路径 - 使用 ES6 的方式引用:使用
import
来引用组件
8. 发布规范
当所有组件研发工作完成后,就需要用npm pulish
来发布组件的模块。每次发布模块都会涉及版本号的更新,同样需要遵循开源社区统一约定的语义化版本(Semantic Versioning)规范。
语义化版本规范是一套版本变化的规范,通常由三个数字部分组成,格式为MAJOR.MINOR.PATCH
,含义如下:
-
MAJOR(主版本号): 当进行了不兼容的 API 更改时,主版本号需要递增。所谓不兼容的 API 更改,是指会破坏现有代码功能的修改。这种变化可能会导致使用旧版本 API 的代码无法正常工作,所以需要通过更新主版本号来提醒使用者。
-
MINOR(次版本号):当以向后兼容的方式添加功能时,次版本号递增。这意味着新添加的功能不会影响现有的功能使用。
-
PATCH(修订版本号):当进行向后兼容的问题修复时,修订版本号递增。这些修复通常是针对一些小的错误,如代码中的逻辑错误、边界情况处理不当等问题,而且这些修复不会改变已有的 API 功能和行为。
除了基本的MAJOR.MINOR.PATCH
格式,还可以添加预发布版本号。预发布版本号是在基本版本号后面通过连接一个连字符(-
)再加上预发布标识符来表示。例如,1.2.3 - alpha.1
,其中alpha.1
是预发布版本号。预发布版本通常用于在正式发布之前,让开发者和用户对软件的不稳定版本进行测试。预发布版本的优先级低于正式发布版本,并且随着开发过程的推进,预发布版本号可以不断更新,直到最终发布正式版本。预发布版本标识包括:
alpha
:内测版,一般用于内部交流或提供给专业测试人员测试用的版本。这个阶段的软件可能不稳定,会有较多的错误和漏洞,甚至部分功能可能还没有完全实现。beta
:公测版,一般用于抢先测试体验的版本。这个阶段的版本更加稳定,但仍然可能存在一些小的问题和需要优化的地方。gamma
:公测版的增强版本,软件功能和稳定性都比较高,一般是在对 beta 版本测试过程中发现的问题进行了集中修复和优化后发布的。RC
:发布候选版本(Release Candidate),已完成全部功能并修复了大部分bug,到了这个阶段只会修复bug,不会再对软件做任何大的变更。
通常只有经过上述测试版本后,才能发布正式版本。测试版本可以将测试功能与正式功能隔离开,保证正式版本的功能基本稳定。
npm version
提供了快捷命令来方便地修改版本号,比如patch
(修订版本号递增)、minor
(次版本号递增)、major
(主版本号递增)、prepatch
(预发布修订版本号递增)、preminor
(预发布次版本号递增)、premajor
(预发布主版本号递增)。
在package.json
中可以添加preversion
钩子,在版本更新时进行自动化打包。
{
"scripts": {
"preversion": "npm run build"
}
}
还可以在package.json
中添加prepublish
钩子,在发版前自动生成对应的版本变更信息。
{
"scripts": {
"prepublish": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
}
}
此外,还要合理配置.npmignore
文件,它帮助npm识别哪些是无关文件,在发布时不需要上传,从而减小npm包的体积。
最后,当组件发布成功后,还需要使用git tag
标记版本,以便后续在修复版本问题时可以快速切换到开发分支。