揭開Vue異步組件的神祕面紗

語言: CN / TW / HK

簡介

在大型應用裏,有些組件可能一開始並不顯示,只有在特定條件下才會渲染,那麼這種情況下該組件的資源其實不需要一開始就加載,完全可以在需要的時候再去請求,這也可以減少頁面首次加載的資源體積,要在Vue中使用異步組件也很簡單:

// AsyncComponent.vue
<template>
  <div>我是異步組件的內容</div>
</template>

<script>
export default {
    name: 'AsyncComponent'
}
</script>
// App.vue
<template>
  <div id="app">
    <AsyncComponent v-if="show"></AsyncComponent>
    <button @click="load">加載</button>
  </div>
</template>

<script>
export default {
  name: 'App',
  components: {
    AsyncComponent: () => import('./AsyncComponent'),
  },
  data() {
    return {
      show: false,
    }
  },
  methods: {
    load() {
      this.show = true
    },
  },
}
</script>

我們沒有直接引入AsyncComponent組件進行註冊,而是使用import()方法來動態的加載,import()ES2015 Loader 規範 定義的一個方法,webpack內置支持,會把AsyncComponent組件的內容單獨打成一個js文件,頁面初始不會加載,點擊加載按鈕後才會去請求,該方法會返回一個promise,接下來,我們從源碼角度詳細看看這一過程。

通過本文,你可以瞭解Vue對於異步組件的處理過程以及webpack的資源加載過程。

編譯產物

首先我們打個包,生成了三個js文件:

image-20211214194854431.png

第一個文件是我們應用的入口文件,裏面包含了main.jsApp.vue的內容,另外還包含了一些webpack注入的方法,第二個文件就是我們的異步組件AsyncComponent的內容,第三個文件是其他一些公共庫的內容,比如Vue

然後我們看看App.vue編譯後的內容:

image-20211224161447196.png

上圖為App組件的選項對象,可以看到異步組件的註冊方式,是一個函數。

image-20211224161252075.png

上圖是App.vue模板部分編譯後的渲染函數,當_vm.showtrue的時候,會執行_c('AsyncComponent'),否則執行_vm._e(),創建一個空的VNode_ccreateElement方法:

vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };

接下來看看當我們點擊按鈕後,這個方法的執行過程。

createElement方法

function createElement (
  context,
  tag,
  data,
  children,
  normalizationType,
  alwaysNormalize
) {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children;
    children = data;
    data = undefined;
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE;
  }
  return _createElement(context, tag, data, children, normalizationType)
}

contextApp組件實例,tag就是_c的參數AsyncComponent,其他幾個參數都為undefinedfalse,所以這個方法的兩個if分支都沒走,直接進入_createElement方法:

function _createElement (
 context,
 tag,
 data,
 children,
 normalizationType
) {
    // 如果data是被觀察過的數據
    if (isDef(data) && isDef((data).__ob__)) {
        return createEmptyVNode()
    }
    // v-bind中的對象語法
    if (isDef(data) && isDef(data.is)) {
        tag = data.is;
    }
    // tag不存在,可能是component組件的:is屬性未設置
    if (!tag) {
        return createEmptyVNode()
    }
    // 支持單個函數項作為默認作用域插槽
    if (Array.isArray(children) &&
        typeof children[0] === 'function'
       ) {
        data = data || {};
        data.scopedSlots = { default: children[0] };
        children.length = 0;
    }
    // 處理子節點
    if (normalizationType === ALWAYS_NORMALIZE) {
        children = normalizeChildren(children);
    } else if (normalizationType === SIMPLE_NORMALIZE) {
        children = simpleNormalizeChildren(children);
    }
    // ...
}

上述邏輯在我們的示例中都不會進入,接着往下看:

function _createElement (
 context,
 tag,
 data,
 children,
 normalizationType
) {
    // ...
    var vnode, ns;
    // tag是字符串
    if (typeof tag === 'string') {
        var Ctor;
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);
        if (config.isReservedTag(tag)) {
            // 是否是保留元素,比如html元素或svg元素
            if (false) {}
            vnode = new VNode(
                config.parsePlatformTagName(tag), data, children,
                undefined, undefined, context
            );
        } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
            // 組件
            vnode = createComponent(Ctor, data, context, children, tag);
        } else {
            // 其他未知標籤
            vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
            );
        }
    } else {
        // tag是組件選項或構造函數
        vnode = createComponent(tag, data, context, children);
    }
    // ...
}

