一文看透 Module Federation

前言

一直在聽說 Webpack5 的新特性 Module Federation 可以很好解決代碼共享的問題,但其實在這兩年並沒有在團隊中使用起來,一方面是現有的項目都不是 Webpack5 的,小範圍項目落地又有侷限性,另一方面是團隊在微前端的方案探索中,在如何解決跨子應用代碼共享的問題中也有了比較好的解決方案。

目前爲了探索 Module Federation 與微前端方案結合起來的可能性,決定深入瞭解一下它的底層原理。

概念

Module Federation

什麼是 Module Federation (下面簡稱 MF) 呢,我們來看看 Webpack 官網裏的描述:

Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually. This is often known as Micro-Frontends, but is not limited to that.

簡單翻譯就是,“一個應用可以由多個獨立的構建組成。這些獨立的構建之間沒有依賴關係,他們可以獨立開發、部署。這就是常被認爲的微前端,但不侷限於此。”

不難發現,MF 想做的事和微前端想解決的問題是類似的,把一個應用進行拆分成多個應用,每個應用可獨立開發,獨立部署,一個應用可以動態加載並運行另一個應用的代碼,並實現應用之間的依賴共享。

爲了實現這樣的功能, MF 在設計上提出了這幾個核心概念。

Container

一個被 ModuleFederationPlugin 打包出來的模塊被稱爲 Container
通俗點講就是,如果我們的一個應用使用了 ModuleFederationPlugin 構建,那麼它就成爲一個 Container,它可以加載其他的 Container,可以被其他的 Container 所加載。

Host&Remote

從消費者和生產者的角度看 ContainerContainer 又可被稱作 HostRemote

可以知道,這裏的 HostRemote 是相對的,因爲 一個 Container 既可以作爲 Host,也可以作爲 Remote

Shared

一個 Container 可以 Shared 它的依賴(如 react、react-dom)給其他 Container 使用,也就是共享依賴。

使用實踐

下面以一個簡單的例子來介紹一下如何使用 MF 的功能。

效果演示

有兩個應用分別爲 app1 和 app2app2 共享它的 Hello 組件給 app1使用,它們共享一份 reactreact-dom 依賴,下面我們來看看核心代碼。

完整代碼可下載 webpack5demo(https://github.com/beyondxgb/webpack5demo) 運行查看。

app1/src/app.js

import React from 'react';
import App2Hello from 'app2/Hello';

const RootComponent = () => {
  return (
    <div>
      <div>app1</div>
      <App2Hello />
    </div>
  );
};

export default RootComponent;

app1/src/bootstrap.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';

ReactDOM.render(<App />, document.getElementById('app'));

app1/src/index.js

import('./bootstrap');

app2/src/Hello.js

import React from 'react';

const Hello = () ={
  return (
    <div>app2 hello</div>
  )
};

export default Hello;

效果如下:

可以看到,因爲app1 引用了 app2  的 Hello 組件,在渲染的時候異步下載了app2的遠程模塊入口代碼和 Hello 組件的代碼,並且只下載了 app1reactreact-dom 代碼,app2直接使用 app1提供的依賴,這樣就實現了一個應用動態加載並運行另一個應用的代碼,並實現應用之間的依賴共享。

如何配置插件?

實現跨應用代碼共享,主要藉助了 Webapck5 提供的一個插件 ModuleFederationPlugin。

在上面的例子,很明顯,app1 使用了 app2 的 Hello 組件,app1 爲消費方,app2 爲提供方。

app2 作爲提供方(Remote),它會把 Hello 組件暴露出來給消費方(Host)使用。

app2/webpack.config.js

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      filename: 'app2RemoteEntry.js',
      exposes: {
        './Hello': './src/Hello',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ]
}

同理**app1作爲消費方(Host)** ,定義需要消費 **app2** 並指定它的資源地址。

app1/webpack.config.js

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      filename: 'app1RemoteEntry.js',
      remotes: {
        'app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    })
  ]
}

下面來解釋下上面幾個核心字段配置。

name
當前應用的別名,當應用作爲 Remotehost 使用的時候,作爲引用前綴,import xx from name/expose

filename
當前應用作爲 RemoteHost 使用的時候,提供的遠程模塊入口文件名,比如上面 app1 在使用 app2 的時候,會先下載 app2RemoteEntry.js 文件。

