1. 程式人生 > >淺析vue封裝自定義外掛

淺析vue封裝自定義外掛

  在使用vue的過程中,經常會用到Vue.use,但是大部分對它一知半解,不瞭解在呼叫的時候具體做了什麼,因此,本文簡要概述下在vue中,如何封裝自定義外掛。

  在開始之前,先補充一句,其實利用vue封裝自定義外掛的本質就是元件例項化的過程或者指令等公共屬性方法的定義過程,比較大的區別在於封裝外掛需要手動干預,就是一些例項化方法需要手動呼叫,而Vue的例項化,很多邏輯內部已經幫忙處理掉了。外掛相對於元件的優勢就是外掛封裝好了之後,可以開箱即用,而元件是依賴於專案的。對元件初始化過程不是很熟悉的可以參考這篇博文。

    我們從vue原始碼中,可以看到Vue.use的方法定義如下: 

Vue.use = function (plugin: Function | Object) {
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    // 已經存在外掛,則直接返回外掛物件
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }

    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)
    // vue外掛形式可以是物件,也可以是方法,預設會傳遞一個Vue的構造方法作為引數
    if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args)
    } else if (typeof plugin === 'function') {
      plugin.apply(null, args)
    }
    installedPlugins.push(plugin)
    return this
  }

  從上述程式碼中,我們可以看出,Vue.use程式碼比較簡潔,處理邏輯不是很多。我們常用的Vue.use(xxx),xxx可以是方法,也可以是物件。在Vue.use中,通過apply呼叫外掛方法,傳入一個引數,Vue的構造方法。舉個栗子,最簡單的Vue外掛封裝如下:

// 方法
function vuePlugins (Vue) {
    Vue.directive('test', {
        bind (el) {
            el.addEventListener('click', function (e) {
                alert('hello world')
            })
        }
    })
}

// 物件
const vuePlugins = {
    install (Vue) {
        Vue.directive('test', {
            bind (el) {
                el.addEventListener('click', function (e) {
                    alert('hello world')
                })
            }
        })
    }
}

  以上兩種封裝方法都可以,說白了,就是將全域性註冊的指令封裝到一個方法中,在Vue.use時呼叫。這個比較顯然易懂。現在舉一個稍微複雜點的例子,tooltip在前端開發中經常會用到,直接通過方法能夠呼叫顯示,防止不必要的元件註冊引入,如果我們單獨封裝一個tooltip元件,應該如何封裝呢?這種封裝方式需要了解元件的初始化過程。區別在於將元件封裝成外掛時,不能通過template將元件例項化掛載到真實DOM中,這一步需要手動去呼叫對應元件例項化生命週期中的方法。具體實現程式碼如下:  

// component
let toast = {
    props: {
        show: {
            type: Boolean,
            default: false
        },
        msg: {
            type: String
        }
    },
    template: '<div v-show="show" class="toast">{{msg}}</div>'
}

  元件初始化過程:

