记Vue+vite+pnpm UI组件库搭建过程

1,724 阅读16分钟

记录一次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 具有安装速度快、节约磁盘空间、安全性好等优点,它的出现也是为了解决npmyarn存在的问题。

  1. pnpm 通过硬链接与符号链接结合的方式,来解决 yarnnpm 的问题。
  • 硬链接 :硬链接可以理解为源文件的副本, pnpm 会在全局 store 存储项目 node_modules 文件的硬链接。硬链接可以使得不同的项目可以从全局 store 寻找到同一个依赖,大大节省了磁盘空间。
  • 软链接 :软链接可以理解为快捷方式, pnpm 在引用依赖时通过符号链接去找到对应磁盘目录(.pnpm)下的依赖地址。

比如 A 依赖 BA下面是没有 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 目录下的硬链接,相同的依赖始终只会被安装一次,多重依赖的问题也得到了解决。

  1. 当然 pnpm 也存在一些局限。
  • pnpm-lock.yamlpackage-lock.json不一致,不能兼容。
  • 一些场景不兼容,比如 Electron
  • 不同应用的依赖是硬链接到同一份文件,所以不能直接修改依赖文件,否则会影响其他项目。而且因为安装结构不同,原来的patch-package之类的工具也不能用了。

虽然还有种种问题,但总体来说瑕不掩瑜。

参考:

developer.51cto.com/article/708…

www.pnpm.cn/motivation

monorepo两种项目的组织方式

  • Multirepo(Multiple):每一个包对应一个项目
  • Monorepo(Monolithic Repository):单一代码库,一个项目仓库中管理多个模块/包

monorepo 就一定需要专门的工具(库)才能实现吗?

答案当然是否定的,严格意义上说,只要将多个项目放在一个存储库里就算 monorepo。 现在主流的 monorepo 所承担的责任并不只是存储的问题,还可以承担比如依赖管理,增量构建等一系列工程化的功能,已经成为工程化技术中非常有价值的一块领域,所以有时你为了实现某个特殊的功能不得不借助社区的力量,或者站在大佬的肩膀上。

Monorepos 的优点

  • 依赖管理:共享依赖,所有的代码都在一个仓库。版本管理非常方便。
  • 代码复用:所有的代码都在一个仓库,很容易抽离出各个项目共用的业务组件或工具,并通过 TypeScript 在代码内引用。
  • 一致性:所有代码在一个仓库,代码质量标准和统一风格会更容易。
  • 透明度:所有人都能看到全部代码,跨团队协作和贡献更容易。

Monorepos 的缺点

  • 性能 :代码越来越多, Git、IDE 之类的工具会越来越卡。
  • 权限 :管理文件权限会更具挑战, Git 目录并没有内置的权限管理系统,整个项目是没办法区分某些部门开放哪个项目,某些部门关闭的。
  • 学习成本:对新人来说,项目变大了,学习成本自然会更高。

Monorepo 策略不完美,但某些方面来说确实解决了一些项目的维护和开发体验。

如果你的项目有多个关联仓库,或者还在用 submodule 方式管理多个仓库,那可以试一试 Monorepo 。

使用 monorepo 策略后,收益最大的两点是:

  1. 避免重复安装包,因此减少了磁盘空间的占用,并降低了构建时间;
  2. 内部代码可以彼此相互引用;

为了实现前面提到的两点收益,您需要在代码中做三件事:

  1. 调整目录结构,将相互关联的项目放置在同一个目录,推荐命名为 packages
  2. 在项目根目录里的package.json文件中,设置workspaces属性,属性值为之前创建的目录;
  3. 同样,在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 好太多)

www.kancloud.cn/chandler/we…

blog.csdn.net/weixin_4469…

mono-repo最出名是使用 Lerna 管理 workspaces。但是后来 pnpm 取代之前的 lernablog.csdn.net/astonishqft…

Lerna 自动化发布 管理发布npm和git

Lernanpm模块的管理工具,为项目提供了集中管理package的目录模式,如统一的 repo 依赖安装、package scripts发版清理工程环境等特性。

blog.csdn.net/Moonoly/art…

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

segmentfault.com/a/119000004…

juejin.cn/post/687376…

组件类型提示

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

参考:

juejin.cn/post/684490…

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

参考:

juejin.cn/post/687261…

segmentfault.com/a/119000004…

排除第三方库

只要ui组件库中引入了第三方库,就会连同第三方库一起打包,所以需要排除,且需要把第三方库安装在packages/qi-ui-plus中,(如果其他项目要引用qi-ui-plus组件库,则在npm i qi-ui-plus的同时 会去自动安装第三方库,因为在qi-ui-pluspackage.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),
    ...
}

参考:www.csdn.net/tags/MtTaEg…

开发utils

Documentation coming soon...

文档doc

vitePress

本UI组件库文档采用vitePress搭建

参考:

www.jianshu.com/p/0210f6030…

www.cfanz.cn/resource/de…

vitepress 自定义主题样式

1、docs/.vitepress/theme/custom.css 下定义css文件

// docs/.vitepress/theme/custom.css
...
.token.number{
    color: #778759 !important;
}
...
  1. 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

参考:

www.freesion.com/article/629…

blog.csdn.net/tglsaturn/a…

项目中使用组件库

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组件内部的stylecss文件,请参考组件内部如果有css打包会报错

全量导入element-plus(难点)

本UI库已全量导入了element-plus,故项目中使用qi-ui-plus时就不用导入element-plus了,直接使用其组件即可;

且开发组件库时docsplay目录也不需要导入element-plus即可使用其组件。

过程如下:

1、doc和play中使用

packages/components/index.ts中导入element-plusdocplay中使用

// 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个地方都导入了elementcss,但是在项目中引用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的样式导入。

参考:

qa.1r1g.com/sf/ask/2686…

www.jianshu.com/p/bbaa0d821…

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