exposes
當前應用作爲 Remote 的時候,可提供哪些屬性(如組件、方法,甚至是一個值)可消費。

new ModuleFederationPlugin({
  name: 'app2',
  ...
  exposes: {
    './Hello': './src/Hello',
  },
}

它是一個對象,它的 key 爲在被 Host 使用的時候的相對路徑,value 爲當前應用暴露的屬性的相對路徑。

如上面的配置,可以這樣提供給 Host 同步引用:

import App2Hello from 'app2/Hello';

當然,也可以異步加載引用:

const App2Hello = React.lazy(() => import('app2/App1Hello'));

remotes
當前應用作爲 Host 的時候,需要消費哪些 Remote 應用。

new ModuleFederationPlugin({
  name: 'app1',
  ...
  remotes: {
    'app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js',
  },
})

它是一個對象,它的 key 爲 Remote 應用定義的別名(name),valueRemote 應用的資源地址,使用 Remote 應用的格式爲_ import *  from {name}{path}_。

import App2Hello from 'app2/Hello';

注意的是,這裏的 name是引用別名,可以跟 Remote 應用定義的 name不一致的。

比如我們定義 app2 的別名爲 @remote/app2

new ModuleFederationPlugin({
  name: 'app1',
  ...
  remotes: {
    '@remote/app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js',
  },
})

那麼,使用的時候則可以這樣子:

import App2Hello from '@remote/app2/Hello';

shared
當前應用無論是作爲 Host 還是 Remote,可以共享的三方庫依賴有哪些。

new ModuleFederationPlugin({
  name: 'app1',
  ...
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})

這是一個對象,它的 key 爲三方依賴的 name,value 則爲該三方依賴的屬性配置項。常用的有 singletonrequiredVersion

比如 singletontrueapp1 的 react 版本爲 16.13.0app2 的 react 版本爲 16.14.0,那麼 app1 和 app2 將會共同使用 16.14.0react 版本,也就是 app2 提供的 react

如果這時 app1 配置的 react 版本  requiredVersion 爲 16.13.0,那麼 app1 將會使用 16.13.0app2 將會使用 16.14.0,相當於它們都沒有共享依賴,各自下載自己的 react 版本。

工作原理

從上面的一個簡單例子可以快速知道 MF 的使用方法,下面來介紹下具體的工作原理。

這部分內容有點枯燥,如果不想了解的話可快速跳過這一節,如果繼續瞭解的話,建議對照着運行代碼來查看

構建上有什麼不同?

在沒有使用 MF 之前,app1app2的構建如下:

使用 MF 之後,對應的構建如下:

對比兩張圖,我們可以看出打包文件發生了變化,在新的打包文件中,我們發現新增了 remoteEntry-chunkshared-chunkexpose-chunk以及 async-chunk

其中remoteEntry-chunkshared-chunkexpose-chunk都是因爲配置了 ModuleFederationPlugin 而生成的,async-chunk卻是人爲分割文件而生成的。

我們來對照着 app2 的插件配置介紹一下每個 chunk 的生成。

app2/webpack.config.js

const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      filename: 'app2RemoteEntry.js',
      exposes: {
        './Hello': './src/Hello',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ]
}

remoteEntry-chunk是當前應用作爲遠程應用(Remote)被調用的時候請求的文件,對應的文件名爲插件裏配置的 filename,比如會生成 app1RemoteEntry.jsapp2RemoteEntry.js

shared-chunk是當前應用開啓了 shared(共享依賴)功能後生成的,比如 shared 指定共享 react 和 react-dom,那麼在構建的時候 react 模塊和 react-dom 模塊會被分離爲新的 shared-chunk,比如vendors-node_modules__react_16_14_0_react_index_js.jsvendors-node_modules__react-dom_16_14_0_react-dom_index_js.js

expose-chunk是當前應用暴露某些屬性提供給外部使用的時候生成的,在構建的時候會根據 exposes 配置項,生成一個或多個 expose-chunk,比如 app2生成了 Hello 這個 chunk

最後講下 async-chunk ,這裏指的是src_bootstrap_tsx.js,爲什麼會有這個異步文件呢?

我們來看看上面提到的 app1的文件:

