自己开发组件库
搭建vue3 & ts组件库脚手架
目标
pnpm搭建monorepo项目,和使用workspace测试组件库- 组件支持
typescript,可以被使用的项目识别 - 组件支持整体导入、按需自动导入
环境要求
node ≥ 18 , pnpm ≥ 8 , vue ≥ 3.3
初始化项目模板
首先使用vite和pnpm 创建一个项目模板,这里使用pnpm,方便后面monorepo的使用。
pnpm create vite
在交互命令中填写项目名称,选择 Vue + Typescript 模板
然后进入项目目录,使用 pnpm install 安装依赖
使用monorepo管理组件库
使用 monorepo 可以将多个包放在一下项目下维护,包之间可以互相引用,相同的依赖和配置也可以统一维护起来。除了组件库,可能后面还会新增工具库和插件库,使用monorepo可以更好的进行管理。
创建过程如下:
-
首先指定
monorepo目录。在项目根目录创建packages文件夹和pnpm-workspace.yaml文件,文件的内容为:packages: - 'packages/**'这样就可以指定项目
packages下的文件夹为子包。 -
在
packages文件夹下新建components文件夹,并在新建的文件夹中新建一个package.json文件,初始内容如下:{ "name": "@giegie/components", "version": "0.0.1", "description": "练习了2年半的高性能组件库", "scripts": {} }其中
@giegie/component是npm包的名称,@giegie是包的作用域,可以避免包的冲突。
创建第一个组件
先来创建一个简单的 Input 组件用作测试,如图所示,需要在src下建立一个Input文件夹,且需要创建几个固定的文件:
-
style/index.scss— 用于定义组件的样式。在里面补充一点简单的样式:.gie-input { &__control { color: red; } }为什么样式要拆开而不是直接写在Input组件里呢? 因为需要在构建时打包成一个css文件用于组件库整体导入。按需导入时,样式放在约定的目录,也方便让按需导入的插件自动引入样式。
-
Input.ts— 用于定义类型文件,如Input的props类型,emit类型和instance类型等,内容如下:import Input from './Input.vue'; /** * 定义props类型 */ export interface InputProps { modelValue: string; disabled?: boolean; } /** * 定义emit类型 */ export type InputEmits = { 'update:modelValue': [value: string]; }; /** * 定义instance类型 */ export type InputInstance = InstanceType<typeof Input>;InputInstance是用来干啥的? 在写公共组件时,会使用defineExpose暴露一些方法。如在element-plus中,就会使用formRef.validate来校验表单,instance里就有暴露方法的类型签名。 -
Input.vue— 组件文件。内容如下:<template> <div class="gie-input"> <input v-model="state" ref="inputRef" class="gie-input__control" type="text" :disabled="props.disabled" /> </div> </template> <script setup lang="ts"> import { computed, ref } from 'vue'; import type { InputEmits, InputProps } from './Input'; defineOptions({ name: 'GieInput', }); const emit = defineEmits<InputEmits>(); const props = withDefaults(defineProps<InputProps>(), { modelValue: '', disabled: false, }); const state = computed({ get: () => props.modelValue, set: (val) => { emit('update:modelValue', val); }, }); const inputRef = ref<HTMLInputElement>(); function focus() { inputRef.value?.focus(); } defineExpose({ focus, }); </script>在该组件中简单的定义了组件名、代理了一下
v-model,并暴露出了一个方法focus。 -
index.ts— 定义Input组件的入口文件import { withInstall } from '../utils/install'; import Input from './Input.vue'; export const GieInput = withInstall(Input); export default GieInput; export * from './Input.vue'; export * from './Input';在入口文件中,使用
withInstall封装了一下导入的Input组件,并默认导出。且在下面导出了所有类型文件。这个
withInstall函数的作用就是把组件封装成了一个可被安装,带install方法的vue插件,这个函数是直接从element-plus项目复制的😂。import type { App, Plugin } from 'vue'; export type SFCWithInstall<T> = T & Plugin; export const withInstall = <T, E extends Record<string, any>>(main: T, extra?: E) => { (main as SFCWithInstall<T>).install = (app): void => { for (const comp of [main, ...Object.values(extra ?? {})]) { app.component(comp.name, comp); } }; if (extra) { for (const [key, comp] of Object.entries(extra)) { (main as any)[key] = comp; } } return main as SFCWithInstall<T> & E; };
完善打包入口文件
-
style.scss— 这个样式文件用来导入所有组件的样式,之后会通过编译生成一个包含所有组件样式的css文件,用于整体导入@import './Input/style/index.scss'; -
components.ts— 这个文件用来代理导出组件里的vue文件和类型声明,内容如下:export * from './Input';这样做的目的,是为了之后可以在项目里对组件或类型进行导入,如:
<template> <gie-input v-model="state" ref="inputRef" /> </template> <script setup lang="ts"> import { ref } from 'vue'; import { GieInput } from '@giegie/components'; import type { InputInstance } from '@giegie/components'; const state = ref(''); const inputRef = ref<InputInstance>(); </script> -
installs.ts— 将组件的默认导出,也就是经过withInstall处理的vue组件插件导入进来,封装成一个数组,给下面的入口文件使用import GieInput from './Input'; export default [GieInput]; -
index.ts— 组件库入口文件,在这个文件里,需要导出components.ts里代理的vue组件和类型,并将installs.ts导出的插件数组交给makeInstaller处理成一个支持整体导入的插件:import { makeInstaller } from './utils/install'; import installs from './installs'; export * from './components'; export default makeInstaller([...installs]);makeInstaller实际上也是一个vue插件,他将组件插件循环进行安装,也是从element-plus复制的😂。import type { App, Plugin } from 'vue'; export const makeInstaller = (components: Plugin[] = []) => { const install = (app: App) => { console.log(components); components.forEach((c) => app.use(c)); }; return { install, }; }; -
global.d.ts— 这个文件位于components包的根目录,用于给vscode的volar插件提示组件的属性的类型declare module 'vue' { export interface GlobalComponents { GieInput: (typeof import('@giegie/components'))['GieInput']; } interface ComponentCustomProperties {} } export {};
编写打包配置
最终的目标是使用vite打包出 es、lib、types 3个目录,lib下的组件是commonjs版的,es下的组件是 es module 版的,types 里是类型声明文件。而且打包出来的文件目录要和src源码的文件目录保持一致,这样才能方便的按需导入。
对于样式,使用gulp和sass进行既对目录下的单独scss文件进行编译,最后也合并成一个文件。
使用gulp不仅用来处理sass文件,更重要的是可以用来控制打包流程。
-
先安装一些依赖
vite-plugin-dts用来生成类型声明文件:pnpm add vite-plugin-dts -wDgulp和相关依赖安装到components子包下pnpm add gulp gulp-sass sass gulp-autoprefixer shelljs -D --filter components -
在
components下 新建一个vite.config.ts文件,配置和说明如下:import { defineConfig } from 'vite'; import type { UserConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import dts from 'vite-plugin-dts'; export default defineConfig(() => { return { build: { rollupOptions: { // 将vue模块排除在打包文件之外,使用用这个组件库的项目的vue模块 external: ['vue'], // 输出配置 output: [ { // 打包成 es module format: 'es', // 重命名 entryFileNames: '[name].js', // 打包目录和开发目录对应 preserveModules: true, // 输出目录 dir: 'es', // 指定保留模块结构的根目录 preserveModulesRoot: 'src', }, { // 打包成 commonjs format: 'cjs', // 重命名 entryFileNames: '[name].js', // 打包目录和开发目录对应 preserveModules: true, // 输出目录 dir: 'lib', // 指定保留模块结构的根目录 preserveModulesRoot: 'src', }, ], }, lib: { // 指定入口文件 entry: 'src/index.ts', // 模块名 name: 'GIE_COMPONENTS', }, }, plugins: [ vue(), dts({ // 输出目录 outDir: ['types'], // 将动态引入转换为静态(例如:`import('vue').DefineComponent` 转换为 `import { DefineComponent } from 'vue'`) staticImport: true, // 将所有的类型合并到一个文件中 rollupTypes: true, }), ], } as UserConfig; }); -
在components文件夹下新建
build文件夹,用于编写打包流程控制逻辑,文件和内容如下:// index.js import gulp from 'gulp'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import dartSass from 'sass'; import gulpSass from 'gulp-sass'; import autoprefixer from 'gulp-autoprefixer'; import shell from 'shelljs'; const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../'); const { src, dest } = gulp; const sass = gulpSass(dartSass); // 删除打包产物 export const removeDist = async () => { shell.rm('-rf', `${componentPath}/lib`); shell.rm('-rf', `${componentPath}/es`); shell.rm('-rf', `${componentPath}/types`); }; // 构建css export const buildRootStyle = () => { return src(`${componentPath}/src/style.scss`) .pipe(sass()) .pipe(autoprefixer()) .pipe(dest(`${componentPath}/es`)) .pipe(dest(`${componentPath}/lib`)); }; // 构建每个组件下单独的css export const buildStyle = () => { return src(`${componentPath}/src/**/style/**.scss`) .pipe(sass()) .pipe(autoprefixer()) .pipe(dest(`${componentPath}/es`)) .pipe(dest(`${componentPath}/lib`)); }; // 打包组件 export const buildComponent = async () => { shell.cd(componentPath); shell.exec('vite build'); };// gulpfile.js import gulp from 'gulp'; import { removeDist, buildRootStyle, buildStyle, buildComponent } from './index.js'; const { series } = gulp; export default series(removeDist, buildComponent, buildStyle, buildRootStyle); -
在components文件夹下新建一个tsconfig.json文件,内容如下:
{ "extends": "../../tsconfig.json", "include": ["src"], "compilerOptions": { "moduleResolution": "node", "baseUrl": "." } }这里主要是将
moduleResolution改为node,使打包出来的类型产物都可以正确的写入到一个文件里 -
修改components包下的package.json文件,添加一些配置:
{ "name": "@giegie/components", "version": "0.0.1", "description": "练习了2年半的高性能组件库", "main": "lib", "module": "es", "type": "module", "types": "types/index.d.ts", "files": ["es", "lib", "types", "global.d.ts"], "scripts": { "build": "gulp -f build/gulpfile.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "gulp": "^4.0.2", "gulp-autoprefixer": "^8.0.0", "gulp-sass": "^5.1.0", "sass": "^1.67.0", "shelljs": "^0.8.5" } }具体修改内容为:
- main指定cjs入口
- module指定esm入口
- type字段的值设置为"module"时,表示该项目是一个ES模块项目
- types表示类型声明文件位置
- files表示发包时哪些文件将上传
- scripts添加build打包命令
-
在根目录的
package.json中加入build命令"scripts": { "build": "pnpm --filter=@giegie/* run build" }这个
build命令的意思是,执行所有的以@giegie开头的子包的build命令 -
准备工作做好后执行
npm run build命令,没有报错的话,会和生成出一样的产出物
整体导入
目前打包出来的产物已经可以直接用来整体导入了,使用pnpm的workspace特性,不需要先发布包就可以直接用pnpm安装这个包用作测试
-
使用命令安装
@giegie/components组件库到根项目pnpm add @giegie/components@* -w -
在项目根目录的
tsconfig.json添加组件类型文件:{ "compilerOptions": { "types": ["@giegie/components/global"] } } -
在src的
main.ts文件中整体导入组件库和样式import { createApp } from 'vue'; import '@giegie/components/es/style.css'; import App from './App.vue'; import GieComponents from '@giegie/components'; console.log(GieComponents); createApp(App).use(GieComponents).mount('#app'); -
在App.vue中编写测试代码
<template> <div> <gie-input v-model="state" ref="inputRef" /> {{ state }} <button @click="onFocus">focus</button> </div> </template> <script setup lang="ts"> import type { InputInstance } from '@giegie/components'; import { ref } from 'vue'; const state = ref(''); const inputRef = ref<InputInstance>(); function onFocus() { inputRef.value?.focus(); } </script> -
运行npm run dev 命令,可以在浏览器中看到效果
按需自动导入
完整导入所有组件会使项目打包出来的产物非常大,在element-plus中可以使用unplugin-vue-components 和 unplugin-auto-import 按需自动导入需要的组件,文档地址。这个插件提供了多个组件的resolver,可以模仿他们的格式,自己写一个解析组件的resolver
在packages新建一个子包,命名为resolver,并创建下面2个文件
-
index.js— 解析插件的入口文件function GieResolver() { return { type: 'component', resolve: (name) => { if (name.startsWith('Gie')) { const partialName = name.slice(3); return { name: 'Gie' + partialName, from: `@giegie/components`, sideEffects: `@giegie/components/es/${partialName}/style/index.css`, }; } }, }; } module.exports = { GieResolver, };上面的代码大概意思是,解析到一个组件以“Gie”开头时,返回组件名称、组件位置、组件样式位置给
unplugin-vue-components和unplugin-auto-import自动导入。 -
package.json{ "name": "@giegie/resolver", "version": "0.0.1", "description": "组件库自动导入插件", "main": "./index", "author": "", "license": "ISC" }
安装自动导入插件和编写的解析插件到根项目
pnpm add unplugin-vue-components unplugin-auto-import @giegie/resolver@* -Dw
在根目录的vite.config.ts 中,加入配置
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import AutoImport from 'unplugin-auto-import/vite';
import { GieResolver } from '@giegie/resolver';
// <https://vitejs.dev/config/>
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [GieResolver()],
}),
AutoImport({
resolvers: [GieResolver()],
}),
],
});
将根目录的 tsconfig.json 中types改成如下文件
{
"compilerOptions": {
"types": ["./components.d.ts", "./auto-imports.d.ts"]
}
}
注释掉main.ts中的完整导入代码
import { createApp } from 'vue';
// import '@giegie/components/es/style.css'
import App from './App.vue';
// import GieComponents from '@giegie/components'
// console.log(GieComponents)
createApp(App).mount('#app');
// .use(GieComponents)
运行 npm run dev ,可以看到类型和网页上的内容都已经成功导入了近来。
组件库版本的管理和发布
目标
- 使用
verdaccio搭建私有npm库 - 使用
pnpm的changesets发布版本
搭建私有npm库
基于安全和速度的考虑,在公司内部发布npm包都会去搭建私有库。
如果只用来测试没有敏感内容的话,可以跳过这段,直接发到公网也没什么问题。
现在可以用来搭建私有库的工具有很多,这里使用verdaccio来进行搭建。
安装
npm install --global verdaccio
假设将数据存储到/data/verdaccio的话,按下面的方法配置
配置文件
mkdir /data/verdaccio
vim /data/verdaccio/config.yaml
编辑配置内容:
storage: /data/verdaccio/storage
auth:
htpasswd:
file: /data/verdaccio/htpasswd
algorithm: bcrypt
rounds: 10
max_users: -1
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'@*/*':
access: $authenticated
publish: $authenticated
proxy: npmjs
'**':
access: $authenticated
publish: $authenticated
proxy: npmjs
web:
enable: true
title: 鸽鸽的前端私有库
login: true
listen: 0.0.0.0:4873
max_body_size: 100mb
log: { type: stdout, format: pretty, level: http }
配置说明: 需要管理员手动新增用户,只有授权的用户才能查看和上传包,上游为npm官方库。
启动
新建启动脚本
vim /data/verdaccio/start.sh
内容:
verdaccio --config /data/verdaccio/config.yaml
添加执行权限
chmod +x /data/verdaccio/start.sh
使用pm2管理服务
npm i -g pm2
pm2 start /data/verdaccio/start.sh -n verdaccio
开机自启
pm2 startup
pm2 save
添加用户
生成 Bcrypt htpasswd 的文件并添加用户
htpasswd -bBc /data/verdaccio/htpasswd user password
继续添加一个用户的话用下面的命令
htpasswd -bB /data/verdaccio/htpasswd user2 password2
到这里搭建就结束了,服务在4873端口,可以用浏览器打开。可以用新建的用户测试一下能否登录。
修改项目配置
接入私有库
私有npm库搭建好后,在项目根目录新建一个.npmrc文件,内容如下:
registry = http://xxx.xxx.xxx.xxx:4873
这一行的意思是修改该项目的npm的源为自己搭建的私有库地址。
修改完成后,使用 pnpm login 命令输入账号、密码登录私有库,最后使用 pnpm i 重新拉取一遍。
指定要发布的包
在上一篇文章里,有一个主包和两个子包。其中两个子包components和resolver希望发布到npm库里去。主包是用来测试的,不可以发到库里。
根项目下的主包是用来测试用的,需要将主包改为私有禁止其提交到库里,可以在根目录的package.json中修改private为true。
{
"private": true
}
使用changesets发包
pnpm推荐使用changesets来进行包的管理,使用changesets可以轻松的管理版本和changelog记录的生成。
安装和初始化changesets
输入以下命令:
pnpm add -Dw @changesets/cli
pnpm changeset init
安装完成后会在根目录生成.changeset文件夹,这个文件夹要随git一起提交上去。
需要注意的是changesets默认需要在分支main上运行,可以去
.changeset/config.json文件下修改baseBranch的值来改变主分支
修改版本和编写changelog
提交代码并切换分支到main合并后,运行pnpm changeset add命令选择要发布的包。
这里把两个子包用空格键都选上
下一步需要升级版本号,先来看看npm包版本号有那些规则:
npm版本号由三部分组成:主版本号(Major)、次版本号(Minor)和修订号(Patch),格式为"Major.Minor.Patch"。
- 主版本号(Major):当进行不兼容的API修改时增加,表示向后不兼容的更新。
- 次版本号(Minor):当进行向下兼容的功能性新增时增加,表示向后兼容的更新。
- 修订号(Patch):当进行向下兼容的问题修复时增加,表示向后兼容的更新。
现在只用把修订号加一即可
运行pnpm changeset version 命令:
刚开始是选择是否是Major更新,这里什么都不选,直接按回车跳过。接下来是Minor,也跳过。最后默认的就是Patch版本号加1。版本号确定后,需要填写更新内容:
填写完成后会看到在子包下生成了CHANGELOG.md文件,里面记录了选择的版本号和输入的更新内容
发布包
提交代码后,运行pnpm publish -r,没有问题的哈,终端会输出发布信息。去私有库网页查看的话,包应该已经发上去了。
在组件库中封装element-plus
安装element-plus
正常情况下,项目中会安装有自己的element-plus版本。如果再将element-plus安装到组件库的话,那么项目安装依赖时会下载多个element-plus的版本。
实际上希望的是组件库能够使用项目的element-plus版本即可。
在这种情况下可以使用package.json的peerDependencies来声明外部依赖。
在组件库的 package.json 里添加声明
// /packages/components/package.json
{
"peerDependencies": {
"element-plus": "^2.3.9"
}
}
然后在测试项目也就是根目录的package.json文件中安装element-plus
// /package.json
{
"dependencies": {
"@giegie/components": "workspace:*",
"vue": "^3.3.4",
"element-plus": "^2.3.9"
}
}
配置都写好后,使用 pnpm install 进行安装
修改项目打包配置
排除打包依赖
在之前的文章中,在打包时排除了vue依赖。这里也一样,要排除element-plus有关的依赖,在组件库的vite.config.ts中进行修改
// /packages/components/vite.config.ts
export default defineConfig(() => {
return {
external: ['vue', 'element-plus', '@element-plus/icons-vue', /\.scss/],
};
});
其中@element-plus/icons-vue是element-plus图标相关的依赖
排除掉 scss是因为要在组件中引入element-plus的样式,但是这个样式也要从外部项目的element-plus依赖中获取
为什么是scss而不是css呢? 因为在项目内通常会定制化element-plus的样式,这都是通过修改element-plus的scss变量来完成的。如果想定制的样式能影响封装在组件库中element-plus组件的话,后面在组件也必须要引入element-plus的scss样式。
自动引入element-plus的样式
在编写组件库的组件时,需要使用按需加载的方式引入element-plus组件,如:
<template> <el-input /> </template>
<script setup lang="ts">
import { ElInput } from 'element-plus';
import 'element-plus/theme-chalk/src/base.scss';
import 'element-plus/theme-chalk/src/input.scss';
</script>
可以看到不仅要引入组件,还需要引入基础样式和组件样式,这个需要的element-plus组件变多的话,非常麻烦。
需要使用unplugin-element-plus帮助自动引入样式
安装unplugin-element-plus 到组件库的包下
pnpm add unplugin-element-plus -D --filter components
在vite配置文件里添加下面配置
// /packages/components/vite.config.ts
import ElementPlus from 'unplugin-element-plus/vite';
export default defineConfig(() => {
return {
plugins: [
// ...
ElementPlus({
// 导入scss而不是css
useSource: true,
}),
],
};
});
配置好后,编写组件时只用向下面这样就行
<template> <el-input /> </template>
<script setup lang="ts">
import { ElInput } from 'element-plus';
</script>
简单封装el-input组件
将之前封装好的gie-input中的input换成el-input组件,功能和之前一样,最终文件如下
<template>
<div class="gie-input">
<el-input v-model="state" ref="inputRef" type="text" :disabled="props.disabled" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ElInput } from 'element-plus';
import type { InputEmits, InputProps } from './Input';
defineOptions({
name: 'GieInput',
});
const emit = defineEmits<InputEmits>();
const props = withDefaults(defineProps<InputProps>(), {
modelValue: '',
disabled: false,
});
const state = computed({
get: () => props.modelValue,
set: (val) => {
emit('update:modelValue', val);
},
});
const inputRef = ref<InstanceType<typeof ElInput>>();
function focus() {
inputRef.value?.focus();
}
defineExpose({
focus,
});
</script>
运行打包和预览命令
npm run build
npm run dev
在浏览器中可以看到结果
将组件接入到el-form的表单校验
在组件库里封装的组件,大概分为3类。表单组件、数据展示组件和布局组件。
其中表单组件一般都会和element-plus里的el-form组件结合使用。假如自己封装一个富文本组件,当输入后失去焦点且字段要求必填时,会自动触发el-form的校验。此时需要显示错误提示和自己封装的富文本的边框变红,这应该如何实现呢?
首先需要在组件里获取到el-form-item组件的实例,element-plus暴露了一个contextKey,可以让方便的将el-form-item实例注入进来:
<script setup lang="ts">
import { formItemContextKey } from 'element-plus';
const elFormItem = inject(formItemContextKey);
</script>
然后通过元素的blur事件调用校验方法:
<template>
<div contenteditable="true" @blur="onBlur"></div>
</template>
<script setup lang="ts">
import { formItemContextKey } from 'element-plus';
const elFormItem = inject(formItemContextKey);
const onBlur = () => {
elFormItem!.validate?.('blur').catch((err) => console.warn(err));
};
</script>
可以看到上面validate方法的参数为blur,这个表示执行项目中规则的trigger为blur的校验。
const rules = reactive<FormRules<RuleForm>>({
test: [{ required: true, message: '请输入文字', trigger: 'blur' }],
});
到这里错误提示可以在失去焦点并校验失败时自动显示出来,下面开始修改错误时的边框样式。
可以看到,当校验失败时,el-form-item上会加上is-error的class,只用通过class来修改边框即可
.gie-richtext{
flex: 1;
&__control{
border: 1px solid #aaa;
background: #eee;
border-radius: 5px;
padding: 16px;
min-height: 100px;
.is-error & {
border-color: red;
}
}
}

浙公网安备 33010602011771号