如何做好一个前端业务组件库

如何做好一个前端业务组件库

  • 前言
  • 业务组件库与基础组件库的区别
  • 技术选型
  • 打包文件格式
  • babel配置
  • 项目结构
  • 依赖包处理
  • package.json 的问题
  • webpack 配置
  • typescript配置
  • 文档配置
  • 文档编写
  • 单元测试
  • 组件化开发
  • 国际化(中英文切换)
  • 自定义主题
  • 视图层与逻辑层分离
  • 提取公共代码,形成工具库
  • 及时进行重构和优化
  • 发布组件
  • 代码工作流
  • 未来的期望
  • 总结

前言

建立业务组件库的目的就是为了维护一套业务组件,然后提供给多个项目使用。解决每个项目相互复制粘贴的问题。同时也减少了维护的成本,减少开发人员的重复工作,提高工作效率。本文将讲述我在公司如何开发出一个前端业务组件库的过程,以及一些思考,问题,经验总结

业务组件库与基础组件库的区别

1、基础组件库是不受业务逻辑的影响的,业务组件是在基础组件的基础上堆积起来的,然后加上对应的业务逻辑就形成了一个业务组件了。

2、基础组件是 UI 层面的封装,业务组件是 UI 层面和业务层面的封装。

3、业务组件中包含了一些静态资源的东西,比如图片,字体图标等等。而基础组件更多的是代码层面的东西

4、基础组件通常都是所有组件打包在一起,然后形成一个npm包。像element-uiAntDesign等组件库都是如此的。而业务组件库由于项目的不同,并不是所有的业务组件都会是使用上,而且由于业务组件会包含很多静态资源,全部打包在一起会造成体积过大。

技术选型

公司主要的技术栈是vue,一开始是打算使用vue来开发我们的业务组件库的。但是综合考虑之后决定不适用vue来开发,而是使用原生dom操作来实现。原因如下:

  • 一旦使用了vue,整个业务组件库都会依赖于vue这个技术框架,如果vue进行了升级(2->3),我们的业务组件库也要随着进行升级,这样子会带来很大的工作量。使用原生dom操作来实现,一开始实现可能会比使用vue开发困难一点,但是后面维护起来就会非常的轻松了,即使vue升级到3,我们也无需改动。

  • 虽然公司的主要技术栈是使用vue,但是我们还是有一部分的项目使用到了react这个框架,为了让我们的业务组件库变得更加通用,所以使用原生dom操作是最好的选择,既可以在vue中使用,也可以在react中使用。

当然,如果你是用了vue开发,并且把vue这个框架也一起打包进去,上面的问题也可以得到解决,但是这样做是没必要的,反而会使代码的体积变大。

语言方面,选择了typescript,这没什么好解释的,使用起来,真香。

UI方面肯定会使用到大量的字符串拼接,然后生成dom,所以我们使用art-template模板引擎。

打包构建工具使用webpack,因为业务组件会涉及到图片,字体图标这些静态资源。所以使用webpack会比rollup更好一点。

综上所诉,技术方面使用的是typescript+webpack+art-template

打包文件格式

上面的技术选型中,我们已经选用了webpack作为我们的打包构建工具。现在我们要确定一下我们的打包产物。

首先是格式要求,常见的格式有cjsesmumdamdcmd

cjscommonjs 的缩写。只能用在node端,如果需要使用在浏览器中,就需要进行打包和转换。如果组件库需要考虑ssr,那么你就需要打包出cjs格式的代码

esm 就是 Javascript 提出的实现一个标准模块系统的方案,但是由于我们使用了webpack作为构建工具,所以打包不出来 esm 格式的代码。如果确实有需要打包出esm格式的代码,可以考虑使用 rollup 作为构建工具。如果组件只会在 vuecli等脚手架中使用,或者项目使用 webpackrollup等工具进行打包构建,你可以考虑打包出 esm 格式的文件。由于我们的组件已经采用了多包架构,已经天然支持按需加载的功能了,并且 webpack 打包不出来 esm 格式的代码,所以 esm 不在我们的考虑范围之内。

amd 是同步导入模块,现在用的很少了

cmd 是异步导入模块,现在用的很少了

umd 实际上就是集大成者,支持上面的所有格式,如果你仔细看打包出来的 umd 格式文件,你会发现里面会有一大堆的if else 判断。如果组件需要考虑到使用 标签进行引入,那么就需要打包出 umd 格式的文件了

我们的项目都是基于cli脚手架生成的,所以我们可以考虑的打包格式有 cjsumdamdcmd已经很少有人使用了,所以不考虑这2中格式。但是考虑到后面可能会有其他的场景需要兼容,所以我们决定打包出 umd 格式的文件。

babel配置