app1/src/bootstrap.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';

ReactDOM.render(<App />, document.getElementById('app'));

app1/src/index.js

import('./bootstrap');

這裏有一個 bootstrap.js文件,它裏面的代碼原本是在放在index.js入口文件裏,爲什麼單獨分離出來,並且在 index.js使用 import('bootstrap')  來異步加載 bootstrap.js呢?

這就是要實現 MF 功能的限制了,我們來看看這段代碼:

app1/src/app.js

import React from 'react';
import App2Hello from 'app2/Hello';

const RootComponent = () => {
  return (
    <div>
      <div>app1</div>
      <App2Hello />
    </div>
  );
};

export default RootComponent;

如果 bootstrap.js不是異步加載的話,而是直接打包在 main.js裏面,那麼import App2Hello from 'app2/Hello';這語句就被立刻執行了,這時會因 app2的資源根本沒有被下載而報錯了。

如果開啓了 shared 功能的話,那麼 import React from 'react';這語句被同步執行也是會報錯的,因爲這時候還沒有初始化好共享依賴,所以經常會出現下面這個報錯。

所以
必須必須必須
把原本的入口代碼放到 bootstrap.js裏面,index.js使用了 import('bootstrap') 來異步加載 bootstrap.js,這樣就可以實現先加載 main.js,然後在異步加載 src_bootstrap_tsx.js的時候,前置先加載好遠程應用的資源以及初始化好共享依賴,最後再執行 bootstrap.js模塊。

如何加載遠程模塊?

app1/src/app.js

import App2Hello from 'app2/Hello';

如上面,我們看到 app1 裏是這樣引用 app2Hello 組件的,背後發生了什麼呢?

我們來看看這段代碼的構建結果:


可以看到 src_bootstrap_tsx的編譯結果裏 src/app.tsx模塊引用了模塊 webpack/container/remote/app2/Hello,也就是我們代碼寫的 app2/Hello,但 webpack/container/remote/app2/Hello又是在哪呢,我們從 app1 的主入口文件 main.js的構建結果可以搜索到它。

/******/  /* webpack/runtime/remotes loading */
/******/  (() => {
/******/   var chunkMapping = {
/******/    "src_bootstrap_tsx": [
/******/     "webpack/container/remote/app2/Hello"
/******/    ]
/******/   };
/******/   var idToExternalAndNameMapping = {
/******/    "webpack/container/remote/app2/Hello": [
/******/     "default",
/******/     "./Hello",
/******/     "webpack/container/reference/app2"
/******/    ]
/******/   };
/******/   __webpack_require__.f.remotes = (chunkId, promises) => {
/******/    if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/     chunkMapping[chunkId].forEach((id) => {
/******/      var data = idToExternalAndNameMapping[id];
/******/      var handleFunction = (fn, arg1, arg2, d, next, first) => {
/******/       try {
/******/        var promise = fn(arg1, arg2);
/******/        if(promise && promise.then) {
/******/         var p = promise.then((result) => (next(result, d)), onError);
/******/         if(first) promises.push(data.p = p); else return p;
/******/        } else {
/******/         return next(promise, d, first);
/******/        }
/******/       } catch(error) {
/******/        onError(error);
/******/       }
/******/      }
/******/      var onExternal = (external, _, first) => (external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError());
/******/      var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
/******/      var onFactory = (factory) => {
/******/       data.p = 1;
/******/       __webpack_modules__[id] = (module) => {
/******/        module.exports = factory();
/******/       }
/******/      };
/******/      handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
/******/     });
/******/    }
/******/   }
/******/  })();

這裏的 __webpack_require__.f.remotes則是加載遠程模塊的核心。代碼中有個 chunkMapping對象,這個對象保存的是當前應用有哪些模塊依賴了遠程模塊,比如 src_bootstrap_tsx依賴了遠程模塊webpack/container/remote/app2/Hello

那麼加載 src_bootstrap_tsx的時候必須先加載完遠程應用的資源,從最後 handleFuncion 語句可以看到,__webpack_require__(data[2]),也就是去加載 webpack/container/reference/app2