對於我們的異步組件,tagAsyncComponent,是個字符串,另外通過resolveAsset方法能找到我們註冊的AsyncComponent組件:

function resolveAsset (
  options,// App組件實例的$options
  type,// components
  id,
  warnMissing
) {
  if (typeof id !== 'string') {
    return
  }
  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] }
  // 本地沒有,則在原型鏈上查找
  var res = assets[id] || assets[camelizedId] || assets[PascalCaseId];
  if (false) {}
  return res
}

Vue會把我們的每個組件都先創建成一個構造函數,然後再進行實例化,在創建過程中會進行選項合併,也就是把該組件的選項和父構造函數的選項進行合併:

image-20211227112643613.png

上圖中,子選項是App的組件選項,父選項是Vue構造函數的選項對象,對於components選項,會以父類的該選項值為原型創建一個對象,然後把子類本身的選項值作為屬性添加到該對象上,最後這個對象作為子類構造函數的options.components的屬性值:

image-20211227113823227.png

image-20211227113909991.png

image-20211227113657329.png

然後在組件實例化時,會以構造函數的options對象作為原型創建一個對象,作為實例的$options

image-20211227135444816.png

所以App實例能通過$options從它的構造函數的options.components對象上找到AsyncComponent組件:

image-20211227140124998.png

可以發現就是我們前面看到過的編譯後的函數。

接下來會執行createComponent方法:

function createComponent (
 Ctor,
 data,
 context,
 children,
 tag
) {
    // ...
    // 異步組件
    var asyncFactory;
    if (isUndef(Ctor.cid)) {
        asyncFactory = Ctor;
        Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
        if (Ctor === undefined) {
            return createAsyncPlaceholder(
                asyncFactory,
                data,
                context,
                children,
                tag
            )
        }
    }
    // ...
}

接着又執行了resolveAsyncComponent方法:

function resolveAsyncComponent (
 factory,
 baseCtor
) {
     // ...
    var owner = currentRenderingInstance;
    if (owner && !isDef(factory.owners)) {
        var owners = factory.owners = [owner];
        var sync = true;
        var timerLoading = null;
        var timerTimeout = null

        ;(owner).$on('hook:destroyed', function () { return remove(owners, owner); });
        var forceRender = function(){}
        var resolve = once(function(){})
        var reject = once(function(){})
        // 執行異步組件的函數
        var res = factory(resolve, reject);
    }
     // ...
}

到這裏終於執行了異步組件的函數,也就是下面這個:

function AsyncComponent() {
    return __webpack_require__.e( /*! import() */ "chunk-1f79b58b").then(__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d"));
}

欲知res是什麼,我們就得看看這幾個webpack的函數是幹什麼的。

加載組件資源

webpack_require.e方法

先看__webpack_require__.e方法:

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    // 已經加載的chunk
    var installedChunkData = installedChunks[chunkId];
    if (installedChunkData !== 0) { // 0代表已經加載
      // 值非0即代表組件正在加載中,installedChunkData[2]為promise對象
      if (installedChunkData) {
        promises.push(installedChunkData[2]);
      } else {
        // 創建一個promise,並且把兩個回調參數緩存到installedChunks對象上
        var promise = new Promise(function (resolve, reject) {
          installedChunkData = installedChunks[chunkId] = [resolve, reject];
        });
        // 把promise對象本身也添加到緩存數組裏
        promises.push(installedChunkData[2] = promise);
        // 開始發起chunk請求
        var script = document.createElement('script');
        var onScriptComplete;
        script.charset = 'utf-8';
        script.timeout = 120;
        // 拼接chunk的請求url
        script.src = jsonpScriptSrc(chunkId);
        var error = new Error();
        // chunk加載完成/失敗的回到
        onScriptComplete = function (event) {
          script.onerror = script.onload = null;
          clearTimeout(timeout);
          var chunk = installedChunks[chunkId];
          if (chunk !== 0) {
            // 如果installedChunks對象上該chunkId的值還存在則代表加載出錯了
            if (chunk) {
              var errorType = event && (event.type === 'load' ? 'missing' : event.type);
              var realSrc = event && event.target && event.target.src;
              error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
              error.name = 'ChunkLoadError';
              error.type = errorType;
              error.request = realSrc;
              chunk[1](error);
            }
            installedChunks[chunkId] = undefined;
          } 
        };
        // 設置超時時間
        var timeout = setTimeout(function () {
          onScriptComplete({
            type: 'timeout',
            target: script
          });
        }, 120000);
        script.onerror = script.onload = onScriptComplete;
        document.head.appendChild(script);
      }
    }
    return Promise.all(promises);
  };