说到babel配置,大家可能首先想到东西有@babel/preset-env@babel/plugin-transform-runtimebabel-polyfill,这几个东西在开发第三方库或者开发项目的时候,经常都会见到它们。

  • @babel/preset-env

    无论是项目或者是第三方库,基本都会使用到它(我最早接触的时候使用的是babel-preset-es2015),因为他是用来做语法转化的,将一些高级的语法转化为浏览器识别的语法,比如 const 转化为 var。通常你需要在这个preset的配置中去配置你所需要支持的浏览器版本

  • babel-polyfill

    它是用来兼容一些低级别浏览器不支持高级别语法的插件,比如说Promise,它会将Promise转化为低级别浏览器识别的语法。

    但是只适合用来做项目上面的开发,因为它会造成全局污染。打个比方说,我现在使用到Array.prototype.includes这个函数,但是在低级别的浏览器中,并没有includes这个函数,babel-polyfill通过一些辅助函数,实现一个功能跟includes函数相同的函数,然后直接挂在到Array.prototype,因为这样子是直接修改了Array的原型链,所以说是全局污染。试想一下,如果第三方库都使用了babel-polyfill,然后都在修改全局的变量,这样势必会造成一些冲突。同时万一哪天浏览器厂商做了一些不兼容性的修改,那这样子势必会造成灾难性的问题。

    在项目上,你可以进行全局babel-polyfill,这样子可以一次性解决所有兼容性问题,但是会造成项目的打包体积变大,引入了一些不需要的polyfill,导致流量浪费。你也可以根据指定的浏览器环境进行按需引入(需要使用@babel/preset-env插件和useBuiltIns属性),在一定程度上面减少了一些不必要的polyfill。

  • @babel/plugin-transform-runtime

    babel-polyfill一样,也是用来做高级别语法的兼容。但是它解决了babel-polyfill所带来的的问题。所以现在无论是第三库还是项目基本上都是使用@babel/plugin-transform-runtime。当然,在我接触的一些16年17年的老项目中,使用的就是babel-polyfill

    @babel/plugin-transform-runtime的好处就是避免全局的冲突,所产生的兼容性代码都是具有局部作用域的,并且实现了按需引入的功能,避免了不必要的polyfill。但是这不代表他没有缺点,缺点就是每个模块会单独引入和定义polyfill函数,会造成重复定义,使代码冗余。

    假设a模块使用了Array.prototype.includes并进行了polyfill,然后a模块也使用了Array.prototype.includes并且也进行了polyfill,然后你再项目中同时使用了ab模块,这样includespolyfill函数就会被重复定义了,也就是会有2份相同的代码,这就是代码的冗余了。最后,要使用这个@babel/plugin-transform-runtime库还需要结合core-js3.x或者core-js2.x(具体看@babel/plugin-transform-runtime配置)

所以综上所诉,我们的babel配置采用@babel/preset-env+@babel/plugin-transform-runtime+core-js3.x来进行配置。

我们的组件库需要兼容到ie11,这样会导致我们的babel配置会复杂一点,同时到打包出来的代码体积大小也会变大。

当然,我们也可以进行源码级别的代码提交和发布,组件库不做任何处理,优缺点如下:

  • 缺点:需要在vuecli或者其他cli脚手架中配置配置一下组件库的打包,因为脚手架那些默认是不会打包node_modules下面的依赖包,当然,我们这里使用了art-template这个库,所以还需要配置一下art-loader。同时也不可以使用script标签这种形式引入。

  • 优点:可以跟项目的babel配置保持一致。公共的依赖可以实现公用,babel转化API(例如 babel-plugin-transform-runtime 或者 babel-polyfill)部分的代码只有一份。假设现在项目不需要兼容ie11,那么我们的组件库打包出来也不需要兼容ie11,打包代码体积减少了很多。如果想采用源码级别的提交和发布,我建议大家可以参考一下 cube-ui 组件库的后编译技术

项目结构

由于我们的业务组件每一个都是需要进行单独发布的,所以每一个业务组件都是一个npm包。但是如果每一个业务组件都新建一个git仓库,这样子必然会导致我们的业务组件库变得难以维护。所以我们决定使用Monorepo这种多包架构,这种多包架构的好处就是对每个组件进行了物理隔离,又可以把每个组件放在同一个仓库当中,而且可以灵活发布,天然支持按需加载。

这里扯一个题外话,就是单包架构和多包架构的区别。

单包架构就是一个仓库一个项目。对于组件库来说,所有组件就是一个整体。他最大的优点就是可以通过相对路径来实现代码与代码之间的引用,公共代码之间的引用。缺点就是所有代码都耦合在一起了,发布npm包得时候必须全量发布,哪怕你只改动了一行代码。并且如果作为一个组件库来说,必须提供按需加载的能力,否则会导致项目的体积增大。babel-plugin-componentbabel-plugin-import就是饿了么Ant Design提供的按需加载产物。当然,你也可以使用 ES ModulesTree shaking 的功能来实现按需加载的功能,Ant Design4.x就是使用ES ModulesTree shaking 功能来实现按需加载功能的

