Vue.extend是 Vue 裡的一個全局 API,它提供了一種靈活的掛載組件的方式,這個 API 在日常開發中很少使用,畢竟只在碰到某些特殊的需求時它才能派上用場,但是我們也要學習它,學習這個 API 可以讓我們對 Vue 更加了解,更加熟悉 Vue 的組件初始化和掛載流程,除此之外,也經常會有面試官問到這個東西。下面我們就來從源碼到應用徹徹底底的看一看這個 API。
Vue.extend 定義引用一個官方的定義:
使用基礎 Vue 構造器,創建一個「子類」。參數是一個包含組件選項的對象。
data 選項是特例,需要注意 - 在 Vue.extend() 中它必須是函數
<div id="mount-point"></div>
複製代碼
// 創建構造器
var Profile = Vue.extend({
template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
data: function() {
return {
firstName: 'Walter',
lastName: 'White',
alias: 'Heisenberg'
}
}
})
// 創建 Profile 實例,並掛載到一個元素上。
new Profile().$mount('#mount-point')
複製代碼
結果如下
<p>Walter White aka Heisenberg</p>
複製代碼
之前沒用過這個 API 的小夥伴看到這個定義肯定會一頭霧水,但是沒關係,相信你看完這篇文章後以定會理解的。
這個 API 可以實現很靈活的功能,比如 ElementUI 裡的$message,我們使用this.$message('hello')的時候,其實就是通過這種方式創建一個組件實例,然後再將這個組件掛載到了 body 上,本篇文章也會分析如何實現這個組件,下面我們先來看下Vue.extend的源碼,從根源上來了解它。
源碼分析你可以在源碼目錄src/core/global-api/extend.js下找到這個函數的定義
export function initExtend(Vue: GlobalAPI) {
// 這個cid是一個全局唯一的遞增的id
// 緩存的時候會用到它
Vue.cid = 0
let cid = 1
/**
* Class inheritance
*/
Vue.extend = function(extendOptions: Object): Function {
// extendOptions就是我我們傳入的組件options
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
// 每次創建完Sub構造函數後,都會把這個函數儲存在extendOptions上的_Ctor中
// 下次如果用再同一個extendOptions創建Sub時
// 就會直接從_Ctor返回
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
const name = extendOptions.name || Super.options.name
if (process.env.NODE_ENV !== 'production' && name) {
validateComponentName(name)
}
// 創建Sub構造函數
const Sub = function VueComponent(options) {
this._init(options)
}
// 繼承Super,如果使用Vue.extend,這裡的Super就是Vue
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
Sub.cid = cid++
// 將組件的options和Vue的options合併,得到一個完整的options
// 可以理解為將Vue的一些全局的屬性,比如全局註冊的組件和mixin,分給了Sub
Sub.options = mergeOptions(Super.options, extendOptions)
Sub['super'] = Super
// 下面兩個設置了下代理,
// 將props和computed代理到了原型上
// 你可以不用關心這個
if (Sub.options.props) {
initProps(Sub)
}
if (Sub.options.computed) {
initComputed(Sub)
}
// 繼承Vue的global-api
Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use
// 繼承assets的api,比如註冊組件,指令,過濾器
ASSET_TYPES.forEach(function(type) {
Sub[type] = Super[type]
})
// 在components裡添加一個自己
// 不是主要邏輯,可以先不管
if (name) {
Sub.options.components[name] = Sub
}
// 將這些options保存起來
// 一會創建實例的時候會用到
Sub.superOptions = Super.options
Sub.extendOptions = extendOptions
Sub.sealedOptions = extend({}, Sub.options)
// 設置緩存
// 就是上文的緩存
cachedCtors[SuperId] = Sub
return Sub
}
}
function initProps(Comp) {
const props = Comp.options.props
for (const key in props) {
proxy(Comp.prototype, `_props`, key)
}
}
function initComputed(Comp) {
const computed = Comp.options.computed
for (const key in computed) {
defineComputed(Comp.prototype, key, computed[key])
}
}
複製代碼
其實這個Vue.extend做的事情很簡單,就是繼承 Vue,正如定義中說的那樣,創建一個子類,最終返回的這個 Sub 是:
const Sub = function VueComponent(options) {
this._init(options)
}
複製代碼
那麼上文的例子中的new Profile()執行的就是這個方法了,因為繼承了 Vue 的原型,這裡的_init就是 Vue 原型上的_init方法,你可以在源碼目錄下src/core/instance/init.js中找到它:
Vue.prototype._init = function(options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++
let startTag, endTag
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
startTag = `vue-perf-start:${vm._uid}`
endTag = `vue-perf-end:${vm._uid}`
mark(startTag)
}
vm._isVue = true
// merge options
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
// expose real self
vm._self = vmnext
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${vm._name} init`, startTag, endTag)
}
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
複製代碼
這個函數裡有很多邏輯,它主要做的事情就是初始化組件的事件,狀態等,大多不是我們本次分析的重點,你目前只需要關心裏面的這一段代碼:
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
複製代碼
執行new Profile()的時候沒有傳任何參數,所以這裡的 options 是 undefined,會走到 else 分值,然後resolveConstructorOptions(vm.constructor)其實就是拿到Sub.options這個東西,你可以在上文的Vue.extend源碼中找到它,然後將Sub.options和new Profile()傳入的options合併,再賦值給實例的$options,所以如果new Profile()的時候傳入了一個 options,這個 options 將會合併到vm.$options上,然後在這個_init函數的最後判斷了下vm.$options.el是否存在,存在的話就執行vm.$mount將組件掛載到 el 上,因為我們沒有傳 options,所以這裡的 el 肯定是不存在的,所以你才會看到例子中的new Profile().$mount('#mount-point')手動執行了$mount方法,其實經過這些分析你就會發現,我們直接執行new Profile({ el: '#mount-point' })也是可以的,除了 el 也可以傳其他參數,接著往下看就知道了。
$mount 方法會執行「掛載」,其實內部的整個過程是很複雜的,會執行 render、update、patch 等等,由於這些不是本次文章的重點,你只需要知道她會將組件的 dom 掛載到對應的 dom 節點上就行了,如$mount('#mount-point')會把組件 dom 掛載到#mount-point這個元素上。
如何使用經過上面的分析,你應該大致了解了Vue.extend的原理以及初始化過程,以及簡單的使用,其實這個初始化和平時的new Vue()是一樣的,畢竟兩個執行的同一個方法。但是在實際的使用中,我們可能還需要給組件傳 props,slots 以及綁定事件,下面我們來看下如何做到這些事情。
使用 props比如我們有一個 MessageBox 組件:
<template>
<div class="message-box">
{{ message }}
</div>
</template>
<script>
export default {
props: {
message: {
type: String,
default: ''
}
}
}
</script>
複製代碼
它需要一個 props 來顯示這個message,在使用Vue.extend時,要想給組件傳參數,我們需要在實例化的時候傳一個 propsData, 如:
const MessageBoxCtor = Vue.extend(MessageBox)
new MessageBox({
propsData: {
message: 'hello'
}
}).$mount('#target')
複製代碼
你可能會不明白為什麼要穿propsData,沒關係,接下來就來搞懂它,畢竟文章的目的就是徹底分析。
在上文的_init函數中,在合併完$options後,還執行了一個函數initState(vm),它的作用就是初始化組件狀態(props,computed,data):
export function initState(vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe((vm._data = {}), true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
複製代碼
別的不看,只看這個:
if (opts.props) initProps(vm, opts.props)
複製代碼
function initProps(vm: Component, propsOptions: Object) {
const propsData = vm.$options.propsData || {}
const props = (vm._props = {})
// ...省略其他邏輯
}
複製代碼
這裡的 propsData 就是數據源,他會從vm.$options.propsData上取,所我們傳入的propsData經過mergeOptions後合併到vm.$options,再到這裡進行 props 的初始化。
綁定事件可能有時候我們還想給組件綁定事件,其實這裡應該很多小夥伴都知道怎麼做,我們可以通過vm.$on給組件綁定事件,這個也是平時經常用到的一個 api
const MessageBoxCtor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBox({
propsData: {
message: 'hello'
}
}).$mount('#target')
messageBoxInstance.$on('some-event', () => {
console.log('success')
})
複製代碼
為了更加靈活的定製組件,我們還可以給組件傳入插槽,比如組件可能是這樣的:
<template>
<div class="message-box">
{{ message }}
<slot name="footer"/>
</div>
</template>
<script>
export default {
props: {
message: {
type: String,
default: ''
}
}
}
</script>
複製代碼
這裡我們先來分析下,如何才能給組件傳入插槽內容?其實這裡寫的 template 會被 Vue 的編譯器編譯成一個 render 函數,組件渲染時執行的是這個渲染函數,我們先來看下這個 template 編譯後的 render 是什麼:
function render() {
with (this) {
return _c(
'div',
{
staticClass: 'message-box'
},
[_v(_s(message)), _t('footer')],
2
)
}
}
複製代碼
這裡的_t('footer')就是渲染插槽時執行的函數,_t是renderSlot的縮寫,你可以在源碼目錄的src/core/instance/render-helpers/render-slot.js中找到這個函數,為方便理解,我將這個函數做了些簡化,去除掉了不重要的邏輯:
export function renderSlot(name, fallback, props) {
const scopedSlotFn = this.$scopedSlots[name]
let nodes /** Array<VNode> */
if (scopedSlotFn) {
// scoped slot
props = props || {}
nodes = scopedSlotFn(props) || fallback
} else {
nodes = this.$slots[name] || fallback
}
return nodes
}
複製代碼
這個函數就是從$scopedSlots中取到對應的插槽函數,然後執行這個函數,得到虛擬節點,然後返回虛擬節點,需要注意的是,Vue 在2.6.x版本中已經將普通插槽和作用域插槽都整合在了$scopedSlots,所有的插槽都是返回虛擬節點的函數,renderSlot裡面的else分支中從$slots取插槽是兼容以前的寫法的,所以說如果你用的是Vue2.6.x版本的話,你是不需要去關心$slots的。
由於renderSlot執行在組件實例的作用域中,所以this.$scopedSlots這裡的this是組件的實例vm,所以我們只需要在創建完組件實例後,在實例上添加$scopedSlots就可以了,再根據之前的分析,這個$scopedSlots是一個對象,其中的 key 是插槽名稱,value 是一個返回虛擬節點數組的函數:
const MessageBoxCtor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBox({
propsData: {
message: 'hello'
}
})
const h = this.$createElement
messageBoxInstance.$scopedSlots = {
footer: function() {
return [h('div', 'slot-content')]
}
}
messageBoxInstance.$mount('#target')
複製代碼
這裡需要注意的是$mount一定要在設置完$scopedSlots之後,因為$mount中會執行渲染函數,我們要保證在執行渲染函數時能獲取到$scopedSlots。
如果你想使用作用域插槽,也很簡單,和普通插槽是一樣的,只需要在函數中接收參數就可以了:
<slot name="head" :message="message"></slot>
複製代碼
messageBoxInstance.$scopedSlots = {
footer: function(slotData) {
return [h('div', slotData.message)]
}
}
複製代碼
這樣就可以成功渲染出message了。
總結本文章針對Vue.extend這個 API,從源碼到使用徹底進行了分析,相信你看過後應該能遊刃有餘地使用了。當然只有這些理論肯定還是不夠的,我們需要在實際開發場景中找到它的應用場景,比如我們常用的命令式彈窗組件,在很多 UI 組件庫裡都能看到它,你也可以去看一下 ElementUI 的 Message 組件的源碼,看看是怎麼實現的,有了這篇文章的基礎後相信你一定能看懂。