這個方法雖然有點長,但是邏輯很簡單,首先函數返回的是一個promise,如果要加載的chunk未加載過,那麼就創建一個promise,然後緩存到installedChunks對象上,接下來創建script標籤來加載chunk,唯一不好理解的是onScriptComplete函數,因為在這裏面判斷該chunkinstalledChunks上的緩存信息不為0則當做失敗處理了,問題是前面才把promise信息緩存過去,也沒有看到哪裏有進行修改,要理解這個就需要看看我們要加載的chunk的內容了:

image-20211227153327294.png

可以看到代碼直接執行了,並往webpackJsonp數組裏添加了一項:

window["webpackJsonp"] = window["webpackJsonp"] || []).push([["chunk-1f79b58b"],{..}])

看着似乎也沒啥問題,其實window["webpackJsonp"]push方法被修改過了:

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
var parentJsonpFunction = oldJsonpFunction;

被修改成了webpackJsonpCallback方法:

function webpackJsonpCallback(data) {
    var chunkIds = data[0];
    var moreModules = data[1];
    var moduleId, chunkId, i = 0,
        resolves = [];
    for (; i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
            // 把該chunk的promise的resolve回調方法添加到resolves數組裏
            resolves.push(installedChunks[chunkId][0]);
        }
        // 標記該chunk已經加載完成
        installedChunks[chunkId] = 0;
    }
    // 將該chunk的module數據添加到modules對象上
    for (moduleId in moreModules) {
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            modules[moduleId] = moreModules[moduleId];
        }
    }
    // 執行原本的push方法
    if (parentJsonpFunction) parentJsonpFunction(data);
    // 執行resolve函數
    while (resolves.length) {
        resolves.shift()();
    }
}

這個函數會取出該chunk加載的promiseresolve函數,然後將它在installedChunks上的信息標記為0,代表加載成功,所以在後面執行的onScriptComplete函數就可以通過是否為0來判斷是否加載失敗。最後會執行resolve函數,這樣前面__webpack_require__.e函數返回的promise狀態就會變為成功。

讓我們再回顧一下AsyncComponent組件的函數:

function AsyncComponent() {
    return __webpack_require__.e( /*! import() */ "chunk-1f79b58b").then(__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d"));
}

chunk加載完成後會執行__webpack_require__方法。

__webpack_require__方法

這個方法是webpack最重要的方法,用來加載模塊:

function __webpack_require__(moduleId) {
    // 檢查模塊是否已經加載過了
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // 創建一個新模塊,並緩存
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
    // 執行模塊函數
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 標記模塊加載狀態
    module.l = true;
    // 返回模塊的導出
    return module.exports;
}

所以上面的__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d")其實是去加載了c61d模塊,這個模塊就在我們剛剛請求回來的chunk裏:

image-20211227161841023.png

這個模塊內部又會去加載它依賴的模塊,最終返回的結果為:

image-20211227162447114.png

其實就是AsyncComponent的組件選項。

回到createElement方法

回到前面的resolveAsyncComponent方法:

var res = factory(resolve, reject);

現在我們知道這個res其實就是一個未完成的promiseVue並沒有等待異步組件加載完成,而是繼續向後執行:

if (isObject(res)) {
    if (isPromise(res)) {
        // () => Promise
        if (isUndef(factory.resolved)) {
            res.then(resolve, reject);
        }
    }
}

return factory.resolved

把定義的resolvereject函數作為參數傳給promise res,最後返回了factory.resolved,這個屬性並沒有被設置任何值,所以是undefined

接下來回到createComponent方法:

Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
if (Ctor === undefined) {
    // 返回異步組件的佔位符節點,該節點呈現為註釋節點,但保留該節點的所有原始信息。
    // 這些信息將用於異步服務端渲染。
    return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
    )
}