多包架构就是一个仓库多个子项目。对于组件库来说,每一个组件都是一个npm包。它最大的优点就是可以灵活单独发布子项目,并且天然支持按需加载功能(因为你使用到了才会去安装使用)。缺点就是模块与模块之间是物理隔离的,对于需要使用到其他模块的代码,只能通过npm包的形式来使用。同时还要借助第三方工具来管理我们的包,比如lerna

回归正题,我们的项目目录结构如下:

- project
  - build            // 打包构建
  - docs             // 文档
  - packages         // 组件代码存放目录
    - common         // 通用工具库
      - utils        // 函数库
      - message      // 消息弹框
    - note           // 笔记组件
      - ui           // 笔记UI部分
      - logic        // 笔记逻辑部分
    - live-comment   // 直播评论组件
      - ui           // 直播评论UI部分
      - logic        // 直播评论逻辑部分
  - script           // 脚本命令

通过上面,我们可以看见,每个组件文件夹下面还会有多个文件夹,主要是因为,我们的业务组件是UI和逻辑分离的,UI部分只负责渲染界面,逻辑部分负责接口请求。遵循单一职责原则

依赖包处理

我们在开发业务组件库的时候,或多或少的会使用一些第三方依赖,那么这些第三方依赖应该如何去处理呢。要么就是把依赖包打包进产物中,要么就是跳过依赖包的打包。我们这里选择跳过依赖包的打包。原因如下:

  • 如果把依赖包也打包进产物当中,那么这样子肯定会增大产物的体积的

  • 方便公用依赖包。以lodash为例,如果其他模块或者项目中页使用到了lodash,我们就可以公用一份代码。如果把lodash也打包进产物当中,那么,模块中就会有一份lodash代码,其他模块或者项目用到了lodash的,也会有一份lodash代码,这样子就会造成代码上面的冗余。

但是如果跳过依赖包的打包,也会有缺点的,就是,如果其他模块或者项目也引用了相同的依赖,但是依赖的版本不一致,如果是小版本号不一致,问题倒不是很大,如果是大版本号不一致,就很容易造成冲突。我相信很多人在平常的开发中都会遇见一些关于版本号问题的 bug,只要降低版本号就可以解决了。

这里还要讲解一下第三方库package.json中的devDependenciesdependenciespeerDependencies这三个字段。

  • devDependencies :开发运行时的依赖,这个字段中声明的依赖,在用户安装了我们这个模块之后,并不会去安装里面的依赖

  • dependencies : 运行时依赖,这个字段中声明的依赖,在用户安装了我们这个模块之后,会自动安装里面的依赖,所以需要跳过打包的依赖,都是写在这里的

  • peerDependencies :这里面声明的依赖,要求开发者在项目中必须要进行安装,否则整个模块将无法运行。像element-ui这些 UI 库都会声明需要项目预安装vue。要特别注意的是,peerDependencies里面声明的依赖,不管你是在本地开发模块,还是说在项目中使用了该模块,peerDependencies中声明的依赖是不会进行安装的。一般做法是,在peerDependencies中声明的依赖,在devDependencies中也声明,这样子在本地开发的时候就可以使用对应依赖,其他开发者在使用该模块的时候不会安装依赖。

package.json 的问题

由于我们的业务组件库是采用多包架构的。所以根目录下会有一个package.json文件,每个子项目中也会有一个package.json文件。这里主要将每个子项目中的package.json文件,需要包含以下几个字段:

  • name : 包名,跟文件夹名称会有关系的,下面会有专门的段落讲解。

  • version : 版本号

  • main : 主入口文件,开发者安装了该模块之后,并且通过import引入该模块的时候,是通过该字段来查找对应的入口文件,这个字段必须有,该字段在 browser 环境和 node 环境均可使用

  • module : es 模块通过该字段进行查找入口文件,比main字段优先级更高,该字段在 browser 环境和 node 环境均可使用。这个字段可有可无,我们这里用不上,因为打包出来的产物格式是umd模块,直接用main字段即可

  • browserbrowser 环境下的入口文件

mainmodulebrowser 字段总结:

这三个字段都是用来声明入口文件的。默认查找优先级为browser>module>main,当然,如果你是用的是webpack或者其他打包构建工具,可以修改模块的入口文件查找规则(下面的段落会提及到)。module要求导出的格式为ESM规范,如果你的打包产物中有ESM格式的文件就写上,没有就不写。如果你的模块仅仅只运行在浏览器环境,而不运行在node环境,那么就使用browser字段。最后,main字段一定一定要把他写上,因为这个字段才是最重要的。browsermodule给我的感觉就是作用不大

  • doc : 开发测试的时候的入口文件,开发的时候通过webpack来修改查找入口文件的字段为doc(下面的段落会讲解到)。与main字段不用的是,doc字段指向的是没打包前的入口文件,main字段指向的时候打包后的入口文件

  • keywords : 关键词,如果是发布到npm上面的,在查找包的时候,会把这些关键字显示出来。但是我们这里是发布到私服上面,可有可无

  • homepage : 模块的官网地址。如果是发布到npm上面的,会显示出来,但是我们是发布到私服上面,可有可以无

  • repository : 仓库地址。如果是发布到npm上面的,会显示出来,但是我们是发布到私服上面,可有可以无

  • author : 作者,可有可无

  • license : 开源协议。如果是发布到npm上面的,必须写,同时还要添加LICENSE协议说明文件。发布在私服上的时候,可以不用管,毕竟是公司内部自己使用

  • publishConfig : 发包配置。默认是发布到npm上面,如果需要发布到其他地址(私服),需要添加registry来表明发布那个地址上面。另外,如果仓库的根目录下的package.json文件中把private声明为true(使用lerna作为多包管理工具的时候,就需要把根目录的package.json中的private声明为true),这表明是一个私包,私包是不允许进行发布的,此时需要在子项目的package.json文件配置publishConfig.access:"public",才能进行发包

总结来说,对于我们的业务组件库来说,由于是发布到私服上面,所以需要填写的字段有nameversionmaindocpublishConfig

webpack 配置

关于 webpack 配置,我只挑选核心的来说,其余那些什么tsjs配置那些就不讲解了,都是一些基础配置。

首先是模板引擎art-template,需要使用art-template-loader来解析。还有一个需要注意的点是,如果你是使用webpack5的,webpack 会报错说找不到art-template,此时需要把webpack5降级到webpack4

静态资源的打包,我们参考了dplayer的做法,把 css 等静态资源都打包进 js 当中,所以需要将url-loaderoptions.limit字段设置为 400000(再大一点也没关系),让所有静态资源以 base64 的形式存在

精灵图插件,业务组件肯定会包含大量的小图标,我们借助webpack-spritesmith把这些小图标合成为一张大的精灵图,不仅可以减少网络请求,还可以提高开发效率(webpack-spritesmith会自动给对应的小图标名称生成对应的 css 类名,不需要我们写任何样式)。同时我们还做了了约定,就是所有小图标的存放目录为src/styles/icons,这样子我们在启动项目的时候会自动查找是否存在对应的文件目录,然后自动添加进去,实现动态加载(如果是新添加icons文件目录,需要重启项目,添加新图标不需要重启项目)。这样子就不需要每新增一个模块,都需要配置一下

resolve字段的配置:

resolve: {
    extensions: [".ts", ".js"],
    alias: {
        "@multi": path.join(__dirname, "../packages/multi"),
        "@live-comment": path.join(__dirname, "../packages/live-comment"),
        "@note": path.join(__dirname, "../packages/note"),
        "@common": path.join(__dirname, "../packages/common"),
        // ...
    },
    mainFields: ["doc", "main"]
}
  • extensions : 我们使用的是typescript,但是typescript在引用模块的时候是不允许添加后缀名的,所以需要配置extensions字段优先查找.ts后缀的文件

  • alias : 别名。这个非常重要。由于我们是是采用多包架构的,所以肯定会有很多不同的包,包与包之间会项目引用。

下面以@common/utils模块为例。@common/utils是我们本地的包,不存在与node_modules中,所以如果少了别名,就会报错找不到对应的模块。

@common/utils对应的目录为/packages/common/utils@common为命名空间,可以随便定义,但是@common/utils中的utils必须跟/packages/common/utils中的utils文件名相同,不相同也会查找不到。

@common/utils会匹配到"@common": path.join(__dirname, "../packages/common")这条规则,所以当我们引用了@common/utils,通过别名的映射关系,实际上是查找到了/packages/common/utils这个文件目录

当然,如果我们通过npm link对模块进行软连接,连接到当前项目根目录下的node_modules文件夹下,这样子就可以不用进行别名配置。但这样子会有一个缺点,就是我们每次有更新或者改动,就需要重新打包和npm link才能生效,这样子做反而降低了开发效率。所以我们不推荐这种做法,不然我们开发环境的热更新功能好像没啥作用

  • mainFieldswebpack的模块入口文件的查找字段优先级定义

下面以@common/utils模块为例,@common/utils通过上面的alias别名配置,查找到了/packages/common/utils这个文件目录

根据webpack的查找规则,如果/packages/common/utils文件目录下面没有package.json文件,就出默认查找index.ts文件和index.js文件,如果这2个文件都没有,就会报错说找不到模块

如果/packages/common/utils文件目录存在package.json文件,那么,webpack会根据package.json文件的mainmodulebrowser字段进行查找入口文件。当然,我们的业务组件库只会有main这个字段,所以我们的项目是根据main字段进行查找入口文件的。而我们的main字段指向的是打包过后的入口文件,这样就会导致我们每次有任何改动都需要重新打包才能生效。我们希望的是,可以在开发的时候,直接指向还没打包前的入口文件,这样子,我们有任何改动,不需要重新打包,只需要保存,即可进行热更新了

