Vue3 插件开发
大约 7 分钟约 2185 字
Vue3 插件开发
简介
Vue3 插件通过 app.use() 注册全局功能,包括全局组件、指令、Provide/Inject 服务和工具方法。插件的本质是一个拥有 install 方法的对象,在 install 中可以访问应用实例 app,从而注册全局组件、指令、混入和提供依赖。理解插件开发模式、provide/inject 替代 globalProperties、插件配置选项和 TypeScript 类型支持,有助于封装可复用的功能模块和团队级组件库。
特点
实现
插件基本结构
// my-plugin/index.ts
import type { App, Plugin } from 'vue';
import MyButton from './components/MyButton.vue';
import MyInput from './components/MyInput.vue';
import permissionDirective from './directives/permission';
// 插件选项接口
export interface MyPluginOptions {
prefix?: string; // 组件前缀
theme?: 'light' | 'dark'; // 主题
locale?: string; // 语言
size?: 'small' | 'medium' | 'large'; // 默认尺寸
}
// 插件定义
const MyPlugin: Plugin = {
install(app: App, options: MyPluginOptions = {}) {
const {
prefix = 'My',
theme = 'light',
locale = 'zh-CN',
size = 'medium',
} = options;
// 1. 注册全局组件
app.component(`${prefix}Button`, MyButton);
app.component(`${prefix}Input`, MyInput);
// 2. 注册全局指令
app.directive('permission', permissionDirective);
// 3. 注入全局服务(推荐方式 — 类型安全)
const config = reactive({
theme,
locale,
size,
prefix,
});
app.provide('myPluginConfig', config);
// 4. 注入工具方法
app.provide('myPluginUtils', {
formatDate: (date: Date) => new Intl.DateTimeFormat(locale).format(date),
formatNumber: (num: number) => new Intl.NumberFormat(locale).format(num),
});
// 5. 全局属性(不推荐 — 缺少类型推断)
// app.config.globalProperties.$myPlugin = { config };
// 6. 添加全局 mixin(谨慎使用)
// app.mixin({ ... });
// 7. 注入 CSS 变量
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', theme);
}
// 8. 错误处理
app.config.errorHandler = (err, instance, info) => {
console.error(`[${prefix} Plugin] Error:`, err, info);
};
},
};
export default MyPlugin;
// ========== 使用 ==========
// main.ts
import { createApp } from 'vue';
import MyPlugin from './plugins/my-plugin';
const app = createApp(App);
app.use(MyPlugin, {
prefix: 'App',
theme: 'dark',
locale: 'en-US',
size: 'large',
});
app.mount('#app');通知系统插件
// notification-plugin/index.ts
import type { App, Plugin, Ref } from 'vue';
import { ref, reactive, createApp, h, TransitionGroup, defineComponent } from 'vue';
interface NotificationItem {
id: number;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
duration: number;
title?: string;
closable?: boolean;
}
interface NotificationOptions {
message: string;
type?: NotificationItem['type'];
title?: string;
duration?: number;
closable?: boolean;
}
class NotificationService {
private notifications: Ref<NotificationItem[]> = ref([]);
private nextId = 0;
private container: HTMLElement | null = null;
show(options: NotificationOptions) {
const id = this.nextId++;
const notification: NotificationItem = {
id,
type: options.type || 'info',
message: options.message,
title: options.title,
duration: options.duration ?? 3000,
closable: options.closable ?? true,
};
this.notifications.value.push(notification);
if (notification.duration > 0) {
setTimeout(() => this.remove(id), notification.duration);
}
return () => this.remove(id);
}
remove(id: number) {
this.notifications.value = this.notifications.value.filter(n => n.id !== id);
}
success(message: string, options?: Omit<NotificationOptions, 'type' | 'message'>) {
return this.show({ ...options, type: 'success', message });
}
error(message: string, options?: Omit<NotificationOptions, 'type' | 'message'>) {
return this.show({ ...options, type: 'error', message, duration: 5000 });
}
warning(message: string, options?: Omit<NotificationOptions, 'type' | 'message'>) {
return this.show({ ...options, type: 'warning', message });
}
info(message: string, options?: Omit<NotificationOptions, 'type' | 'message'>) {
return this.show({ ...options, type: 'info', message });
}
clear() {
this.notifications.value = [];
}
getItems() {
return this.notifications;
}
}
// 插件
export const NotificationPlugin: Plugin = {
install(app: App) {
const service = new NotificationService();
// 通过 provide 注入
app.provide('notification', service);
// 全局属性(支持 Options API)
app.config.globalProperties.$notify = service;
// 创建通知容器
const container = document.createElement('div');
container.id = 'notification-container';
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;';
document.body.appendChild(container);
service['container'] = container;
},
};
// ========== 使用 ==========
// Composition API
const notify = inject<NotificationService>('notification')!;
notify.success('操作成功');
notify.error('操作失败', { title: '错误', duration: 5000 });
// Options API
export default {
mounted() {
this.$notify.success('组件已加载');
},
};组合式函数插件
// auth-plugin/index.ts
import type { App, Plugin } from 'vue';
import { ref, computed, readonly, provide, inject, reactive } from 'vue';
interface AuthUser {
id: string;
name: string;
email: string;
roles: string[];
avatar?: string;
}
interface AuthConfig {
apiUrl: string;
tokenKey: string;
loginPath: string;
}
// 认证状态
interface AuthState {
user: AuthUser | null;
token: string | null;
loading: boolean;
error: string | null;
}
// 创建认证 Store
function createAuthStore(config: AuthConfig) {
const state = reactive<AuthState>({
user: null,
token: localStorage.getItem(config.tokenKey),
loading: false,
error: null,
});
const isAuthenticated = computed(() => !!state.token);
const isAdmin = computed(() => state.user?.roles.includes('admin') ?? false);
async function login(email: string, password: string) {
state.loading = true;
state.error = null;
try {
const res = await fetch(`${config.apiUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
state.token = data.token;
state.user = data.user;
localStorage.setItem(config.tokenKey, data.token);
} catch (err) {
state.error = (err as Error).message;
throw err;
} finally {
state.loading = false;
}
}
function logout() {
state.token = null;
state.user = null;
localStorage.removeItem(config.tokenKey);
window.location.href = config.loginPath;
}
async function refreshUser() {
if (!state.token) return;
try {
const res = await fetch(`${config.apiUrl}/auth/me`, {
headers: { Authorization: `Bearer ${state.token}` },
});
state.user = await res.json();
} catch {
logout();
}
}
// 初始化
if (state.token) {
refreshUser();
}
return {
state: readonly(state),
isAuthenticated,
isAdmin,
login,
logout,
refreshUser,
};
}
// 插件
export interface AuthPluginOptions {
apiUrl: string;
tokenKey?: string;
loginPath?: string;
}
export const AuthPlugin: Plugin = {
install(app: App, options: AuthPluginOptions) {
const authStore = createAuthStore({
apiUrl: options.apiUrl,
tokenKey: options.tokenKey || 'auth-token',
loginPath: options.loginPath || '/login',
});
app.provide('auth', authStore);
// 全局属性
app.config.globalProperties.$auth = authStore;
},
};
// ========== Composable ==========
import type { InjectionKey } from 'vue';
export const AUTH_KEY: InjectionKey<ReturnType<typeof createAuthStore>> = Symbol('auth');
export function useAuth() {
const auth = inject(AUTH_KEY);
if (!auth) throw new Error('AuthPlugin 未注册');
return auth;
}
// 使用
const { isAuthenticated, isAdmin, user, login, logout } = useAuth();TypeScript 类型增强
// types/global.d.ts — 为 globalProperties 添加类型
import { NotificationService } from './notification-plugin';
declare module 'vue' {
interface ComponentCustomProperties {
$notify: NotificationService;
$auth: ReturnType<typeof createAuthStore>;
}
interface GlobalComponents {
MyButton: typeof import('./components/MyButton.vue').default;
MyInput: typeof import('./components/MyInput.vue').default;
}
}按需导入插件
// 支持按需导入的组件注册
// my-plugin/components/index.ts
import MyButton from './MyButton.vue';
import MyInput from './MyInput.vue';
export { MyButton, MyInput };
// my-plugin/index.ts
import type { App } from 'vue';
export { MyButton, MyInput } from './components';
export default {
install(app: App) {
// 全量注册
app.component('MyButton', MyButton);
app.component('MyInput', MyInput);
},
};
// 按需导入 — unplugin-vue-components 自动导入
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import { MyPluginResolver } from './resolvers';
export default defineConfig({
plugins: [
Components({
resolvers: [MyPluginResolver()],
}),
],
});i18n 国际化插件
// i18n-plugin/index.ts
import type { App, Plugin } from 'vue';
import { ref, computed, readonly } from 'vue';
interface Messages {
[locale: string]: Record<string, string>;
}
class I18nService {
private messages: Messages;
private currentLocale = ref('zh-CN');
constructor(messages: Messages, defaultLocale: string) {
this.messages = messages;
this.currentLocale.value = defaultLocale;
}
get locale() {
return readonly(this.currentLocale);
}
setLocale(locale: string) {
if (this.messages[locale]) {
this.currentLocale.value = locale;
document.documentElement.setAttribute('lang', locale);
}
}
t(key: string, params?: Record<string, string | number>): string {
const message = this.messages[this.currentLocale.value]?.[key] || key;
if (!params) return message;
return Object.entries(params).reduce(
(msg, [k, v]) => msg.replace(`{${k}}`, String(v)),
message
);
}
availableLocales = computed(() => Object.keys(this.messages));
}
export interface I18nPluginOptions {
messages: Messages;
defaultLocale?: string;
}
export const I18nPlugin: Plugin = {
install(app: App, options: I18nPluginOptions) {
const i18n = new I18nService(
options.messages,
options.defaultLocale || 'zh-CN'
);
app.provide('i18n', i18n);
// 全局属性
app.config.globalProperties.$t = i18n.t.bind(i18n);
app.config.globalProperties.$i18n = i18n;
// 全局 $t 函数(模板中直接使用 {{ $t('key') }})
},
};
// Composable
export function useI18n() {
const i18n = inject<I18nService>('i18n')!;
return {
t: i18n.t.bind(i18n),
locale: i18n.locale,
setLocale: i18n.setLocale.bind(i18n),
availableLocales: i18n.availableLocales,
};
}
// 使用
// main.ts
app.use(I18nPlugin, {
messages: {
'zh-CN': {
'app.title': '我的应用',
'user.welcome': '欢迎, {name}!',
'user.logout': '退出登录',
},
'en-US': {
'app.title': 'My App',
'user.welcome': 'Welcome, {name}!',
'user.logout': 'Logout',
},
},
defaultLocale: 'zh-CN',
});
// 组件中使用
const { t, setLocale } = useI18n();
console.log(t('user.welcome', { name: '张三' })); // 欢迎, 张三!
setLocale('en-US');主题切换插件
// theme-plugin/index.ts
import type { App, Plugin } from 'vue';
import { ref, watch, readonly } from 'vue';
type Theme = 'light' | 'dark' | 'auto';
class ThemeService {
private theme = ref<Theme>('auto');
private storageKey: string;
constructor(storageKey = 'app-theme') {
this.storageKey = storageKey;
const saved = localStorage.getItem(storageKey) as Theme | null;
if (saved) this.theme.value = saved;
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (this.theme.value === 'auto') this.apply();
});
}
get current() { return readonly(this.theme); }
get resolvedTheme(): 'light' | 'dark' {
if (this.theme.value !== 'auto') return this.theme.value;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
setTheme(theme: Theme) {
this.theme.value = theme;
localStorage.setItem(this.storageKey, theme);
this.apply();
}
toggle() {
const next = this.resolvedTheme === 'dark' ? 'light' : 'dark';
this.setTheme(next);
}
private apply() {
const resolved = this.resolvedTheme;
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.classList.toggle('dark', resolved === 'dark');
}
}
export interface ThemePluginOptions {
defaultTheme?: Theme;
storageKey?: string;
}
export const ThemePlugin: Plugin = {
install(app: App, options: ThemePluginOptions = {}) {
const service = new ThemeService(options.storageKey);
if (options.defaultTheme) service.setTheme(options.defaultTheme);
app.provide('theme', service);
app.config.globalProperties.$theme = service;
},
};
export function useTheme() {
const theme = inject<ThemeService>('theme')!;
return {
current: theme.current,
resolvedTheme: computed(() => theme.resolvedTheme),
setTheme: theme.setTheme.bind(theme),
toggle: theme.toggle.bind(theme),
};
}优点
缺点
总结
Vue3 插件通过 install 方法向应用注册全局功能。优先使用 provide/inject 替代 globalProperties。为插件提供 TypeScript 类型声明和配置选项接口。建议将插件发布为 npm 包方便复用,同时支持全量注册和按需导入。
关键知识点
- 插件必须有 install 方法,接收 app 和 options 参数。
- provide/inject 是推荐的跨层级通信方式。
- globalProperties 需要模块扩展才能获得类型提示。
- app.use() 返回 app 实例,支持链式调用。
项目落地视角
- 为团队组件库封装为插件,支持按需导入。
- 插件提供配置项(前缀、主题、API 地址)。
- 为插件编写文档和 TypeScript 类型。
常见误区
- 在插件中注册过多全局组件影响 Tree Shaking。
- 使用 globalProperties 替代 provide/inject。
- 插件内部副作用未在卸载时清理。
进阶路线
- 学习 Vue3 插件的单元测试策略。
- 研究组件库(Element Plus)的插件架构。
- 了解 Vite 插件与 Vue 插件的区别。
适用场景
- 团队级组件库封装。
- 全局服务(通知、权限、主题)注册。
- 跨项目复用的功能模块。
落地建议
- 为插件提供 options 接口和默认值。
- 使用 provide/inject 替代 globalProperties。
- 文档化插件的使用方式和配置项。
排错清单
- 确认 app.use() 在创建组件之前调用。
- 检查 provide 的 key 是否与 inject 匹配。
- 验证 globalProperties 的类型声明是否正确。
复盘问题
- 插件和 Composable 各适合什么场景?
- 如何实现插件的按需加载减少包体积?
- 插件的配置如何在运行时动态修改?