/***/ "webpack/container/reference/app2":
/*!****************************************************************!*\
  !*** external "app2@http://127.0.0.1:8002/app2RemoteEntry.js" ***!
  \****************************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {

"use strict";
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
 if(typeof app2 !== "undefined") return resolve();
 __webpack_require__.l("http://127.0.0.1:8002/app2RemoteEntry.js", (event) => {
  if(typeof app2 !== "undefined") return resolve();
   ...
 }, "app2");
}).then(() => (app2));

/***/ })

我們找到這個模塊的定義,這裏會去異步加載 app2RemoteEntry.js,也就是我們在配置 app1ModuleFederationPlugin 的時候指定的 app2 遠程模塊入口文件的資源地址,加載完後返回 app2 這個全局變量作爲 webpack/container/reference/app2模塊的輸出值。

但這只是獲取到了app2遠程入口模塊的輸出值,怎麼獲取到 Hello 組件呢?

我們來看下 app2RemoteEntry.js的具體內容:

var moduleMap = {
 "./Hello": () => {
  return Promise.all([__webpack_require__.e("webpack_sharing_consume_default_react_react-_091a"), __webpack_require__.e("src_Hello_tsx")]).then(() => (() => ((__webpack_require__(/*! ./src/Hello */ "./src/Hello.tsx")))));
 }
};
var get = (module, getScope) => {
 __webpack_require__.R = getScope;
 getScope = (
  __webpack_require__.o(moduleMap, module)
   ? moduleMap[module]()
   : Promise.resolve().then(() => {
    throw new Error('Module "' + module + '" does not exist in container.');
   })
 );
 __webpack_require__.R = undefined;
 return getScope;
};
var init = (shareScope, initScope) => {
 ....
};

// This exports getters to disallow modifications
__webpack_require__.d(exports, {
 get: () => (get),
 init: () => (init)
});

它暴露了 getinit方法,我們回到上面 __webpack_require__.f.remotes裏的一個方法:

var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));

在加載完遠程模塊入口文件後,返回了 app2 全局變量,最後執行 app2.get('./Hello') 來異步獲取 Hello 組件。

總結一下流程,app1 加載 src_bootstrap_tsx模塊,判斷它依賴了遠程模塊 webpack/container/remote/app2/Hello,那麼先去下載遠程模塊 webpack/container/reference/app2,也就是app2RemoteEntry.js,返回 app2 全局變量,執行  app2.get('./Hello') 來異步獲取 Hello 組件,遠程應用的資源以及 src_bootstrap_tsx資源全部下載完成,最後再執行 src_bootstrap_tsx模塊。

它們如何共享依賴?

webpack 的構建中,每個構建結果其實都是隔離的,那麼它是如何打破這個隔離,實現應用間共享依賴呢?

這裏的關鍵在於 sharedScope,共享作用域,在 HostRemote 應用之間建立一個可共享的 sharedScope,它包含了所有可共享的依賴,大家都按一定規則往 sharedScope 裏獲取對應的依賴

app1/src/bootstrap.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';

ReactDOM.render(<App />, document.getElementById('app'));

如上面,我們知道,reactreact-dom 已經被配置爲 shared 的,在 bootsrap.js引用 reactreact-dom 的時候,背後會是怎麼引用這兩個模塊呢?我們來看看 app1的主入口文件 main.js的構建結果。

/******/   var moduleToHandlerMapping = {
/******/    "webpack/sharing/consume/default/react/react?923c": () => (loadSingletonVersionCheckFallback("default", "react", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js"), __webpack_require__.e("node_modules__object-assign_4_1_1_object-assign_index_js-node_modules__prop-types_15_8_1_prop-5b9d13")]).then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js"))))))),
/******/    "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersionCheckFallback("default", "react-dom", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react-dom_16_14_0_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! react-dom */ "./node_modules/_react-dom@16.14.0@react-dom/index.js"))))))),
/******/    "webpack/sharing/consume/default/react/react?20fb": () => (loadSingletonVersionCheckFallback("default", "react", [1,16,14,0], () => (__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js")))))))
/******/   };
/******/   var chunkMapping = {
/******/    "src_bootstrap_tsx": [
/******/     "webpack/sharing/consume/default/react/react?923c",
/******/     "webpack/sharing/consume/default/react-dom/react-dom"
/******/    ],
/******/    "webpack_sharing_consume_default_react_react": [
/******/     "webpack/sharing/consume/default/react/react?20fb"
/******/    ]
/******/   };
/******/   __webpack_require__.f.consumes = (chunkId, promises) => {
/******/    if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/     chunkMapping[chunkId].forEach((id) => {
/******/      ...
/******/      try {
/******/       var promise = moduleToHandlerMapping[id]();
/******/       if(promise.then) {
/******/        promises.push(installedModules[id] = promise.then(onFactory)['catch'](onError));
/******/       } else onFactory(promise);
/******/      } catch(e) { onError(e); }
/******/     });
/******/    }
/******/   }
/******/  })();

