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

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

我們知道,元件是Vue體系的核心,熟練使用元件是掌握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)
  }
})
複製程式碼
6.1.2.1 require.ensure

在結合webpack進行非同步元件程式碼分離時,經常需要關注分離檔案的chunkname,這時可以使用webpack提供的require.ensure進行程式碼分離。webpack 在編譯時,會靜態地解析程式碼中的 require.ensure(),同時將模組新增到一個分開的 chunk 中,其中函式的第三個引數為分離程式碼塊的名字。修改上述的程式碼寫法:

Vue.component('asyncComponent', function (resolve, reject) {
   require.ensure([], function () {
     resolve(require('./test.vue'));
   }, 'asyncComponent'); // asyncComponent為chunkname
})
複製程式碼
6.1.2.2 流程分析

有了上一節元件註冊的基礎,我們來分析非同步元件的實現邏輯。簡單回憶一下,子元件的建立分為vnode節點的建立和vnode到真實節點patch的過程,而子vnode建立的過程發生在根節點掛載時遞迴建立子vnode中,遇到子佔位符時,會呼叫createComponent方法。而非同步元件也放在這一階段處理。

// 建立子元件過程
  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
    }
  }
複製程式碼

工廠函式的處理,總結來說就三點:

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

先關注一下高階函式once, 為了防止多個地方同時呼叫非同步元件時,resolve,reject呼叫多次,once函式保證了函式在程式碼只執行一次。

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

成功和失敗的處理邏輯如下:

// 成功處理
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.3 Promise非同步元件

非同步元件的第二種寫法是在工廠函式中返回一個promise物件,我們知道import是es6引入模組載入的用法,但是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.4 高階非同步元件

為了在操作上更加靈活,比如使用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.2 函式式元件

Vue提供了一種就可以讓元件變為無狀態、無例項的函式化元件,既然這個元件是函式,那麼它的渲染開銷會低很多。一搬情況下,當我們需要在多個元件中選擇一個來代為渲染,或者在將children,props,data等資料傳遞給子元件前進行資料處理,我們都可以用函式式元件來完成,其本質上也是對元件的一個外部包裝。

6.2.1 使用場景

    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)
  }
}
複製程式碼
  • 2.定義函式式元件
Vue.component('test3', {
  // 函式式元件的標誌 functional設定為true
  functional: true,
  props: ['msg'],
  render: function (createElement, context) {
    var get = function() {
      return test1
    }
    return createElement(get(), context)
  }
})
複製程式碼
  • 3.使用函式式元件
<test3 :msg="msg" id="test">
</test3>
new Vue({
  el: '#app',
  data: {
    msg: 'test'
  }
})
複製程式碼

最終渲染的結果為:

<h2>test</h2>
複製程式碼

6.2.2 原始碼分析

函式式元件會在元件的物件定義中,將functional屬性設定為true,在根例項掛載的過程中,遇到函式式元件的佔位符依然會進入建立子元件createComponent的流程。由於functional屬性的存在,程式碼會進入函式式元件的分支中,並返回createFunctionalComponent呼叫的結果。

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 renderContext = new FunctionalRenderContext(data,props,children,contextVm,Ctor);
  // 執行render函式
  var vnode = options.render.call(null, renderContext._c, renderContext)
}
複製程式碼

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

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

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

從原始碼中可以看出,函式式元件並不會像普通元件那樣有掛載元件週期鉤子,監聽狀態和管理資料的過程,它只會原封不動的接收傳遞給元件的資料做處理。因此作為純粹的函式它只做資料的處理以及渲染元件的選擇,這也大大降低了函式式元件的開銷。

6.3 小結

這一小節介紹了元件兩個進階的用法,非同步元件和函式式元件。它們都是為了解決某些型別的場景引入的高階元件用法。其中非同步元件是首屏效能優化的一個解決方案,並且Vue提供了多達三種的使用方法,高階配置的用法讓非同步元件的使用更加靈活。函式式元件在多元件中選擇渲染元件的過程中效果同樣顯著。


相關文章