深入剖析Vue原始碼 - 元件基礎

不做祖國的韭菜發表於2019-05-29

元件是Vue的一個重要核心,我們在進行專案工程化時,會將頁面的結構元件化。元件化意味著獨立和共享,而兩個結論並不矛盾,獨立的元件開發可以讓開發者專注於某個功能項的開發和擴充套件,而元件的設計理念又使得功能項更加具有複用性,不同的頁面可以進行元件功能的共享。對於開發者而言,編寫Vue元件是掌握Vue開發的核心基礎,Vue官網也花了大量的篇幅介紹了元件的體系和各種使用方法。這一節內容,我們會深入Vue元件內部的原始碼,瞭解元件註冊的實現思路,並結合上一節介紹的例項掛載分析元件渲染掛載的基本流程,最後我們將分析元件和元件之間是如何建立聯絡的。我相信,掌握這些底層的實現思路對於我們今後在解決vue元件相關問題上會有明顯的幫助。

5.1 元件兩種註冊方式

熟悉Vue開發流程的都知道,Vue元件在使用之前需要進行註冊,而註冊的方式有兩種,全域性註冊和區域性註冊。在進入原始碼分析之前,我們先回憶一下兩者的用法,以便後續掌握兩者的差異。

5.1.1 全域性註冊
Vue.component('my-test', {
    template: '<div>{{test}}</div>',
    data () {
        return {
            test: 1212
        }
    }
})
var vm = new Vue({
    el: '#app',
    template: '<div id="app"><my-test><my-test/></div>'
})
複製程式碼

其中元件的全域性註冊需要在全域性例項化Vue前呼叫,註冊之後可以用在任何新建立的Vue例項中呼叫。

5.1.2 區域性註冊
var myTest = {
    template: '<div>{{test}}</div>',
    data () {
        return {
            test: 1212
        }
    }
}
var vm = new Vue({
    el: '#app',
    component: {
        myTest
    }
})
複製程式碼

當只需要在某個區域性用到某個元件時,可以使用區域性註冊的方式進行元件註冊,此時區域性註冊的元件只能在註冊該元件內部使用。

5.1.3 註冊過程

在簡單回顧元件的兩種註冊方式後,我們來看註冊過程到底發生了什麼,我們以全域性元件註冊為例。它通過Vue.component(name, {...})進行元件註冊,Vue.component是在Vue原始碼引入階段定義的靜態方法。

// 初始化全域性api
initAssetRegisters(Vue);
var ASSET_TYPES = [
    'component',
    'directive',
    'filter'
];
function initAssetRegisters(Vue){
    // 定義ASSET_TYPES中每個屬性的方法,其中包括component
    ASSET_TYPES.forEach(function (type) {
    // type: component,directive,filter
      Vue[type] = function (id,definition) {
          if (!definition) {
            // 直接返回註冊元件的建構函式
            return this.options[type + 's'][id]
          }
          ...
          if (type === 'component') {
            // 驗證component元件名字是否合法
            validateComponentName(id);
          }
          if (type === 'component' && isPlainObject(definition)) {
            // 元件名稱設定
            definition.name = definition.name || id;
            // Vue.extend() 建立子元件,返回子類構造器
            definition = this.options._base.extend(definition);
          }
          // 為Vue.options 上的component屬性新增將子類構造器
          this.options[type + 's'][id] = definition;
          return definition
        }
    });
}
複製程式碼

Vue.components有兩個引數,一個是需要註冊元件的元件名,另一個是元件選項,如果第二個引數沒有傳遞,則會直接返回註冊過的元件選項。否則意味著需要對該元件進行註冊,註冊過程先會對元件名的合法性進行檢測,要求元件名不允許出現非法的標籤,包括Vue內建的元件名,如slot, component等。

