手寫Vue2核心(四):生命周期及組件的合并策略

屬性與生命周期合并策略


Vue.mixin實現(xiàn)

在vue中有一個靜態(tài)方法:Vue.mixin,用于屬性與生命周期的合并
vue3已經(jīng)廢棄,因為該方法存在一些問題:

  • 可能被開發(fā)者濫用(全局混入,導致變量沖突)
  • 來源不明確(某些方法與屬性需要去到minxin中查找)

在Vue上新增靜態(tài)方法,如之前一樣,使用混入的方式

// index.js
+ import { initGlobalAPI } from './global-api/index.js'

+ initGlobalAPI(Vue)
// global-api\index.js
import { mergeOptions } from "@/util.js"

export function initGlobalAPI (Vue) {
    Vue.options = {} // 用來存儲全局的配置

    // Vue還有一些其他的靜態(tài)方法諸如:filter directive component
    Vue.mixin = function (mixin) {
        this.options = mergeOptions(this.options, mixin)
        return this
    }
}

合并策略主要分為兩個:屬性合并與生命周期合并

屬性合并

屬性合并主要實現(xiàn)思路是對象合并,規(guī)則如下(其實就是Object.assgin的規(guī)則):

  • 如果父組件有子組件也有,應該用子組件替換父組件
  • 如果父組件有值,子組件沒有,用父組件的
    當然這里用父子組件描述其實也不合適,但沒想到什么好的描述,其實就是一個先后順序,看誰先往Vue上注入(可以簡單理解為誰先執(zhí)行Vue.mixin
// util.js
// 同nextTick,并沒有如源碼那樣拆分出來,有興趣的自行g(shù)ithub擼源碼

// 合并策略,屬性采用對象合并(Object.assgin規(guī)則),生命周期則包裝成數(shù)組,后面依次執(zhí)行
export function mergeOptions (parent, child) {
    const options = {}
    // 如果父親有兒子也有,應該用兒子替換父親;如果父親有值兒子沒有,用父親的
    // {a: 1} {a: 2} => {a: 2}
    // {a: 1} {b: 2} => {a:1, b: 2}

    // 使用for,主要考慮到深拷貝
    for (let key in parent) {
        mergeField(key)
    }

    for (let key in child) {
        if (!parent.hasOwnProperty(key)) { // 如果父組件也有該屬性,合并過了,子組件無需再處理
            mergeField(key)
        }
    }

    // vue這種做法,老是在函數(shù)中寫函數(shù)我也是醉了…
    function mergeField (key) {    
        // data屬性的合并處理
        if (isObject(parent[key]) && isObject(child[key])) {
            options[key] = {...parent[key], ...child[key]}
        } else {
            if (child[key]) { // 如果兒子有值
                options[key] = child[key]
            } else {
                options[key] = parent[key]
            }
        }
    }

    return options
}

生命周期的合并

生命周期合并,不同于屬性,函數(shù)是沒法合并的,需要依次執(zhí)行,實現(xiàn)的思路是隊列
但是Vue的生命周期方法有很多個,如果一直if...else if,那么將會很不恰當,解決的辦法是使用策略模式

// util.js

// 沒全寫,主要是實現(xiàn)合并原理
const LIFECYCLE_HOOKS = [
    'beforeCreate',
    'created',
    'beforeMount',
    'mounted'
]

const strats = {}
LIFECYCLE_HOOKS.forEach(hook => {
    strats[hook] = mergeHook
})

// 鉤子合并策略,數(shù)組形式
function mergeHook (parentVal, childVal) {
    if (childVal) {
        if (parentVal) {
            // 如果兒子有父親也有
            return parentVal.concat(childVal)
        } else {
            // 如果兒子有父親沒有
            return [childVal]
        }
    } else {
        return parentVal // 兒子沒有直接采用父親
    }
}
// 同上面同一文件,個人筆記可以diff,簡書不支持
// 合并策略,屬性采用對象合并(Object.assgin規(guī)則),生命周期則包裝成數(shù)組,后面依次執(zhí)行
export function mergeOptions (parent, child) {
    // vue這種做法,老是在函數(shù)中寫函數(shù)我也是醉了…
    function mergeField (key) {
        // 策略模式,生命周期合并處理
+       if (strats[key]) {
+           return options[key] = strats[key](parent[key], child[key]) // 這里相當于調(diào)用mergeHook,因為沒完全實現(xiàn)(比如components等那些合并策略并沒有實現(xiàn))
+       }
    }

    return options
}

這里說一下為什么返回的一定為數(shù)組吧,如果只看上面局部代碼可能理解不了
初始化時(也就是第一次),傳入的Vue.options = {},因此第一次傳入的parentVal為undefined
而如果我們在Vue實例化時如果有傳入生命周期,走進策略中的時候,childVal就會有值,因此第一次返回結(jié)果必為return [childVal]

生命周期合并策略

lifecycle中新增callHook方法,用于調(diào)用(在合適的時機調(diào)用對應的生命周期函數(shù))

// lifecycle.js
export function lifecycleMixin (Vue) {
    Vue.prototype._update = function (vnode) {
+       vm.$el = patch(vm.$el, vnode) // 這里之前實現(xiàn)寫錯了,寫到$options.el去了,改回來
    }
}

// 調(diào)用合并的生命周期,依次執(zhí)行
+ export function callHook (vm, hook) { // 發(fā)布模式
+   const handlers = vm.$options[hook]
+   if (handlers) {
+   // 這里的實現(xiàn)也就是為什么vue的什么周期不能用箭頭函數(shù),call將無效,this指向了window而不是vm
+       handlers.forEach(handlers => handlers.call(vm)) 
+   }
+ }

調(diào)用生命周期函數(shù)(僅作示例,一樣不會寫全)

+ import { mountComponent, callHook } from './lifecycle.js'
+ import { mergeOptions, nextTick } from '@/util'

// 通過原型混合的方式,往vue的原型添方法
export function initMixin (Vue) {
    Vue.prototype._init = function (options) { // options是用戶傳入的對象
        const vm = this
        // 實例上有個屬性 $options ,表示的是用戶傳入的所有屬性
+       // vm.$options = options
+       // 這里vm.constructor.options不能使用this,否則調(diào)用時this就指向了子組件實例,而不是Vue了
+       vm.$options = mergeOptions(vm.constructor.options, options)

+       callHook(this, 'beforeCreate')
        // 初始化狀態(tài)
        initState(vm)
+       callHook(this, 'created')
    }

    Vue.prototype.$mount = function (el) {
+       vm.$el = el // 同上,之前寫錯了

        // code...

        mountComponent(vm, el) // 組件掛載
    }
}

組件合并與渲染原理


組件的合并

內(nèi)部使用的Vue.extend,返回通過對象創(chuàng)建一個類,通過這個類取創(chuàng)建一個組件去使用
先查找自己身上是否存在,沒有則查找父親的__proto__,使用Object.create來繼承(這里的父子不是父子組件,需要理解為全局注冊的和局部注冊的組件)

// global-api\index.js
export function initGlobalAPI (Vue) {
    Vue.options = {} // 用來存儲全局的配置

    // filter directive component
    Vue.mixin = function (mixin) {
        this.options = mergeOptions(this.options, mixin)
        return this
    }

+   // 調(diào)用生成組件
+   Vue.options._base = Vue // 永遠指向Vue的構(gòu)造函數(shù)
+   Vue.options.components = {} // 用來存放組件的定義
+   Vue.component = function (id, definition) {
+       definition.name = definition.name || id // 組件名,如果定義中有name屬性則使用name,否則以組件名命名
+       definition = this.options._base.extend(definition) // 通過對象產(chǎn)生一個構(gòu)造函數(shù)
+       this.options.components[id] = definition
+   }

+   let cid = 0
+   // 子組件初始化時,會 new VueComponent(options),產(chǎn)生一個子類Sub
+   Vue.extend = function (options) {
+       const Super = this // Vue構(gòu)造函數(shù),此時還未被實例化
+       const Sub = function VueComponent (options) {
+           this._init(options)
+       }

+       Sub.cid = cid++ // 防止組件時同一個構(gòu)造函數(shù)產(chǎn)生的,因為不同組件可能命名卻是一樣,會導致createComponent中出問題
+       Sub.prototype = Object.create(Super.prototype) // 都是通過Vue來繼承的
+       Sub.prototype.constructor = Sub // 常規(guī)操作,原型變更,將實例所指向的原函數(shù)也改掉,這樣靜態(tài)屬性也會被同步過來
+       // 注意這一步不是在替換$options.component,而是在將Vue.component方法進行統(tǒng)一,都是使用的上面那個Vue.component = function (id, definition)函數(shù)
+       Sub.component = Super.component
+       // ...省略其余操作代碼
+       Sub.options = mergeOptions(Super.options, options) // 將全局組件與該實例化的組件options合并(注意之前的實現(xiàn),只會合并屬性與生命周期)
+       return Sub // 這個構(gòu)造函數(shù)是由對象(options)產(chǎn)生而來的
+   }
}
// util.js
const strats = {}
LIFECYCLE_HOOKS.forEach(hook => {
    strats[hook] = mergeHook
})

+ // 組件合并策略
+ strats.components = function (parentVal, childVal) {
+     const res = Object.create(parentVal)
+     if (childVal) {
+         for (let key in childVal) {
+             res[key] = childVal[key]
+         }
+     }
+     return res
+ }
傳入組件中的options與Sub構(gòu)造函數(shù)

組件的渲染原理

回顧之前的渲染流程:解析成ast語法樹 -> 轉(zhuǎn)變?yōu)榭蓤?zhí)行的render(generate方法) -> 創(chuàng)建出vnode
而現(xiàn)在的問題在于,創(chuàng)建出來的vnode是一個自定義標簽節(jié)點,而不是真實Dom,所以應該生成vnode時,應該將真實的組件內(nèi)容替換掉這個自定義節(jié)點(組件)
因此在createElement(創(chuàng)建虛擬節(jié)點)時,我們需要區(qū)分該節(jié)點是自定義組件節(jié)點,還是真實節(jié)點。Vue源碼中是寫了大量的真實節(jié)點標簽,通過標簽名來進行識別