所以,我们需要在每次子项目的package.json文件中配置一个doc字段,doc字段为没打包前的入口文件(也就是打包的入口文件),然后在修改mainFields字段为["doc", "main"],这样子,webpack会优先查找doc字段,doc字段不存在才会去找main字段。这样子就可以提高我们的开发效率了,一保存就可以进行热更新了

typescript配置

typescript配置都是在项目根目录下的tsconfig.json文件下面进行配置的,我们需要注意以下几个字段:

  • compilerOptions.paths : 由于我们采用的是多包架构,所以需要配置一下子项目的包名的所对应的目录(即模块名到基于 baseUrl 的路径映射的列表),不然会包找不到对应的模块
{
    "compilerOptions":{
        "paths": {
        "@multi/*": [
            "packages/multi/*"
        ],
        "@live-comment/*": [
            "packages/live-comment/*"
        ],
        // ...
        }
    }
}
  • compilerOptions.importHelpers : 从 tslib 导⼊辅助⼯具函数,当我们将该字段声明为true时,需要安装 tslib 这个库,这个库是用来将高级语法转化为低级浏览器所是识别的语法,跟 @babel/plugin-transform-runtime 的作用差不多。我们需要兼容到ie11,所以这个字段需要声明为true

  • compilerOptions.declaration : 是否打包声明文件,我们这里设置为true,表示打包声明文件

  • compilerOptions.declarationDir : 声明文件存放目录

  • exclude : 需要排除的文件,一般都会排除掉node_modules文件目录

  • include : 包含的文件,必须填写,因为我们的文档使用的是vuepressvuepress配置支持typescript后,必须填写这个字段,声明包含哪些文件,否则会有坑

文档配置

文档方面我们使用vuepress进行编写。但是我们需要进行配置一下,才能结合我们的项目进行使用。vuepress配置是在docs/.vuepress/config.js文件进行配置的

首先是 webpack 配置,我们需要在configureWebpack字段下面进行配置,配置跟webpack 配置这个段落中写的差不多

const path = require("path");
module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        "@multi": path.join(__dirname, "../../packages/multi"),
        "@live-comment": path.join(__dirname, "../../packages/live-comment"),
        "@note": path.join(__dirname, "../../packages/note"),
        "@common": path.join(__dirname, "../../packages/common")
      },
      mainFields: ["doc", "main"]
    },
    module: {
      rules: [
        {
          test: /\.art$/,
          loader: "art-template-loader"
        }
      ]
    }
  }
};

vuepress默认是不支持typescript的,所以我们要借助vuepress-plugin-typescript插件支持typescript。但是使用起来还是有些需要注意的细节,否则会采坑。

  • vuepress-plugin-typescript的配置必须开启composite:true,声明为项目打包,否则会报错,配置如下:
const path = require("path");
module.exports = {
  plugins: {
    "vuepress-plugin-typescript": {
      tsLoaderOptions: {
        configFile: path.resolve(__dirname, "../../tsconfig.json"),
        compilerOptions: {
          composite: true
        }
      }
    }
  }
};

  • tsconfig.json 文件的compilerOptions.declaration字段必须设置为trueinclude字段也必须填写

总的来说,文档这方面配置起来不算难。但是关于vuepress使用vuepress-plugin-typescript插件支持typescript这里我还是遇见了不少的问题,花费了不少时间,最终通过百度或者issue,才最终找出解决方案。

文档编写

每个组件都需要有对应的文档说明,不然其他开发者也不知道怎么去使用你的组件。我认为文档编写需要包含如下几部分:

  • 演示效果:给其他人看看组件的最终效果是怎么样的,跟项目上所需要的功能是否一致

  • 介绍:简单介绍一下这个组件是干什么的,有什么用

  • 安装:告诉别人怎么去安装你的组件

  • 快速开始:这里是告诉别人怎么快速初始化一个简单的组件实例,再初始化的时候,把一些必填的参数写进去,别人看见了就可以一目了然了

  • 参数:这里列举出所有在初始化时可填写的参数,每个参数的说明类型可选值默认值,都要说清楚

  • 组件实例属性和方法:这里需要说明实例化出来的组件有什么方法,每个方法是干什么用的,需要传什么参数。还有组件实例的属性,也需要说明是干什么的

  • 参数结构:有些参数可能是一个Object对象,我们需要在这里说明一下这个Object对象有哪些键值对,每个键值对的说明类型可选值默认值都要说清楚

  • 自定义事件:组件的会派发出一些事件给外部使用,每个事件的事件名称说明(触发条件)回调函数要说明白

  • 主题定制:因为我们是使用原生css变量来实现自定义组件主题的,我们要告诉其他使用者怎么去进行自定义,所以每个原生css变量需要有对应的变量名说明默认值等字段的说明

  • 国际化(中英文切换):这里要说明怎么进行中英文切换,怎么去自定义语言包。

