在使用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>