插件开发
Vue3插件开发详解:插件结构、app.use注册、provide/inject、指令插件与全局组件注册。
前置知识
- Pinia 状态管理详解:建议先完成前一篇的学习
学习目标
- 掌握「1. 插件基础」的核心机制、典型用法与常见陷阱
- 掌握「2. 插件开发实战」的核心机制、典型用法与常见陷阱
- 掌握「3. 插件配置与类型安全」的核心机制、典型用法与常见陷阱
- 掌握「4. 常见问题与解决方案」的核心机制、典型用法与常见陷阱
- 掌握「5. 总结与最佳实践」的核心机制、典型用法与常见陷阱
1. 插件基础
1.1 什么是 Vue 插件
Vue插件是自包含的代码,用于向Vue应用添加全局级功能。插件可以:
- 注册全局组件、指令、过渡等
- 通过 provide/inject 注入全局服务
- 添加全局属性或方法
- 注入组合式函数
1.2 插件基本结构
// 方式1:对象式插件(带install方法)
import type { App, Plugin } from 'vue';
const myPlugin: Plugin = {
install(app: App, options?: PluginOptions) {
// 插件逻辑
},
};
// 方式2:函数式插件
const myPlugin2: Plugin = (app: App, options?: PluginOptions) => {
// 插件逻辑
};
// 使用插件
// app.use(myPlugin, { /* options */ })
2. 插件开发实战
2.1 全局属性插件
// plugins/i18n/index.ts
import type { App, Plugin } from 'vue';
import { ref, computed } from 'vue';
import en from './locales/en';
import zh from './locales/zh';
type Messages = Record<string, Record<string, string>>;
const messages: Messages = { en, zh };
export function createI18n(options: { locale: string }) {
const locale = ref(options.locale);
function t(key: string): string {
return messages[locale.value]?.[key] ?? key;
}
function setLocale(newLocale: string) {
locale.value = newLocale;
}
return { locale, t, setLocale };
}
export type I18nInstance = ReturnType<typeof createI18n>;
const i18nPlugin: Plugin = {
install(app: App, options: { locale: string }) {
const i18n = createI18n(options);
// 注入全局属性
app.config.globalProperties.$t = i18n.t;
app.config.globalProperties.$locale = i18n.locale;
// 通过provide/inject提供(推荐方式)
app.provide('i18n', i18n);
// 注入响应式locale用于模板
app.provide('locale', i18n.locale);
},
};
export default i18nPlugin;
// composables/useI18n.ts
import { inject } from 'vue';
import type { I18nInstance } from '@/plugins/i18n';
export function useI18n(): I18nInstance {
const i18n = inject<I18nInstance>('i18n');
if (!i18n) {
throw new Error('i18n plugin not installed');
}
return i18n;
}
// main.ts
// app.use(i18nPlugin, { locale: 'zh' })
2.2 全局组件注册插件
// plugins/ui/index.ts
import type { App, Plugin } from 'vue';
// 自动导入组件
const components = import.meta.glob('../components/ui/*.vue', { eager: true });
const uiPlugin: Plugin = {
install(app: App) {
for (const path in components) {
const component = components[path] as any;
// 从文件路径提取组件名: ../components/ui/FButton.vue → FButton
const name = path.split('/').pop()?.replace('.vue', '');
if (name) {
app.component(name, component.default || component);
}
}
},
};
export default uiPlugin;
// main.ts
// app.use(uiPlugin)
// 现在所有ui组件都全局可用: <FButton>, <FInput>, <FModal> 等
2.3 指令插件
// plugins/directives/index.ts
import type { App, Plugin, Directive, DirectiveBinding } from 'vue';
// v-loading: 加载指令
const vLoading: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<boolean>) {
if (binding.value) {
addLoading(el);
}
},
updated(el: HTMLElement, binding: DirectiveBinding<boolean>) {
if (binding.value) {
addLoading(el);
} else {
removeLoading(el);
}
},
};
function addLoading(el: HTMLElement) {
el.style.position = 'relative';
const mask = document.createElement('div');
mask.className = 'v-loading-mask';
mask.innerHTML = '<div class="v-loading-spinner"></div>';
el.appendChild(mask);
}
function removeLoading(el: HTMLElement) {
const mask = el.querySelector('.v-loading-mask');
if (mask) {
el.removeChild(mask);
}
}
// v-debounce: 防抖点击指令
const vDebounce: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value: handler, arg: delay = '300' } = binding;
let timer: ReturnType<typeof setTimeout>;
el.addEventListener('click', () => {
clearTimeout(timer);
timer = setTimeout(() => {
handler();
}, Number(delay));
});
},
};
// v-permission: 权限指令
const vPermission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string[]>) {
const userPermissions = getUserPermissions(); // 获取当前用户权限
const requiredPermissions = binding.value;
if (!requiredPermissions.some((p) => userPermissions.includes(p))) {
el.parentNode?.removeChild(el);
}
},
};
function getUserPermissions(): string[] {
// 从store或token中获取
return ['read', 'write'];
}
const directivesPlugin: Plugin = {
install(app: App) {
app.directive('loading', vLoading);
app.directive('debounce', vDebounce);
app.directive('permission', vPermission);
},
};
export default directivesPlugin;
// 使用
// <button v-debounce:500="handleSave">保存</button>
// <div v-loading="isLoading">内容</div>
// <button v-permission="['admin']">删除</button>
2.4 通知/Toast 插件
// plugins/toast/index.ts
import type { App, Plugin } from 'vue';
import { createApp, h, ref } from 'vue';
import ToastContainer from './ToastContainer.vue';
interface ToastOptions {
message: string;
type?: 'success' | 'error' | 'warning' | 'info';
duration?: number;
}
const toasts = ref<Array<{ id: number } & ToastOptions>>([]);
let nextId = 0;
function addToast(options: ToastOptions) {
const id = nextId++;
toasts.value.push({ id, type: 'info', duration: 3000, ...options });
setTimeout(() => {
removeToast(id);
}, options.duration ?? 3000);
}
function removeToast(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
const toast = {
success: (message: string, duration?: number) => addToast({ message, type: 'success', duration }),
error: (message: string, duration?: number) => addToast({ message, type: 'error', duration }),
warning: (message: string, duration?: number) => addToast({ message, type: 'warning', duration }),
info: (message: string, duration?: number) => addToast({ message, type: 'info', duration }),
};
const toastPlugin: Plugin = {
install(app: App) {
// 全局属性
app.config.globalProperties.$toast = toast;
// provide/inject
app.provide('toast', toast);
// 挂载Toast容器
const container = document.createElement('div');
document.body.appendChild(container);
const toastApp = createApp({
render() {
return h(ToastContainer, { toasts: toasts.value, onClose: removeToast });
},
});
toastApp.mount(container);
},
};
export default toastPlugin;
export { toast };
// composables/useToast.ts
import { inject } from 'vue';
export function useToast() {
const toast = inject<typeof toast>('toast');
if (!toast) {
throw new Error('Toast plugin not installed');
}
return toast;
}
// 使用
// const { success, error } = useToast()
// success('操作成功!')
3. 插件配置与类型安全
3.1 类型安全的插件配置
// plugins/myPlugin/types.ts
export interface MyPluginOptions {
prefix?: string;
debug?: boolean;
theme?: 'light' | 'dark';
}
// plugins/myPlugin/index.ts
import type { App, Plugin } from 'vue';
import type { MyPluginOptions } from './types';
const defaultOptions: Required<MyPluginOptions> = {
prefix: 'my',
debug: false,
theme: 'light',
};
const myPlugin: Plugin = {
install(app: App, userOptions: MyPluginOptions = {}) {
const options = { ...defaultOptions, ...userOptions };
if (options.debug) {
console.log('[MyPlugin] Installing with options:', options);
}
app.provide('myPluginOptions', options);
},
};
export default myPlugin;
// 扩展ComponentCustomProperties
declare module 'vue' {
interface ComponentCustomProperties {
$myPlugin: {
options: Required<MyPluginOptions>;
};
}
}
3.2 插件组合
// plugins/index.ts
import type { App } from 'vue';
import i18nPlugin from './i18n';
import uiPlugin from './ui';
import directivesPlugin from './directives';
import toastPlugin from './toast';
export function installPlugins(app: App) {
app.use(i18nPlugin, { locale: 'zh' });
app.use(uiPlugin);
app.use(directivesPlugin);
app.use(toastPlugin);
}
// main.ts
import { installPlugins } from './plugins';
const app = createApp(App);
installPlugins(app);
app.mount('#app');
4. 常见问题与解决方案
4.1 插件中的响应式数据
// 问题:全局属性不是响应式的
// 错误
app.config.globalProperties.$theme = 'light'; // 非响应式
// 正确:使用ref/reactive
import { ref } from 'vue';
const theme = ref('light');
app.provide('theme', theme);
// 在组件中
const theme = inject<Ref<string>>('theme');
4.2 插件类型声明
// env.d.ts 或 shims-vue.d.ts
declare module 'vue' {
interface ComponentCustomProperties {
$toast: {
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
};
$t: (key: string) => string;
}
}
4.3 插件顺序
// 插件安装顺序很重要
// 依赖其他插件功能的插件应后安装
app.use(i18nPlugin); // 先安装i18n
app.use(formPlugin); // form插件可能依赖i18n
5. 总结与最佳实践
5.1 插件设计原则
- 单一职责:一个插件做一件事
- 可配置:通过options参数允许自定义
- 类型安全:提供完整的TypeScript类型
- provide/inject优先:比globalProperties更灵活
- 可测试:导出核心逻辑,便于单元测试
5.2 最佳实践
- 使用 provide/inject:替代 globalProperties,更利于类型推导
- 导出 composable:提供
useXxx函数供组件使用 - 自动注册组件:使用
import.meta.glob批量注册 - 清理副作用:插件创建的全局监听器需提供清理方法
- 文档化:说明插件的配置选项和使用方式
插件结构
插件对象形式
import type { App } from 'vue';
interface MyPluginOptions {
apiBase: string;
timeout?: number;
}
const MyPlugin = {
install(app: App, options?: MyPluginOptions) {
// 插件逻辑
const finalOptions = { apiBase: '/api', ...options };
app.provide('apiBase', finalOptions.apiBase);
}
};
export default MyPlugin;
插件函数形式
import type { App } from 'vue';
export default function MyPlugin(app: App, options?: PluginOptions) {
app.provide('config', options);
app.config.globalProperties.$api = createApi(options);
}
插件安装
app.use 安装插件
app.use(<plugin>, [options]);
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from 'pinia';
import MyPlugin from './plugins/my-plugin';
const app = createApp(App);
app.use(router);
app.use(createPinia());
app.use(MyPlugin, { apiBase: '/api', timeout: 3000 });
app.mount('#app');
链式安装
createApp(App)
.use(router)
.use(createPinia())
.use(MyPlugin, { apiBase: '/api' })
.mount('#app');
插件能力
注册全局组件
import type { App } from 'vue';
import MyButton from './MyButton.vue';
import MyInput from './MyInput.vue';
export default {
install(app: App) {
app.component('MyButton', MyButton);
app.component('MyInput', MyInput);
}
};
注册全局指令
import type { App } from 'vue';
export default {
install(app: App) {
app.directive('focus', {
mounted(el) { el.focus(); }
});
app.directive('permission', {
mounted(el, binding) {
if (!hasPermission(binding.value)) {
el.parentNode?.removeChild(el);
}
}
});
}
};
provide 全局依赖
import type { App } from 'vue';
import { ref } from 'vue';
export default {
install(app: App, options: { apiBase: string }) {
app.provide('apiBase', options.apiBase);
app.provide('user', ref(null));
app.provide(Symbol('config'), options);
}
};
扩展 globalProperties
import type { App } from 'vue';
declare module 'vue' {
interface ComponentCustomProperties {
$apiBase: string;
$format: (value: number, digits?: number) => string;
$toast: (message: string, type?: 'info' | 'success' | 'error') => void;
}
}
export default {
install(app: App, options: { apiBase: string }) {
app.config.globalProperties.$apiBase = options.apiBase;
app.config.globalProperties.$format = (value: number, digits = 2) =>
value.toFixed(digits);
app.config.globalProperties.$toast = (message, type = 'info') => {
console.log(`[${type}] ${message}`);
};
}
};
注入组合式 API
import type { App } from 'vue';
import { ref, inject } from 'vue';
const ToastKey = Symbol('toast');
export default {
install(app: App) {
const toasts = ref<{ id: number; message: string; type: string }[]>([]);
function show(message: string, type: string = 'info') {
const id = Date.now();
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id);
}, 3000);
}
app.provide(ToastKey, { toasts, show });
}
};
// 在组件中使用
export function useToast() {
const toast = inject(ToastKey);
if (!toast) throw new Error('useToast 必须在 ToastPlugin 之后使用');
return toast;
}
插件配置
默认配置合并
import type { App } from 'vue';
interface PluginOptions {
apiBase?: string;
timeout?: number;
retry?: number;
}
const DEFAULT_OPTIONS: Required<PluginOptions> = {
apiBase: '/api',
timeout: 30000,
retry: 3
};
export default {
install(app: App, options: PluginOptions = {}) {
const finalOptions = { ...DEFAULT_OPTIONS, ...options };
app.provide('config', finalOptions);
}
};
多环境配置
export default {
install(app: App) {
const env = import.meta.env.MODE;
const config = {
development: { apiBase: 'http://localhost:3000', debug: true },
production: { apiBase: 'https://api.example.com', debug: false }
}[env];
app.provide('envConfig', config);
}
};
Pinia 插件
Pinia 插件结构
import type { PiniaPluginContext } from 'pinia';
export function myPiniaPlugin(context: PiniaPluginContext) {
// context.store: 当前 store 实例
// context.options: defineStore 选项
// 添加持久化
const saved = localStorage.getItem(context.store.$id);
if (saved) {
context.store.$patch(JSON.parse(saved));
}
context.store.$subscribe((mutation, state) => {
localStorage.setItem(context.store.$id, JSON.stringify(state));
});
// 给所有 store 添加通用方法
return {
reset() {
context.store.$reset();
}
};
}
// 使用
const pinia = createPinia();
pinia.use(myPiniaPlugin);
app.use(pinia);
Router 插件
Router 拦截插件
import type { App } from 'vue';
import type { Router } from 'vue-router';
export function createAuthPlugin(router: Router) {
return {
install(app: App) {
router.beforeEach((to, from) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
return { name: 'login', query: { redirect: to.fullPath } };
}
});
app.provide('auth', {
login(token: string) {
localStorage.setItem('token', token);
},
logout() {
localStorage.removeItem('token');
router.push('/login');
}
});
}
};
}
// 使用
import router from './router';
app.use(createAuthPlugin(router));
完整插件示例
Toast 插件
// plugins/toast.ts
import type { App, Plugin } from 'vue';
import { ref, inject, type Ref } from 'vue';
export interface ToastItem {
id: number;
message: string;
type: 'info' | 'success' | 'error' | 'warning';
}
export interface ToastAPI {
toasts: Ref<ToastItem[]>;
show(message: string, type?: ToastItem['type']): void;
success(message: string): void;
error(message: string): void;
info(message: string): void;
remove(id: number): void;
}
const ToastKey = Symbol('toast');
export const ToastPlugin: Plugin = {
install(app: App) {
const toasts = ref<ToastItem[]>([]);
function remove(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id);
}
function show(message: string, type: ToastItem['type'] = 'info') {
const id = Date.now() + Math.random();
toasts.value.push({ id, message, type });
setTimeout(() => remove(id), 3000);
}
const api: ToastAPI = {
toasts,
show,
success: (msg) => show(msg, 'success'),
error: (msg) => show(msg, 'error'),
info: (msg) => show(msg, 'info'),
remove
};
app.provide(ToastKey, api);
app.config.globalProperties.$toast = api;
}
};
export function useToast(): ToastAPI {
const api = inject<ToastAPI>(ToastKey);
if (!api) throw new Error('useToast 必须在 ToastPlugin 之后使用');
return api;
}
main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { ToastPlugin } from './plugins/toast';
const app = createApp(App);
app.use(ToastPlugin);
app.mount('#app');
组件中使用
import { useToast } from './plugins/toast';
const toast = useToast();
toast.success('保存成功');
toast.error('网络错误');
toast.show('自定义', 'warning');