从零到一,组件库的进化

2,182 阅读12分钟

前言

今年早些时候用preact 做了个新项目,并自己开发了所有组件,于是萌生了做一个组件库的想法,在闲暇时终于慢慢地磨出来一个组件库。当是记录和总结一下开发的二三事吧。

如果感兴趣的话,可以看看 组件库文档 ,源码在 项目地址


起手式

工欲善其事,必先利其器。个人建议不着急先写组件,一开始我就是太着急了后面就一直在大改,以经验来说,可以先设计好我们需要的。可以从如下几点入手进行设计:

  1. 调试和预览组件:

    我们需要创建demo 去预览组件,一般是用脚手架或自己搭建一个实际开发环境去引入组件。这个时候我们就可以代入使用者的实际使用情况来考虑我们的各个方面,比如:如何做到按需加载、怎么兼容 CommonJsES Module 的组件引入等这一类情况

  2. 文档编写:

    当我们写完组件,需要编写使用文档。我们可以考虑是不是可以做到代码即文档,怎么高效地去写一份好的组件文档,还有多语言支持。

  3. 脚本设计:

    当我们需要创建和删除组件,人工去总是可能存在遗漏,也不够优雅,这个时候由脚本去帮我们把一系列操作集成是更好的选择。包括到后续的构建、自动生成文档及推送更新文档等操作,我们都可以用脚本来辅助我们开发。

带着这些预设,我们就可以开始从大局出发进行组件库的设计了。

目录架构

  1. 首先我们可以创建一个demo,大多数情况下,cli 脚手架都相较成熟完善,也是很多开发者的写项目的默认选择,我们可以直接用cli 去建立自己的demo,再去定制或集成一些开发工具。

    同时,最终呈现给开发者的文档大多都含有demo 演示,因此,我们可以将demo 和doc 合二为一,在开发文档时调试demo。以笔者的项目为例,便是用preact-cli 创建的项目,然后只需要定义路由及一些loader等配置即可。

    创建完demo,我们就可以开始准备引入我们的组件库来开发了。

  2. 我们通常需要以 import { Comp } from 组件库名 的方式引入组件,那就需要创建整个组件库的入口文件,以 export { default as Comp } from './comp'; 的方式来导出单个组件。此时目录如下:

     |-- src/ // 源代码
         |-- comp/ // 组件目录
             index.tsx // 组件入口文件
         index.ts // 整个组件库的入口
    
  3. 有了入口文件,大多数组件都需要样式文件。对于样式文件的位置,有的人会选择独立于组件库目录,在使用时再去以如 import '组件库名/style/comp.scss' 的方式加载样式。

    但如果希望兼容 babel-plugin-import 的方式,根据下图可知,实际上该插件是通过引入组件目录下的 style 目录,假定你设置 styletrue, 则会查找comp/style/index.js,为css时则是comp/style/css.js, 这些js 的作用就是去以 import ../index.scss的方式去 引入组件目录下的样式文件。因此,我们在编写组件时,并不需要预先在组件中引入样式文件,一旦引入则无法实现按需引入了。

    另外,我们也需要在整个组件库的目录下再多一个样式入口文件,引入基类样式和各个组件样式,这样就支持一次性将所有样式引入了。

    此时我们的目录又变成如下样子了:

    |-- comp/ // 组件目录
        |-- style/ // 组件样式
            index.ts
            css.ts
        index.scss // 组件样式
    
  4. 我们写完组件,通常需要进行测试,同样式文件一样,你可以考虑将其独立在组件目录外,但你查找时就需要在两个目录中查找对应的文件,因此我会建议将测试文件置于组件目录中,方便管理。

  5. 组件完成了,该编写文档了。如果你觉得需要为组件增加篇幅较多的文档说明,在组件目录中添加一个文件,在文档中根据实际情况自行引入,如果需要多语言支持,可以考虑在组件目录下同样建立文档目录,放上不同语言的文档文件,如:zh-CN.mden-US.md等。

    至此,我们的组件目录已经清晰很多了,该有的都有了

     |-- comp/ // 组件目录
         |-- style/ // 组件样式
             index.ts
             css.ts
         |-- test/ // 测试文件
             index.test.js
         |-- doc // 文档目录
            index.tsx // 组件入口文件
            index.scss // 组件样式|-- comp/ // 组件目录
        index.scss // 组件样式
    
  6. 组件的架构完成了,接下来可以考虑设计脚本来管理组件了。

    • 当我们每次想创建或删除组件时,需要创建对应文件,并在入口文件中进行引入。 如果每次都手动操作,可能会出现遗漏,也可能会不符合预期规范,很麻烦,也不够优雅。当我们设计好了架构之后,我们就可以根据架构写好脚本,每次只需调用脚本,传入组件名,即可生成符合预期的组件目录及文件,并在入口文件添加依赖。删除时只需删除组件目录并删除依赖即可。

    • 当开发者想要引入我们的组件库时,一般是引入我们构建过的代码,如果我们使用了typescript编写或开发者希望使用CommonJs 的方式引入,我们都需要进行编译。这部分可以根据情况,使用webpackgulp 进行编译,我的项目复杂度不高,所以直接创建脚本进行编译。