開啓了 shared 功能後,app1構建代碼會多了 __webpack_require__.f.consumes 這段代碼邏輯,代碼中有個chunkMapping對象,這個對象保存的是**當前應用有哪些模塊依賴了共享依賴,比如 src_bootstrap_tsx模塊依賴了 react 和 react-dom 這兩個共享依賴。

那麼加載 src_bootstrap_tsx的時候必須先加載完這些共享依賴的資源,也就是webpack/sharing/consume/default/react/react?923cwebpack/sharing/consume/default/react-dom/react-dom這兩個模塊,它們是通過 loadSingletonVersionCheckFallback來獲取值的。

/******/   var init = (fn) => (function(scopeName, a, b, c) {
/******/    var promise = __webpack_require__.I(scopeName);
/******/    if (promise && promise.then) return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], a, b, c));
/******/    return fn(scopeName, __webpack_require__.S[scopeName], a, b, c);
/******/   });
/******/   var loadSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
/******/    if(!scope || !__webpack_require__.o(scope, key)) return fallback();
/******/    return getSingletonVersion(scope, scopeName, key, version);
/******/   });

在執行 loadSingletonVersionCheckFallback之前,首先要執行了 init方法,init方法調用了 __webpack_require__.I,這纔來到了共享依賴的重點方法。

/******/  /* webpack/runtime/sharing */
/******/  (() => {
/******/   __webpack_require__.S = {};
/******/   __webpack_require__.I = (name, initScope) => {
/******/    if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
/******/    // runs all init snippets from all modules reachable
/******/    var scope = __webpack_require__.S[name];
/******/    var uniqueName = "atom-workbench-app1";
/******/    var register = (name, version, factory, eager) => {
/******/     var versions = scope[name] = scope[name] || {};
/******/     var activeVersion = versions[version];
/******/     if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager };
/******/    };
/******/    var initExternal = (id) => {
/******/     var handleError = (err) => (warn("Initialization of sharing external failed: " + err));
/******/     try {
/******/      var module = __webpack_require__(id);
/******/      if(!module) return;
/******/      var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
/******/      if(module.then) return promises.push(module.then(initFn, handleError));
/******/      var initResult = initFn(module);
/******/      if(initResult && initResult.then) return promises.push(initResult['catch'](handleError));
/******/     } catch(err) { handleError(err); }
/******/    }
/******/    var promises = [];
/******/    switch(name) {
/******/     case "default": {
/******/      register("react-dom", "16.14.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules__react-dom_16_14_0_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! ./node_modules/_react-dom@16.14.0@react-dom/index.js */ "./node_modules/_react-dom@16.14.0@react-dom/index.js"))))));
/******/      register("react", "16.14.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js"), __webpack_require__.e("node_modules__object-assign_4_1_1_object-assign_index_js-node_modules__prop-types_15_8_1_prop-5b9d13")]).then(() => (() => (__webpack_require__(/*! ./node_modules/_react@16.14.0@react/index.js */ "./node_modules/_react@16.14.0@react/index.js"))))));
/******/      initExternal("webpack/container/reference/app2");
/******/     }
/******/     break;
/******/    }
/******/    if(!promises.length) return initPromises[name] = 1;
/******/    return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
/******/   };
/******/  })();

這裏的 __webpack_require__.S 就是保存共享依賴的信息,它是應用間共享依賴的橋樑。在經過 register 方法後,可以看到 webpack_require.S 保存的信息。

其中 default 爲 sharedScope 的名稱,reactreact-dom 爲對應在 shared 配置項中的共享依賴,共享依賴保存着每個版本的信息,每個版本的 from 代表這個共享依賴來自哪個應用,get 則爲共享依賴的獲取方法。