单元测试

单元测试方面,我们是用的jest,但是由于jest本身是不支持.ts(typescript),.scss(scss样式文件),.art(art-template文件)文件和一些静态资源文件的,所以我们要对这些文件进行配置。

.ts文件使用 ts-jest 进行转化,当然,现在最新版的babel-jest也是支持typescript文件的转化的。

.art文件使用 jest-transformer-arttemplate 进行转化。

.scss文件和静态资源文件使用自定义处理器进行转化,直接返回一个空字符串回来即可

还需要注意的是需要配置testEnvironment:jsdom,设置为浏览器环境。我记得[email protected]之前的版本是不用写这个东西的,但是在后来的版本中需要写一下,不然会报错

现在还需要做的就是给个子项目配置一个别名,不然找不到对应的子项目。我们需要在moduleNameMapper这个字段中配置别名

还有一点需要注意的是,我们引入的typescript文件是没有后缀名的,所以需要使用moduleFileExtensions这个字段相应的配置一下优先匹配那些后缀名的文件

其实,无论是别名的配置,还是文件后缀名的配置,其实跟webpack的别名和后缀名配置大同小异的

最后,配置如下:

const path = require("path");

module.exports = {
  testEnvironment: "jsdom",
  moduleFileExtensions: ["ts", "json", "js", "art"],
  transform: {
    ".*\\.(ts)$": "ts-jest",
    ".+\\.art$": "jest-transformer-arttemplate",
    "\\.(css|scss)$": "/tests/__mocks__/styleTransformer.js",
    "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
      "/tests/__mocks__/fileMock.js"
  },
  rootDir: path.join(__dirname),
  moduleNameMapper: {
    "^@common/(.*)$": "/packages/common/$1/index.ts",
    // ...
  },
  testMatch: [
    // 匹配测试用例的文件
    "/**/__tests__/*.test.ts"
  ]
};

组件化开发

虽然我们没有使用到vue或者react这些框架,但是我们也要遵循组件化开发的思想,提高组件的复用度。目前我们的业务组件中会有一些基础组件,比如button按钮组件,checkbox多选框组件和image图片组件,这些基础组件是比较通用的,所有必须把它封装成一个组件,未来可能会有更多类型的组件。

首先我们封装的组件将会是一个类(image组件是个函数),通过new的形式去实例化组件实例,跟vuereact等框架类似,每个组件都会有自己的属性,行为和DOM

组件与外部的交互。以button组件为例,button组件会有点击事件,组件内部是不做任何处理行为,通过事件派发的形式派发出去,交由外部去进行处理。这个时候,就需要button组件继承我们已经封装好的EventEmit类(实际上就是发布订阅模式,跟nodejsEventEmit类似)。EventEmit类提供了$emit发射自定义事件,$on监听自定义事件,$once监听一次自定义事件,$off取消监听自定义事件,clear清除所有自定义事件。button组件通过$emit把点击事件派发出去,组件外部通过$on或者$once监听点击事件

组件的行为。还是以button组件为例。button组件可以设置禁用状态,也可以设置loading状态

button组件代码示例如下:

import { EventEmit, parseStrToDom } from "@common/utils";
import i18n from "../locale/index";
import buttonTpl from "../template/button.art";

const disabledClassName = "note-is-disabled";

interface ButtonOptions {
  // 按钮内容
  label: string;
  // 按钮类名
  className?: string;
  // 插槽,按钮摆放的位置
  slotElement: HTMLElement;
  // 是否替换插槽,`true`时`button`组件将会替换掉插槽,`false`时`button`组件将会追加到插槽当中
  replace?: boolean;
}

class Button extends EventEmit {
  private options: ButtonOptions;
  // button元素
  element: HTMLButtonElement;
  // button是否禁用标志位
  private isDisabled = false;
  constructor(options: ButtonOptions) {
    super();
    this.options = options;
    this.initHtml();
    this.initListener();
  }

  private initHtml() {
    const html = buttonTpl({
      ...this.options
    });
    this.element = parseStrToDom(html) as HTMLButtonElement;
    const slotElement = this.options.slotElement;
    if (this.options.replace) {
      slotElement.parentElement?.replaceChild(this.element, slotElement);
    } else {
      slotElement.appendChild(this.element);
    }
  }

  private initListener() {
    // 监听按钮的点击事件
    this.element.addEventListener("click", () => {
      // 交给外部处理
      this.$emit("click");
    });
  }

  // 设置按钮禁用状态
  setDisabled(disabled: boolean) {
    if (this.isDisabled !== disabled) {
	    if (disabled) {
	      this.element.classList.add(disabledClassName);
	    } else {
	      this.element.classList.remove(disabledClassName);
	    }
	    this.element.disabled = disabled;
	    this.isDisabled = disabled;
    }
  }