// utils.js
+ function makeUp (str) {
+     const map = {}
+ 
+     str.split(',').forEach(tagName => {
+         map[tagName] = true
+     })
+ 
+     return tag => map[tag] || false
+ }
+ 
+ // 標簽太多,隨便寫幾個,源碼里太多了。高階函數(shù),比起直接使用數(shù)組的include判斷,用字典時間復雜度為O(1)
+ export const isReservedTag = makeUp('a,p,div,ul,li,span,input,button')

通過isReservedTag方法,就能將自定義節(jié)點(組件名)與真實節(jié)點區(qū)分出來,如果是組件,那么去調(diào)用createComponent方法來創(chuàng)建對應的vnode
創(chuàng)建組件vnode時,還需要給組件添加生命周期(并非beforeCreate等vue的生命周期),因為不同于vue,組件是沒有$el的(這句話看不懂就想一下自己寫組件也不會在里面?zhèn)魅雃l吧),所以需要手動掛載來觸發(fā)后續(xù)的update

// vdom\index.js
- import { isObject } from "@/util.js"
+ import { isObject, isReservedTag } from "@/util.js"

// 創(chuàng)建 Dom虛擬節(jié)點(代碼邏輯變更)
export function createdElement (vm, tag, data = {}, ...children) {
    // 需要對標簽名做過濾,因為有可能標簽名是一個自定義組件
+   if (isReservedTag(tag)) {
+       return vnode(vm, tag, data, data.key, children, undefined)
+   } else {
+       // 自定義組件
+       const Ctor = vm.$options.components[tag] // Ctor是個對象或者函數(shù)
+       // 核心:vue.extend,繼承父組件,通過原型鏈向上查找,封裝成函數(shù)
+       return createComponent(vm, tag, data, data.key, children, Ctor)
+   }
}