最後調用 initExternal 方法,加載依賴的遠程應用 webpack/container/reference/app2,也就是加載 app2RemoteEntry.js,加載完後調用這個遠程入口文件模塊的 init 方法。

var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))

我們再看看 app2 的app2RemoteEntry.js

var get = (module, getScope) => {
 ...
};
var init = (shareScope, initScope) => {
 if (!__webpack_require__.S) return;
 var name = "default"
 var oldScope = __webpack_require__.S[name];
 if(oldScope && oldScope !== shareScope) throw new Error("Container initialization failed as it has already been initialized with a different share scope");
 __webpack_require__.S[name] = shareScope;
 return __webpack_require__.I(name, initScope);
};

// This exports getters to disallow modifications
__webpack_require__.d(exports, {
 get: () => (get),
 init: () => (init)
});

可以看到,init 方法會使用 app1webpack_require.S 初始化 app2webpack_require.S!由於這是引用關係,所以 app1 和 app2共用了一個的 sharedScope

這裏注意的是 app2 也調用了自己的 __webpack_require__.I,也會 register 自己的共享依賴,那麼最終的 webpack_require.S 會是怎樣呢?

如果 app2也是使用 16.14.0 版本的 react 的話,那麼 webpack_require.S 是不變的,還是跟上面 app1的一樣,如果 app2使用的是 16.13.0 版本的 react  的話,那麼會增加一個版本信息。

webpack_require.S 已經初始化好了,那麼在 app1app2在使用 react 或 react-dom 的時候究竟取哪個版本呢?這就要回到 loadSingletonVersionCheckFallback方法了。

app1/main.js

/******/   var loadSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
/******/    if(!scope || !__webpack_require__.o(scope, key)) return fallback();
/******/    return getSingletonVersion(scope, scopeName, key, version);
/******/   });

/******/   var moduleToHandlerMapping = {
/******/    "webpack/sharing/consume/default/react/react?923c": () => (loadSingletonVersionCheckFallback("default", "react", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js"), __webpack_require__.e("node_modules__object-assign_4_1_1_object-assign_index_js-node_modules__prop-types_15_8_1_prop-5b9d13")]).then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js"))))))),
/******/    "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersionCheckFallback("default", "react-dom", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react-dom_16_14_0_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! react-dom */ "./node_modules/_react-dom@16.14.0@react-dom/index.js"))))))),
/******/    "webpack/sharing/consume/default/react/react?20fb": () => (loadSingletonVersionCheckFallback("default", "react", [1,16,14,0], () => (__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js")))))))
/******/   };

比如 app1 在獲取 webpack/sharing/consume/default/react/react?923c的時候,也就是獲取 react 的 16.14.0 版本,在 loadSingletonVersionCheckFallback方法裏判斷了 scope 裏是不是有 react這個共享依賴,如果沒有的話就走 fallback 方法,也就是共享依賴沒有可取的,那麼就去下載當前應用打包的 react 模塊,如果有的話,那麼就調用 getSingletonVersion方法。

app1/main.js

/******/   var getSingletonVersion = (scope, scopeName, key, requiredVersion) => {
/******/    var version = findSingletonVersionKey(scope, key);
/******/    if (!satisfy(requiredVersion, version)) typeof console !== "undefined" && console.warn && console.warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
/******/    return get(scope[key][version]);
/******/   };

這裏面其實在是 webpack_require.S 尋找適合的版本,在這裏是會取最高的 react 版本。

其實這裏不一定是調用 getSingletonVersion方法的,取決於我們在配置 shared 的時候如何配置。

app1/webpack.config.js

new ModuleFederationPlugin({
  ...
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

這裏我們配置了 singleton: true,所以才調用 getSingletonVersion方法,如果配置了requiredVersion的話,則會調用 findValidVersion方法,會去尋找特定的版本。

app2/webapck.config.js

new ModuleFederationPlugin({
  ...
  shared: { react: { requiredVersion: '16.13.0' }, 'react-dom': { requiredVersion: '16.13.0' } },
}),

app2/app2RemoteEntry.js

/******/   var loadStrictVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
/******/    var entry = scope && __webpack_require__.o(scope, key) && findValidVersion(scope, key, version);
/******/    return entry ? get(entry) : fallback();
/******/   });

/******/   var moduleToHandlerMapping = {
/******/    "webpack/sharing/consume/default/react/react": () => (loadStrictVersionCheckFallback("default", "react", [4,16,13,0], () => (__webpack_require__.e("vendors-node_modules__react_16_13_0_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.13.0@react/index.js")))))))
/******/   };

