深入剖析Vue原始碼 - 元件進階

不做祖國的韭菜發表於2019-06-04

我們知道,元件是Vue體系的核心,熟練使用元件是掌握Vue進行開發的基礎。上一節中,我們深入瞭解了Vue元件註冊到使用渲染的完整流程。這一節我們會在上一節的基礎上介紹元件的兩個高階用法:非同步元件和函式式元件。

6.1 非同步元件

6.1.1 使用場景

Vue作為單頁面應用遇到最棘手的問題是首屏載入時間的問題,單頁面應用會把頁面指令碼打包成一個檔案,這個檔案包含著所有業務和非業務的程式碼,而指令碼檔案過大也是造成首頁渲染速度緩慢的原因。因此作為首屏效能優化的課題,最常用的處理方法是對檔案的拆分和程式碼的分離。按需載入的概念也是在這個前提下引入的。我們往往會把一些非首屏的元件設計成非同步元件,部分不影響初次視覺體驗的元件也可以設計為非同步元件。這個思想就是按需載入。通俗點理解,按需載入的思想讓應用在需要使用某個元件時才去請求載入元件程式碼。我們藉助webpack打包後的結果會更加直觀。

深入剖析Vue原始碼 - 元件進階

深入剖析Vue原始碼 - 元件進階
webpack遇到非同步元件,會將其從主指令碼中分離,減少指令碼體積,加快首屏載入時間。當遇到場景需要使用該元件時,才會去載入元件指令碼。

6.1.2 工廠函式

Vue中允許使用者通過工廠函式的形式定義元件,這個工廠函式會非同步解析元件定義,元件需要渲染的時候才會觸發該工廠函式,載入結果會進行快取,以供下一次呼叫元件時使用。 具體使用:

// 全域性註冊:
Vue.component('asyncComponent', function(resolve, reject) {
  require(['./test.vue'], resolve)
})
// 區域性註冊:
var vm = new Vue({
  el: '#app',
  template: '<div id="app"><asyncComponent></asyncComponent></div>',
  components: {
    asyncComponent: (resolve, reject) => require(['./test.vue'], resolve),
    // 另外寫法
    asyncComponent: () => import('./test.vue'),
  }
})
複製程式碼
6.1.3 流程分析

有了上一節元件註冊的基礎,我們來分析非同步元件的實現邏輯。簡單回憶一下上一節的流程,例項的掛載流程分為根據渲染函式建立Vnode和根據Vnode產生真實節點的過程。期間建立Vnode過程,如果遇到子的佔位符節點會呼叫creatComponent,這裡會為子元件做選項合併和鉤子掛載的操作,並建立一個以vue-component-為標記的子Vnode,而非同步元件的處理邏輯也是在這個階段處理。

// 建立子元件過程
  function createComponent (
    Ctor, // 子類構造器
    data,
    context, // vm例項
    children, // 子節點
    tag // 子元件佔位符
  ) {
    ···
    // 針對區域性註冊元件建立子類構造器
    if (isObject(Ctor)) {
      Ctor = baseCtor.extend(Ctor);
    }
    // 非同步元件分支
    var asyncFactory;
    if (isUndef(Ctor.cid)) {
      // 非同步工廠函式
      asyncFactory = Ctor;
      // 建立非同步元件函式
      Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
      if (Ctor === undefined) {
        return createAsyncPlaceholder(
          asyncFactory,
          data,
          context,
          children,
          tag
        )
      }
    }
    ···
    // 建立子元件vnode
    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
  }
複製程式碼

**工廠函式的用法使得Vue.component(name, options)的第二個引數不是一個物件,因此不論是全域性註冊還是區域性註冊,都不會執行Vue.extend生成一個子元件的構造器,**所以Ctor.cid不會存在,程式碼會進入非同步元件的分支。

非同步元件分支的核心是resolveAsyncComponent,它的處理邏輯分支眾多,我們先關心工廠函式處理部分。

function resolveAsyncComponent (
    factory,
    baseCtor
  ) {
    if (!isDef(factory.owners)) {

      // 非同步請求成功處理
      var resolve = function() {}
      // 非同步請求失敗處理
      var reject = function() {}

      // 建立子元件時會先執行工廠函式,並將resolve和reject傳入
      var res = factory(resolve, reject);

      // resolved 同步返回
      return factory.loading
        ? factory.loadingComp
        : factory.resolved
    }
  }
複製程式碼

如果經常使用promise進行開發,我們很容易發現,這部分程式碼像極了promsie原理內部的實現,針對非同步元件工廠函式的寫法,大致可以總結出以下三個步驟:

    1. 定義非同步請求成功的函式處理,定義非同步請求失敗的函式處理;
    1. 執行元件定義的工廠函式;
    1. 同步返回請求成功的函式處理。

