Vue3 插件开发实战 | 从 0 开发一个全局通知组件(Toast/Message)并发布到 npm

一、为什么要自己写插件?

在日常 Vue3 开发中,我们经常使用 Element Plus 或 Ant Design Vue 的 Message/Toast 组件。但你有没有想过:

  • 这些组件是怎么实现 this.$message.success('操作成功') 这种调用的?
  • 为什么它们不需要在模板里写 <message /> 就能显示?
  • 如何把自己写的组件发布到 npm 供别人使用?

今天,我们就从 0 到 1,手写一个全局通知插件,并发布到 npm,成为真正的"开源贡献者"!

二、插件基础结构

Vue3 插件本质上是一个对象或函数,它暴露一个 install 方法。当使用 app.use(plugin) 时,install 方法会被调用,并接收 app 实例和可选的 options

typescript 复制代码
// 插件基础结构
const MyPlugin = {
  install(app: App, options?: any) {
    // 在这里添加全局功能
    // 1. 注册全局组件
    // 2. 添加全局属性/方法
    // 3. 提供全局指令
    // 4. 注入依赖
  }
}

三、项目初始化

我们使用 Vite 创建一个专门用于插件开发的项目:

bash 复制代码
npm create vite@latest vue3-toast-plugin -- --template vue-ts
cd vue3-toast-plugin
npm install

为了打包到 npm,我们需要的目录结构如下:

text 复制代码
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 组件需要支持:

  • 四种类型:successerrorwarninginfo
  • 可配置:显示时长、是否可关闭、位置、自定义内容
  • 支持链式调用:Toast.success('成功').then(...)
  • 支持手动关闭
  • 多个 Toast 自动堆叠

4.2 组件实现

vue 复制代码
<!-- 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)。

css 复制代码
/* 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 管理器

typescript 复制代码
// 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 模块等多种格式。

typescript 复制代码
// 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
  }
})
json 复制代码
// 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 文件。

json 复制代码
// tsconfig.json 中开启声明
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist",
    "emitDeclarationOnly": true
  }
}

也可以在 src/index.ts 中导出类型:

typescript 复制代码
// src/index.ts
export type { ToastOptions, ToastType, ToastPosition } from './components/Toast.vue'

八、本地测试

在发布之前,本地测试非常重要。我们可以使用 npm link 或者在项目的 example 目录下测试。

8.1 创建测试项目

bash 复制代码
# 在插件项目根目录执行
npm link

# 进入测试项目(比如一个新建的 Vue3 项目)
cd ../vue3-test-project
npm link vue3-toast-plugin

8.2 在测试项目中使用

typescript 复制代码
// 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')
vue 复制代码
<!-- 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 打包

bash 复制代码
npm run build

9.3 发布

bash 复制代码
npm publish --access public

如果版本更新,需要修改 version 后再次发布:

bash 复制代码
npm version patch  # 1.0.0 -> 1.0.1
npm publish

十、编写 README 文档

一个好的开源项目必须有清晰的文档。README.md 应该包含:

  • 安装方法
  • 基本使用
  • API 文档
  • 示例代码
  • 贡献指南
markdown 复制代码
# vue3-toast-plugin

一个轻量级、高度可定制的 Vue3 全局通知插件。

安装

bash 复制代码
npm install vue3-toast-plugin

使用

js 复制代码
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')
vue 复制代码
<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

text 复制代码
## 十一、进阶:支持 Vue3 和 Nuxt3

如果你想让插件同时支持 Vue3 和 Nuxt3,可以增加判断环境自动适配的逻辑。Nuxt3 中插件需要写在 `plugins` 目录下,并提供 `ssr: false` 选项。

```typescript
// nuxt 插件适配示例
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(ToastPlugin)
})

十二、总结

通过本篇文章,我们完成了一个完整的 Vue3 插件从开发、打包、测试到发布的全流程。你不仅掌握了插件的核心机制(installcreateVNoderender),还学会了如何管理多个动态组件实例,以及如何让插件具有良好的 TypeScript 支持。

核心收获

  • Vue3 插件本质:{ install(app) {} }
  • 动态渲染组件:createVNode + render
  • 多个实例管理:维护实例数组,提供关闭/销毁逻辑
  • 打包配置:vite.config.tsbuild.lib 配置
  • 发布流程:npm loginnpm run buildnpm publish

现在,你可以骄傲地告诉别人:"我发布过一个 npm 包!" 下次遇到重复的组件需求,不妨考虑封装成插件,提升团队复用效率。🚀


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,让更多人学会 Vue3 插件开发!

相关推荐
程序员Ctrl喵2 小时前
Flutter 第三阶段:基础 Widget 全面指南
开发语言·javascript·flutter
韭菜炒大葱2 小时前
事件捕获、事件冒泡、事件源对象、事件委托
javascript·面试
品克缤2 小时前
Vue3 + Router 页面切换时滚动条闪烁问题记录
前端·javascript·css·vue.js
冰暮流星2 小时前
javascript之dom方法访问内容
开发语言·前端·javascript
竹林8182 小时前
React + wagmi 实战:从零构建一个能“读”能“写”的 DeFi 前端,我踩了这些坑
前端·javascript
我命由我123452 小时前
在 React 项目中,配置了 setupProxy.js 文件,无法正常访问 http://localhost:3000
开发语言·前端·javascript·react.js·前端框架·ecmascript·js
俺不会敲代码啊啊啊2 小时前
封装 ECharts Hook 适配多种图表容器
前端·vue.js·typescript·echarts
J2虾虾2 小时前
在Vue3中推荐使用的函数定义方法
前端·javascript·vue.js
辻戋2 小时前
从零手写mini-react
javascript·react.js·ecmascript