function validateComponentName(name) {
    if (!new RegExp(("^[a-zA-Z][\\-\\.0-9_" + (unicodeRegExp.source) + "]*$")).test(name)) {
      // 正則判斷檢測是否為非法的標籤
      warn(
        'Invalid component name: "' + name + '". Component names ' +
        'should conform to valid custom element name in html5 specification.'
      );
    }
    // 不能使用Vue自身自定義的元件名,如slot, component,不能使用html的保留標籤,如 h1, svg等
    if (isBuiltInTag(name) || config.isReservedTag(name)) {
      warn(
        'Do not use built-in or reserved HTML elements as component ' +
        'id: ' + name
      );
    }
  }
複製程式碼

在經過元件名的合法性檢測後,會呼叫extend方法為元件建立一個子類構造器,此時的this.options._base代表的就是Vue構造器。extend方法的定義在介紹選項合併章節有重點介紹過,它會基於父類去建立一個子類,此時的父類是Vue,並且建立過程子類會繼承父類的方法,並會和父類的選項進行合併,最終返回一個子類構造器。

程式碼處還有一個邏輯,Vue.component()預設會把第一個引數作為元件名稱,但是如果元件選項有name屬性時,name屬性值會將元件名覆蓋。

總結起來,全域性註冊元件就是Vue例項化前建立一個基於Vue的子類構造器,並將元件的資訊載入到例項options.components物件中。

**接下來自然而然會想到一個問題,區域性註冊和全域性註冊在實現上的區別體現在哪裡?**我們不急著分析區域性元件的註冊流程,先以全域性註冊的元件為基礎,看看作為元件,它的掛載流程有什麼不同。

5.2 元件Vnode建立

上一節內容我們介紹了Vue如何將一個模板,通過render函式的轉換,最終生成一個Vnode tree的,在不包含元件的情況下,_render函式的最後一步是直接呼叫new Vnode去建立一個完整的Vnode tree。然而有一大部分的分支我們並沒有分析,那就是遇到元件佔位符的場景。執行階段如果遇到元件,處理過程要比想像中複雜得多,我們通過一張流程圖展開分析。

5.2.1 Vnode建立流程圖

深入剖析Vue原始碼 - 元件基礎

5.2.2 具體流程分析

我們結合實際的例子對照著流程圖分析一下這個過程:

  • 場景
Vue.component('test', {
  template: '<span></span>'
})
var vm = new Vue({
  el: '#app',
  template: '<div><test></test></div>'
})
複製程式碼
  • render函式
function() {
  with(this){return _c('div',[_c('test')],1)}
}
複製程式碼
  • Vue根例項初始化會執行 vm.$mount(vm.$options.el)例項掛載的過程,按照之前的邏輯,完整流程會經歷render函式生成Vnode,以及Vnode生成真實DOM的過程。
  • render函式生成Vnode過程中,子會優先父執行生成Vnode過程,也就是_c('test')函式會先被執行。'test'會先判斷是普通的html標籤還是元件的佔位符。
  • 如果為一般標籤,會執行new Vnode過程,這也是上一章節我們分析的過程;如果是元件的佔位符,則會在判斷元件已經被註冊過的前提下進入createComponent建立子元件Vnode的過程。
  • createComponent是建立元件Vnode的過程,建立過程會再次合併選項配置,並安裝元件相關的內部鉤子(後面文章會再次提到內部鉤子的作用),最後通過new Vnode()生成以vue-component開頭的Virtual DOM
  • render函式執行過程也是一個迴圈遞迴呼叫建立Vnode的過程,執行3,4步之後,完整的生成了一個包含各個子元件的Vnode tree

_createElement函式的實現之前章節分析過一部分,我們重點看看元件相關的操作。

// 內部執行將render函式轉化為Vnode的函式
function _createElement(context,tag,data,children,normalizationType) {
  ···
  if (typeof tag === 'string') {
    // 子節點的標籤為普通的html標籤,直接建立Vnode
    if (config.isReservedTag(tag)) {
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      );
    // 子節點標籤為註冊過的元件標籤名,則子元件Vnode的建立過程
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 建立子元件Vnode
      vnode = createComponent(Ctor, data, context, children, tag);
    }
  }
}
複製程式碼

