Skip to content

插件开发

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 应用功能的强大方式,掌握插件开发将让你能够创建可复用的功能模块!

基于 Vue.js 官方文档构建的学习宝典