// JavaScript初始化邏輯
// 獲取toast構造例項
const TempConstructor = Vue.extend(toast)
// 例項化toast
let instance = new TempConstructor()
// 手動建立toast的掛載容器
let div = document.createElement('div')
// 解析掛載toast
instance.$mount(div)
// 將toast掛載到body中
document.body.append(instance.$el)
// 將toast的呼叫封裝成一個方法,掛載到Vue的原型上
Vue.prototype.$toast = function (msg) {
    instance.show = true
    instance.msg = msg
    setTimeout(() => {
        instance.show = false
    }, 5000)
}

  元件的定義,和普通的元件宣告一致。元件的外掛化過程,和普通的元件例項化一致,區別在於外掛化時元件部分初始化方法需要手動呼叫。比如:

    1、Vue.extend作用是組裝元件的構造方法VueComponent

    2、new TempConstructor()是例項化元件例項。例項化構造方法,只是對元件的狀態資料進行了初始化,並沒有解析元件的template,也沒有後續的生成vnode和解析vnode

    3、instance.$mount(div)的作用是解析模板檔案,生成render函式,進而呼叫createElement生成vnode,最後生成真實DOM,將生成的真實DOM掛載到例項instance的$el屬性上,也就是說,例項instance.$el即為元件例項化最終的結果。

    4、元件中,props屬性最終會宣告在元件例項上,所以直接通過例項的屬性,也可以響應式的更改屬性的傳參。元件的屬性初始化方法如下:

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}
// 屬性代理,從一個原物件中拿資料
export function proxy (target: Object, sourceKey: string, key: string) {
  // 設定物件屬性的get/set,將data中的資料代理到元件物件vm上
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

  從上述可以看出,最終會在構造方法中,給所有的屬性宣告一個變數,本質上是讀取_props中的內容,_props中的屬性,會在例項化元件,initState中的InitProps中進行響應式的宣告,具體程式碼如下:

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      const hyphenatedKey = hyphenate(key)
      if (isReservedAttribute(hyphenatedKey) ||
          config.isReservedAttr(hyphenatedKey)) {
        warn(
          `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
          vm
        )
      }
      defineReactive(props, key, value, () => {
        if (!isRoot && !isUpdatingChildComponent) {
          warn(
            `Avoid mutating a prop directly since the value will be ` +
            `overwritten whenever the parent component re-renders. ` +
            `Instead, use a data or computed property based on the prop's ` +
            `value. Prop being mutated: "${key}"`,
            vm
          )
        }
      })
    } else {
      defineReactive(props, key, value)
    }
    // static props are already proxied on the component's prototype
    // during Vue.extend(). We only need to proxy props defined at
    // instantiation here.
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

  這裡會遍歷所有訂單props,響應式的宣告屬性的get和set。當對屬性進行讀寫時,會呼叫對應的get/set,進而會觸發檢視的更新,vue的響應式原理在後面的篇章中會進行介紹。這樣,我們可以通過方法引數的傳遞,來動態的去修改元件的props,進而能夠將元件外掛化。

  有些人可能會有疑問,到最後掛載到body上的元素是通過document.createElement('div')建立的div,還是模板的template解析後的結果。其實,最終掛載只是元件解析後的結果。在呼叫__patch__的過程中,執行流程是,首先,記錄老舊的節點,也就是$mount(div)中的div;然後,根據模板解析後的render生成的vnode的節點,去建立DOM節點,建立後的DOM節點會放到instance.$el中;最後一步,會將老舊節點給移除掉。所以,在我們封裝一個外掛的過程中,實際上手動建立的元素只是一箇中間變數,並不會保留在最後。可能大家還會注意到,外掛例項化完成後的DOM掛載也是我們手動掛載的,執行的程式碼是document.body.append(instance.$el)。

  附:test.html 測試程式碼

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <style>
    .toast{
      position: absolute;
      left: 45%;
      top: 10%;
      width: 10%;
      height: 5%;
      background: #ccc;
      border-radius: 5px;
    }
  </style>
  <title>Hello World</title>
  <script src="https://cdn.bootcss.com/vue/2.6.10/vue.js"></script>
</head>
<body>
<div id='app' v-test>
  <button @click="handleClick">我是按鈕</button>
</div>
<script>
function vuePlugins (Vue) {
    Vue.directive('test', {
        bind (el) {
            el.addEventListener('click', function (e) {
                alert('hello world')
            })
        }
    })
}
// const vuePlugins = {
//     install (Vue) {
//         Vue.directive('test', {
//             bind (el) {
//                 el.addEventListener('click', function (e) {
//                     alert('hello world')
//                 })
//             }
//         })
//     }
// }
Vue.use(vuePlugins)
let toast = {
    props: {
        show: {
            type: Boolean,
            default: false
        },
        msg: {
            type: String
        }
    },
    template: '<div v-show="show" class="toast">{{msg}}</div>'
}
// 獲取toast構造例項
const TempConstructor = Vue.extend(toast)
// 例項化toast
let instance = new TempConstructor()
// 手動建立toast的掛載容器
let div = document.createElement('div')
// 解析掛載toast
instance.$mount(div)
// 將toast掛載到body中
document.body.append(instance.$el)
// 將toast的呼叫封裝成一個方法,掛載到Vue的原型上
Vue.prototype.$toast = function (msg) {
    instance.show = true
    instance.msg = msg
    setTimeout(() => {
        instance.show = false
    }, 5000)
}
var vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello World',
        a: 11
    },
    methods: {
        test () {
            console.log('這是一個主方法')
        },
        handleClick () {
            this.$toast('hello world')
        }
    },
    created() {
        console.log('執行了主元件上的created方法')
    },
})
</script>
</body>
</html>