+ function createComponent (vm, tag, data, key, children, Ctor) {
+     if (isObject(Ctor)) { // 對象,是個子組件,也封裝成函數(shù),統(tǒng)一
+         Ctor = vm.$options._base.extend(Ctor)
+     }
+ 
+     // 給組件增加生命周期(源碼中是抽離出去的,所以需要將vnode傳進入,而不是直接使用Ctor)
+     data.hook = {
+         init (vnode) {
+             // 調(diào)用子組件的構(gòu)造函數(shù)
+             const child = vnode.componentInstance = new vnode.componentOptions.Ctor({})
+             child.$mount() // 手動掛載 vnode.componentInstance.$el = 真實的元素
+         }
+     }
+ 
+     // 組件的虛擬節(jié)點擁有 hook 和當前組件的 componentOptions ,Ctor中存放了組件的構(gòu)造函數(shù)
+     return vnode(vm, `vue-component-${Ctor.cid}-${tag}`, data, key, undefined, undefined, {Ctor})
+ }

function vnode (vm, tag, data, key, children, text, componentOptions) {
    return {
        vm,
        tag,
        children,
        data,
        key,
        text,
+       componentOptions
    }
}

有了組件的vnode后,在Vue初始化時(查看init.js邏輯),會調(diào)用$mount,而$mount中會掛載組件mountComponent,mountComponent中觸發(fā)vue._update來更新視圖,vue._update中會使用patch來生成真實節(jié)點,而上面也說過,組件是不會有$el的,所以直接通過vnode來創(chuàng)建真實節(jié)點即可,創(chuàng)建真實節(jié)點時,這里有點騷。正常人應該像前面一樣通過標簽名再來一次判斷,但是這里是通過去獲取是否有vnode.data.hook來判斷,有則調(diào)用init(vnode)直接去去調(diào)用實例化方法

