在經過初始化階段之后,即將開始組件的掛載,不過在掛載之前很有必要提一下虛擬Dom的概念。這個想必大家有所耳聞,我們知道vue@2.0開始引入了虛擬Dom,主要解決的問題是,大部分情況下可以降低使用JavaScript去操作跨線程的龐大Dom所需要的昂貴性能,讓Dom操作的性能更高;以及虛擬Dom可以用於SSR以及跨端使用。虛擬Dom,顧名思義並不是真實的Dom,而是使用JavaScript的對象來對真實Dom的一個描述。一個真實的Dom也無非是有標簽名,屬性,子節點等這些來描述它,如頁面中的真實Dom是這樣的:
<div id='app' class='wrap'> <h2> hello </h2> </div>
我們可以在render函數內這樣描述它:
new vue({ render(h) { return h('div', { attrs: { id: 'app', class: 'wrap' } }, [ h('h2', 'hello') ]) } })
這個時候它並不是用對象來描述的,使用的是render函數內的數據結構去描述的真實Dom,而現在我們需要將這段描述轉為用對象的形式,render函數使用的是參數h方法並用VNode這個類來實例化它們,所以我們再了解h的實現原理前,首先來看下VNode類是什么,找到它定義的地方:
export default class VNode { constructor ( tag data children text elm context componentOptions asyncFactory ) { this.tag = tag // 標簽名 this.data = data // 屬性 如id/class this.children = children // 子節點 this.text = text // 文本內容 this.elm = elm // 該VNode對應的真實節點 this.ns = undefined // 節點的namespace this.context = context // 該VNode對應實例 this.fnContext = undefined // 函數組件的上下文 this.fnOptions = undefined // 函數組件的配置 this.fnScopeId = undefined // 函數組件的ScopeId this.key = data && data.key // 節點綁定的key 如v-for this.componentOptions = componentOptions // 組件VNode的options this.componentInstance = undefined // 組件的實例 this.parent = undefined // vnode組件的占位符節點 this.raw = false // 是否為平台標簽或文本 this.isStatic = false // 靜態節點 this.isRootInsert = true // 是否作為根節點插入 this.isComment = false // 是否是注釋節點 this.isCloned = false // 是否是克隆節點 this.isOnce = false // 是否是v-noce節點 this.asyncFactory = asyncFactory // 異步工廠方法 this.asyncMeta = undefined // 異步meta this.isAsyncPlaceholder = false // 是否為異步占位符 } get child () { // 別名 return this.componentInstance } }
這是VNode類定義的地方,挺嚇人的,它支持一共最多八個參數,其實經常用到的並不多。如tag是元素節點的名稱,children為它的子節點,text是文本節點內的文本。實例化后的對象就有二十三個屬性作為在vue的內部一個節點的描述,它描述的是將它創建為一個怎樣的真實Dom。大部分屬性默認是false或undefined,而通過這些屬性有效的值就可以組裝出不同的描述,如真實的Dom中會有元素節點、文本節點、注釋節點等。而通過這樣一個VNode類,也可以描述出相應的節點,部分節點vue內部還做了相應的封裝:
資源搜索網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com
注釋節點
export const createEmptyVNode = (text = '') => { const node = new VNode() node.text = text node.isComment = true return node }
創建一個空的VNode,有效屬性只有text和isComment來表示一個注釋節點。
真實的注釋節點:
<!-- 注釋節點 --> VNode描述: createEmptyVNode ('注釋節點') { text: '注釋節點', isComment: true }
文本節點
export function createTextVNode (val) { return new VNode(undefined, undefined, undefined, String(val)) }
只是設置了text屬性,描述的是標簽內的文本
VNode描述:
createTextVNode('文本節點') { text: '文本節點' }
克隆節點
export function cloneVNode (vnode) { const cloned = new VNode( vnode.tag, vnode.data, vnode.children, vnode.text, vnode.elm, vnode.context, vnode.componentOptions, vnode.asyncFactory ) cloned.ns = vnode.ns cloned.isStatic = vnode.isStatic cloned.key = vnode.key cloned.isComment = vnode.isComment cloned.fnContext = vnode.fnContext cloned.fnOptions = vnode.fnOptions cloned.fnScopeId = vnode.fnScopeId cloned.asyncMeta = vnode.asyncMeta cloned.isCloned = true return cloned }
將一個現有的VNode節點拷貝一份,只是被拷貝節點的isCloned屬性為false,而拷貝得到的節點的isCloned屬性為true,除此之外它們完全相同。
真實的元素節點:
<div> hello <span>Vue!</span> </div> VNode描述: { tag: 'div', children: [ { text: 'hello' }, { tag: 'span', children: [ { text: Vue! } ] } ], }
組件節點
渲染App組件:
new Vue({ render(h) { return h(App) } }) VNode描述: { tag: 'vue-component-2', componentInstance: {...}, componentOptions: {...}, context: {...}, data: {...} } 組件的VNode會和元素節點相比會有兩個特有的屬性componentInstance和componentOptions。VNode的類型有很多,它們都是從這個VNode類中實例化出來的,只是屬性不同。
開始掛載階段
this._init() 方法的最后:
... 初始化
if (vm.$options.el) { vm.$mount(vm.$options.el) }
如果用戶有傳入el屬性,就執行vm.$mount方法並傳入el開始掛載。這里的$mount方法在完整版和運行時版本又會有點不同,他們區別如下:
運行時版本:
Vue.prototype.$mount = function(el) { // 最初的定義 return mountComponent(this, query(el)); } 完整版: const mount = Vue.prototype.$mount Vue.prototype.$mount = function(el) { // 拓展編譯后的 if(!this.$options.render) { ---| if(this.$options.template) { ---| ...經過編譯器轉換后得到render函數 ---| 編譯階段 } ---| } ---| return mount.call(this, query(el)) } ----------------------------------------------- export function query(el) { // 獲取掛載的節點 if(typeof el === 'string') { // 比如#app const selected = document.querySelector(el) if(!selected) { return document.createElement('div') } return selected } else { return el } }
完整版有一個騷操作,首先將$mount方法緩存到mount變量上,然后使用函數劫持的手段重新定義$mount函數,並在其內部增加編譯相關的代碼,最后還是使用原來定義的$mount方法掛載。所以核心是要了解最初定義$mount方法時內的mountComponent方法:
export function mountComponent(vm, el) { vm.$el = el ... callHook(vm, 'beforeMount') ... const updateComponent = function () { vm._update(vm._render()) } ... }
首先將傳入的el賦值給vm.$el,這個時候el是一個真實dom,接着會執行用戶自己定義的beforeMount鈎子。接下來會定義一個重要的函數變量updateComponent,它的內部首先會執行vm._render()方法,將返回的結果傳入vm._update()內再執行。我們這章主要就來分析這個vm._render()方法做了什么事情,來看下它的定義:
Vue.prototype._render = function() { const vm = this const { render } = vm.$options const vnode = render.call(vm, vm.$createElement) return vnode }
首先會得到自定義的render函數,傳入vm.$createElement這個方法(也就是上面例子內的h方法),將執行的返回結果賦值給vnode,這里也就完成了render函數內數據結構轉為vnode的操作。而這個vm.$createElement是在之前初始化initRender方法內掛載到vm實例下的:
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false) // 編譯 vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) // 手寫
無論是編譯而來還是手寫的render函數,它們都是返回了createElement這個函數,繼續查找它的定義:
const SIMPLE_NORMALIZE = 1 const ALWAYS_NORMALIZE = 2 export default createElement( context, tag, data, children, normalizationType, alwaysNormalize) { if(Array.isArray(data) || isPrimitive(data)) { // data是數組或基礎類型 normalizationType = children --| children = data --| 參數移位 data = undefined --| } if (isTrue(alwaysNormalize)) { // 如果是手寫render normalizationType = ALWAYS_NORMALIZE } return _createElement(contenxt, tag, data, children, normalizationType) }
這里是對傳入的參數處理,如果第三個參數傳入的是數組(子元素)或者是基礎類型的值,就將參數位置改變。然后對傳入的最后一個參數是true還是false做處理,這會決定之后對children屬性的處理方式。這里又是對_createElement做的封裝,所以我們還要繼續看它的定義:
export function _createElement( context, tag, data, children, normalizationType ) { if (normalizationType === ALWAYS_NORMALIZE) { // 手寫render函數 children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { //編譯render函數 children = simpleNormalizeChildren(children) } if(typeof tag === 'string') { // 標簽 let vnode, Ctor if(config.isReservedTag(tag)) { // 如果是html標簽 vnode = new VNode(tag, data, children, undefined, undefined, context) } ... } else { // 就是組件了 vnode = createComponent(tag, data, context, children) } ... return vnode }
首先我們會看到針對最后一個參數的布爾值對children做不同的處理,如果是編譯的render函數,就將children格式化為一維數組:
function simpleNormalizeChildren(children) { // 編譯render的處理函數 for (let i = 0; i < children.length; i++) { if (Array.isArray(children[i])) { return Array.prototype.concat.apply([], children) } } return children }
我們現在主要看下手寫的render函數是怎么處理的,從接下來的_createElement方法我們知道,轉化VNode是分為兩種情況的:
1. 普通的元素節點轉化為VNode
以一段children是二維數組代碼為示例,我們來說明普通元素是如何轉VNode的:
render(h) {
return h( "div", [ [ [h("h1", "title h1")], [h('h2', "title h2")] ], [ h('h3', 'title h3') ] ] ); }
因為_createElement方法是對h方法的封裝,所以h方法的第一個參數對應的就是_createElement方法內的tag,第二個參數對應的是data。又因為h方法是遞歸的,所以首先從h('h1', 'title h1')開始解析,經過參數上移之后children就是title h1這段文本了,所以會在normalizeChildren方法將它轉為[createTextVNode(children)]一個文本的VNode節點:
function normalizeChildren(children) { // 手寫`render`的處理函數 return isPrimitive(children) //原始類型 typeof為string/number/symbol/boolean之一 ? [createTextVNode(children)] // 轉為數組的文本節點 : Array.isArray(children) // 如果是數組 ? normalizeArrayChildren(children) : undefined }
接着會滿足_createElement方法內的這個條件:
if(typeof tag === 'string'){ tag為h1標簽 if(config.isReservedTag(tag)) { // 是html標簽 vnode = new VNode( tag, // h1 data, // undefined children, 轉為了 [{text: 'title h1'}] undefined, undefined, context ) } } ... return vnode 返回的vnode結構為: { tag: h1, children: [ { text: title h1 } ] }
然后依次處理h('h2', "title h2"),h('h3', 'title h3')會得到三個VNode實例的節點。接着會執行最外層的h(div, [[VNode,VNode],[VNode]])方法,注意它的結構是二維數組,這個時候它就滿足normalizeChildren方法內的Array.isArray(children)這個條件了,會執行normalizeArrayChildren這個方法:
function normalizeArrayChildren(children) { const res = [] // 存放結果 for(let i = 0; i < children.length; i++) { // 遍歷每一項 let c = children[i] if(isUndef(c) || typeof c === 'boolean') { // 如果是undefined 或 布爾值 continue // 跳過 } if(Array.isArray(c)) { // 如果某一項是數組 if(c.length > 0) { c = normalizeArrayChildren(c) // 遞歸結果賦值給c,結果就是[VNode] ... 合並相鄰的文本節點 res.push.apply(res, c) //小操作 } } else { ... res.push(c) } } return res }
如果children內的某一項是數組就遞歸調用自己,將自身傳入並將返回的結果覆蓋自身,遞歸內的結果就是res.push(c)得到的,這里c也是[VNode]數組結構。覆蓋自己之后執行res.push.apply(res, c),添加到res內。這里vue秀了一個小操作,在一個數組內push一個數組,本來應該是二維數組的,使用這個寫法后res.push.apply(res, c)后,結果最后是就是一維數組了。res最后返回的結果[VNode, VNode, VNode],這也是children最終的樣子。接着執行h('div', [VNode, VNode, VNode])方法,又滿足了之前同樣的條件:
if (config.isReservedTag(tag)) { // 標簽為div vnode = new VNode( tag, data, children, undefined, undefined, context ) } return vnode
所以最終得到的vnode結構就是這樣的:
{
tag: 'div', children: [VNode, VNode, VNode] }
以上就是普通元素節點轉VNode的具體過程。
2. 組件轉化為VNode
接下來我們來了解組件VNode的創建過程,常見示例如下:
main.js
new Vue({ render(h) { return h(App) } }) app.vue import Child from '@/pages/child' export default { name: 'app', components: { Child } }
不知道大家有將引入的組件直接打印出來過沒有,我們在main.js內打印下App組件:
{
beforeCreate: [ƒ]
beforeDestroy: [ƒ]
components: {Child: {…}}
name: "app" render: ƒ () staticRenderFns: [] __file: "src/App.vue" _compiled: true }
我們只是定義了name和components屬性,打印出來為什么會多了這么多屬性?這是vue-loader解析后添加的,例如render: ƒ ()就是將App組件的template模板轉換而來的,我們記住這個一個組件對象即可。
讓我們簡單看一眼之前_createElement函數:
export function _createElement( context, tag, data, children, normalizationType ) { ... if(typeof tag === 'string') { // 標簽 ... } else { // 就是組件了 vnode = createComponent( tag, // 組件對象 data, // undefined context, // 當前vm實例 children // undefined ) } ... return vnode }
很明顯這里的tag並不一個string,轉而會調用createComponent()方法:
export function createComponent ( // 上 Ctor, data = {}, context, children, tag ) { const baseCtor = context.$options._base if (isObject(Ctor)) { // 組件對象 Ctor = baseCtor.extend(Ctor) // 轉為Vue的子類 } ... }
這里要補充一點,在new Vue()之前定義全局API時:
export function initGlobalAPI(Vue) { ... Vue.options._base = Vue Vue.extend = function(extendOptions){...} }
經過初始化合並options之后當前實例就有了context.$options._base這個屬性,然后執行它的extend這個方法,傳入我們的組件對象,看下extend方法的定義:
Vue.cid = 0 let cid = 1 Vue.extend = function (extendOptions = {}) { const Super = this // Vue基類構造函數 const name = extendOptions.name || Super.options.name const Sub = function (options) { // 定義構造函數 this._init(options) // _init繼承而來 } Sub.prototype = Object.create(Super.prototype) // 繼承基類Vue初始化定義的原型方法 Sub.prototype.constructor = Sub // 構造函數指向子類 Sub.cid = cid++ Sub.options = mergeOptions( // 子類合並options Super.options, // components, directives, filters, _base extendOptions // 傳入的組件對象 ) Sub['super'] = Super // Vue基類 // 將基類的靜態方法賦值給子類 Sub.extend = Super.extend Sub.mixin = Super.mixin Sub.use = Super.use ASSET_TYPES.forEach(function (type) { // ['component', 'directive', 'filter'] Sub[type] = Super[type] }) if (name) { 讓組件可以遞歸調用自己,所以一定要定義name屬性 Sub.options.components[name] = Sub // 將子類掛載到自己的components屬性下 } Sub.superOptions = Super.options Sub.extendOptions = extendOptions return Sub }
仔細觀察extend這個方法不難發現,我們傳入的組件對象相當於就是之前new Vue(options)里面的options,也就是用戶自定義的配置,然后和vue之前就定義的原型方法以及全局API合並,然后返回一個新的構造函數,它擁有Vue完整的功能。讓我們繼續createComponent的其他邏輯:
export function createComponent ( // 中 Ctor, data = {}, context, children, tag ) { ... const listeners = data.on // 父組件v-on傳遞的事件對象格式 data.on = data.nativeOn // 組件的原生事件 installComponentHooks(data) // 為組件添加鈎子方法 ... }
之前說明初始化事件initEvents時,這里的data.on就是父組件傳遞給子組件的事件對象,賦值給變量listeners;data.nativeOn是綁定在組件上有native修飾符的事件。接着會執行一個組件比較重要的方法installComponentHooks,它的作用是往組件的data屬性下掛載hook這個對象,里面有init,prepatch,insert,destroy四個方法,這四個方法會在之后的將VNode轉為真實Dom的patch階段會用到,當我們使用到時再來看它們的定義是什么。我們繼續createComponent的其他邏輯:
export function createComponent ( // 下 Ctor, data = {}, context, children, tag ) { ... const name = Ctor.options.name || tag // 拼接組件tag用 const vnode = new VNode( // 創建組件VNode `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, // 對應tag屬性 data, // 有父組件傳遞自定義事件和掛載的hook對象 undefined, // 對應children屬性 undefined, // 對應text屬性 undefined, // 對應elm屬性 context, // 當前實例 { // 對應componentOptions屬性 Ctor, // 子類構造函數 propsData, // props具體值的對象集合 listeners, // 父組件傳遞自定義事件對象集合 tag, // 使用組件時的名稱 children // 插槽內的內容,也是VNode格式 }, asyncFactory ) return vnode }
組件生成的VNode如下:
{
tag: 'vue-component-1-app', context: {...}, componentOptions: { Ctor: function(){...}, propsData: undefined, children: undefined, tag: undefined, children: undefined }, data: { on: undefined, // 為原生事件 data: { init: function(){...}, insert: function(){...}, prepatch: function(){...}, destroy: function(){...} } } }
如果看到tag屬性是vue-component開頭就是組件了,以上就組件VNode的初始化。簡單理解就是如果h函數的參數是組件對象,就將它轉為一個Vue的子類,雖然組件VNode的children,text,ele為undefined,但它的獨有屬性componentOptions保存了組件需要的相關信息。它們的VNode生成了,接下來的章節我們將使用它們,將它們變為真實的Dom~。
最后我們還是以一道vue可能會被問到的面試題作為本章的結束吧~
