如何开发公司组件库(下)

1,291 阅读10分钟

上一篇中介绍了在开发公司的组件库之前需要的准备工作,主要包括:需求评审、调研、组件设计和提出 issue。 本篇主要介绍在具体的开发环节需要注意哪些事项。

创建开发环境

组件开发的时候需要不断测试当前已开发的功能,这里建议单独配置一个私人 demo 项目用于本地开发。创建组件对应的初始库(详见Angular 的创建库),执行ng build [库名称]生成本地的组件库。配置完成后,使用npm link链接刚才打包好的组件库和 demo 项目,然后在 demo 项目的angular.json中添加preserveSymlinks: true(如下),完成创建开发环境。这是为了防止本地库的引用依赖错误。

{
    "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
    "version": 1,
    "newProjectRoot": "projects",
    "projects": {
        "my-project": {
            "root": "",
            "sourceRoot": "src",
            "projectType": "application",
            "architect": {
                "build": {
                    "builder": "@angular-devkit/build-angular:browser",
                    "options": {
                       "outputPath": "target",
                       "index": "src/index.html",
                       "main": "src/main.ts",
                       "tsConfig": "src/tsconfig.app.json",
                       "polyfills": "src/showcase/polyfills.ts",
                       "preserveSymlinks": true,
...

确定组件的开发方式

组件开发分为自行开发组件和基于第三方组件的二次封装,这两种在开发时略有不同。前者通常是从自身的业务抽离而来比如标注组件,后者通常会有比较成熟的开源方案可供使用比如表格、图谱等。通常自行开发组件的成本会比较高,在业务相关性低,交互逻辑复杂,有比较成熟的开源方案的情况下,最好基于第三方组件做二次封装。

中介者模式

二次封装通常有两种方式,可以用设计模式来表示:装饰者模式与中介者模式。

装饰者模式是指在不改变原类及不使用继承的情况下,动态的扩展一个对象的功能。通常来说就是用装饰器来实现一些原第三方组件做不到的功能。这种模式并不常用,这里不多介绍。

我们通常使用的是中介者模式。中介者模式是指用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 比如下面的例子中,TableComponent充当了一个中介者,将输入的data属性转化为第三方组件st可以接受的数据类型,因此可以不加修改的直接引用原来的组件。

// table.component.ts
export class TableComponent implements OnChanges {
  @Input()
  data: Data[];
  _data: STData[];

  get _data(): STData[] {
    return this.data.map((item, index) => {
      if (this.selectedRows && this.selectedRows.includes(index)) {
        return {
          checked: true,
          ...item
        };
      }
      return item;
    });
  }
}
<st [data]="_data" ...></st>

是否应该使用组件继承

基于第三方组件的开发除了上述两种模式,其实还可以用继承来实现。Angular 的组件是使用@Component装饰器来实现的,因此组件的继承等同于 TypeScript 中带有类装饰器的类的继承。以下是一个组件继承的简单例子。像DerivedComponent一样,派生类如果拥有自己的@Component,其实相当于被两个@Component装饰器修饰过构造函数。根据 TypeScript 的装饰器组合原则,最外层的@Component被调用后对构造函数的修改会覆盖里层的@Component,最终生效的只是派生类的@Component中传入的参数。因此,如果需要改变@Component中的参数,则需要传入组件所需的全部参数。特别是providers中组件级的依赖,也是需要传入的,但第三方组件往往不会导出它的组件级依赖,就会给开发造成困难。而如果直接继承基类,像DirectDerivedComponent,该组件会继承@Component中所有的元参数和 BaseComponent 提供的类型与构造函数。但是因为不能自定义组件的元参数,导致这种使用方式受限很多。

在 TypeScript 里,当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 由上至下依次对装饰器表达式求值。
  2. 求值的结果会被当作函数,由下至上依次调用。
// BaseComponent 基类组件
@Component({
  selector: 'app-base',
  templateUrl: './base.component.html',
  styleUrls: ['./base.component.less'],
  providers: [BaseService]
})
export class BaseComponent {
  construct(baseService: BaseService) {}
}

// DerivedComponent 派生类组件
@Component({
  selector: 'app-derived',
  templateUrl: './derived.component.html',
  styleUrls: ['./derived.component.less'],
  providers: [BaseService] // 装饰器中的元参数会覆盖基类组件,因此需要重新提供 providers
})
export class DerivedComponent extends BaseComponent {}

// DirectDerivedComponent 直接继承的派生类组件
export class DirectDerivedComponent extends BaseComponent {}

以上分析说明,在 Angular 中使用继承的方式开发组件是一件受限很多且复杂的事情。而且 Angular 官方也没有提供关于组件继承相关的文档。只能从源码分析猜测组件继承的机制。因此,所有这些其实只是想说明一件事:尽量避免使用组件继承。如果你足够细心,会发现在 Angular 在DI 实战一节有此说明。(不仅 Angular, React 官方也强调避免使用组件继承。)当然也不是完全不能使用,当组件遵循第三方组件的大多数交互逻辑只是在视图层有所区别时,才建议考虑使用继承的方式实现。

想了解更多关于组件继承,这篇文章可能会有帮助。

引入第三方组件

如果你确定引入第三方组件,此时很容易忽略的一个问题就是第三方组件的 npm 包依赖问题。请选择第三方组件一个合适版本引入,确保兼容公司组件库的核心依赖库。

另外值得说明的是,在开发库的过程中,请在组件库根目录的 package.json 中的 devDependencies 中安装所有的同级依赖。而在目标库的 package.json 中使用 peerDependencies 说明同级依赖。关于为何这样做的原因可以参考 Angular 文档开发 Angualr 库与 ng-packagr 的文档dependencies

实现一个最小 demo

在一切准备就绪之后,先不要立刻投入组件具体功能的实现中。我建议第一步实现一个最小的 demo,实现最基本最重要的功能,确保我们的思路是可行的,相当于 POC。比如我在开发 table 组件的时候,第一版的设计是这样的。table 列是通过 jsx 语法来实现自定义的,列的 API 设计也依赖于此。如果你之前用 React 的话会觉得这看起来是非常自然的设计。

@Component({
  selector: 'table-demo',
  template: `
    <bx-table
      [data]="data"
      [columns]="columns"
    ></bx-table>
  `
})
export class TableDemo {
  // 表格行配置
  columns = [
    {
      title: '姓名',
      dataIndex: 'name',
      key: 'name'
    },
    {
      title: 'Action',
      key: 'action',
      template: (value, record, index) =>
        `<a>Action 一 {{ record.name }}</a><nz-divider nzType="vertical"></nz-divider><a>Delete</a>`
    }
  ];
}

然而 Angular 是不支持这种语法的,这是 React 的特性。导致我只能另寻他法。最终选择仿照 delon 实现自定义模板,然后重新改写 API。所以这个环节可以防止我们犯那些很严重的错误。往往我们第一次设计的 API 有诸多不合理之处,这是一个很好的机会进行调整,同时成功的 demo 也让我们有信心继续开发下去。

继续开发,逐步实现 issue 中的功能

接下来就需要我们慢慢逐步地实现 issue 中所列的功能了,在这期间可能会遇到很多意想不到的困难导致开发需要延期,也可能会需要调整很多次 API 以及增删一些功能。我们是组件的设计者也是开发者同时也是使用者,我们应该对它有自己的想法,开发时不断和大家沟通,可以力排众议也可以虚怀若谷,对它负责到底。这会是一个比较漫长的过程。我们会像西西弗斯一样,不断踩坑爬坑,踩坑爬坑,踩坑爬坑。因为造轮子会比用轮子复杂许多,可能经常会碰到我们知识的边界,会比较痛苦。但是当你最痛苦的时候,就是你进步最快的时候(也是头发掉的最快的时候)。

tsconfig.json 中的路径映射

在开发 angular 库的过程中,必然要进行打包(ng build)。由于 angular 默认的打包工具(@angular-devkit/build-ng-packagr)来自于ng-packagr,这里把我在使用ng-packagr时遇到的一个比较常见的坑和大家分享一下。

我们经常需要用别名指代某个位置的文件,比如需要用@component/*指代node_modules/component/dist/*。像这样的路径映射 typescript 是通过 tsconfig.json 的compilerOptionsbaseUrlpaths来支持的。在 Angular 项目中会使用 angular.json 中指定的 tsconfig 文件,而 VS Code 编辑器中会使用根目录下的 tsconfig.json 文件。因此有时会出现编辑器不能正确解析路径映射的情况,但是 angular 是可以正常构建的。(来源见这里

在开发库的时候,一般来说路径映射应该在主目录下的 tsconfig.json中配置,但有时候需要在库级别进行路径映射,也就是在 tsconfig.lib.json进行配置。这里值得注意的是,paths指向的路径必须是已编译的文件所在路径,而且除了目标本身,也需要对目标的子文件进行映射。例如同处于projects目录下的lib-alib-blib-b引入了lib-a,此时假如我们必须要在lib-b里进行路径映射,那么lib-b/tsconfig.lib.json需要配置大致如下。(关于此问题的讨论详见这里

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "lib-a": ["../../dist/lib-a"],
      "lib-a/*": ["../../dist/lib-a/*"]
    }
  }
}

开发终于结束了

经历一段时间辛苦的开发之后,组件终于可以投入使用了。在组件发布之前,请检查以下工作是否完成。

  • 对比 issue 中的设计,查看是否完成所有的功能以及 API 是否有变动。如果有和 issue 设计不一致的地方,向大家说明原因。当然,最好是在开发的时候就积极沟通,和同事达成一致。
  • 添加组件的 README,进行 API 说明。如果有必要的话,尽量补充使用范例。
  • 编辑.gitignore使其不再忽略相应的编译文件,提交编译文件。

接下来只需要按照流程进行发布即可。

  1. 发起 MR(如果在开发前已经发起了 WIP 状态的 MR,现在需要移除 WIP 标志)
  2. Code Review
  3. 合并入主分支
  4. 决定是否发布新版本
    • 切出 release 分支,更新版本号
    • 使用 cheers 发布 beta 版本(默认会打上 tag)
    • 使用 demo 库测试 beta 版本,测试无误后合并入 master 分支,移除 release 分支

工作流介绍

我们的项目一般采用 gitflow 作为 git 工作流。它拥有两个长期分支 develop 与 master 和一些临时分支 feature分支、release 分支、hotfix 分支等。日常的开发在临时分支上进行,开发完毕后合并入 develop 分支,更新版本的时候通过 release 分支分别合并入 develop 分支和 master 分支。下图是 gitflow 的工作流图。(了解更多移步这里)

gitflow

不过组件库项目开发一般采用github flow。github flow 与 gitflow 不同的是移除了 develop 分支,只需维护一个长期分支 master(实际上它更类似于 github flow,见下图)。因此组件功能的更新不需要经过合入 develop 分支与切换 release 分支等操作,直接合入主分支即可。等到变动积累到一定量,会在主分支切出 release 分支,修改版本号然后打上 tag 进行发布。如此设计原因在于 gitflow 的工作流使用 develop (或 release 分支)来进行测试环境的更新,master 来进行生产环境的更新,适合于业务开发。但是组件库不依赖于测试环境,因此也就无需 develop 分支。常见的开源项目一般都采用 github flow 的形式管理工作流。

github flow

至此所有的工作就都结束了,停下来歇一歇,总结一下开发过程中的收获,和同事们分享你的成长吧!

相关链接: 如何开发公司组件库(上)