比如 app2 配置了 react 需要特定的 16.13.0 版本,那麼它會調用 findValidVersionwebpack_require.S 裏尋找 16.13.0 的版本,而不會像 getSingletonVersion一樣,匹配到最高的版本 16.14.0

總結一下流程,如果應用配置了 shared共享依賴後,那麼依賴了這些共享依賴的模塊,在加載前都會調用 __webpack_require__.I先初始化好共享依賴,使用__webpack_require__.S對象來保存着每個應用的共享依賴版本信息,在每個應用引用共享依賴的時候,根據不同的規則從__webpack_require__.S獲取到適合的共享依賴版本,__webpack_require__.S是應用間共享依賴的橋樑。

應用場景

代碼共享

以前的遇到一個應用需要引用另一個應用的代碼的時候,有三種解法:

  1. 直接複製代碼,鄙視

  2. 建立一個庫存放公用代碼併發布到 npm 上,低效

  3. 使用微前端 MicroApp 異步加載子應用並定位到對應的組件,優雅不成標準

現在可以使用 MF 來解決這個問題,任何一個應用要想暴露組件方法甚至一個,只需要配置一下 exposes 即可,使用方則需要配置一下 remotes 就可以引用另一個應用的暴露屬性。

可以使用同步異步兩種方式來引用,比如有個 optimus應用暴露 ServiceInfo 組件。

同步引用

import ServiceInfo from 'optimus/ServiceInfo';

異步引用

const ServiceInfo = React.lazy(() => import('optimus/ServiceInfo'));

同步引用,頁面 chunk 會等待 optimusRemoteEntry.js下載完成再執行,異步引用,頁面 chunk 下載完成立即執行,然後異步下載 optimusRemoteEntry.js

更爲大膽的用法,optimus應用暴露一個值或方法:

import getServiceTagDage from 'optimus/utils/getServiceTagDage;
import ServiceStatus from 'optimus/ServiceStatus';

這也給業務組件庫的實現提供另一種方式,而且不需要藉助 babel-plugin-import 就可以實現按需加載

比如一個業務組件庫 tracks,它有 PageHeaderAddressEmpty 等組件,它可以像以前一樣正常開發組件,最後只需要在構建文件裏配置一下  ModuleFederationPlugin 即可。

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'tracks',
      filename: 'tracksRemoteEntry.js',
      exposes: {
        './PageHeader': './src/components/PageHeader',
        './Address': './src/components/Address',
        './Empty': './src/components/Empty',
        ...
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ]
}

使用的時候可以根據需要同步或異步加載組件,所以它不僅可以實現按需加載,還可以實現懶加載

import PageHeader from 'tracks/PageHeader';

const PageHeader = React.lazy(() => import('tracks/PageHeader'));

看到這裏,是否覺得 MF 解決了以前很多痛點問題,但這新的開發模式也帶來兩個核心問題。

第一個問題,在引用 Remote 應用的時候,缺乏了類型提示。即使 Remote 應用生成了類型文件,但在 Host 引用它的時候,只是建立一個引用關係,所以根本獲取不到它對應的類型文件。

第二個問題,沒有工具支持多個應用同時啓動、同時開發。在這種開發模式普遍起來後,一個頁面涉及到多個應用的代碼是必然存在的,需要有對應的開發工具來支持。

但是問題都是比較好解決的,可以自行開發對應的工具來解決,但仍期待 Webpack 官方後續能提供標準的方案,理論上解決了第二個問題,第一個問題就迎刃而解了,

公共依賴

公共依賴的處理一直是大家在做性能優化必須考慮的事情,以前主要有兩種解法:

解法一,傳統 webpack externals 方案,提前把需要的公共依賴腳本放置頁面上,暴露全局變量提供應用使用。這種做法的弊端在於所有依賴是全量加載的(Webpack5 可做到按需加載了),而且依賴順序需要人工保證,對於公共依賴有多個版本共存的情況也無法支持。

