插件编写

掌握插件基础后,本节通过实际案例学习如何编写功能完整的插件。

工具类插件

工具类插件提供常用的工具方法:

const utilsPlugin = {
  install(Vue, options = {}) {
    const utils = {
      debounce(fn, delay = 300) {
        let timer = null
        return function(...args) {
          clearTimeout(timer)
          timer = setTimeout(() => {
            fn.apply(this, args)
          }, delay)
        }
      },
      
      throttle(fn, delay = 300) {
        let lastTime = 0
        return function(...args) {
          const now = Date.now()
          if (now - lastTime >= delay) {
            lastTime = now
            fn.apply(this, args)
          }
        }
      },
      
      deepClone(obj) {
        if (obj === null || typeof obj !== 'object') return obj
        const clone = Array.isArray(obj) ? [] : {}
        for (const key in obj) {
          if (obj.hasOwnProperty(key)) {
            clone[key] = this.deepClone(obj[key])
          }
        }
        return clone
      },
      
      formatDate(date, format = 'YYYY-MM-DD') {
        const d = new Date(date)
        const year = d.getFullYear()
        const month = String(d.getMonth() + 1).padStart(2, '0')
        const day = String(d.getDate()).padStart(2, '0')
        const hours = String(d.getHours()).padStart(2, '0')
        const minutes = String(d.getMinutes()).padStart(2, '0')
        const seconds = String(d.getSeconds()).padStart(2, '0')
        
        return format
          .replace('YYYY', year)
          .replace('MM', month)
          .replace('DD', day)
          .replace('HH', hours)
          .replace('mm', minutes)
          .replace('ss', seconds)
      },
      
      formatNumber(num, decimals = 2) {
        return Number(num).toFixed(decimals)
      },
      
      formatCurrency(value, symbol = '¥') {
        return symbol + Number(value).toLocaleString('zh-CN', {
          minimumFractionDigits: 2,
          maximumFractionDigits: 2
        })
      }
    }
    
    Vue.prototype.$utils = utils
    Vue.utils = utils
  }
}

export default utilsPlugin

请求插件

封装 HTTP 请求功能:

import axios from 'axios'

const httpPlugin = {
  install(Vue, options = {}) {
    const instance = axios.create({
      baseURL: options.baseURL || '',
      timeout: options.timeout || 10000,
      headers: options.headers || {}
    })
    
    instance.interceptors.request.use(
      config => {
        if (options.onRequest) {
          options.onRequest(config)
        }
        const token = localStorage.getItem('token')
        if (token) {
          config.headers.Authorization = `Bearer ${token}`
        }
        return config
      },
      error => {
        return Promise.reject(error)
      }
    )
    
    instance.interceptors.response.use(
      response => {
        if (options.onResponse) {
          return options.onResponse(response)
        }
        return response.data
      },
      error => {
        if (options.onError) {
          return options.onError(error)
        }
        const message = error.response?.data?.message || '请求失败'
        console.error(message)
        return Promise.reject(error)
      }
    )
    
    const http = {
      get(url, params, config = {}) {
        return instance.get(url, { params, ...config })
      },
      
      post(url, data, config = {}) {
        return instance.post(url, data, config)
      },
      
      put(url, data, config = {}) {
        return instance.put(url, data, config)
      },
      
      delete(url, config = {}) {
        return instance.delete(url, config)
      }
    }
    
    Vue.prototype.$http = http
    Vue.http = http
  }
}

export default httpPlugin

提示插件

创建消息提示功能:

const toastPlugin = {
  install(Vue, options = {}) {
    const defaultOptions = {
      duration: 3000,
      position: 'top',
      type: 'info'
    }
    
    const ToastConstructor = Vue.extend({
      template: `
        <transition name="toast-fade">
          <div v-if="visible" :class="['toast', 'toast-' + type, 'toast-' + position]">
            <span class="toast-icon">{{ icon }}</span>
            <span class="toast-message">{{ message }}</span>
          </div>
        </transition>
      `,
      data() {
        return {
          visible: false,
          message: '',
          type: 'info',
          position: 'top'
        }
      },
      computed: {
        icon() {
          const icons = {
            success: '✓',
            error: '✗',
            warning: '⚠',
            info: 'ℹ'
          }
          return icons[this.type]
        }
      }
    })
    
    let instance = null
    
    const toast = {
      show(message, type = 'info', duration = defaultOptions.duration) {
        if (!instance) {
          instance = new ToastConstructor()
          document.body.appendChild(instance.$mount().$el)
        }
        
        instance.message = message
        instance.type = type
        instance.position = defaultOptions.position
        instance.visible = true
        
        setTimeout(() => {
          instance.visible = false
        }, duration)
      },
      
      success(message, duration) {
        this.show(message, 'success', duration)
      },
      
      error(message, duration) {
        this.show(message, 'error', duration)
      },
      
      warning(message, duration) {
        this.show(message, 'warning', duration)
      },
      
      info(message, duration) {
        this.show(message, 'info', duration)
      }
    }
    
    Vue.prototype.$toast = toast
    Vue.toast = toast
  }
}

export default toastPlugin

确认框插件

