插件开发
Vue 插件是一种为 Vue 应用添加全局功能的强大方式。插件可以添加全局方法、指令、组件、混入等功能。
什么是插件?
插件是一个对象或函数,它暴露一个 install
方法。这个方法会在插件被安装时调用,并接收应用实例和选项作为参数。
javascript
// 基本插件结构
const myPlugin = {
install(app, options) {
// 插件逻辑
}
}
// 或者使用函数形式
function myPlugin(app, options) {
// 插件逻辑
}
// 安装插件
import { createApp } from 'vue'
const app = createApp({})
app.use(myPlugin, { /* 选项 */ })
基本插件示例
简单的全局方法插件
javascript
// plugins/logger.js
export default {
install(app, options = {}) {
const { prefix = '[LOG]' } = options
// 添加全局属性
app.config.globalProperties.$log = {
info(message) {
console.log(`${prefix} INFO:`, message)
},
warn(message) {
console.warn(`${prefix} WARN:`, message)
},
error(message) {
console.error(`${prefix} ERROR:`, message)
}
}
// 提供注入
app.provide('logger', app.config.globalProperties.$log)
}
}
使用插件:
javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import LoggerPlugin from './plugins/logger.js'
const app = createApp(App)
app.use(LoggerPlugin, { prefix: '[MyApp]' })
app.mount('#app')
在组件中使用:
vue
<template>
<div>
<button @click="logInfo">Log Info</button>
<button @click="logError">Log Error</button>
</div>
</template>
<script setup>
import { inject, getCurrentInstance } from 'vue'
// 方式1: 使用 inject
const logger = inject('logger')
// 方式2: 使用全局属性
const instance = getCurrentInstance()
const $log = instance?.appContext.config.globalProperties.$log
function logInfo() {
logger.info('This is an info message')
}
function logError() {
$log.error('This is an error message')
}
</script>
高级插件示例
表单验证插件
javascript
// plugins/validator.js
import { ref, computed } from 'vue'
// 验证规则
const rules = {
required: (value) => {
if (value === null || value === undefined || value === '') {
return 'This field is required'
}
return true
},
email: (value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return 'Please enter a valid email address'
}
return true
},
minLength: (min) => (value) => {
if (value && value.length < min) {
return `Minimum length is ${min} characters`
}
return true
},
maxLength: (max) => (value) => {
if (value && value.length > max) {
return `Maximum length is ${max} characters`
}
return true
},
pattern: (regex, message) => (value) => {
if (value && !regex.test(value)) {
return message || 'Invalid format'
}
return true
}
}
// 创建表单验证器
function createValidator(schema) {
const errors = ref({})
const touched = ref({})
const validate = (field, value) => {
const fieldRules = schema[field]
if (!fieldRules) return true
for (const rule of fieldRules) {
const result = rule(value)
if (result !== true) {
errors.value[field] = result
return false
}
}
delete errors.value[field]
return true
}
const validateAll = (data) => {
let isValid = true
for (const field in schema) {
const fieldValid = validate(field, data[field])
if (!fieldValid) {
isValid = false
}
touched.value[field] = true
}
return isValid
}
const touch = (field) => {
touched.value[field] = true
}
const reset = () => {
errors.value = {}
touched.value = {}
}
const isValid = computed(() => {
return Object.keys(errors.value).length === 0
})
const getFieldError = (field) => {
return touched.value[field] ? errors.value[field] : null
}
return {
errors,
touched,
isValid,
validate,
validateAll,
touch,
reset,
getFieldError
}
}
// 插件定义
export default {
install(app, options = {}) {
// 注册全局组件
app.component('ValidatedInput', {
props: {
modelValue: String,
rules: Array,
label: String,
type: {
type: String,
default: 'text'
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const error = ref('')
const touched = ref(false)
const validate = (value) => {
if (!props.rules) return true
for (const rule of props.rules) {
const result = rule(value)
if (result !== true) {
error.value = result
return false
}
}
error.value = ''
return true
}
const handleInput = (event) => {
const value = event.target.value
emit('update:modelValue', value)
if (touched.value) {
validate(value)
}
}
const handleBlur = () => {
touched.value = true
validate(props.modelValue)
}
return {
error,
touched,
handleInput,
handleBlur
}
},
template: `
<div class="form-field">
<label v-if="label">{{ label }}</label>
<input
:type="type"
:value="modelValue"
@input="handleInput"
@blur="handleBlur"
:class="{ 'error': error && touched }"
>
<span v-if="error && touched" class="error-message">{{ error }}</span>
</div>
`
})
// 提供验证器工厂函数
app.provide('createValidator', createValidator)
// 提供验证规则
app.provide('validationRules', rules)
// 全局属性
app.config.globalProperties.$validator = {
create: createValidator,
rules
}
}
}
使用验证插件:
vue
<template>
<form @submit.prevent="handleSubmit" class="registration-form">
<h2>用户注册</h2>
<ValidatedInput
v-model="form.username"
label="用户名"
:rules="[rules.required, rules.minLength(3)]"
/>
<ValidatedInput
v-model="form.email"
label="邮箱"
type="email"
:rules="[rules.required, rules.email]"
/>
<ValidatedInput
v-model="form.password"
label="密码"
type="password"
:rules="[rules.required, rules.minLength(8)]"
/>
<ValidatedInput
v-model="form.confirmPassword"
label="确认密码"
type="password"
:rules="[rules.required, passwordMatchRule]"
/>
<button type="submit" :disabled="!validator.isValid.value">
注册
</button>
<div v-if="!validator.isValid.value" class="form-errors">
<p>请修正以下错误:</p>
<ul>
<li v-for="(error, field) in validator.errors.value" :key="field">
{{ error }}
</li>
</ul>
</div>
</form>
</template>
<script setup>
import { reactive, inject, computed } from 'vue'
const createValidator = inject('createValidator')
const rules = inject('validationRules')
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})
// 自定义验证规则
const passwordMatchRule = computed(() => (value) => {
if (value !== form.password) {
return 'Passwords do not match'
}
return true
})
// 创建验证器
const validator = createValidator({
username: [rules.required, rules.minLength(3)],
email: [rules.required, rules.email],
password: [rules.required, rules.minLength(8)],
confirmPassword: [rules.required, passwordMatchRule.value]
})
function handleSubmit() {
if (validator.validateAll(form)) {
console.log('Form is valid:', form)
// 提交表单
}
}
</script>
<style scoped>
.registration-form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
}
.form-field {
margin-bottom: 15px;
}
.form-field label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-field input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-field input.error {
border-color: #e74c3c;
}
.error-message {
color: #e74c3c;
font-size: 12px;
margin-top: 5px;
display: block;
}
.form-errors {
background-color: #fdf2f2;
border: 1px solid #e74c3c;
border-radius: 4px;
padding: 10px;
margin-top: 15px;
}
.form-errors ul {
margin: 5px 0 0 20px;
}
button {
background-color: #3498db;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
width: 100%;
}
button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
</style>
国际化插件
javascript
// plugins/i18n.js
import { ref, computed } from 'vue'
class I18n {
constructor(options = {}) {
this.locale = ref(options.locale || 'en')
this.fallbackLocale = options.fallbackLocale || 'en'
this.messages = options.messages || {}
}
t(key, params = {}) {
const message = this.getMessage(key, this.locale.value) ||
this.getMessage(key, this.fallbackLocale) ||
key
return this.interpolate(message, params)
}
getMessage(key, locale) {
const keys = key.split('.')
let message = this.messages[locale]
for (const k of keys) {
if (message && typeof message === 'object') {
message = message[k]
} else {
return null
}
}
return message
}
interpolate(message, params) {
if (typeof message !== 'string') return message
return message.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match
})
}
setLocale(locale) {
this.locale.value = locale
}
addMessages(locale, messages) {
if (!this.messages[locale]) {
this.messages[locale] = {}
}
Object.assign(this.messages[locale], messages)
}
}
export default {
install(app, options = {}) {
const i18n = new I18n(options)
// 全局属性
app.config.globalProperties.$t = i18n.t.bind(i18n)
app.config.globalProperties.$i18n = i18n
// 提供注入
app.provide('i18n', i18n)
// 全局组件
app.component('i18n-t', {
props: {
keypath: {
type: String,
required: true
},
params: {
type: Object,
default: () => ({})
}
},
setup(props) {
return () => i18n.t(props.keypath, props.params)
}
})
// 全局指令
app.directive('t', {
mounted(el, binding) {
el.textContent = i18n.t(binding.value)
},
updated(el, binding) {
el.textContent = i18n.t(binding.value)
}
})
}
}
使用国际化插件:
javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import I18nPlugin from './plugins/i18n.js'
const messages = {
en: {
hello: 'Hello',
welcome: 'Welcome, {name}!',
nav: {
home: 'Home',
about: 'About',
contact: 'Contact'
}
},
zh: {
hello: '你好',
welcome: '欢迎,{name}!',
nav: {
home: '首页',
about: '关于',
contact: '联系'
}
}
}
const app = createApp(App)
app.use(I18nPlugin, {
locale: 'zh',
fallbackLocale: 'en',
messages
})
app.mount('#app')
vue
<template>
<div class="app">
<nav>
<button @click="switchLanguage">{{ currentLang }}</button>
</nav>
<main>
<!-- 使用 $t 方法 -->
<h1>{{ $t('hello') }}</h1>
<!-- 使用组件 -->
<p>
<i18n-t keypath="welcome" :params="{ name: userName }" />
</p>
<!-- 使用指令 -->
<ul>
<li v-t="'nav.home'"></li>
<li v-t="'nav.about'"></li>
<li v-t="'nav.contact'"></li>
</ul>
</main>
</div>
</template>
<script setup>
import { ref, inject, computed } from 'vue'
const i18n = inject('i18n')
const userName = ref('张三')
const currentLang = computed(() => {
return i18n.locale.value === 'zh' ? '中文' : 'English'
})
function switchLanguage() {
const newLocale = i18n.locale.value === 'zh' ? 'en' : 'zh'
i18n.setLocale(newLocale)
}
</script>
插件的组合和扩展
插件依赖
javascript
// plugins/http.js - HTTP 客户端插件
export default {
install(app, options = {}) {
const { baseURL = '', timeout = 5000 } = options
const http = {
async get(url, config = {}) {
// HTTP GET 实现
},
async post(url, data, config = {}) {
// HTTP POST 实现
}
}
app.provide('http', http)
app.config.globalProperties.$http = http
}
}
// plugins/api.js - API 插件(依赖 HTTP 插件)
export default {
install(app, options = {}) {
// 检查依赖
const http = app._context.provides.http
if (!http) {
throw new Error('API plugin requires HTTP plugin to be installed first')
}
const api = {
users: {
getAll: () => http.get('/users'),
getById: (id) => http.get(`/users/${id}`),
create: (data) => http.post('/users', data)
}
}
app.provide('api', api)
app.config.globalProperties.$api = api
}
}
插件配置和环境
javascript
// plugins/analytics.js
export default {
install(app, options = {}) {
const {
trackingId,
debug = false,
enableInDevelopment = false
} = options
// 检查环境
const isDevelopment = process.env.NODE_ENV === 'development'
if (isDevelopment && !enableInDevelopment) {
console.log('Analytics disabled in development')
return
}
if (!trackingId) {
console.warn('Analytics tracking ID is required')
return
}
const analytics = {
track(event, properties = {}) {
if (debug) {
console.log('Analytics track:', event, properties)
}
// 发送分析数据
if (typeof gtag !== 'undefined') {
gtag('event', event, properties)
}
},
page(path) {
if (debug) {
console.log('Analytics page:', path)
}
if (typeof gtag !== 'undefined') {
gtag('config', trackingId, {
page_path: path
})
}
}
}
app.provide('analytics', analytics)
app.config.globalProperties.$analytics = analytics
// 自动页面跟踪
if (app._context.provides.router) {
const router = app._context.provides.router
router.afterEach((to) => {
analytics.page(to.path)
})
}
}
}
插件测试
单元测试
javascript
// tests/plugins/logger.test.js
import { createApp } from 'vue'
import LoggerPlugin from '@/plugins/logger.js'
describe('Logger Plugin', () => {
let app
beforeEach(() => {
app = createApp({})
})
test('should install plugin with default options', () => {
app.use(LoggerPlugin)
const logger = app.config.globalProperties.$log
expect(logger).toBeDefined()
expect(typeof logger.info).toBe('function')
expect(typeof logger.warn).toBe('function')
expect(typeof logger.error).toBe('function')
})
test('should use custom prefix', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
app.use(LoggerPlugin, { prefix: '[TEST]' })
const logger = app.config.globalProperties.$log
logger.info('test message')
expect(consoleSpy).toHaveBeenCalledWith('[TEST] INFO:', 'test message')
consoleSpy.mockRestore()
})
test('should provide logger via injection', () => {
app.use(LoggerPlugin)
const TestComponent = {
setup() {
const logger = inject('logger')
return { logger }
},
template: '<div></div>'
}
const wrapper = mount(TestComponent, {
global: {
plugins: [[LoggerPlugin]]
}
})
expect(wrapper.vm.logger).toBeDefined()
})
})
集成测试
javascript
// tests/integration/validator.test.js
import { mount } from '@vue/test-utils'
import ValidatorPlugin from '@/plugins/validator.js'
const TestForm = {
template: `
<form @submit.prevent="submit">
<ValidatedInput
v-model="email"
:rules="[rules.required, rules.email]"
data-testid="email-input"
/>
<button type="submit" :disabled="!isValid">Submit</button>
</form>
`,
setup() {
const email = ref('')
const rules = inject('validationRules')
const createValidator = inject('createValidator')
const validator = createValidator({
email: [rules.required, rules.email]
})
const isValid = computed(() => {
return validator.validateAll({ email: email.value })
})
return { email, rules, isValid }
}
}
describe('Validator Plugin Integration', () => {
test('should validate email input', async () => {
const wrapper = mount(TestForm, {
global: {
plugins: [ValidatorPlugin]
}
})
const input = wrapper.find('[data-testid="email-input"] input')
const button = wrapper.find('button')
// 初始状态
expect(button.attributes('disabled')).toBeDefined()
// 输入无效邮箱
await input.setValue('invalid-email')
await input.trigger('blur')
expect(wrapper.find('.error-message').exists()).toBe(true)
expect(button.attributes('disabled')).toBeDefined()
// 输入有效邮箱
await input.setValue('test@example.com')
await input.trigger('blur')
expect(wrapper.find('.error-message').exists()).toBe(false)
expect(button.attributes('disabled')).toBeUndefined()
})
})
插件发布
包结构
my-vue-plugin/
├── src/
│ ├── index.js
│ ├── components/
│ └── utils/
├── dist/
│ ├── index.js
│ ├── index.esm.js
│ └── index.umd.js
├── types/
│ └── index.d.ts
├── tests/
├── package.json
├── README.md
└── rollup.config.js
package.json 配置
json
{
"name": "my-vue-plugin",
"version": "1.0.0",
"description": "A Vue 3 plugin",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"unpkg": "dist/index.umd.js",
"types": "types/index.d.ts",
"files": [
"dist",
"types",
"src"
],
"scripts": {
"build": "rollup -c",
"test": "jest",
"test:coverage": "jest --coverage"
},
"peerDependencies": {
"vue": "^3.0.0"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0",
"jest": "^27.0.0",
"rollup": "^2.0.0",
"vue": "^3.0.0"
},
"keywords": [
"vue",
"vue3",
"plugin"
]
}
TypeScript 类型定义
typescript
// types/index.d.ts
import { App } from 'vue'
export interface PluginOptions {
// 插件选项类型定义
}
export interface PluginInstance {
// 插件实例类型定义
}
declare const plugin: {
install(app: App, options?: PluginOptions): void
}
export default plugin
// 扩展全局属性
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$myPlugin: PluginInstance
}
}
最佳实践
1. 插件命名和结构
javascript
// ✅ 好的插件结构
export default {
install(app, options = {}) {
// 验证选项
const config = {
...defaultOptions,
...options
}
// 添加功能
// ...
// 提供开发工具支持
if (process.env.NODE_ENV === 'development') {
app.config.globalProperties.__MY_PLUGIN__ = true
}
}
}
// ❌ 避免的做法
export default {
install(app) {
// 没有选项验证
// 直接修改原型
Vue.prototype.$badPlugin = {}
}
}
2. 错误处理
javascript
export default {
install(app, options = {}) {
try {
// 插件逻辑
} catch (error) {
console.error('[MyPlugin] Installation failed:', error)
// 提供降级方案
app.config.globalProperties.$myPlugin = {
// 空实现或降级实现
}
}
}
}
3. 性能考虑
javascript
export default {
install(app, options = {}) {
// 延迟初始化
let instance = null
const getPlugin = () => {
if (!instance) {
instance = createPluginInstance(options)
}
return instance
}
app.config.globalProperties.$myPlugin = getPlugin
}
}
4. 文档和示例
markdown
# My Vue Plugin
## 安装
```bash
npm install my-vue-plugin
使用
javascript
import { createApp } from 'vue'
import MyPlugin from 'my-vue-plugin'
const app = createApp({})
app.use(MyPlugin, {
// 选项
})
API
选项
option1
(string): 描述option2
(boolean): 描述
方法
$myPlugin.method1()
: 描述$myPlugin.method2()
: 描述
## 下一步
- [组合式函数](../guide/composables.md)
- [自定义指令](../guide/custom-directives.md)
- [状态管理 (Pinia)](../ecosystem/pinia.md)
- [测试](../best-practices/testing.md)
插件是扩展 Vue 应用功能的强大方式,掌握插件开发将让你能够创建可复用的功能模块!