// vdom\patch.js
export function patch(oldVnode, vnode) {
+   // 組件沒有oldVnode,直接創(chuàng)建元素
+   if (!oldVnode) {
+       return createdElm(vnode) // 根據(jù)虛擬節(jié)點創(chuàng)建元素
+   }

    // 之前的code...
}

+ // 創(chuàng)建節(jié)點真實Dom
+ function createComponent (vnode) {
+     let i = vnode.data
+     // 先將vnode.data賦值給i,然后將i.hook賦值給i,如果i存在再將i.init賦值給i,瘋狂改變i的類型,雖然js中都屬于Object,但真的好嗎…
+     if ((i = i.hook) && (i = i.init)) {
+         i(vnode) // 調(diào)用組件的初始化方法
+     }
+ 
+     if (vnode.componentInstance) { // 如果虛擬節(jié)點上有組件的實例說明當前這個vnode是組件
+         return true
+     }
+ 
+     return false
+ }

function createdElm (vnode) { // 根據(jù)虛擬節(jié)點創(chuàng)建真實節(jié)點,不同于createElement
    let { vm, tag, data, key, children, text } = vnode    

    if (typeof tag === 'string') {
+       // 可能是組件,如果是組件,就直接創(chuàng)造出組件的真實節(jié)點
+       if (createComponent(vnode)) {
+           // 如果返回true,說明這個虛擬節(jié)點是組件
+           return vnode.componentInstance.$el
+       }

        vnode.el = document.createElement(tag) // 用vue的指令時,可以通過vnode拿到真實dom
        updateProperties(vnode)
        children.forEach(child => {
            vnode.el.appendChild(createdElm(child)) // 遞歸創(chuàng)建插入節(jié)點,現(xiàn)代瀏覽器appendChild并不會插入一次回流一次
        })
    } else {
        vnode.el = document.createTextNode(text)
    }

    return vnode.el
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容