config.isReservedTag(tag)用來判斷標籤是否為普通的html標籤,如果是普通節點會直接建立Vnode節點,如果不是,則需要判斷這個佔位符元件是否已經註冊到,我們可以通過context.$options.components[元件名]拿到註冊後的元件選項。如何判斷元件是否已經全域性註冊,看看resolveAsset的實現。

// 需要明確元件是否已經被註冊
  function resolveAsset (options,type,id,warnMissing) {
    // 標籤為字串
    if (typeof id !== 'string') {
      return
    }
    // 這裡是 options.component
    var assets = options[type];
    // 這裡的分支分別支援大小寫,駝峰的命名規範
    if (hasOwn(assets, id)) { return assets[id] }
    var camelizedId = camelize(id);
    if (hasOwn(assets, camelizedId)) { return assets[camelizedId] }
    var PascalCaseId = capitalize(camelizedId);
    if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] }
    // fallback to prototype chain
    var res = assets[id] || assets[camelizedId] || assets[PascalCaseId];
    if (warnMissing && !res) {
      warn(
        'Failed to resolve ' + type.slice(0, -1) + ': ' + id,
        options
      );
    }
    // 最終返回子類的構造器
    return res
  }
複製程式碼

拿到註冊過的子類構造器後,呼叫createComponent方法建立子元件Vnode

 // 建立子元件過程
  function createComponent (
    Ctor, // 子類構造器
    data,
    context, // vm例項
    children, // 子節點
    tag // 子元件佔位符
  ) {
    ···
    // Vue.options裡的_base屬性儲存Vue構造器
    var baseCtor = context.$options._base;

    // 針對區域性元件註冊場景
    if (isObject(Ctor)) {
      Ctor = baseCtor.extend(Ctor);
    }
    data = data || {};
    // 構造器配置合併
    resolveConstructorOptions(Ctor);
    // 掛載元件鉤子
    installComponentHooks(data);

    // return a placeholder vnode
    var name = Ctor.options.name || tag;
    // 建立子元件vnode,名稱以 vue-component- 開頭
    var vnode = new VNode(("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),data, undefined, undefined, undefined, context,{ Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },asyncFactory);

    return vnode
  }
複製程式碼

這裡將大部分的程式碼都拿掉了,只留下建立Vnode相關的程式碼,最終會通過new Vue例項化一個名稱以vue-component-開頭的Vnode節點。其中兩個關鍵的步驟是配置合併和安裝元件鉤子函式,選項合併的內容可以檢視這個系列的前兩節,這裡看看installComponentHooks安裝元件鉤子函式時做了哪些操作。

  // 元件內部自帶鉤子
 var componentVNodeHooks = {
    init: function init (vnode, hydrating) {
    },
    prepatch: function prepatch (oldVnode, vnode) {
    },
    insert: function insert (vnode) {
    },
    destroy: function destroy (vnode) {
    }
  };
var hooksToMerge = Object.keys(componentVNodeHooks);
// 將componentVNodeHooks 鉤子函式合併到元件data.hook中 
function installComponentHooks (data) {
    var hooks = data.hook || (data.hook = {});
    for (var i = 0; i < hooksToMerge.length; i++) {
      var key = hooksToMerge[i];
      var existing = hooks[key];
      var toMerge = componentVNodeHooks[key];
      // 如果鉤子函式存在,則執行mergeHook$1方法合併
      if (existing !== toMerge && !(existing && existing._merged)) {
        hooks[key] = existing ? mergeHook$1(toMerge, existing) : toMerge;
      }
    }
  }
function mergeHook$1 (f1, f2) {
  // 返回一個依次執行f1,f2的函式
    var merged = function (a, b) {
      f1(a, b);
      f2(a, b);
    };
    merged._merged = true;
    return merged
  }
複製程式碼

元件預設自帶的這幾個鉤子函式會在後續patch過程的不同階段執行,這部分內容不在本節的討論範圍。

5.2.3 區域性註冊和全域性註冊的區別

在說到全域性註冊和區域性註冊的用法時留下了一個問題,區域性註冊和全域性註冊兩者的區別在哪裡。其實區域性註冊的原理同樣簡單,我們使用區域性註冊元件時會通過在父元件選項配置中的components新增子元件的物件配置,這和全域性註冊後在Vueoptions.component新增子元件構造器的結果很相似。區別在於:

- 1.區域性註冊新增的物件配置是在某個元件下,而全域性註冊新增的子元件是在根例項下。 - 2.區域性註冊新增的是一個子元件的配置物件,而全域性註冊新增的是一個子類構造器。

因此區域性註冊中缺少了一步構建子類構造器的過程,這個過程放在哪裡進行呢? 回到createComponent的原始碼,原始碼中根據選項是物件還是函式來區分區域性和全域性註冊元件,如果選項的值是物件,則該元件是區域性註冊的元件,此時在建立子Vnode時會呼叫 父類的extend方法去建立一個子類構造器。

function createComponent (...) {
  ...
  var baseCtor = context.$options._base;

  // 針對區域性元件註冊場景
  if (isObject(Ctor)) {
      Ctor = baseCtor.extend(Ctor);
  }
}

複製程式碼

5.3 元件Vnode渲染真實DOM

根據前面的分析,不管是全域性註冊的元件還是區域性註冊的元件,元件並沒有進行例項化,那麼元件例項化的過程發生在哪個階段呢?我們接著看Vnode tree渲染真實DOM的過程。

5.3.1 真實節點渲染流程圖

深入剖析Vue原始碼 - 元件基礎

5.3.2 具體流程分析
    1. 經過vm._render()生成完整的Virtual Dom樹後,緊接著執行Vnode渲染真實DOM的過程,這個過程是vm.update()方法的執行,而其核心是vm.__patch__
    1. vm.__patch__內部會通過 createElm去建立真實的DOM元素,期間遇到子Vnode會遞迴呼叫createElm方法。
    1. 遞迴呼叫過程中,判斷該節點型別是否為元件型別是通過createComponent方法判斷的,該方法和渲染Vnode階段的方法createComponent不同,他會呼叫子元件的init初始化鉤子函式,並完成元件的DOM插入。
    1. init初始化鉤子函式的核心是new例項化這個子元件並將子元件進行掛載,例項化子元件的過程又回到合併配置,初始化生命週期,初始化事件中心,初始化渲染的過程。例項掛載又會執行$mount過程。
    1. 完成所有子元件的例項化和節點掛載後,最後才回到根節點的掛載。

__patch__核心程式碼是通過createElm建立真實節點,當建立過程中遇到子vnode時,會呼叫createChildren,createChildren的目的是對子vnode遞迴呼叫createElm建立子元件節點。

// 建立真實dom
function createElm (vnode,insertedVnodeQueue,parentElm,refElm,nested,ownerArray,index) {
  ···
  // 遞迴建立子元件真實節點,直到完成所有子元件的渲染才進行根節點的真實節點插入
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }
  ···
  var children = vnode.children;
  // 
  createChildren(vnode, children, insertedVnodeQueue);
  ···
  insert(parentElm, vnode.elm, refElm);
}
function createChildren(vnode, children, insertedVnodeQueue) {
  for (var i = 0; i < children.length; ++i) {
    // 遍歷子節點,遞迴呼叫建立真實dom節點的方法 - createElm
    createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i);
  }
}
複製程式碼