解法二,微前端的方案,比如它有自己的一套模塊管理,子應用聲明需要的公共依賴,在加載子應用的時候先加載完全部公共依賴方可執行子應用。這種做法其實就是以前 seajs 做的事情,顯示聲明依賴,可靈活控制加載順序,它雖然解決了按需加載依賴管理多個版本共存的問題,但自身的模塊管理並不成標準,無法與社區的其他方案融合,而且需要成套的技術體系來支撐。

那利用 MF 的特性可以怎麼更優雅解決這個問題呢?其實跟微前端方案同樣的思路,只不過應用間的依賴關係以及應用的異步加載全交給 Webpack 去實現了,如下圖所示。

所有公共依賴均可作爲一個應用,子應用依賴公共依賴,公共依賴之間也會相互依賴,比如 ReactDom 依賴 React,Antd 依賴 React 和 ReactDom,比如 React16 作爲一個應用,它可這樣暴露值出去:

index.js

import * as React from 'react';

export default React;

webpack.config.js

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'react16',
      filename: 'react16RemoteEntry.js',
      exposes: {
        './index': './src/index',
      },
    }),
  ]
}

那麼其他引用使用它的時候,則可以這樣子:

app.js

import React from 'react16/index';

const RootComponent = () => {
  return (
    <div class>
      ...
    </div>
  );
};

export default RootComponent;

webpack.config.js

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'optimus',
      remotes: {
        'react16': 'react16@http://{cdnUrl}/react16RemoteEntry.js',
      },
    }),
  ]
}

這樣做的話也有兩個核心問題需要解決:

  1. 依賴別名問題,可以看到上面爲了使用 react 公共依賴,寫了 import React from 'react16/index' ,肯定不能要求使用者這樣寫的,體驗上需要做到無感知地引用。

  2. 性能問題,每個公共依賴一個應用,那麼啓動的時候需要異步下載非常多的資源,因爲每個公共依賴都有一個 reamoteEntry.js 和一個 對應的依賴.js

第一個問題相信比較好解決,只要有約定規範,使用 babel 插件是可以做到自動替換的。

第二個問題目前來看也沒有比較好的辦法,但也有一個折衷的辦法可以把 reamoteEntry.js的數量降至爲只有一個,也就是建一個庫應用存放所有的公共依賴,缺陷就是解決不了依賴有多個版本並存的問題,因爲在庫應用裏裝不了兩個版本的依賴,如果不需要解決多版本的問題,這種方式比較好一點,這也是目前在極致優化本地項目構建速度的時候採取的方案,依賴關係如下圖所示。

總結

從上面的內容,已經知道了如何使用 MF,清楚了它的原理,瞭解了它的應用場景,現在總結一下它的優缺點。

優點

缺點

思考

MF 極致地發揮出模塊動態加載與依賴自動管理的優勢,使得我們對於應用的拆分和代碼的複用有了新的思路。

回到前言,它與微前端方案結合起來的可能性有嗎?答案是必須有的,而且是互補的。MF 專注於在應用間的代碼共享依賴共享,從原生構建上解決模塊之間的依賴關係無可質疑是最適合的,任何一個框架做都不會完美。而微前端更專注於從宏觀角度上構建一套完整的解決方案,如有對應的框架做應用動態模塊加載、生命週期管理、沙箱管理等,有對應的研發平臺做應用的版本管理,有對應的開發工具解決應用本地開發的問題。

從上面的分析也可以看到,即使 MF 的新特性或許能給我們帶來新的思路,解決了以前比較難解的問題,但也存在一些缺陷,而且某些缺陷可能就成爲技術選型的絆腳石。目前還處於相對不穩定、不完善的階段吧,長期來說,相信官方也會持續優化,從這兩年的改變就能看出來,優化還是蠻大的,值得長期關注。但也並不是只能等待官方來解決這些缺陷,因爲這些問題都是可以解決的,要不要在 MF 的基礎上自行解決,這就要考慮投入產出比的問題了。

無論怎樣,MF 絕對是值得長期關注並投入時間去探索,相信它會與微前端很好地結合起來。

Alibaba F2E 阿里巴巴前端官方公衆號

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WCQvPbd_w8P-Tn36Sc0SXQ