因為Ctorundefined,所以會執行createAsyncPlaceholder方法返回一個佔位符節點:

function createAsyncPlaceholder (
  factory,
  data,
  context,
  children,
  tag
) {
  // 創建一個空的VNode,其實就是註釋節點
  var node = createEmptyVNode();
  // 保留組件的相關信息
  node.asyncFactory = factory;
  node.asyncMeta = { data: data, context: context, children: children, tag: tag };
  return node
}

最後讓我們再回到_createElement方法:

// ...
vnode = createComponent(Ctor, data, context, children, tag);
// ...
return vnode

很簡單,對於異步節點,直接返回創建的註釋節點,最後把虛擬節點轉換成真實節點,會實際創建一個註釋節點:

image-20211227181319356.png

現在讓我們來看看resolveAsyncComponent函數裏面定義的resolve,也就是當chunk加載完成後會執行的:

var resolve = once(function (res) {d
    // 緩存結果
    factory.resolved = ensureCtor(res, baseCtor);
    // 非同步解析時調用
    // (SSR會把異步解析為同步)
    if (!sync) {
        forceRender(true);
    } else {
        owners.length = 0;
    }
});

resAsyncComponent的組件選項,baseCtorVue構造函數,會把它們作為參數調用ensureCtor方法:

function ensureCtor (comp, base) {
  if (
    comp.__esModule ||
    (hasSymbol && comp[Symbol.toStringTag] === 'Module')
  ) {
    comp = comp.default;
  }
  return isObject(comp)
    ? base.extend(comp)
    : comp
}

可以看到實際上是調用了extend方法:

image-20211227182323558.png

前面也提到過,Vue會把我們的組件都創建一個對應的構造函數,就是通過這個方法,這個方法會以baseCtor為父類創建一個子類,這裏就會創建AsyncComponent子類:

image-20211227182849384.png

子類創建成功後會執行forceRender方法:

var forceRender = function (renderCompleted) {
    for (var i = 0, l = owners.length; i < l; i++) {
        (owners[i]).$forceUpdate();
    }

    if (renderCompleted) {
        owners.length = 0;
        if (timerLoading !== null) {
            clearTimeout(timerLoading);
            timerLoading = null;
        }
        if (timerTimeout !== null) {
            clearTimeout(timerTimeout);
            timerTimeout = null;
        }
    }
};

owners裏包含着App組件實例,所以會調用它的$forceUpdate方法,這個方法會迫使 Vue 實例重新渲染,也就是重新執行渲染函數,進行虛擬DOMdiffpath更新。

所以會重新執行App組件的渲染函數,那麼又會執行前面的createElement方法,又會走一遍我們前面提到的那些過程,只是此時AsyncComponent組件已經加載成功並創建了對應的構造函數,所以對於createComponent方法,這次執行resolveAsyncComponent方法的結果不再是undefined,而是AsyncComponent組件的構造函數:

Ctor = resolveAsyncComponent(asyncFactory, baseCtor);

function resolveAsyncComponent (
 factory,
 baseCtor
) {
    if (isDef(factory.resolved)) {
        return factory.resolved
    }
}

接下來就會走正常的組件渲染邏輯:

var name = Ctor.options.name || tag;
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,具體怎麼把該組件的VNode渲染成真實DOM不是本文的重點就不介紹了,大致就是在虛擬DOMdiffpatch過程中如果遇到的VNode是組件類型,那麼會new一個該組件的實例關聯到VNode上,組件實例化和我們new Vue()沒有什麼區別,都會先進行選項合併、初始化生命週期、初始化事件、數據觀察等操作,然後執行該組件的渲染函數,生成該組件的VNode,最後進行patch操作,生成實際的DOM節點,子組件的這些操作全部完成後才會再回到父組件的diffpatch過程,因為子組件的DOM已經創建好了,所以插入即可,更詳細的過程有興趣可自行了解。

以上就是本文全部內容。