createComponent方法會對子元件Vnode進行處理中,還記得在Vnode生成階段為子Vnode安裝了一系列的鉤子函式嗎,在這個步驟我們可以通過是否擁有這些定義好的鉤子來判斷是否是已經註冊過的子元件,如果條件滿足,則執行元件的init鉤子。

init鉤子做的事情只有兩個,例項化元件構造器,執行子元件的掛載流程。(keep-alive分支看具體的文章分析)

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  var i = vnode.data;
  // 是否有鉤子函式可以作為判斷是否為元件的唯一條件
  if (isDef(i = i.hook) && isDef(i = i.init)) {
    // 執行init鉤子函式
    i(vnode, false /* hydrating */);
  }
  ···
}
var componentVNodeHooks = {
  // 忽略keepAlive過程
  // 例項化
  var child = vnode.componentInstance = createComponentInstanceForVnode(vnode,activeInstance);
  // 掛載
  child.$mount(hydrating ? vnode.elm : undefined, hydrating);
}
function createComponentInstanceForVnode(vnode, parent) {
  ···
  // 例項化Vue子元件例項
  return new vnode.componentOptions.Ctor(options)
}

複製程式碼

顯然Vnode生成真實DOM的過程也是一個不斷遞迴建立子節點的過程,patch過程如果遇到子Vnode,會優先例項化子元件,並且執行子元件的掛載流程,而掛載流程又會回到_render,_update的過程。在所有的子Vnode遞迴掛載後,最終才會真正掛載根節點。