最终目录如下

|-- assets/ //资源文件夹
|-- test/ // 测试初始化目录
|-- src/ // 源代码
    |-- comp/ // 组件目录
        |-- style/ // 组件样式,按需加载所需,后续讲解
            index.ts
            css.ts
        |-- test/ // 测试文件
            index.test.js
        |-- doc // 文档目录
        index.tsx // 组件入口文件
        index.scss // 组件样式
    index.ts // 整个组件库的入口js
    index.scss // 整个组件库的样式入口
|-- scripts/ // 脚本
|-- doc/ // 文档

开发

  • 组件

    在编写组件时,为了更良好地使得可复用性及通用性提高,可以在设计组件时注意以下几个方面:

    • 前缀命名空间

      通用组件总是不能完全适应所有人的需求的,开发者常常有定制的需求。为你的组件暴露一个前缀命名空间,让使用者在定制组件上有更高的自由度,仅需修改组件的前缀即可在符合约定的情况下去定制组件。

    • 检查属性类型

      在编写React 组件时,定义propTypesinterface,检查输入属性的类型提高了代码的健壮性,开发者也可以更清楚自己需要输入什么样的数据。另外,良好的定义属性及注释,可以使用如react-docgen 之类的工具提取组件属性来生成文档,笔者的组件库就用了这种方式。

    • 受控与非受控

      在Vue 中,我们常常可以通过v-model 这一类语法糖来完成双向绑定,而在react 系的语法中并没有原生的此类支持,我们在设计组件的时候就必须清晰组件是否需要受控,尽量避免混用导致最后状态混乱。

  • 图标处理

    大多数组件库中都存在图标组件,一般来说,可以通过svgiconfont的方式引入图标。 两者各有优劣,查阅之后总结了各自的优缺点,仅供参考:

    • iconfont

      优点: 矢量,兼容性良好,可以控制颜色及透明度甚至是渐变效果

      缺点: 1. 一些浏览器会进行抗锯齿处理,导致图标清晰度下降; 2. 无法控制图标各部分颜色,也就是无法实现彩色图标,通常是纯色图标;3. 会收到行高,间距等及字体相关css 属性影响;

    • svg

      优点: 1. 不受抗锯齿影像,同样是矢量;2. 可以控制图标各部分颜色;3. 可以直接插入页面,方便控制,语义化更好;

      缺点: 1. 在PC 端个别浏览器兼容较差,但移动端兼容良好;2. 可能会增加页面体积;

  • 定制主题

    我们在编写样式时,一般是用Sass 或Less 预处理器来编写,这使得我们得以方便地通过覆盖变量来定制主题。为了让样式文件支持变量覆盖,我们可以将一些关键的希望可定制的变量抽离出来。

    例如,有一个calendar组件,希望可以定制其圆角,我们就可以将border-radius 设置为$calendar-border-radius,然后将该变量以$calendar-border-radius: 16px !default; 的方式写在style/_var.scss文件中,变量后面加!default是代表它是默认变量,可以被覆盖。然后在组件样式中引入../style/_var.scss 即可。

  • 构建

    1. 组件构建

      组件开发完成之后,一般会将其进行编译为ES ModuleCommonJs 的形式,在使用Babel7.x 时,编译配置项一般是存在babel.config.js中,它导出了一个可传递配置函数 Api 的函数,意味着我们可以在执行时变更配置。

      以我的项目为例,我通过复制源代码到libes 目录中,并通过设置process.env 来决定以何种方式编译。具体代码可以参考build-components.js 文件。

      // scripts/build-component.js
      const fs = require('fs-extra');
      // ...
      fs.copySync(srcDir, esDir);
      compile(esDir);
      
      process.env.BABEL_MODULE = 'commonjs';
      fs.copySync(srcDir, libDir);
      compile(libDir);
      
      // babel.config.js
      // ...
      const { BABEL_MODULE } = process.env;
      const useESModules = BABEL_MODULE !== 'commonjs';
      return {
          presets: [
            [
              '@babel/preset-env',
              {
                loose: true,
                modules: useESModules ? false : 'commonjs'
              }
            ],
            '@babel/preset-typescript'
          ],
      // ...
      
    2. 样式构建

      实际上,前面代码中说到按需引入所需要的style 目录中的代码是不完整的,因为我们常常会在引入组件库中的其他组件,这时候按需加载是不会帮我们找到依赖组件的样式的,我们需要在style目录中的入口文件中引入依赖组件的样式。但如果我们手动添加依赖是很麻烦的,这时候,我们可以先通过dependency-tree 去查找依赖的组件,并检查该依赖组件是否存在样式文件,若有,便添加到style目录中的入口文件中。

      完成了依赖组件样式的引入之后,我们就可以将预处理器样式编译为css 了。编译完成之后,要在style目录中生成css.js以支持按需加载style 选项设置为'css' 时的查找。

      假定A组件引入了icon组件,此时A组件style 目录下的文件应该如下:

      // index.js
      import '../../icon/index.scss';
      import '../index.scss';
      
      // css.js
      import '../../icon/index.css';
      import '../index.css';
      

      具体的构建代码可以在 build-style.js 中找到。

发布

  • 提交规范

    良好的commit 记录在什么时候都是一个好习惯,对于后续的协同review 及管理都很重要,由于篇幅问题,这部分的实际操作可以参考 优雅的提交你的 Git Commit Message

  • npm 发布

    一般来说,组件库的开发者在使用我们的组件时,并不需要源代码及文档代码,我们可以通过在package.json 中,增加"files": ["dist", "lib", "es"], 的字段,限制开发者在下载的安装包只包含哪些文件,同时也能减少npm 体积。

    同时,我们也需要添加"main": "./lib/index.js", "module": "./es/index.js",两个字段,让开发者能够以需要的方式引入我们的代码,对其定义如下:

    main : 定义了 npm 包的入口文件,browser 环境和 node 环境均可使用

    module : 定义 npm 包的 ESM 规范的入口文件,browser 环境和 node 环境均可使用

    browser : 定义 npm 包在 browser 环境下的入口文件

文档

组件开发完成后,如果我们一个个在文档目录中去新增及编写组件文档,是很麻烦的,如果组件需要删除,我们还要去删除对应的文档。这个时候就可以以组件为单位,用脚本去提取文档。

组件库在迭代的过程中,偶尔需要更改属性名或类型,这个时候文档就需要手动更新,如果能够让文档及时与代码同步,那就更完美了。react 组件可以通过react-docgen,只要写好 propTypesinterface,注释完善之后,就可以提取出属性信息,再根据实际需求,比如加载不同语言文档的说明,然后生成目标文档。

以我的组件库为例,在doc 目录中会有markdown 目录,按类型划分文档,比如组件文档在components目录下,通过docgen 提取信息,生成markdown 写入该目录;将说明文档放在doc 目录中,手动编写维护。这样的好处就是方便查找,每次执行生成文档命令时只更新必要的文档。然后在webpack 中配置自定义的markdown-loader,通过prismjs 高亮代码展示。

比如组件库中的icon 组件的interface定义如下

最终生成的属性列表如下

你也可以通过诸如styleguidiststorybook 等工具去生成你喜欢的文档。

github page

文档写完了,我们需要发布出来让其他人也能看到,最普遍的方式就是直接用 Github Page 托管我们的文档。 Github Page 的说明可以查阅此帮助文章,简述就是当我们创建了<用户名>.github.io 仓库后,只要在我们当前项目仓库下创建一个gh-pages分支(也可以是master 分支中的 docs 文件夹),就可以通过<用户名>.github.io/项目名/的方式去访问该分支下的静态项目了。

作为程序员,每次提交都要切分支实在麻烦,因此,我们可以借助 gh-pages 的力量,指定好仓库和分支名,执行一下命令即可自动提交文档,至此,我们的文档就发布完成了。

一些感想

  1. 如果多人开发,最好在拉取代码后进行bootstrap,即移除node_modules再安装依赖,以免有人变更了依赖之后导致开发问题;
  2. 如果希望多人使用,发布前尽量谨慎测试及检查,做好规范和自动测试很有必要。

至此,一个组件库的雏形就已然出现,我们可以参考成熟的组件库,站在巨人的肩膀上,发展出自己的一套优秀的组件库。

再次放一下地址,希望大佬们不吝赐教,让我学习改进,谢谢。

组件库文档

项目地址