🔌

Vue 3のプラグイン開発について調べてみました

に公開

Vue 3のプラグイン開発

Vue 3のプラグインについて、実用的な例とベストプラクティスを交えながら詳しく解説します。プラグインを活用することで、アプリケーション全体に機能を追加し、再利用可能なライブラリを構築できます。

プラグインとは?

プラグインは、Vueアプリケーションにグローバルな機能を追加するための仕組みです。以下のような機能を提供できます:

  • グローバルメソッドやプロパティの追加
  • ディレクティブ、フィルター、トランジションの追加
  • コンポーネントオプションの注入
  • グローバルアセットの追加
  • 外部ライブラリとの統合

プラグインの基本構造

Vue 3のプラグインは、installメソッドを持つオブジェクトまたは関数として定義されます:

// オブジェクト形式
const MyPlugin = {
  install(app, options) {
    // プラグインの実装
  }
}

// 関数形式
function MyPlugin(app, options) {
  // プラグインの実装
}

基本的なプラグインの例

1. グローバルメソッドの追加

// plugins/globalMethods.js
export const GlobalMethodsPlugin = {
  install(app) {
    // グローバルメソッドの追加
    app.config.globalProperties.$formatDate = (date) => {
      return new Date(date).toLocaleDateString('ja-JP')
    }
    
    app.config.globalProperties.$formatCurrency = (amount) => {
      return new Intl.NumberFormat('ja-JP', {
        style: 'currency',
        currency: 'JPY'
      }).format(amount)
    }
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { GlobalMethodsPlugin } from './plugins/globalMethods'

const app = createApp(App)
app.use(GlobalMethodsPlugin)
app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <p>日付: {{ $formatDate(new Date()) }}</p>
    <p>価格: {{ $formatCurrency(1000) }}</p>
  </div>
</template>

2. グローバルプロパティの追加

// plugins/globalProperties.js
export const GlobalPropertiesPlugin = {
  install(app) {
    // グローバルプロパティの追加
    app.config.globalProperties.$appName = 'My Vue App'
    app.config.globalProperties.$version = '1.0.0'
    app.config.globalProperties.$apiUrl = 'https://api.example.com'
  }
}
<template>
  <div>
    <h1>{{ $appName }} v{{ $version }}</h1>
    <p>API URL: {{ $apiUrl }}</p>
  </div>
</template>

3. カスタムディレクティブの追加

// plugins/directives.js
export const DirectivesPlugin = {
  install(app) {
    // フォーカスディレクティブ
    app.directive('focus', {
      mounted(el) {
        el.focus()
      }
    })
    
    // 色変更ディレクティブ
    app.directive('color', {
      mounted(el, binding) {
        el.style.color = binding.value
      },
      updated(el, binding) {
        el.style.color = binding.value
      }
    })
  }
}
<template>
  <div>
    <input v-focus placeholder="自動でフォーカス" />
    <p v-color="'red'">赤いテキスト</p>
  </div>
</template>

実用的なプラグインの例

1. HTTP クライアントプラグイン

// plugins/http.js
class HttpClient {
  constructor(baseURL = '') {
    this.baseURL = baseURL
    this.interceptors = {
      request: [],
      response: []
    }
  }
  
  addRequestInterceptor(interceptor) {
    this.interceptors.request.push(interceptor)
  }
  
  addResponseInterceptor(interceptor) {
    this.interceptors.response.push(interceptor)
  }
  
  async request(url, options = {}) {
    let requestConfig = {
      url: this.baseURL + url,
      ...options
    }
    
    // リクエストインターセプターを適用
    for (const interceptor of this.interceptors.request) {
      requestConfig = await interceptor(requestConfig)
    }
    
    try {
      const response = await fetch(requestConfig.url, {
        method: requestConfig.method || 'GET',
        headers: {
          'Content-Type': 'application/json',
          ...requestConfig.headers
        },
        body: requestConfig.body ? JSON.stringify(requestConfig.body) : undefined
      })
      
      let responseData = await response.json()
      
      // レスポンスインターセプターを適用
      for (const interceptor of this.interceptors.response) {
        responseData = await interceptor(responseData)
      }
      
      return responseData
    } catch (error) {
      throw error
    }
  }
  
  get(url, options = {}) {
    return this.request(url, { ...options, method: 'GET' })
  }
  
  post(url, data, options = {}) {
    return this.request(url, { ...options, method: 'POST', body: data })
  }
  
  put(url, data, options = {}) {
    return this.request(url, { ...options, method: 'PUT', body: data })
  }
  
  delete(url, options = {}) {
    return this.request(url, { ...options, method: 'DELETE' })
  }
}

export const HttpPlugin = {
  install(app, options = {}) {
    const http = new HttpClient(options.baseURL)
    
    // グローバルプロパティとして追加
    app.config.globalProperties.$http = http
    
    // provide/inject用に提供
    app.provide('http', http)
    
    // インターセプターの設定
    if (options.interceptors) {
      if (options.interceptors.request) {
        http.addRequestInterceptor(options.interceptors.request)
      }
      if (options.interceptors.response) {
        http.addResponseInterceptor(options.interceptors.response)
      }
    }
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { HttpPlugin } from './plugins/http'

const app = createApp(App)

app.use(HttpPlugin, {
  baseURL: 'https://api.example.com',
  interceptors: {
    request: async (config) => {
      // 認証トークンを追加
      const token = localStorage.getItem('token')
      if (token) {
        config.headers = {
          ...config.headers,
          'Authorization': `Bearer ${token}`
        }
      }
      return config
    },
    response: async (data) => {
      // レスポンスの処理
      console.log('Response:', data)
      return data
    }
  }
})

app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <button @click="fetchUsers">ユーザーを取得</button>
    <button @click="createUser">ユーザーを作成</button>
    <div v-if="users.length">
      <ul>
        <li v-for="user in users" :key="user.id">
          {{ user.name }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup>
import { ref, inject } from 'vue'

const http = inject('http')
const users = ref([])

const fetchUsers = async () => {
  try {
    users.value = await http.get('/users')
  } catch (error) {
    console.error('ユーザー取得エラー:', error)
  }
}

const createUser = async () => {
  try {
    const newUser = await http.post('/users', {
      name: '新しいユーザー',
      email: 'user@example.com'
    })
    users.value.push(newUser)
  } catch (error) {
    console.error('ユーザー作成エラー:', error)
  }
}
</script>

2. 状態管理プラグイン

// plugins/store.js
class SimpleStore {
  constructor() {
    this.state = {}
    this.subscribers = []
  }
  
  setState(newState) {
    this.state = { ...this.state, ...newState }
    this.notifySubscribers()
  }
  
  getState() {
    return this.state
  }
  
  subscribe(callback) {
    this.subscribers.push(callback)
    return () => {
      const index = this.subscribers.indexOf(callback)
      if (index > -1) {
        this.subscribers.splice(index, 1)
      }
    }
  }
  
  notifySubscribers() {
    this.subscribers.forEach(callback => callback(this.state))
  }
}

export const StorePlugin = {
  install(app, options = {}) {
    const store = new SimpleStore()
    
    // 初期状態を設定
    if (options.initialState) {
      store.setState(options.initialState)
    }
    
    // グローバルプロパティとして追加
    app.config.globalProperties.$store = store
    
    // provide/inject用に提供
    app.provide('store', store)
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { StorePlugin } from './plugins/store'

const app = createApp(App)

app.use(StorePlugin, {
  initialState: {
    user: null,
    theme: 'light',
    notifications: []
  }
})

app.mount('#app')
<!-- App.vue -->
<template>
  <div :class="theme">
    <h1>Vue Store Plugin</h1>
    <p>ユーザー: {{ user?.name || '未ログイン' }}</p>
    <p>テーマ: {{ theme }}</p>
    <button @click="toggleTheme">テーマ切り替え</button>
    <button @click="login">ログイン</button>
  </div>
</template>

<script setup>
import { computed, inject } from 'vue'

const store = inject('store')

const user = computed(() => store.getState().user)
const theme = computed(() => store.getState().theme)

const toggleTheme = () => {
  const newTheme = theme.value === 'light' ? 'dark' : 'light'
  store.setState({ theme: newTheme })
}

const login = () => {
  store.setState({
    user: { name: 'テストユーザー', email: 'test@example.com' }
  })
}
</script>

<style>
.light {
  background-color: white;
  color: black;
}

.dark {
  background-color: black;
  color: white;
}
</style>

3. UI コンポーネントプラグイン

// plugins/ui.js
import Toast from './components/Toast.vue'
import Modal from './components/Modal.vue'
import Loading from './components/Loading.vue'

export const UIPlugin = {
  install(app) {
    // グローバルコンポーネントとして登録
    app.component('Toast', Toast)
    app.component('Modal', Modal)
    app.component('Loading', Loading)
    
    // トースト機能の追加
    app.config.globalProperties.$toast = {
      success(message) {
        // トースト表示の実装
        console.log('Success:', message)
      },
      error(message) {
        console.log('Error:', message)
      },
      info(message) {
        console.log('Info:', message)
      }
    }
    
    // モーダル機能の追加
    app.config.globalProperties.$modal = {
      show(component, props = {}) {
        // モーダル表示の実装
        console.log('Modal show:', component, props)
      },
      hide() {
        console.log('Modal hide')
      }
    }
  }
}
<!-- components/Toast.vue -->
<template>
  <div v-if="visible" class="toast" :class="type">
    {{ message }}
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  message: String,
  type: {
    type: String,
    default: 'info'
  },
  duration: {
    type: Number,
    default: 3000
  }
})

const visible = ref(false)

onMounted(() => {
  visible.value = true
  setTimeout(() => {
    visible.value = false
  }, props.duration)
})
</script>

<style>
.toast {
  position: fixed;
  top: 20px;
  right: 20px;
  padding: 12px 16px;
  border-radius: 4px;
  color: white;
  z-index: 1000;
}

.toast.success {
  background-color: #4caf50;
}

.toast.error {
  background-color: #f44336;
}

.toast.info {
  background-color: #2196f3;
}
</style>
<!-- App.vue -->
<template>
  <div>
    <h1>UI Plugin Demo</h1>
    <button @click="showSuccessToast">成功トースト</button>
    <button @click="showErrorToast">エラートースト</button>
    <button @click="showModal">モーダル表示</button>
    
    <!-- グローバルコンポーネントの使用 -->
    <Toast message="テストメッセージ" type="info" />
  </div>
</template>

<script setup>
import { getCurrentInstance } from 'vue'

const instance = getCurrentInstance()

const showSuccessToast = () => {
  instance.proxy.$toast.success('操作が成功しました!')
}

const showErrorToast = () => {
  instance.proxy.$toast.error('エラーが発生しました')
}

const showModal = () => {
  instance.proxy.$modal.show('UserModal', { userId: 123 })
}
</script>

4. ログプラグイン

// plugins/logger.js
class Logger {
  constructor(options = {}) {
    this.level = options.level || 'info'
    this.enabled = options.enabled !== false
    this.levels = {
      error: 0,
      warn: 1,
      info: 2,
      debug: 3
    }
  }
  
  log(level, message, ...args) {
    if (!this.enabled || this.levels[level] > this.levels[this.level]) {
      return
    }
    
    const timestamp = new Date().toISOString()
    const prefix = `[${timestamp}] [${level.toUpperCase()}]`
    
    switch (level) {
      case 'error':
        console.error(prefix, message, ...args)
        break
      case 'warn':
        console.warn(prefix, message, ...args)
        break
      case 'info':
        console.info(prefix, message, ...args)
        break
      case 'debug':
        console.debug(prefix, message, ...args)
        break
      default:
        console.log(prefix, message, ...args)
    }
  }
  
  error(message, ...args) {
    this.log('error', message, ...args)
  }
  
  warn(message, ...args) {
    this.log('warn', message, ...args)
  }
  
  info(message, ...args) {
    this.log('info', message, ...args)
  }
  
  debug(message, ...args) {
    this.log('debug', message, ...args)
  }
}

export const LoggerPlugin = {
  install(app, options = {}) {
    const logger = new Logger(options)
    
    // グローバルプロパティとして追加
    app.config.globalProperties.$logger = logger
    
    // provide/inject用に提供
    app.provide('logger', logger)
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { LoggerPlugin } from './plugins/logger'

const app = createApp(App)

app.use(LoggerPlugin, {
  level: 'debug',
  enabled: process.env.NODE_ENV !== 'production'
})

app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <h1>Logger Plugin Demo</h1>
    <button @click="testLogs">ログをテスト</button>
  </div>
</template>

<script setup>
import { inject } from 'vue'

const logger = inject('logger')

const testLogs = () => {
  logger.debug('デバッグメッセージ')
  logger.info('情報メッセージ')
  logger.warn('警告メッセージ')
  logger.error('エラーメッセージ')
}
</script>

プラグインの配布とTypeScript対応

1. npm パッケージとして配布

// package.json
{
  "name": "vue3-my-plugin",
  "version": "1.0.0",
  "description": "My Vue 3 Plugin",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  },
  "peerDependencies": {
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "@rollup/plugin-typescript": "^8.0.0",
    "rollup": "^2.0.0",
    "typescript": "^4.0.0"
  }
}
// src/index.ts
import { App } from 'vue'

export interface PluginOptions {
  baseURL?: string
  timeout?: number
  retries?: number
}

export interface MyPlugin {
  install(app: App, options?: PluginOptions): void
}

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $myPlugin: any
  }
}

const MyPlugin: MyPlugin = {
  install(app: App, options: PluginOptions = {}) {
    const config = {
      baseURL: options.baseURL || '',
      timeout: options.timeout || 5000,
      retries: options.retries || 3
    }
    
    app.config.globalProperties.$myPlugin = {
      config,
      // プラグインの機能
    }
  }
}

export default MyPlugin
// src/types.ts
export interface User {
  id: number
  name: string
  email: string
}

export interface ApiResponse<T> {
  data: T
  status: number
  message: string
}

2. プラグインの使用例(TypeScript)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import MyPlugin, { PluginOptions } from 'vue3-my-plugin'

const app = createApp(App)

const options: PluginOptions = {
  baseURL: 'https://api.example.com',
  timeout: 10000,
  retries: 5
}

app.use(MyPlugin, options)
app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <h1>My Plugin Demo</h1>
    <button @click="usePlugin">プラグインを使用</button>
  </div>
</template>

<script setup lang="ts">
import { getCurrentInstance } from 'vue'

const instance = getCurrentInstance()

const usePlugin = () => {
  // TypeScriptで型安全に使用
  const plugin = instance?.proxy?.$myPlugin
  if (plugin) {
    console.log('Plugin config:', plugin.config)
  }
}
</script>

ベストプラクティス

1. プラグインの設計原則

// ✅ 良い例:単一責任の原則
export const ValidationPlugin = {
  install(app, options) {
    // バリデーション機能のみに集中
    app.config.globalProperties.$validate = {
      email: (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
      required: (value) => value != null && value !== '',
      minLength: (value, min) => value.length >= min
    }
  }
}

// ❌ 悪い例:複数の責任を持つ
export const BadPlugin = {
  install(app, options) {
    // バリデーション、HTTP、ログなど複数の機能を混在
    app.config.globalProperties.$validate = { /* ... */ }
    app.config.globalProperties.$http = { /* ... */ }
    app.config.globalProperties.$logger = { /* ... */ }
  }
}

2. エラーハンドリング

export const SafePlugin = {
  install(app, options = {}) {
    try {
      // プラグインの初期化
      const config = this.validateOptions(options)
      this.initializePlugin(app, config)
    } catch (error) {
      console.error('Plugin initialization failed:', error)
      // フォールバック処理
      this.initializeFallback(app)
    }
  },
  
  validateOptions(options) {
    if (!options.requiredOption) {
      throw new Error('requiredOption is required')
    }
    return options
  },
  
  initializePlugin(app, config) {
    // メインの初期化処理
  },
  
  initializeFallback(app) {
    // フォールバック処理
  }
}

3. パフォーマンスの考慮

export const PerformancePlugin = {
  install(app, options) {
    // 遅延初期化
    let initialized = false
    
    app.config.globalProperties.$lazyFeature = () => {
      if (!initialized) {
        this.initializeHeavyFeature()
        initialized = true
      }
      return this.heavyFeature
    }
  },
  
  initializeHeavyFeature() {
    // 重い処理は必要になった時のみ実行
  }
}

4. プラグインのテスト

// plugins/__tests__/myPlugin.test.js
import { createApp } from 'vue'
import MyPlugin from '../myPlugin'

describe('MyPlugin', () => {
  let app
  
  beforeEach(() => {
    app = createApp({})
  })
  
  test('should install plugin correctly', () => {
    app.use(MyPlugin, { testOption: 'test' })
    
    expect(app.config.globalProperties.$myPlugin).toBeDefined()
  })
  
  test('should handle options correctly', () => {
    const options = { baseURL: 'https://test.com' }
    app.use(MyPlugin, options)
    
    const plugin = app.config.globalProperties.$myPlugin
    expect(plugin.config.baseURL).toBe('https://test.com')
  })
})

まとめ

Vue 3のプラグインは、以下の利点を提供します:

  • 機能の拡張: アプリケーション全体に機能を追加
  • 再利用性: 複数のプロジェクトで同じ機能を共有
  • モジュール性: 機能を独立したモジュールとして管理
  • 統合性: サードパーティライブラリとの統合が容易

プラグインを適切に設計・実装することで、保守性が高く、拡張性のあるVueアプリケーションを構築できます。ただし、過度なグローバル汚染は避け、適切なスコープで機能を提供することが重要です。

参考リンク

GitHubで編集を提案

Discussion