5.4 建立元件聯絡

日常開發中,我們可以通過vm.$parent拿到父例項,也可以在父例項中通過vm.$children拿到例項中的子元件。顯然,Vue在元件和元件之間建立了一層關聯。接下來的內容,我們將探索如何建立元件之間的聯絡。

不管是父例項還是子例項,在初始化例項階段有一個initLifecycle的過程。這個過程會**把當前例項新增到父例項的$children屬性中,並設定自身的$parent屬性指向父例項。**舉一個具體的應用場景:

<div id="app">
    <component-a></component-a>
</div>
Vue.component('component-a', {
    template: '<div>a</div>'
})
var vm = new Vue({ el: '#app'})
console.log(vm) // 將例項物件輸出
複製程式碼

由於vue例項向上沒有父例項,所以vm.$parentundefinedvm$children屬性指向子元件componentA 的例項。

深入剖析Vue原始碼 - 元件基礎

子元件componentA$parent屬性指向它的父級vm例項,它的$children屬性指向為空

深入剖析Vue原始碼 - 元件基礎

原始碼解析如下:

function initLifecycle (vm) {
    var options = vm.$options;
    // 子元件註冊時,會把父元件的例項掛載到自身選項的parent上
    var parent = options.parent;
    // 如果是子元件,並且該元件不是抽象元件時,將該元件的例項新增到父元件的$parent屬性上,如果父元件是抽象元件,則一直往上層尋找,直到該父級元件不是抽象元件,並將,將該元件的例項新增到父元件的$parent屬性
    if (parent && !options.abstract) {
        while (parent.$options.abstract && parent.$parent) {
        parent = parent.$parent;
        }
        parent.$children.push(vm);
    }
    // 將自身的$parent屬性指向父例項。
    vm.$parent = parent;
    vm.$root = parent ? parent.$root : vm;

    vm.$children = [];
    vm.$refs = {};

    vm._watcher = null;
    vm._inactive = null;
    vm._directInactive = false;
    // 該例項是否掛載
    vm._isMounted = false;
    // 該例項是否被銷燬
    vm._isDestroyed = false;
    // 該例項是否正在被銷燬
    vm._isBeingDestroyed = false;
}

複製程式碼

最後簡單講講抽象元件,在vue中有很多內建的抽象元件,例如<keep-alive></keep-alive>,<slot><slot>等,這些抽象元件並不會出現在子父級的路徑上,並且它們也不會參與DOM的渲染。

5.5 小結

這一小節,結合了實際的例子分析了元件註冊流程到元件掛載渲染流程,Vue中我們可以定義全域性的元件,也可以定義區域性的元件,全域性元件需要進行全域性註冊,核心方法是Vue.component,他需要在根元件例項化前進行宣告註冊,原因是我們需要在例項化前拿到元件的配置資訊併合併到options.components選項中。註冊的本質是呼叫extend建立一個子類構造器,全域性和區域性的不同是區域性建立子類構造器是發生在建立子元件Vnode階段。而建立子Vnode階段最關鍵的一步是定義了很多內部使用的鉤子。有了一個完整的Vnode tree接下來會進入真正DOM的生成,在這個階段如果遇到子元件Vnode會進行子構造器的例項化,並完成子元件的掛載。遞迴完成子元件的掛載後,最終才又回到根元件的掛載。 有了元件的基本知識,下一節我們重點分析一下元件的進階用法。


相關文章