一、为什么要自己写插件?
在日常 Vue3 开发中,我们经常使用 Element Plus 或 Ant Design Vue 的 Message/Toast 组件。但你有没有想过:
- 这些组件是怎么实现
this.$message.success('操作成功')这种调用的? - 为什么它们不需要在模板里写
<message />就能显示? - 如何把自己写的组件发布到 npm 供别人使用?
今天,我们就从 0 到 1,手写一个全局通知插件,并发布到 npm,成为真正的“开源贡献者”!
二、插件基础结构
Vue3 插件本质上是一个对象或函数,它暴露一个 install 方法。当使用 app.use(plugin) 时,install 方法会被调用,并接收 app 实例和可选的 options。
// 插件基础结构
const MyPlugin = {
install(app: App, options?: any) {
// 在这里添加全局功能
// 1. 注册全局组件
// 2. 添加全局属性/方法
// 3. 提供全局指令
// 4. 注入依赖
}
}
三、项目初始化
我们使用 Vite 创建一个专门用于插件开发的项目:
npm create vite@latest vue3-toast-plugin -- --template vue-ts
cd vue3-toast-plugin
npm install
为了打包到 npm,我们需要的目录结构如下:
vue3-toast-plugin/
├── src/
│ ├── components/
│ │ └── Toast.vue # 通知组件本体
│ ├── types/
│ │ └── index.ts # 类型定义
│ ├── index.ts # 插件入口
│ └── style.css # 样式(可选)
├── dist/ # 打包输出
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md
四、开发 Toast 组件
4.1 组件功能设计
一个成熟的 Toast/Message 组件需要支持:
- 四种类型:
success、error、warning、info - 可配置:显示时长、是否可关闭、位置、自定义内容
- 支持链式调用:
Toast.success('成功').then(...) - 支持手动关闭
- 多个 Toast 自动堆叠
4.2 组件实现
<!-- src/components/Toast.vue -->
<template>
<Transition name="toast-fade" @after-leave="handleAfterLeave">
<div
v-if="visible"
class="toast"
:class="[`toast--${type}`, positionClass]"
:style="customStyle"
@mouseenter="pauseTimer"
@mouseleave="resumeTimer"
>
<div class="toast__icon">
<span v-html="iconMap[type]"></span>
</div>
<div class="toast__content">
<slot>{{ message }}</slot>
</div>
<button v-if="closable" class="toast__close" @click="close">×</button>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'
const props = withDefaults(defineProps<{
message: string
type?: ToastType
duration?: number
closable?: boolean
position?: ToastPosition
onClose?: () => void
}>(), {
type: 'info',
duration: 3000,
closable: false,
position: 'top'
})
const visible = ref(true)
let timer: ReturnType<typeof setTimeout> | null = null
const iconMap = {
success: '✓',
error: '✕',
warning: '⚠',
info: 'ℹ'
}
const positionClass = computed(() => `toast--${props.position}`)
const customStyle = computed(() => ({})) // 可扩展自定义样式
const startTimer = () => {
if (props.duration > 0) {
timer = setTimeout(() => {
close()
}, props.duration)
}
}
const clearTimer = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
const pauseTimer = () => clearTimer()
const resumeTimer = () => startTimer()
const close = () => {
visible.value = false
}
const handleAfterLeave = () => {
props.onClose?.()
}
onMounted(() => {
startTimer()
})
</script>
<style scoped>
/* 样式在下一节给出 */
</style>
4.3 样式设计
为了让通知美观且不影响页面布局,我们使用固定定位(fixed)。
/* src/style.css */
.toast {
position: fixed;
z-index: 9999;
min-width: 200px;
max-width: 300px;
padding: 12px 16px;
border-radius: 8px;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
transition: all 0.3s ease;
}
/* 位置 */
.toast--top {
top: 20px;
left: 50%;
transform: translateX(-50%);
}
.toast--top-right {
top: 20px;
right: 20px;
}
.toast--top-left {
top: 20px;
left: 20px;
}
.toast--bottom {
bottom: 20px;
left: 50%;
transform: translateX(-50%);
}
.toast--bottom-right {
bottom: 20px;
right: 20px;
}
.toast--bottom-left {
bottom: 20px;
left: 20px;
}
/* 类型颜色 */
.toast--success {
border-left: 4px solid #67c23a;
}
.toast--success .toast__icon {
color: #67c23a;
}
.toast--error {
border-left: 4px solid #f56c6c;
}
.toast--error .toast__icon {
color: #f56c6c;
}
.toast--warning {
border-left: 4px solid #e6a23c;
}
.toast--warning .toast__icon {
color: #e6a23c;
}
.toast--info {
border-left: 4px solid #409eff;
}
.toast--info .toast__icon {
color: #409eff;
}
.toast__icon {
font-size: 18px;
font-weight: bold;
}
.toast__content {
flex: 1;
word-break: break-word;
}
.toast__close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 0 4px;
}
.toast__close:hover {
color: #333;
}
/* 过渡动画 */
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
transform: translateY(-20px) scale(0.9);
}
.toast-fade-leave-to {
transform: translateY(-20px) scale(0.9);
}
五、插件核心逻辑:管理多个 Toast 实例
为了实现链式调用和多个 Toast 同时存在,我们需要一个管理器(Manager),负责创建、销毁 Toast 实例。
5.1 创建 Toast 管理器
// src/index.ts
import type { App, ComponentPublicInstance } from 'vue'
import { createVNode, render } from 'vue'
import ToastComponent from './components/Toast.vue'
import './style.css'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'
export interface ToastOptions {
message: string
type?: ToastType
duration?: number
closable?: boolean
position?: ToastPosition
onClose?: () => void
}
// 存储所有活跃的 Toast 实例
let toastInstances: ComponentPublicInstance[] = []
// 生成唯一 ID(用于区分实例)
let seed = 0
function createToast(options: ToastOptions) {
const container = document.createElement('div')
document.body.appendChild(container)
// 创建虚拟节点
const vnode = createVNode(ToastComponent, {
...options,
onClose: () => {
// 卸载组件并移除容器
render(null, container)
container.remove()
toastInstances = toastInstances.filter(ins => ins !== vnode.component?.proxy)
options.onClose?.()
}
})
// 渲染组件
render(vnode, container)
const instance = vnode.component?.proxy
if (instance) {
toastInstances.push(instance)
}
return instance
}
// 核心 API
function show(message: string, options?: Partial<ToastOptions>): Promise<void> {
return new Promise((resolve) => {
createToast({
message,
type: 'info',
duration: 3000,
...options,
onClose: () => {
options?.onClose?.()
resolve()
}
})
})
}
// 快捷方法
function success(message: string, options?: Partial<ToastOptions>) {
return show(message, { ...options, type: 'success' })
}
function error(message: string, options?: Partial<ToastOptions>) {
return show(message, { ...options, type: 'error' })
}
function warning(message: string, options?: Partial<ToastOptions>) {
return show(message, { ...options, type: 'warning' })
}
function info(message: string, options?: Partial<ToastOptions>) {
return show(message, { ...options, type: 'info' })
}
// 关闭所有 Toast
function closeAll() {
toastInstances.forEach(instance => {
if (instance && instance.close) {
(instance as any).close()
}
})
toastInstances = []
}
// 导出插件对象
export default {
install(app: App) {
// 添加全局属性 $toast
app.config.globalProperties.$toast = {
show,
success,
error,
warning,
info,
closeAll
}
}
}
// 单独导出 API(用于按需引入)
export { show, success, error, warning, info, closeAll }
六、Vite 打包配置
为了发布到 npm,我们需要将组件打包成 UMD、ES 模块等多种格式。
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'Vue3ToastPlugin',
fileName: (format) => `vue3-toast-plugin.${format}.js`,
formats: ['es', 'umd']
},
rollupOptions: {
// 确保外部化处理那些你不希望打包进库的依赖
external: ['vue'],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: {
vue: 'Vue'
},
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'style.css'
return assetInfo.name || 'assets/[name]-[hash][extname]'
}
}
},
cssCodeSplit: false, // 将所有 CSS 打包成一个文件
sourcemap: true,
emptyOutDir: true
}
})
// package.json 关键字段配置
{
"name": "vue3-toast-plugin",
"version": "1.0.0",
"type": "module",
"main": "./dist/vue3-toast-plugin.umd.js",
"module": "./dist/vue3-toast-plugin.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/vue3-toast-plugin.es.js",
"require": "./dist/vue3-toast-plugin.umd.js",
"types": "./dist/index.d.ts"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build && npm run build:types",
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist"
},
"peerDependencies": {
"vue": "^3.2.0"
}
}
七、生成类型声明文件
为了让 TypeScript 用户有良好的体验,我们需要生成 .d.ts 文件。
// tsconfig.json 中开启声明
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true
}
}
也可以在 src/index.ts 中导出类型:
// src/index.ts
export type { ToastOptions, ToastType, ToastPosition } from './components/Toast.vue'
八、本地测试
在发布之前,本地测试非常重要。我们可以使用 npm link 或者在项目的 example 目录下测试。
8.1 创建测试项目
# 在插件项目根目录执行
npm link
# 进入测试项目(比如一个新建的 Vue3 项目)
cd ../vue3-test-project
npm link vue3-toast-plugin
8.2 在测试项目中使用
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'
const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<!-- App.vue -->
<template>
<div>
<button @click="$toast.success('操作成功!')">成功提示</button>
<button @click="$toast.error('出错了!')">错误提示</button>
<button @click="$toast.warning('警告信息')">警告提示</button>
<button @click="$toast.info('普通消息')">普通提示</button>
</div>
</template>
九、发布到 npm
9.1 准备工作
- 注册 npm 账号:www.npmjs.com/
- 在终端登录:
npm login - 确保
package.json中的name未被占用
9.2 打包
npm run build
9.3 发布
npm publish --access public
如果版本更新,需要修改 version 后再次发布:
npm version patch # 1.0.0 -> 1.0.1
npm publish
十、编写 README 文档
一个好的开源项目必须有清晰的文档。README.md 应该包含:
- 安装方法
- 基本使用
- API 文档
- 示例代码
- 贡献指南
# vue3-toast-plugin
一个轻量级、高度可定制的 Vue3 全局通知插件。
安装
npm install vue3-toast-plugin
使用
import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'
const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<template>
<button @click="$toast.success('Hello World!')">Show Toast</button>
</template>
API
$toast.success(message, options)
显示成功提示。
| 参数 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| message | string | - | 提示内容 |
| options | object | {} | 可选配置 |
Options
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| duration | number | 3000 | 显示时长(ms),设为0则不自动关闭 |
| closable | boolean | false | 是否显示关闭按钮 |
| position | string | 'top' | 位置,可选值见下方 |
位置选项:top, top-right, top-left, bottom, bottom-right, bottom-left
License
MIT
## 十一、进阶:支持 Vue3 和 Nuxt3
如果你想让插件同时支持 Vue3 和 Nuxt3,可以增加判断环境自动适配的逻辑。Nuxt3 中插件需要写在 `plugins` 目录下,并提供 `ssr: false` 选项。
```typescript
// nuxt 插件适配示例
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(ToastPlugin)
})
十二、总结
通过本篇文章,我们完成了一个完整的 Vue3 插件从开发、打包、测试到发布的全流程。你不仅掌握了插件的核心机制(install、createVNode、render),还学会了如何管理多个动态组件实例,以及如何让插件具有良好的 TypeScript 支持。
核心收获:
- Vue3 插件本质:
{ install(app) {} } - 动态渲染组件:
createVNode+render - 多个实例管理:维护实例数组,提供关闭/销毁逻辑
- 打包配置:
vite.config.ts的build.lib配置 - 发布流程:
npm login→npm run build→npm publish
现在,你可以骄傲地告诉别人:“我发布过一个 npm 包!” 下次遇到重复的组件需求,不妨考虑封装成插件,提升团队复用效率。🚀
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,让更多人学会 Vue3 插件开发!