前言
随着Vue 3的普及,组件库开发也迎来了新的最佳实践。无论是企业内部的业务组件库,还是开源的UI库,开发者都希望能够在多个项目中复用组件,避免重复开发。但组件从诞生到被他人使用,中间涉及许多工程化环节:打包配置、类型支持、文档演示、版本管理,以及在微前端架构下的运行时共享。本文将使用Vue 3 + TypeScript + Vite,完整记录一套组件库的构建流程,并探讨如何利用Webpack 5的模块联邦(Module Federation)在另一个Vue 3应用中动态加载组件,实现跨项目实时共享。
实现目标
- 开发一组基础Vue 3组件(以Button、Input为例),使用TypeScript +
<script setup>语法,支持按需引入和类型提示。 - 搭建组件文档站点,方便团队查看组件用法和API。
- 将组件库发布到npm,供其他项目通过
npm install安装使用。 - 通过模块联邦,在另一个独立的Vue 3项目中直接引用组件库中的组件,无需安装npm包,实现运行时共享。
最终我们将得到一个完整的组件库工程,既能作为传统npm包使用,也能在微前端场景下作为远程组件动态加载。
整体思路
采用以下技术栈:
- 构建工具:Vite(开发体验好,对库模式支持完善)
- 框架:Vue 3 + TypeScript
- 组件开发:使用Vue单文件组件(SFC) +
<script setup>+ CSS Modules或普通CSS - 文档工具:Storybook for Vue 3(交互式组件演示)或 VitePress(更轻量)。本文选择Storybook,因为它专为组件设计,且支持Vue 3。
- 打包发布:Vite的库模式打包,输出ES模块和CommonJS格式,并生成
.d.ts类型声明文件。 - 模块联邦:由于Vite原生不支持模块联邦,我们将使用
@originjs/vite-plugin-federation插件,在消费者项目(Vite + Vue 3)中远程加载组件库的暴露模块。同时,组件库本身也需要作为联邦暴露方进行配置。
整体流程:初始化项目 -> 开发组件 -> 配置Storybook -> 打包 -> 发布npm -> 创建消费者应用 -> 配置模块联邦引用组件。
创建项目
创建一个新目录并初始化:
npm create vue@latest my-ui-lib
# 选择 TypeScript、JSX 支持(可选),不需要Router/Pinia等
cd my-ui-lib
Vue官方脚手架会生成一个基础Vue 3 + Vite项目。我们需要调整项目结构,准备作为组件库发布。
调整目录结构
我们将组件源码放在src/components下,每个组件一个文件夹。同时创建src/index.ts作为库的入口文件。
my-ui-lib/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.vue
│ │ │ ├── index.ts
│ │ │ └── style.css
│ │ └── Input/
│ │ ├── Input.vue
│ │ ├── index.ts
│ │ └── style.css
│ └── index.ts
├── package.json
├── vite.config.ts
└── tsconfig.json
安装必要依赖
除了项目初始依赖,我们还需要安装一些开发依赖:
npm install -D vite-plugin-dts # 生成类型声明文件
vite-plugin-dts 会在打包时生成.d.ts文件。
修改 vite.config.ts
我们需要配置Vite以库模式构建:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import path from 'path'
export default defineConfig({
plugins: [
vue(),
dts({
insertTypesEntry: true,
outDir: 'dist/types',
include: ['src/**/*.ts', 'src/**/*.vue'],
// 跳过故事文件
exclude: ['**/*.stories.ts', '**/*.stories.vue'],
}),
],
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'MyUiLib',
formats: ['es', 'cjs'],
fileName: (format) => `my-ui-lib.${format}.js`,
},
rollupOptions: {
// 将vue作为外部依赖,不打包进库
external: ['vue'],
output: {
globals: {
vue: 'Vue',
},
},
},
},
})
配置 package.json
设置入口文件和类型定义,并将vue移到peerDependencies:
{
"name": "my-ui-lib",
"version": "0.1.0",
"type": "module",
"files": ["dist"],
"main": "dist/my-ui-lib.cjs.js",
"module": "dist/my-ui-lib.es.js",
"types": "dist/types/index.d.ts",
"exports": {
".": {
"import": "./dist/my-ui-lib.es.js",
"require": "./dist/my-ui-lib.cjs.js",
"types": "./dist/types/index.d.ts"
}
},
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"type-check": "vue-tsc --noEmit"
},
"peerDependencies": {
"vue": ">=3.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.0",
"vite-plugin-dts": "^3.0.0",
"vue-tsc": "^1.0.0"
}
}
组件开发
以Button组件为例,编写一个简单但类型完善的组件。
组件实现
src/components/Button/Button.vue:
<template>
<button
class="btn"
:class="[`btn--${type}`]"
:disabled="disabled"
@click="onClick"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
export type ButtonType = 'primary' | 'default' | 'danger'
const props = defineProps<{
type?: ButtonType
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
const onClick = (e: MouseEvent) => {
emit('click', e)
}
</script>
<style scoped>
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn--primary {
background-color: #42b883;
color: white;
}
.btn--default {
background-color: #e5e7eb;
color: #1f2937;
}
.btn--danger {
background-color: #ef4444;
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
src/components/Button/index.ts:
import Button from './Button.vue'
export default Button
统一导出
src/index.ts:
export { default as Button } from './components/Button'
export { default as Input } from './components/Input' // Input组件类似,此处略
类型支持
由于我们使用了<script setup>,组件本身的Props类型会自动生成,但为了更好的IDE支持,我们可以在index.ts中重新导出类型:
export type { ButtonProps } from './components/Button/Button.vue'
但需要从.vue文件导出类型,需在Button.vue中添加:
<script setup lang="ts">
// ...
</script>
<script lang="ts">
export type ButtonProps = {
type?: 'primary' | 'default' | 'danger'
disabled?: boolean
}
</script>
然后就可以在index.ts中导出该类型。
文档配置
我们将使用Storybook搭建组件文档。Storybook提供了交互式演示,非常适合组件库。
初始化Storybook
在项目根目录运行:
npx storybook@latest init --type vue3
这会自动安装Storybook for Vue 3并生成配置。
配置Storybook支持Vite
Storybook 7默认支持Vite,无需额外配置。
编写组件故事
在src/components/Button目录下创建Button.stories.ts:
import type { Meta, StoryObj } from '@storybook/vue3'
import Button from './Button.vue'
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
type: {
control: { type: 'select' },
options: ['primary', 'default', 'danger'],
},
disabled: { control: 'boolean' },
onClick: { action: 'clicked' },
},
}
export default meta
type Story = StoryObj<typeof Button>
export const Primary: Story = {
args: {
type: 'primary',
default: 'Primary Button',
},
}
export const Danger: Story = {
args: {
type: 'danger',
default: 'Danger Button',
},
}
export const Disabled: Story = {
args: {
disabled: true,
default: 'Disabled Button',
},
}
运行文档
npm run storybook
访问http://localhost:6006即可查看组件文档。
组件npm发布
组件开发完成后,我们需要将其打包并发布到npm。
打包
执行:
npm run build
检查dist目录,应该包含my-ui-lib.es.js、my-ui-lib.cjs.js以及types文件夹。
配置 package.json 的 files 字段
确保files字段包含dist,这样发布时只上传必要文件。
登录npm并发布
npm login
npm publish --access public
如果包名已被占用,需要修改package.json中的name。
发布成功后,其他项目即可通过npm install my-ui-lib安装使用。
模块联邦使用
除了作为npm包,我们还可以通过Webpack 5的模块联邦(Module Federation)在另一个Vue 3项目中直接远程引用组件库的组件,实现运行时共享。由于我们的组件库使用Vite构建,而模块联邦通常与Webpack配合更好,我们将采用@originjs/vite-plugin-federation插件来让Vite项目同时支持联邦的导出和导入。
改造组件库(远程暴露方)
首先,在组件库项目中安装插件:
npm install -D @originjs/vite-plugin-federation
修改vite.config.ts,添加联邦配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'myUiLib',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button/Button.vue',
'./Input': './src/components/Input/Input.vue',
},
shared: ['vue'],
}),
],
build: {
target: 'esnext',
// 注意:库模式和联邦不能同时使用,我们需要单独为联邦模式构建一个输出
// 可以创建一个专门的配置文件,如vite.federation.config.ts
},
})
由于库模式和联邦模式在输出上有所不同,我们可能需要创建两个构建配置。简单起见,我们可以在package.json中添加一个单独的脚本用于构建联邦版本:
"build:federation": "vite build --config vite.federation.config.ts"
新建vite.federation.config.ts,内容专门用于联邦构建:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'myUiLib',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button/Button.vue',
'./Input': './src/components/Input/Input.vue',
},
shared: ['vue'],
}),
],
build: {
target: 'esnext',
outDir: 'dist-federation', // 输出到单独目录,避免与npm包冲突
},
})
然后运行npm run build:federation,生成dist-federation目录,包含remoteEntry.js和各个组件代码。我们需要将这些文件部署到静态服务器(例如使用vercel、netlify或本地serve),假设部署后的基础URL为https://my-ui-lib.com/。
创建消费者项目(远程使用方)
新建一个Vue 3项目作为消费者:
npm create vue@latest my-app
cd my-app
安装联邦插件:
npm install -D @originjs/vite-plugin-federation
修改vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'myApp',
remotes: {
myUiLib: 'https://my-ui-lib.com/remoteEntry.js',
},
shared: ['vue'],
}),
],
})
在消费者项目的组件中使用远程组件:
<template>
<div>
<h1>我的应用</h1>
<Button type="primary" @click="handleClick">远程按钮</Button>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const Button = defineAsyncComponent(() => import('myUiLib/Button'))
// 或者直接在模板中使用,需要类型声明
const handleClick = () => console.log('clicked')
</script>
为了让TypeScript识别远程模块,可以在src/shims.d.ts中添加:
declare module 'myUiLib/Button' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{ type?: string; disabled?: boolean }>
export default component
}
运行npm run dev,即可看到远程加载的Button组件正常工作。
注意事项
- 远程组件库和消费者项目必须共享相同的Vue版本(由
shared配置保证)。 - 联邦构建输出的文件需要部署到支持CORS的静态服务器。
- 开发环境下,远程模块可能因跨域问题无法加载,可以配置代理或使用
https模式。