  setLoading(loading: boolean) {
    if (loading) {
      this.element.innerHTML = i18n.t("saving");
    } else {
      this.element.innerHTML = i18n.t("save");
    }
    this.element.disabled = loading;
  }
}

export default Button;

image组件不需要封装太多的东西,只需要根据传入的数组图片,从第一张开始加载,第一张加载失败就加载第二张,直到全部加载失败,所以只需要封装成一个函数即可。

image组件代码示例如下:

export default function createImage(list: string[]) {
  const image = new Image();
  list = list.filter(Boolean);
  if (list.length === 0) {
    return image;
  }
  let index = 0;
  image.src = list[index];
  const onError = () => {
    if (index < list.length - 1) {
      index++;
      image.src = list[index];
    } else {
      removeListener();
    }
  };
  const onLoad = () => {
    removeListener();
  };
  const removeListener = () => {
    image.removeEventListener("error", onError);
    image.removeEventListener("load", onLoad);
  };
  image.addEventListener("load", onLoad);
  image.addEventListener("error", onError);
  return image;
}

国际化(中英文切换)

考虑到有些项目需要做中英文切换的功能,所以我们的业务组件库也需要支持中英文切换。

我们的做法是通过全局来设置中英文语言,而不是new的时候去初始化语言,这样子做的好处在于可以很方面的管理我们的业务组件的语言设置,因为我们的业务组件不仅仅只有一个,会有多个,如果每次都需要在初始化的时候传入语言参数,不方便项目上面的管理

因为每个组件都需要实现中英文切换功能,所有我们也有必要将中英文切换的功能抽离出来,方便每个组件复用。我们只需要传入中英文语言,即可得到tusei18nsetLangcurrentLang等函数和属性。如果有使用过vue-i18n这个东西的同学应该对上面那些函数和属性都很熟悉的

自定义主题

每个项目使用的主题色都是不一样的,所以我们需要提供自定义主题的功能。

常规的做法就是像element-ui等UI库一样,将所有变量写在一个样式文件中,如果需要自定义主题就需要去改变样式文件中的变量,然后重新打包。这样子做的弊端有:

  • 每个项目的主题色都不一样,导致每次都需要重新修改样式变量,将样式进行重新打包编译,然后放到项目中

  • 如果需要实现换肤功能的需求,那还要准备多套不同的主题色的样式,导致打包出来的代码包体积变大

为了解决上述的弊端,我们是使用 css 原生变量来实现自定义主题的。这样做的好处有:

  • 通过复写 css 变量,将原有的变量进行覆盖,就可以轻松换主题色,无需进行重新打包

  • 动态改变主题色,由于 css 变量可以通过 js 进行修改,所以你可以轻松的变换组件库的主题色

通过 css 改变主题色

:root {
  --note-theme: green;
  
  /* ... */
}

通过 js 改变主题色

document.documentElement.style.setProperty("--note-theme", "green");

// ...

视图层与逻辑层分离

在平常的项目开发中,我们都知道前后端分离。那么在我们的业务组件库开发中也要对视图层逻辑层进行分离,遵循mvvm开发模式,这样子做可以提高我们的代码复用度。

虽然说我们的业务组件库在每个项目中的表现形式是差不多的,但是有些项目(或者是需求)是需要进行定制化开发的,我们实现的业务组件功能跟客户需要的可能会有所差别,可能是视图层上的差别,也可能是逻辑层上面的差别。

所以,我们有必要将视图层逻辑层进行分离,视图层提供修改界面元素的接口,比如增删改查一个DOM元素。逻辑层利用视图层提供的接口实现各种需求。

当我们有项目需要进行定制化开发的时候,如果是视图层有差别,那么,我们仅仅只需要重写视图层等东西,然后提供相对应的接口给逻辑层使用即可;如果是逻辑层有差别,那么我们只需要重写逻辑层即可,利用已有的视图层接口实现自己的逻辑

逻辑层,你可以继续细分,比如又可以细分为api层数据层api层负责接口请求,数据层负责数据的处理

提取公共代码,形成工具库

跟其他第三方库一样,我们的业务组件库肯定会有很多重复的代码,所以我们有必要抽取成一个工具库,方便每个业务组件去复用

比如我们编写UI的时候,是通过art-template生成字符串,然后再把字符串转化为DOM,虽然代码上面只有2行,但是使用的地方有很多,很多人都认为代码量少,就没必要进行提取了。但是你事想一下,如果有一天,来需求了,要在这些DOM上面都添加一个custom-class的默认类名,如果你进行了封装,那么只需要改动一个地方。如果你没有封装,那么你就需要通过开发工具,进行全局搜索,然后在一个一个地方的去改