resolve, reject的實現,都是once方法執行的結果,所以我們先關注一下高階函式once的原理。為了防止當多個地方呼叫非同步元件時,resolve,reject不會重複執行,once函式保證了函式在程式碼只執行一次。也就是說,once快取了已經請求過的非同步元件

// once函式保證了這個呼叫函式只在系統中呼叫一次
function once (fn) {
  // 利用閉包特性將called作為標誌位
  var called = false;
  return function () {
    // 呼叫過則不再呼叫
    if (!called) {
      called = true;
      fn.apply(this, arguments);
    }
  }
}
複製程式碼

成功resolve和失敗reject的詳細處理邏輯如下:

// 成功處理
var resolve = once(function (res) {
  // 轉成元件構造器,並將其快取到resolved屬性中。
  factory.resolved = ensureCtor(res, baseCtor);
  if (!sync) {
    //強制更新渲染檢視
    forceRender(true);
  } else {
    owners.length = 0;
  }
});
// 失敗處理
var reject = once(function (reason) {
  warn(
    "Failed to resolve async component: " + (String(factory)) +
    (reason ? ("\nReason: " + reason) : '')
  );
  if (isDef(factory.errorComp)) {
    factory.error = true;
    forceRender(true);
  }
});
複製程式碼

非同步元件載入完畢,會呼叫resolve定義的方法,方法會通過ensureCtor將載入完成的元件轉換為元件構造器,並儲存在resolved屬性中,其中 ensureCtor的定義為:

function ensureCtor (comp, base) {
    if (comp.__esModule ||(hasSymbol && comp[Symbol.toStringTag] === 'Module')) {
      comp = comp.default;
    }
    // comp結果為物件時,呼叫extend方法建立一個子類構造器
    return isObject(comp)
      ? base.extend(comp)
      : comp
  }
複製程式碼

元件構造器建立完畢,會進行一次檢視的重新渲染,由於Vue是資料驅動檢視渲染的,而元件在載入到完畢的過程中,並沒有資料發生變化,因此需要手動強制更新檢視。forceRender函式的內部會拿到每個呼叫非同步元件的例項,執行原型上的$forceUpdate方法,這部分的知識等到響應式系統時介紹。

非同步元件載入失敗後,會呼叫reject定義的方法,方法會提示並標記錯誤,最後同樣會強制更新檢視。

回到非同步元件建立的流程,執行非同步過程會同步為載入中的非同步元件建立一個註釋節點Vnode

  function createComponent (){
    ···
    // 建立非同步元件函式
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
    if (Ctor === undefined) {
      // 建立註釋節點
      return createAsyncPlaceholder(asyncFactory,data,context,children,tag)
    }
  }
複製程式碼

createAsyncPlaceholder的定義也很簡單,其中createEmptyVNode之前有介紹過,是建立一個註釋節點vnode,而asyncFactory,asyncMeta都是用來標註該節點為非同步元件的臨時節點和相關屬性。

// 建立註釋Vnode
function createAsyncPlaceholder (factory,data,context,children,tag) {
  var node = createEmptyVNode();
  node.asyncFactory = factory;
  node.asyncMeta = { data: data, context: context, children: children, tag: tag };
  return node
}
複製程式碼

執行forceRender觸發元件的重新渲染過程時,又會再次呼叫resolveAsyncComponent,這時返回值Ctor不再為 undefined了,因此會正常走元件的render,patch過程。這時,舊的註釋節點也會被取代。

6.1.4 Promise非同步元件

非同步元件的第二種寫法是在工廠函式中返回一個promise物件,我們知道importes6引入模組載入的用法,但是import是一個靜態載入的方法,它會優先模組內的其他語句執行。因此引入了import(),import()是一個執行時載入模組的方法,可以用來類比require()方法,區別在於前者是一個非同步方法,後者是同步的,且import()會返回一個promise物件。

具體用法:

Vue.component('asyncComponent', () => import('./test.vue'))
複製程式碼

原始碼依然走著非同步元件處理分支,並且大部分的處理過程還是工廠函式的邏輯處理,區別在於執行非同步函式後會返回一個promise物件,成功載入則執行resolve,失敗載入則執行reject.

var res = factory(resolve, reject);
// res是返回的promise
if (isObject(res)) {
  if (isPromise(res)) {
    if (isUndef(factory.resolved)) {
      // 核心處理
      res.then(resolve, reject);
    }
  }
}
複製程式碼

其中promise物件的判斷最簡單的是判斷是否有thencatch方法:

 // 判斷promise物件的方法
  function isPromise (val) {
    return (isDef(val) && typeof val.then === 'function' && typeof val.catch === 'function')
  }
複製程式碼
6.1.5 高階非同步元件

