前端工程质量保障:组件建设

93 阅读18分钟

最初,前端没有组件化的概念,随着工程复杂度的上升,需要考虑代码的复用、维护等问题,组件化才引起前端工程师的重视。组件化是一种非常优雅的提高效率、保障质量的解决方案,对于研发人员而言,可以实现功能复用,降低代码重复率,提高研发效率;对于设计师而言,可以快速构建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. 构建打包

组件开发完成后,需要构建打包并输出编译后的文件,便于作为第三方依赖被其他模块使用。在一般情况下,开发人员会将打包结果输出在工程根目录下的buildlib文件夹下,并把该目录添加到.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 模块是静态的,在编译阶段就确定了模块的依赖关系,importexport语句是不可变的。

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标记版本,以便后续在修复版本问题时可以快速切换到开发分支。