所以我们在开发业务组件库的时候约定,只要出现超过2次的代码,基本都要提取出来进行封装,哪怕只有一行代码,也要进行封装。这是我做项目以来总结出来经验,可能刚开始的时候,体现不出来提取公共代码出来封装的好处,甚至还会觉得很麻烦(毕竟可能只有一两行代码),但是这种封装公共代码的好处在后期是越来越有用的。当代码体积达到一定程度时,提取出来的公共代码不仅可以减少你的代码量,还很方便的进行管理

及时进行重构和优化

很多代码并不是说一下子就能写的很好,需要反复进行推敲,思考这样做到底好不好,好的地方在哪里,不好的地方又在哪里,是否有进步的空间。

我们要在开发是不断的进行优化,重构。假设你在开发某个功能的时候,突然灵感大发,发现某个地方可以做的更好,可以提高性能。那么,请不要犹豫,马上进行你的优化或者重构,否则等到开发完成之后,再去优化和重构,就会显得非常困难。

其实我在一开始开发第一个业务组件的时候,并没有进行组件化开发的,到后来,开发第二个业务组件的时候,发现其实有很多共同点的,比如button按钮组件,image图片组件,这些其实是可以抽取出来进行复用的,然后我就突然灵感爆发,想起了组件化开发,开始了第一次的重构和优化,当时的代码量也不多,重构和优化进行起来也是非常快的。直到现在,组件化开发这个东西,用起来真爽。

还有一点就是我们进行DOM操作的时候,肯定会涉及到很多DOM的查询和DOM操作,我们可以把所有获取DOM元素的操作都封装进一个类中,通过这个类实例去获取你所需要的DOM。因为在进行组件化开发的时候,发现组件可能会需要获取同一个DOM,这样子就会导致,如果你修改了这个DOM的类名,你就需要修改多个地方。如果我们可以把所有获取DOM元素的操作都放在一起,那么就可以只修改一个地方了。其实这个把所有DOM元素的获取都放在一起进行管理的思想我是参考了dplayer源码的东西

发布组件

由于我们采用的是多包架构,所以不能像单包架构那样,直接在项目根目录运行npm publish,只能进入到子项目的目录中运行npm publish。但是这样子是很不方便的,原因如下:

  • 打包是在项目根目录下运行npm run build,而发布组件就需要进入到子项目根目录中进行npm pulish,需要来回切换目录,比较麻烦

  • 因为我们采用的是多包架构,子项目之间可能会存在依赖,比如@note/ui依赖于@common/[email protected],一旦@common/utils升级到了1.0.1,那么@note/ui所依赖的@common/utils也需要升级到1.0.1,而且需要我们手动去升级版本号,然后在进行发包,相当麻烦。

为了解决上述问题,我们参考了lerna工具的发包流程,自定义了一个发包工具,在项目根目录下运行npm run pub [packageName]即可进行发包,发包流程如下,以 @common/utils 为例:

  • 检查代码区是否有代码没提交

    • 是,直接报错,提示先提交代码
    • 否,执行下一步
  • 检查@common/utils包名是否正确或者是否存在

    • 不正确或者不存在,直接报错,给出对应提示
    • 检查通过,执行下一步
  • 检查@common/utils是否为第一次发布

    • 是,执行下一步
      • 给出升级的版本号列表,用户选择版本号,然后回写package.json文件的version字段
      • 检查其他子项目中是否依赖了@common/utils。如果依赖了,就会把依赖的@common/utils版本号升级到最新
  • 执行打包

  • 开始发包

  • 结束

    • 成功:给出提示,发包成功
    • 错误:回滚代码(回滚版本号那些)

代码工作流

这里主要是检查代码是否规范,以及在提交代码的时候对代码进行校验,保证代码的格式统一。这里就不详细去讲了,有兴趣的可以参考我的另一篇博客,里面讲的很详细的。

前端代码工作流

未来的期望

就目前这个阶段来说,很多东西已经做得很完备了。但是还有些功能是需要去优化和实现的,后期可能还会添加更多的功能。比如目前没有一套完成的CICD流程,自己写的发布组件脚本,虽然可以发布组件,但是还缺少了自动打tag的功能等等。所以期望点如下:

  • 实现一套完备的CICD流程,但是这方面的知识我比较薄弱,需要加强学习

  • 给发布组件脚本添加一个自动打tag的功能

  • 子项目安装第三方依赖目前已经有对应的脚本已经实现了,但是子项目卸载第三方依赖的脚本还没有实现,需要去实现一下,完备一下功能

  • 后期还需要对组件进行性能调优,提高组件的性能。比如笔记组件添加缓存功能等等

总结

实现一个业务组件库并不难,难的是怎么把这个业务组件库做好,一个好的项目架构,才能更加方便后期去维护。最后如果大家有什么好的建议或者问题,欢迎在下方留言

你可能感兴趣的:(笔记,文章,业务组件库,webpack,typescript)