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

Posted 在厕所喝茶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何做好一个前端业务组件库相关的知识,希望对你有一定的参考价值。

前言

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

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

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 判断。如果组件需要考虑到使用 <script></script> 标签进行引入,那么就需要打包出 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,设置为浏览器环境。我记得jest@26.x之前的版本是不用写这个东西的,但是在后来的版本中需要写一下,不然会报错

现在还需要做的就是给个子项目配置一个别名,不然找不到对应的子项目。我们需要在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)$": "<rootDir>/tests/__mocks__/styleTransformer.js",
    "\\\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
      "<rootDir>/tests/__mocks__/fileMock.js"
  ,
  rootDir: path.join(__dirname),
  moduleNameMapper: 
    "^@common/(.*)$": "<rootDir>/packages/common/$1/index.ts",
    // ...
  ,
  testMatch: [
    // 匹配测试用例的文件
    "<rootDir>/**/__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 = lis

以上是关于如何做好一个前端业务组件库的主要内容,如果未能解决你的问题,请参考以下文章

搭建前端组件库

大前端时代,如何做好C 端业务下的React SSR?

Tailwind.css 体验总结

Tailwind.css 体验总结

前端项目目录如何组织

低代码平台灵魂组件:JVS·逻辑引擎2.1.7版本更新说明