记录一次UI组件库搭建过程,涉及到的技术很多,也遇到很多问题,由于已经有很多成熟的方案,故没有详细介绍一步一步搭建过程,只是记录关键技术点和遇到的问题,大量借鉴各社区大佬文章及解决方案,最终得以实现,站在巨人肩膀上,致敬,学习!
下面内容,你可以跳过直接去gitee查看源码,如果对你有帮助,希望start一下 谢谢!
gitee: gitee.com/cheerqjy/qi…
有任何问题欢迎和我交流、探讨、学习。
介绍
一个基于monorepo
模式管理的的一个vue组件库
。
- 💋 支持按需加载 / 全量加载
- 👀 支持esm 和 umd 打包模式
- 🐌 plop 自动化创建组件
- 🔥 基于pnpm的workspace管理的Monorepo
- 💪 完善的组件提示功能 三斜线方式
- 📏 代码风格统一
- 💉 全量导入
element-plus
及其icon
你可以跳过本章节,直接进入下一章节,了解开发UI组件库的核心步骤
技术选型
- vue3
- vite
- pnpm
- gulp + rollup
- vitePress
- typeScript
- plop自动化构建组件文件
- ts 类型声明三斜线,完善的组件提示
- eslint团队代码规范
目录结构
目录含义
|-- qi-ui-plus
|-- plopfile.js # 自动化创建组件+docs
|-- pnpm-workspace.yaml # pnpm 子包管理
|-- build # 打包配置
| |-- component.ts
|-- docs # 文档
| |-- index.md # 文档首页配置
| |-- vite.config.ts # 打包element-plus css时报错 配置哦
| |-- .vitepress
| | |-- config.js # 文档路由配置
| | |-- theme
| | |-- custom.css
| | |-- index.js # 相当于vue项目的main.js 引入element-plus
| |-- components
| | |-- icon
| | |-- index.md # 组件对应的文档
| |-- guide
| |-- install
| | |-- index.md # UI组件库的开发步骤文档
| |-- intro
| | |-- index.md # 介绍文档
| |-- quickstart
| |-- index.md # 快速上手文档
|-- packages
| |-- components # 子包:组件库目录
| | |-- index.ts # 组件库入口文件
| | |-- icon # icon组件
| | |-- index.ts
| | |-- src
| | |-- icon.ts # icon组件的props
| | |-- icon.vue
| |-- qi-ui-plus # 子包:打包依据
| | |-- index.ts
| |-- theme-chalk # 子包:样式
| | |-- gulpfile.ts
| | |-- src
| | |-- icon.scss
| | |-- index.scss # 应在此文件中导入其他scss文件
| | |-- fonts
| | | |-- iconfont.ttf
| | |-- mixins
| | |-- mixin.scss
| |-- types
| | |-- index.ts # 类型声明,组件提示,新建组件后必须在此注册
| |-- utils
|-- play # 本地测试用的vue项目 目录
|-- plop-template
| |-- config.js # plop自动化脚手架 生成组件+docs用的配置
| |-- component
| | |-- index.hbs # 创建组件的入口模板
| | |-- src
| | |-- ts.hbs # 创建组件的props的模板
| | |-- vue.hbs # 创建组件的模板
| |-- docs
| |-- md.hbs # 创建文档的模板
自动生成目录结构
# 1、安装mddir
npm install mddir -g
# 2、cd 到你想生成目录的工程结构,直接运行mddir
mddir
# 会有一个叫directoryList.md的文件,项目对应的目录结构就在里面
pnpm
pnpm 介绍
pnpm 具有安装速度快、节约磁盘空间、安全性好等优点,它的出现也是为了解决npm
和yarn
存在的问题。
pnpm
通过硬链接与符号链接结合的方式,来解决yarn
和npm
的问题。
- 硬链接 :硬链接可以理解为源文件的副本,
pnpm
会在全局store
存储项目node_modules
文件的硬链接。硬链接可以使得不同的项目可以从全局store
寻找到同一个依赖,大大节省了磁盘空间。 - 软链接 :软链接可以理解为快捷方式,
pnpm
在引用依赖时通过符号链接去找到对应磁盘目录(.pnpm)
下的依赖地址。
比如 A 依赖 B
,A
下面是没有 node_modules
的,而是一个软链接。实际真正的文件位于 .pnpm
中对应的 A@1.0.0/node_modules/A
目录并硬链接到全局 store 中。
而 B
的依赖存在于 .pnpm/B@1.0.0/node_modules/B
。
而 A
依赖的B
,用软链接链到 上面的地址 ,也就是B \--> ../../B@1.0.0/node_modules/B
node_modules
├── A --> .pnpm/A@1.0.0/node_modules/A
└── .pnpm
├── B@1.0.0
│ └── node_modules
│ └── B ==> <store> /B
└── A@1.0.0
└── node_modules
├── B --> ../../B@1.0.0/node_modules/B
└── A ==> <store> /A
-->
代表软链接, ==》
代表硬链接
而这种嵌套node_modules
结构的好处在于只有真正在依赖项中的包才能访问,很好地解决了幽灵依赖的问题。此外,因为依赖始终都是存在 store 目录下的硬链接,相同的依赖始终只会被安装一次,多重依赖的问题也得到了解决。
- 当然 pnpm 也存在一些局限。
pnpm-lock.yaml
和package-lock.json
不一致,不能兼容。- 一些场景不兼容,比如
Electron
。 - 不同应用的依赖是硬链接到同一份文件,所以不能直接修改依赖文件,否则会影响其他项目。而且因为安装结构不同,原来的
patch-package
之类的工具也不能用了。
虽然还有种种问题,但总体来说瑕不掩瑜。
参考:
developer.51cto.com/article/708…
monorepo两种项目的组织方式
- Multirepo(Multiple):每一个包对应一个项目
- Monorepo(Monolithic Repository):单一代码库,一个项目仓库中管理多个模块/包
monorepo 就一定需要专门的工具(库)才能实现吗?
答案当然是否定的,严格意义上说,只要将多个项目放在一个存储库里就算 monorepo。 现在主流的 monorepo 所承担的责任并不只是存储的问题,还可以承担比如依赖管理,增量构建等一系列工程化的功能,已经成为工程化技术中非常有价值的一块领域,所以有时你为了实现某个特殊的功能不得不借助社区的力量,或者站在大佬的肩膀上。
Monorepos 的优点
- 依赖管理:共享依赖,所有的代码都在一个仓库。版本管理非常方便。
- 代码复用:所有的代码都在一个仓库,很容易抽离出各个项目共用的业务组件或工具,并通过 TypeScript 在代码内引用。
- 一致性:所有代码在一个仓库,代码质量标准和统一风格会更容易。
- 透明度:所有人都能看到全部代码,跨团队协作和贡献更容易。
Monorepos 的缺点
- 性能 :代码越来越多, Git、IDE 之类的工具会越来越卡。
- 权限 :管理文件权限会更具挑战, Git 目录并没有内置的权限管理系统,整个项目是没办法区分某些部门开放哪个项目,某些部门关闭的。
- 学习成本:对新人来说,项目变大了,学习成本自然会更高。
Monorepo 策略不完美,但某些方面来说确实解决了一些项目的维护和开发体验。
如果你的项目有多个关联仓库,或者还在用 submodule 方式管理多个仓库,那可以试一试 Monorepo 。
使用 monorepo 策略后,收益最大的两点是:
- 避免重复安装包,因此减少了磁盘空间的占用,并降低了构建时间;
- 内部代码可以彼此相互引用;
为了实现前面提到的两点收益,您需要在代码中做三件事:
- 调整目录结构,将相互关联的项目放置在同一个目录,推荐命名为 packages;
- 在项目根目录里的
package.json
文件中,设置workspaces
属性,属性值为之前创建的目录; - 同样,在
package.json
文件中,设置private
属性为true(为了避免我们误操作将仓库发布);
经过修改,您的项目目录看起来应该是这样:
.
├── package.json // `devDependencies`and`scripts`for the monorepo
└── packages/
├── @mono/project_1 // 社区推荐使用 `@<项目名>/<子项目名>` 的方式命名
│ ├── index.js
│ └── package.json // `dependencies`, unique`devDependencies`and`scripts`for the package
└── @mono/project_2/
│ ├── index.js
│ └── package.json
参考:www.kancloud.cn/chandler/we…
pnpm 、npm 、yarn 、lerna
npm/yarn
采用了直接平铺的方式,而 pnpm
则是采用 .pnpm
隐藏目录隐藏真实的平铺结构,再使用链接(symbollink)的方式将真实安装的目录映射到 node_modules 下
参考链接(非常推荐):平铺的结构不是 node_modules 的唯一实现方式
天生支持 monorepo(workspace 特性,体验也比 lerna 或是 yarn workspace 好太多)
mono-repo
最出名是使用 Lerna 管理 workspaces。但是后来pnpm
取代之前的lerna
,blog.csdn.net/astonishqft…
Lerna 自动化发布 管理发布npm和git
Lerna
是npm
模块的管理工具,为项目提供了集中管理package
的目录模式,如统一的 repo
依赖安装、package scripts
和发版
、清理工程环境
等特性。
workspace依赖管理
如果不用workspaces
时,因为各个package
理论上都是独立的,所以每个package
都维护着自己的dependencies
,而很大的可能性,package
之间有不少相同的依赖,而这就可能使install
时出现重复安装,使本来就很大的 node_modules
继续膨胀(这就是「依赖爆炸」...)。
yarn
也有workspaces
依赖管理
初始化项目
pnpm init -y
pnpm init -f # -y:yes /-f:force
pnpm-workspace.yaml管理workspace
pnpm-workspace.yaml
中注册的文件夹为pnpm
管理的子项目
, 在根目录(workspace root)中执行pnpm install
会安装node_modules
到所有子项目中包括根目录
packages:
- docs # 文档预览
- packages/* # 存放编写的组件的
- play # 测试编写组件
安装包到根目录
# 安装到工作区根目录并且是开发依赖
pnpm install 包名 -D -w
安装包到子目录 --filter
利用--filter
可以直接在根目录指定安装依赖到子包,当然--filter
还有其他功能。
# --filter 或者 -F <package_name> 可以在指定目录 package 执行任务
pnpm i -F demo # 在根目录中向demo目录安装所有依赖
pnpm i vue -F demo # 在根目录中向demo目录安装vue
那问题来了,如果多个包都配置好了依赖,想要一键安装怎么办,一个一个包去这样做吗?
还是说pnpm i -F core ui util
三个还好说,如果有三十个呢?
这个时候了解一下 -r, --recursive 命令可以做到一键递归安装
pnpm i -r
操作子目录命令 --filter
除了一些全局生效的命令之外,像我们可以按需求配置执行 project 的启动和打包
// root package.json
"script": {
"dev:demo": "pnpm -F demo dev",
"build:demo": "pnpm -F demo build"
}
// demo子包 package.json
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
}
操作子目录命令 -C
...
"scripts": {
"dev": "pnpm -C play dev" # 执行play下的dev脚本
},
...
-C <path>, --dir <path>
在 <path> 中启动 pnpm ,而不是当前的工作目录。
...
-w, --workspace-root
在工作空间的根目录中启动 pnpm ,而不是当前的工作目录。
pnpm 清理
在依赖乱掉或者工程混乱的情况下,清理依赖
"clean": "pnpm run clean:dist && pnpm run clean --filter ./packages/ --stream",
"clean:dist": "rimraf dist",
参考:element-plus 作者 zhuanlan.zhihu.com/p/484016976
plop
plop是一个命令行工具,通过配置模板,生成对应文件,可以理解为脚手架工具, 在开发组件库时,只需要简单的通过npm run plop
,即可一键快速创建组件
及docs文档
。
高效且准确,不用关心如何配置。
.
├── plopfile.js
└── plop-template/
├── config.js
参考:tg-ui
组件类型提示
ts的三斜线 类型提示
qi-ui-plus
提供了所有组件的类型定义,你可以参考下面的代码进行导入类型声明。(参考idux)
// env.d.ts
/// <reference types="vite/client" />
/// <reference types="qi-ui-plus/types" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
以下无用:
element-plus的根目录下components.d.ts有类似的文件,但是element是给volar使用的,xbb-plus的组件提示 也是一样
declare module '@vue/runtime-core' {
export interface GlobalComponents {
"dx-button": typeof import('@tophant-cd-ui/components/button')['default']
DxButton: typeof import('@tophant-cd-ui/components/button')['default']
}
}
发布到@types
Documentation coming soon
参考:
webstorm编译器的提示
Documentation coming soon
"web-types": "highlight/web-types.json"
代码风格统一
prettierrc.js
Documentation coming soon
Rollup打包
代码压缩
已支持rollup打包 压缩代码
https://blog.csdn.net/weixin_39951988/article/details/121857641
清除无用代码
启用rollup清除无用代码工具rollup-plugin-cleanup
rollup打包后删除console
rollup打包后删除console
组件内部如果有css打包会报错
默认情况下如果组件内部有style
标签,会导致不能识别,利用rollup-plugin-postcss
提取成单独的css文件。
// build/components.ts 和 full-components.ts
import vue from "rollup-plugin-vue";
import RollupPluginPostcss from 'rollup-plugin-postcss'; // 组件内部如果有css 打包会报错
import Autoprefixer from 'autoprefixer'
vue({
preprocessStyles: false
}),
RollupPluginPostcss({ extract: 'theme-chalk/components-style.css' , plugins: [Autoprefixer(),cssnano()] }),
参考:my.oschina.net/skywingjian…
gulp 打包utils
Documentation coming soon
参考:
排除第三方库
只要ui组件库中引入了第三方库,就会连同第三方库一起打包,所以需要排除,且需要把第三方库安装在packages/qi-ui-plus
中,(如果其他项目要引用qi-ui-plus
组件库,则在npm i qi-ui-plus
的同时 会去自动安装第三方库,因为在qi-ui-plus
的package.json
中依赖了第三方库)
// build/component.ts
const config = {
...
// external: (id) => /^vue/.test(id) || /^@tophant-cd-ui/.test(id) || /^moment/.test(id) || /^element-plus/.test(id), // 排除掉vue和@qi-ui-plus 和 moment、element-plus的依赖, 只要是组件中引入了(import)第三方库都需要排除
external: (id: string) => /^(vue|@vue|@vueuse|element-plus|@element-plus|@qi-ui-plus|moment|lodash)/.test(id),
...
}
开发utils
Documentation coming soon...
文档doc
vitePress
本UI组件库文档采用vitePress搭建
参考:
vitepress 自定义主题样式
1、docs/.vitepress/theme/custom.css
下定义css文件
// docs/.vitepress/theme/custom.css
...
.token.number{
color: #778759 !important;
}
...
docs/.vitepress/theme/index.js
下导入
// docs/.vitepress/theme/index.js
...
import DefaultTheme from 'vitepress/theme'
import './custom.css' //自定义主题样式
...
vitepress文档站内搜索
Documentation coming soon...
可选:可视化组件库storybook
Documentation coming soon...
本地(docs/play)测试"包"
全量引入(推荐)
import QiUi from '@qi-ui-plus/components' // 本地测试时,全量导入 也可以按需导入
import '@qi-ui-plus/theme-chalk/src/index.scss'; // 导入css样式 icon图标组件需要
按需引入
import { QiIcon } from '@qi-ui-plus/components'
模拟发布后
import QiUi from 'qi-ui-plus' // 本地测试时,可以把打包后的dist复制到node_modules中并改名为qi-ui-plus
发布NPM
发布到npm的方法很简单, 首先我们需要先注册去npm官网注册一个账号, 然后控制台登录即可,最后我们执行npm publish
即可.
发布流程
// 本地编译好组件库代码,进入编译后的目录
// 登录
npm login
// 发布过程
// 确保 registry 是 https://registry.npmjs.org
npm config get registry
// 如果不是则先修改 registry
npm config set registry=https://registry.npmjs.org
// 发布
npm publish
// 如果发布失败提示权限问题,请执行以下命令
npm publish --access public
//删除已发布的组件(不推荐删除已发布的组件),则执行以下命令(加 --force 强制删除)
> npm unpublish --force
删除指定版本的包,比如包名为 vue-vant 版本 0.1.0
> npm unpublish vue-vant@0.1.0
如果24小时内有删除过同名的组件包,那么将会发布失败
只能换一个名称发布或者等24小时之后发布,所以不要随便删除已发布的组件(万一有项目已经引用)
npm相关知识的简单了解:
1. .npmignore 配置文件
.npmignore
配置文件类似于 .gitignore
文件,如果没有 .npmignore
,会使用.gitignore
来取代他的功能。
2. npm发包的版本管理
npm的发包遵循语义化版本,一个版本号格式如下:Major.Minor.Patch,每一部分具体介绍如下:
- Major 表示主版本号,做了不兼容的API修改时需要更新
- Minor 表示次版本号,做了向下兼容的功能性需求时需要更新
- Patch 表示修订号, 做了向下兼容的问题修正时需要更新
对应的npm也提供了脚本帮我们实现自动更新版本号,如下:
npm version patch
npm version minor
npm version major
还有更加深入的知识比如版本的tag化这些,大家感兴趣也可以研究一下. 本文的组件库搭建参考element的目录组织方式,大家也可以直接采用element或者其他开源组件库的脚手架来实现.
NPM私有服搭建(verdaccio)
Documentation coming soon
参考:
项目中使用组件库
import { createApp } from 'vue'
import QiUi from 'qi-ui-plus' // 全量导入
// import { QiIcon } from 'qi-ui-plus' // 按需导入
// import QiUi,{ QiIcon } from 'qi-ui-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
createApp(App)
.use(QiUi)
.mount('#app')
项目中引入Css的问题(难点)
全量引入css
import 'qi-ui-plus/theme-chalk/index.css';
开发UI组件时请尽量不要组件内部写style(虽然也会提取出来放在
dist/theme-chalk/components-style.css
),而应该写在theme-chalk目录下按照组件名命名,例如button.scss
vue组件内部style的处理
在打包时会把所有组件内的style抽离成components-style.css
并复制打包进dist/theme-chalk/components-style.css
,但是要注意开发UI组件时,组件内的style 不要加scoped
,因为组件库打包不需要作用域
一开始考虑把组件内的css也统一打包进theme-chalk/index.scss
,但是没必要,最终采取不在vue
组件内写style
,而在theme-chalk
目录内创建组件对应的scss
文件,然后统一在theme-chalk/index.scss
中引入,所以实际项目中不需要单独引入此样式
关于为什么会抽取
vue
组件内部的style
为css
文件,请参考组件内部如果有css打包会报错
全量导入element-plus(难点)
本UI库已全量导入了element-plus
,故项目中使用qi-ui-plus
时就不用导入element-plus
了,直接使用其组件即可;
且开发组件库时docs
和play
目录也不需要导入element-plus
即可使用其组件。
过程如下:
1、doc和play中使用
在packages/components/index.ts
中导入element-plus
供doc
和play
中使用
// packages/components/index.ts
import ElementPlus from 'element-plus' // 此处加element-plus 主要是给本地docs、play测试用
import 'element-plus/dist/index.css' // components中需要导入css,打包时会打包这句话,但是不起作用
// 注册所有的组件
const install = function (app: App): void {
...
app.use(ElementPlus)
}
2、最终一起打包进dist中
在packages/qi-ui-plus/index.ts
中导入element-plus
供最终一起打包进dist
中
// packages/qi-ui-plus/index.ts
import ElementPlus from 'element-plus' // 此处加element-plus 主要是打包进最终的dist中(tophant-cd-ui)
// import 'element-plus/dist/index.css' // 不需要导入element-plus的样式,因为样式已经在packages/theme-chalk/gulpfile.ts中 在执行打包css后,向打包后的index.css中动态导入了
const install = (app: App) => {
...
app.use(ElementPlus)
};
3、css在哪儿导入呢?
一开始在上面2个地方都导入了element
的css
,但是在项目中引用element
的组件发现样式无效,故有如下几种测试:
1、一开始考虑就在上面2个地方 像平时导入css一样,但是打包后在项目中引用qi-ui-plus
后使用element-plus
的组件发现css样式根本没有起作用;也就是打包后的组件库中导入的element-plus
不起作用,但是打包自己的项目的时候,又可以了,因为会去找所有第三方库并打包。
2、在packages/theme-chalk/src/index.scss
中导入element-plus
的css,但是发现打包的时候会把element-plus
的所有css拷贝进dist/index.css
中;
// @use "../../../node_modules/element-plus/dist/index.css"; // 不需要显示导入,因为打包的时候会把element-plus的所有css拷贝过来,所以利用gulp的插件在打包后的index.css文件的头部插入element的样式引入,就避免了打包所有的element的css
@use 'icon.scss';
@use 'date.scss';
3、如果只是在打包后的index.css
中有一句导入element-plus
的样式,而不需要拷贝其所有css就解决这个问题了;
// packages/theme-chalk/gulpfile.ts
import header from "gulp-header";
// import footer from "gulp-footer";
// 向打包后的theme-chalk/dist/index.css的头部添加element-plus的css
function addElementToHeader(){
return src(path.resolve(__dirname, "./dist/index.css"))
.pipe(header('@import \"element-plus/dist/index.css\";\n'))
.pipe(dest('./dist'));
}
export default series(compile, addElementToHeader, copyfont, copyfullstyle);
gulp 向文件插入代码
如上,利用gulp-header
向打包后的theme-chalk/dist/index.css
的头部添加element-plus
的css,以此来实现element-plus
的样式导入。
参考:
4、全量导入element-icon
// packages/components/index.ts 和 packages/qi-ui-plus/index.ts
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const install = (app: App) => {
...
// 统一注册Icon图标
Object.entries(ElementPlusIconsVue).forEach(([iconName, component]) => {
app.component(iconName, component)
})
};
项目中就可以直接按照@element-plus/icons-vue
的方式使用其icon图标
。
杂项
开源许可协议
Documentation coming soon