為了在操作上更加靈活,比如使用loading元件處理元件載入時間過長的等待問題,使用error元件處理載入元件失敗的錯誤提示等,Vue在2.3.0+版本新增了返回物件形式的非同步元件格式,物件中可以定義需要載入的元件component,載入中顯示的元件loading,載入失敗的元件error,以及各種延時超時設定,原始碼同樣進入非同步元件分支。

Vue.component('asyncComponent', () => ({
  // 需要載入的元件 (應該是一個 `Promise` 物件)
  component: import('./MyComponent.vue'),
  // 非同步元件載入時使用的元件
  loading: LoadingComponent,
  // 載入失敗時使用的元件
  error: ErrorComponent,
  // 展示載入時元件的延時時間。預設值是 200 (毫秒)
  delay: 200,
  // 如果提供了超時時間且元件載入也超時了,
  // 則使用載入失敗時使用的元件。預設值是:`Infinity`
  timeout: 3000
}))
複製程式碼

非同步元件函式執行後返回一個物件,並且物件的component執行會返回一個promise物件,因此進入高階非同步元件處理分支。

if (isObject(res)) {
  if (isPromise(res)) {}
  // 返回物件,且res.component返回一個promise物件,進入分支
  // 高階非同步元件處理分支
  else if (isPromise(res.component)) {
    // 和promise非同步元件處理方式相同
    res.component.then(resolve, reject);
    ···
  }
}
複製程式碼

非同步元件會等待響應成功失敗的結果,與此同時,程式碼繼續同步執行。高階選項設定中如果設定了errorloading元件,會同時建立兩個子類的構造器,

if (isDef(res.error)) {
  // 非同步錯誤時元件的處理,建立錯誤元件的子類構造器,並賦值給errorComp
  factory.errorComp = ensureCtor(res.error, baseCtor);
}

if (isDef(res.loading)) {
  // 非同步載入時元件的處理,建立錯誤元件的子類構造器,並賦值給errorComp
  factory.loadingComp = ensureCtor(res.loading, baseCtor);
}
複製程式碼

如果存在delay屬性,則通過settimeout設定loading元件顯示的延遲時間。factory.loading屬性用來標註是否是顯示loading元件。

if (res.delay === 0) {
  factory.loading = true;
} else {
  // 超過時間會成功載入,則執行失敗結果
  setTimeout(function () {
    if (isUndef(factory.resolved) && isUndef(factory.error)) {
      factory.loading = true;
      forceRender(false);
    }
  }, res.delay || 200);
}
複製程式碼

如果在timeout時間內,非同步元件還未執行resolve的成功結果,即resolve沒有賦值,則進行reject失敗處理。

接下來依然是渲染註釋節點或者渲染loading元件,等待非同步處理結果,根據處理結果重新渲染檢視節點,相似過程不再闡述。

6.1.6 wepack非同步元件用法

webpack作為Vue應用構建工具的標配,我們需要知道Vue如何結合webpack進行非同步元件的程式碼分離,並且需要關注分離後的檔名,這個名字在webpack中稱為chunkNamewebpack為非同步元件的載入提供了兩種寫法。

  • require.ensure:它是webpack傳統提供給非同步元件的寫法,在編譯時,webpack會靜態地解析程式碼中的 require.ensure(),同時將模組新增到一個分開的 chunk 中,其中函式的第三個引數為分離程式碼塊的名字。修改後的程式碼寫法如下:
Vue.component('asyncComponent', function (resolve, reject) {
   require.ensure([], function () {
     resolve(require('./test.vue'));
   }, 'asyncComponent'); // asyncComponent為chunkname
})
複製程式碼
  • import(/* webpackChunkName: "asyncComponent" */, component): 有了es6,import的寫法是現今官方最推薦的做法,其中通過註釋webpackChunkName來指定分離後元件模組的命名。修改後的寫法如下:
Vue.component('asyncComponent', () => import(/* webpackChunkName: "asyncComponent" */, './test.vue'))
複製程式碼

至此,我們已經掌握了所有非同步元件的寫法,並深入瞭解了其內部的實現細節。我相信全面的掌握非同步元件對今後單頁面效能優化方面會起到積極的指導作用。

6.2 函式式元件

Vue提供了一種可以讓元件變為無狀態、無例項的函式化元件。從原理上說,一般子元件都會經過例項化的過程,而單純的函式元件並沒有這個過程,它可以簡單理解為一箇中間層,只處理資料,不建立例項,也是由於這個行為,它的渲染開銷會低很多。實際的應用場景是,當我們需要在多個元件中選擇一個來代為渲染,或者在將children,props,data等資料傳遞給子元件前進行資料處理時,我們都可以用函式式元件來完成,它本質上也是對元件的一個外部包裝。

6.2.1 使用場景

  • 定義兩個元件物件,test1,test2
