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

阅读: 评论:0

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

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

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

  • 前言
  • 业务组件库与基础组件库的区别
  • 技术选型
  • 打包文件格式
  • 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 判断。如果组件需要考虑到使用 <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");
ports = {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");
ports = {plugins: {"vuepress-plugin-typescript": {tsLoaderOptions: {configFile: solve(__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");ports = {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 (place) {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.ve(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 = () => {veEventListener("error", onError);veEventListener("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/utils@1.0.0,一旦@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的功能

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

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

总结

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

本文发布于:2024-02-01 13:21:08,感谢您对本站的认可!

本文链接:https://www.4u4v.net/it/170676486936890.html

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。

标签:好一个   如何做   组件   业务
留言与评论(共有 0 条评论)
   
验证码:

Copyright ©2019-2022 Comsenz Inc.Powered by ©

网站地图1 网站地图2 网站地图3 网站地图4 网站地图5 网站地图6 网站地图7 网站地图8 网站地图9 网站地图10 网站地图11 网站地图12 网站地图13 网站地图14 网站地图15 网站地图16 网站地图17 网站地图18 网站地图19 网站地图20 网站地图21 网站地图22/a> 网站地图23