如何做好一个前端业务组件库
Posted 在厕所喝茶
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何做好一个前端业务组件库相关的知识,希望对你有一定的参考价值。
如何做好一个前端业务组件库
- 前言
- 业务组件库与基础组件库的区别
- 技术选型
- 打包文件格式
- babel配置
- 项目结构
- 依赖包处理
- package.json 的问题
- webpack 配置
- typescript配置
- 文档配置
- 文档编写
- 单元测试
- 组件化开发
- 国际化(中英文切换)
- 自定义主题
- 视图层与逻辑层分离
- 提取公共代码,形成工具库
- 及时进行重构和优化
- 发布组件
- 代码工作流
- 未来的期望
- 总结
前言
建立业务组件库的目的就是为了维护一套业务组件,然后提供给多个项目使用。解决每个项目相互复制粘贴的问题。同时也减少了维护的成本,减少开发人员的重复工作,提高工作效率。本文将讲述我在公司如何开发出一个前端业务组件库的过程,以及一些思考,问题,经验总结
业务组件库与基础组件库的区别
1、基础组件库是不受业务逻辑的影响的,业务组件是在基础组件的基础上堆积起来的,然后加上对应的业务逻辑就形成了一个业务组件了。
2、基础组件是 UI 层面的封装,业务组件是 UI 层面和业务层面的封装。
3、业务组件中包含了一些静态资源的东西,比如图片,字体图标等等。而基础组件更多的是代码层面的东西
4、基础组件通常都是所有组件打包在一起,然后形成一个npm包
。像element-ui
,AntDesign
等组件库都是如此的。而业务组件库由于项目的不同,并不是所有的业务组件都会是使用上,而且由于业务组件会包含很多静态资源,全部打包在一起会造成体积过大。
技术选型
公司主要的技术栈是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
作为我们的打包构建工具。现在我们要确定一下我们的打包产物。
首先是格式要求,常见的格式有cjs
,esm
,umd
,amd
,cmd
cjs
是 commonjs
的缩写。只能用在node端,如果需要使用在浏览器中,就需要进行打包和转换。如果组件库需要考虑ssr,那么你就需要打包出cjs
格式的代码
esm
就是 javascript
提出的实现一个标准模块系统的方案,但是由于我们使用了webpack
作为构建工具,所以打包不出来 esm
格式的代码。如果确实有需要打包出esm
格式的代码,可以考虑使用 rollup
作为构建工具。如果组件只会在 vuecli
等脚手架中使用,或者项目使用 webpack
,rollup
等工具进行打包构建,你可以考虑打包出 esm
格式的文件。由于我们的组件已经采用了多包架构,已经天然支持按需加载的功能了,并且 webpack
打包不出来 esm
格式的代码,所以 esm
不在我们的考虑范围之内。
amd
是同步导入模块,现在用的很少了
cmd
是异步导入模块,现在用的很少了
umd
实际上就是集大成者,支持上面的所有格式,如果你仔细看打包出来的 umd
格式文件,你会发现里面会有一大堆的if else
判断。如果组件需要考虑到使用 <script></script>
标签进行引入,那么就需要打包出 umd
格式的文件了
我们的项目都是基于cli脚手架生成的,所以我们可以考虑的打包格式有 cjs
和umd
,amd
和cmd
已经很少有人使用了,所以不考虑这2中格式。但是考虑到后面可能会有其他的场景需要兼容,所以我们决定打包出 umd
格式的文件。
babel配置
说到babel配置,大家可能首先想到东西有@babel/preset-env
,@babel/plugin-transform-runtime
和babel-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
,然后你再项目中同时使用了a
和b
模块,这样includes
的polyfill
函数就会被重复定义了,也就是会有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-component
和babel-plugin-import
就是饿了么
和Ant Design
提供的按需加载产物。当然,你也可以使用 ES Modules
的 Tree shaking
的功能来实现按需加载的功能,Ant Design4.x
就是使用ES Modules
的 Tree 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
中的devDependencies
,dependencies
,peerDependencies
这三个字段。
-
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
字段即可 -
browser
:browser
环境下的入口文件
main
,module
,browser
字段总结:
这三个字段都是用来声明入口文件的。默认查找优先级为browser
>module
>main
,当然,如果你是用的是webpack
或者其他打包构建工具,可以修改模块的入口文件查找规则(下面的段落会提及到)。module
要求导出的格式为ESM
规范,如果你的打包产物中有ESM
格式的文件就写上,没有就不写。如果你的模块仅仅只运行在浏览器环境,而不运行在node
环境,那么就使用browser
字段。最后,main
字段一定一定要把他写上,因为这个字段才是最重要的。browser
和module
给我的感觉就是作用不大
-
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"
,才能进行发包
总结来说,对于我们的业务组件库来说,由于是发布到私服上面,所以需要填写的字段有name
,version
,main
,doc
,publishConfig
webpack 配置
关于 webpack 配置,我只挑选核心的来说,其余那些什么ts
,js
配置那些就不讲解了,都是一些基础配置。
首先是模板引擎art-template
,需要使用art-template-loader
来解析。还有一个需要注意的点是,如果你是使用webpack5
的,webpack 会报错说找不到art-template
,此时需要把webpack5
降级到webpack4
静态资源的打包,我们参考了dplayer
的做法,把 css 等静态资源都打包进 js 当中,所以需要将url-loader
的options.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
才能生效,这样子做反而降低了开发效率。所以我们不推荐这种做法,不然我们开发环境的热更新功能好像没啥作用
mainFields
:webpack
的模块入口文件的查找字段优先级定义
下面以@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
文件的main
,module
,browser
字段进行查找入口文件。当然,我们的业务组件库只会有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
: 包含的文件,必须填写,因为我们的文档使用的是vuepress
,vuepress
配置支持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
字段必须设置为true
,include
字段也必须填写
总的来说,文档这方面配置起来不算难。但是关于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
的形式去实例化组件实例,跟vue
,react
等框架类似,每个组件都会有自己的属性,行为和DOM
。
组件与外部的交互。以button
组件为例,button
组件会有点击事件,组件内部是不做任何处理行为,通过事件派发的形式派发出去,交由外部去进行处理。这个时候,就需要button
组件继承我们已经封装好的EventEmit
类(实际上就是发布订阅模式,跟nodejs
的EventEmit
类似)。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 以上是关于如何做好一个前端业务组件库的主要内容,如果未能解决你的问题,请参考以下文章