var test1 = {
  props: ['msg'],
  render: function (createElement, context) {
    return createElement('h1', this.msg)
  }
}
var test2 = {
  props: ['msg'],
  render: function (createElement, context) {
    return createElement('h2', this.msg)
  }
}
複製程式碼
  • 定義一個函式式元件,它會根據計算結果選擇其中一個元件進行選項
Vue.component('test3', {
  // 函式式元件的標誌 functional設定為true
  functional: true,
  props: ['msg'],
  render: function (createElement, context) {
    var get = function() {
      return test1
    }
    return createElement(get(), context)
  }
})
複製程式碼
  • 函式式元件的使用
<test3 :msg="msg" id="test">
</test3>
new Vue({
  el: '#app',
  data: {
    msg: 'test'
  }
})
複製程式碼
  • 最終渲染的結果為:
<h2>test</h2>
複製程式碼

6.2.2 原始碼分析

函式式元件會在元件的物件定義中,將functional屬性設定為true,這個屬性是區別普通元件和函式式元件的關鍵。同樣的在遇到子元件佔位符時,會進入createComponent進行子元件Vnode的建立。**由於functional屬性的存在,程式碼會進入函式式元件的分支中,並返回createFunctionalComponent呼叫的結果。**注意,執行完createFunctionalComponent後,後續建立子Vnode的邏輯不會執行,這也是之後在建立真實節點過程中不會有子Vnode去例項化子元件的原因。(無例項)

function createComponent(){
  ···
  if (isTrue(Ctor.options.functional)) {
    return createFunctionalComponent(Ctor, propsData, data, context, children)
  }
}
複製程式碼

createFunctionalComponent方法會對傳入的資料進行檢測和合並,例項化FunctionalRenderContext,最終呼叫函式式元件自定義的render方法執行渲染過程。

function createFunctionalComponent(
  Ctor, // 函式式元件構造器
  propsData, // 傳入元件的props
  data, // 佔位符元件傳入的attr屬性
  context, // vue例項
  children// 子節點
){
  // 資料檢測合併
  var options = Ctor.options;
  var props = {};
  var propOptions = options.props;
  if (isDef(propOptions)) {
    for (var key in propOptions) {
      props[key] = validateProp(key, propOptions, propsData || emptyObject);
    }
  } else {
    // 合併attrs
    if (isDef(data.attrs)) { mergeProps(props, data.attrs); }
    // 合併props
    if (isDef(data.props)) { mergeProps(props, data.props); }
  }
  var renderContext = new FunctionalRenderContext(data,props,children,contextVm,Ctor);
  // 呼叫函式式元件中自定的render函式
  var vnode = options.render.call(null, renderContext._c, renderContext)
}
複製程式碼

FunctionalRenderContext這個類最終的目的是定義一個和真實元件渲染不同的render方法。

function FunctionalRenderContext() {
  // 省略其他邏輯
  this._c = function (a, b, c, d) { return createElement(contextVm, a, b, c, d, needNormalization); };
}
複製程式碼

執行render函式的過程,又會遞迴呼叫createElement的方法,這時的元件已經是真實的元件,開始執行正常的元件掛載流程。

問題:為什麼函式式元件需要定義一個不同的createElement方法?- 函式式元件createElement和以往唯一的不同是,最後一個引數的不同,之前章節有說到,createElement會根據最後一個引數決定是否對子Vnode進行拍平,一般情況下,children編譯生成結果都是Vnode型別,只有函式式元件比較特殊,它可以返回一個陣列,這時候拍平就是有必要的。我們看下面的例子:

Vue.component('test', {  
  functional: true,  
  render: function (createElement, context) {  
    return context.slots().default  
  }  
}) 

<test> 
     <p>slot1</p> 
     <p>slot</p> 
</test>
複製程式碼

此時函式式元件testrender函式返回的是兩個slotVnode,它是以陣列的形式存在的,這就是需要拍平的場景。

簡單總結一下函式式元件,從原始碼中可以看出,函式式元件並不會像普通元件那樣有例項化元件的過程,因此包括元件的生命週期,元件的資料管理這些過程都沒有,它只會原封不動的接收傳遞給元件的資料做處理,並渲染需要的內容。因此作為純粹的函式可以也大大降低渲染的開銷。

6.3 小結

這一小節在元件基礎之上介紹了兩個進階的用法,非同步元件和函式式元件。它們都是為了解決某些型別場景引入的高階元件用法。其中非同步元件是首屏效能優化的一個解決方案,並且Vue提供了多達三種的使用方法,高階配置的用法更讓非同步元件的使用更加靈活。當然大部分情況下,我們會結合webpack進行使用。另外,函式式元件在多元件中選擇渲染內容的場景作用非凡,由於是一個無例項的元件,它在渲染開銷上比普通元件的效能更好。


相關文章