大家好,我是鸽鸽。
在vue
项目中,我们一般使用element-plus
和vant
组件库。
多个项目会有一些公共的组件,我们如何将其打包成一个组件库维护起来呢?
如何暴露组件类型,按需自动导入又是如何实现的?
这篇文章我们一起来学习一下
相关代码在这 learn-create-compoents-lib/class1,建议搭配食用
系列文章
《搭建vue3 & ts组件库脚手架 - 自己撸组件库篇一》
《组件库版本的管理和发布 - 自己撸组件库篇二》
《在组件库中封装element-plus - 自己撸组件库篇三》
目标
pnpm
搭建monorepo
项目,和使用workspace
测试组件库- 组件支持
typescript
,可以被使用的项目识别 - 组件支持整体导入、按需自动导入
环境要求
node ≥ 18
, pnpm ≥ 8
, vue ≥ 3.3
初始化项目模板
首先使用vite
和pnpm
创建一个项目模板,这里我们使用pnpm
,方便后面monorepo
的使用。
bash
pnpm create vite
在交互命令中填写项目名称,选择 Vue + Typescript 模板
然后进入项目目录,使用 pnpm install
安装依赖
使用monorepo管理组件库
使用 monorepo
可以将多个包放在一下项目下维护,包之间可以互相引用,相同的依赖和配置也可以统一维护起来。除了组件库,我们可能后面还会新增工具库和插件库,使用monorepo
可以更好的进行管理。
创建过程如下:
-
首先指定
monorepo
目录。在项目根目录创建packages
文件夹和pnpm-workspace.yaml
文件,文件的内容为:yamlpackages: - "packages/**"
这样就可以指定项目
packages
下的文件夹为子包。 -
在
packages
文件夹下新建components
文件夹,并在新建的文件夹中新建一个package.json
文件,初始内容如下:json{ "name": "@giegie/components", "version": "0.0.1", "description": "练习了2年半的高性能组件库", "scripts": { } }
其中
@giegie/component
是npm
包的名称,@giegie
是包的作用域,可以避免包的冲突。
创建第一个组件
我们先来创建一个简单的 Input
组件用作测试,如图所示,需要在src
下建立一个Input
文件夹,且需要创建几个固定的文件:
-
style/index.scss
--- 用于定义组件的样式。在里面补充一点简单的样式:scss.gie-input{ &__control{ color: red; } }
为什么样式要拆开而不是直接写在Input组件里呢? 因为我们需要在构建时打包成一个css文件用于组件库整体导入。按需导入时,样式放在约定的目录,也方便让按需导入的插件自动引入样式。
-
Input.ts
--- 用于定义类型文件,如Input
的props
类型,emit
类型和instance
类型等,内容如下:typescriptimport 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
--- 组件文件。内容如下:html<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
组件的入口文件typescriptimport { 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
项目复制的😂。typescriptimport 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文件,用于整体导入scss@import "./Input/style/index.scss";
-
components.ts
--- 这个文件用来代理导出组件里的vue
文件和类型声明,内容如下:typescriptexport * from './Input'
这样做的目的,是为了之后可以在项目里对组件或类型进行导入,如:
html<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
组件插件导入进来,封装成一个数组,给下面的入口文件使用typescriptimport GieInput from './Input' export default [ GieInput ]
-
index.ts
--- 组件库入口文件,在这个文件里,我们需要导出components.ts
里代理的vue组件和类型,并将installs.ts
导出的插件数组交给makeInstaller
处理成一个支持整体导入的插件:typescriptimport { makeInstaller } from './utils/install' import installs from './installs' export * from './components' export default makeInstaller([...installs])
makeInstaller
实际上也是一个vue
插件,他将组件插件循环进行安装,也是从element-plus
复制的😂。typescriptimport 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
插件提示我们组件的属性的类型typescriptdeclare 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
用来生成类型声明文件:bashpnpm add vite-plugin-dts -wD
gulp
和相关依赖安装到components
子包下bashpnpm add gulp gulp-sass sass gulp-autoprefixer shelljs -D --filter components
-
在
components
下 新建一个vite.config.ts
文件,配置和说明如下:typescriptimport { 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' }, { // 打包成 commonjs format: 'cjs', // 重命名 entryFileNames: '[name].js', // 打包目录和开发目录对应 preserveModules: true, // 输出目录 dir: 'lib' }, ], }, 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
文件夹,用于编写打包流程控制逻辑,文件和内容如下:javascript// 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') }
javascript// 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文件,内容如下:
json{ "extends": "../../tsconfig.json", "include": [ "src" ], "compilerOptions": { "moduleResolution": "node", "baseUrl": "." } }
这里主要是将
moduleResolution
改为node
,使打包出来的类型产物都可以正确的写入到一个文件里 -
修改components包下的package.json文件,添加一些配置:
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
命令json"scripts": { "build": "pnpm --filter=@giegie/* run build" }
这个
build
命令的意思是,执行所有的以@giegie
开头的子包的build
命令 -
准备工作做好后执行
npm run build
命令,没有报错的话,会和我生成出一样的产出物
整体导入
目前打包出来的产物已经可以直接用来整体导入了,使用pnpm
的workspace
特性,不需要先发布包就可以直接用pnpm
安装这个包用作测试
-
使用命令安装我们的
@giegie/components
组件库到根项目bashpnpm add @giegie/components@* -w
-
在项目根目录的
tsconfig.json
添加组件类型文件:json{ "compilerOptions": { "types": ["@giegie/components/global"] } }
-
在src的
main.ts
文件中整体导入组件库和样式typescriptimport { 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中编写测试代码
html<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
--- 解析插件的入口文件javascriptfunction 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
json{ "name": "@giegie/resolver", "version": "0.0.1", "description": "组件库自动导入插件", "main": "./index", "author": "", "license": "ISC" }
安装自动导入插件和我们编写的解析插件到根项目
bash
pnpm add unplugin-vue-components unplugin-auto-import @giegie/resolver@* -Dw
在根目录的vite.config.ts
中,加入配置
typescript
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
改成如下文件
json
{
"compilerOptions": {
"types": ["./components.d.ts","./auto-imports.d.ts"]
}
}
注释掉main.ts
中的完整导入代码
typescript
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
,可以看到类型和网页上的内容都已经成功导入了近来。
总结
本篇文章介绍了如何使用pnpm和vite搭建组件库脚手架,组件ts类型的导出,以及整体导入和按需导入实现的方法。
但是写好的组件库还没有发布到npm仓库上。
使用pnpm管理组件库版本和发布的方法鸽到下一篇文章再说咯,拜拜。