const confirmPlugin = {
  install(Vue, options = {}) {
    const ConfirmConstructor = Vue.extend({
      template: `
        <transition name="confirm-fade">
          <div v-if="visible" class="confirm-overlay" @click.self="cancel">
            <div class="confirm-box">
              <div class="confirm-header">
                <h3>{{ title }}</h3>
              </div>
              <div class="confirm-body">
                <p>{{ message }}</p>
              </div>
              <div class="confirm-footer">
                <button class="btn-cancel" @click="cancel">{{ cancelText }}</button>
                <button class="btn-confirm" @click="confirm">{{ confirmText }}</button>
              </div>
            </div>
          </div>
        </transition>
      `,
      data() {
        return {
          visible: false,
          title: '提示',
          message: '',
          confirmText: '确定',
          cancelText: '取消',
          resolve: null,
          reject: null
        }
      },
      methods: {
        confirm() {
          this.visible = false
          if (this.resolve) {
            this.resolve(true)
          }
        },
        cancel() {
          this.visible = false
          if (this.resolve) {
            this.resolve(false)
          }
        }
      }
    })
    
    let instance = null
    
    const confirm = {
      show(message, title = '提示', options = {}) {
        return new Promise((resolve) => {
          if (!instance) {
            instance = new ConfirmConstructor()
            document.body.appendChild(instance.$mount().$el)
          }
          
          instance.message = message
          instance.title = title
          instance.confirmText = options.confirmText || '确定'
          instance.cancelText = options.cancelText || '取消'
          instance.resolve = resolve
          instance.visible = true
        })
      }
    }
    
    Vue.prototype.$confirm = confirm
    Vue.confirm = confirm
  }
}

export default confirmPlugin

加载插件

const loadingPlugin = {
  install(Vue, options = {}) {
    const LoadingConstructor = Vue.extend({
      template: `
        <transition name="loading-fade">
          <div v-if="visible" class="loading-overlay">
            <div class="loading-spinner">
              <div class="spinner"></div>
              <p v-if="text" class="loading-text">{{ text }}</p>
            </div>
          </div>
        </transition>
      `,
      data() {
        return {
          visible: false,
          text: ''
        }
      }
    })
    
    let instance = null
    let count = 0
    
    const loading = {
      show(text = '') {
        count++
        if (!instance) {
          instance = new LoadingConstructor()
          document.body.appendChild(instance.$mount().$el)
        }
        instance.text = text
        instance.visible = true
      },
      
      hide() {
        count--
        if (count <= 0) {
          count = 0
          if (instance) {
            instance.visible = false
          }
        }
      }
    }
    
    Vue.prototype.$loading = loading
    Vue.loading = loading
  }
}

export default loadingPlugin

指令插件

const directivePlugin = {
  install(Vue, options = {}) {
    Vue.directive('click-outside', {
      bind(el, binding) {
        el.__clickOutside__ = function(event) {
          if (!(el === event.target || el.contains(event.target))) {
            binding.value(event)
          }
        }
        document.addEventListener('click', el.__clickOutside__)
      },
      unbind(el) {
        document.removeEventListener('click', el.__clickOutside__)
        delete el.__clickOutside__
      }
    })
    
    Vue.directive('focus', {
      inserted(el, binding) {
        if (binding.value !== false) {
          el.focus()
        }
      }
    })
    
    Vue.directive('lazy', {
      inserted(el, binding) {
        const observer = new IntersectionObserver(entries => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              el.src = binding.value
              observer.unobserve(el)
            }
          })
        })
        observer.observe(el)
        el.__observer__ = observer
      },
      unbind(el) {
        if (el.__observer__) {
          el.__observer__.disconnect()
        }
      }
    })
    
    Vue.directive('permission', {
      inserted(el, binding) {
        const { value } = binding
        const permissions = options.permissions || []
        
        if (value && !permissions.includes(value)) {
          el.parentNode?.removeChild(el)
        }
      }
    })
    
    Vue.directive('debounce', {
      inserted(el, binding) {
        let timer = null
        el.addEventListener('input', () => {
          clearTimeout(timer)
          timer = setTimeout(() => {
            el.dispatchEvent(new Event('change'))
          }, binding.value || 300)
        })
      }
    })
  }
}

export default directivePlugin

组件库插件

import Button from './components/Button.vue'
import Input from './components/Input.vue'
import Modal from './components/Modal.vue'
import Table from './components/Table.vue'

const components = {
  Button,
  Input,
  Modal,
  Table
}

const uiPlugin = {
  install(Vue, options = {}) {
    const prefix = options.prefix || 'V'
    
    Object.keys(components).forEach(name => {
      const componentName = prefix + name
      Vue.component(componentName, components[name])
    })
    
    Vue.prototype.$UI_CONFIG = {
      size: options.size || 'medium',
      theme: options.theme || 'light'
    }
  }
}

export default uiPlugin

小结

插件编写要点:

  1. 工具类插件:提供通用工具方法
  2. 请求插件:封装 HTTP 请求
  3. 提示插件:消息提示功能
  4. 指令插件:自定义指令集合
  5. 组件库插件:注册一组组件