前言
一直在听说 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
  • Host:消费方,它动态加载并运行其他 Container 的代码。
  • Remote:提供方,它暴露属性(如组件、方法等)供 Host 使用
可以知道,这里的 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>

  );

};


exportdefault
 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>
  )

};


exportdefault
 Hello;

效果如下:


可以看到,因为
app1
 引用了
app2
  的
Hello
组件,在渲染的时候异步下载了
app2
的远程模块入口代码和
Hello
组件的代码,并且只下载了
app1
react
react-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
  • singleton:是否开启单例模式,如果开启的话,共享的依赖则只会加载一次(优先取版本高的)。
  • requiredVersion:指定共享依赖的版本。
比如 singleton trueapp1 的 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 之前,
app1
app2
的构建如下:


使用 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>

  );

};


exportdefault
 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); 
elsereturn
 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__ = 
newError
();

module
.exports = 
newPromise
(
(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"
() =>
 {

returnPromise
.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"
)))));

 }

};

varget
 = (module, getScope) => {

 __webpack_require__.R = getScope;

 getScope = (

  __webpack_require__.o(moduleMap, 
module
)

   ? moduleMap[
module
]()

   : 
Promise
.resolve().then(
() =>
 {

thrownewError
(
'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/[email protected]@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/[email protected]@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/[email protected]@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
 {

/******/varmodule
 = __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/[email protected]@react-dom/index.js */"./node_modules/[email protected]@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/[email protected]@react/index.js */"./node_modules/[email protected]@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 的名称,
react
react-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
varget
 = (module, getScope) => {

 ...

};

var
 init = 
(shareScope, initScope) =>
 {

if
 (!__webpack_require__.S) 
return
;

var
 name = 
"default"
var
 oldScope = __webpack_require__.S[name];

if
(oldScope && oldScope !== shareScope) 
thrownewError
(
"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/[email protected]@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/[email protected]@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/[email protected]@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)) 
typeofconsole
 !== 
"undefined"
 && 
console
.warn && 
console
.warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));

/******/returnget
(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/[email protected]@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'
;


exportdefault
 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
 (

<divclassName="atom-app">
      ...

</div>

  );

};


exportdefault
 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,清楚了它的原理,了解了它的应用场景,现在总结一下它的优缺点。

优点

  • 解决方案与框架无关,提供了一种拆分巨石应用的快速方式。
  • 解决了多个应用间共享代码的问题,一个应用可以很方便共享模块给其他应用使用。
  • 提供了一套依赖共享机制,并且支持多版本的依赖共存
  • 基于 Webpack 的生态,学习成本、改造成本、实施成本都比较低。

缺点

  • 为了实现依赖共享,资源需要各种异步加载,可能会对页面的性能造成负面影响。
  • 依赖的远程应用需要显式配置其资源路径,存在版本控制的话,存在和 NPM 包管理一样的问题。
  • 引用远程应用模块的时候,没有类型提示,存在代码质量问题。
  • 缺乏官方工具支持多个应用一起启动、一起开发
思考
MF 极致地发挥出模块动态加载与依赖自动管理的优势,使得我们对于应用的拆分和代码的复用有了新的思路。
回到前言,它与微前端方案结合起来的可能性有吗?答案是必须有的,而且是互补的。MF 专注于在应用间的代码共享依赖共享,从原生构建上解决模块之间的依赖关系无可质疑是最适合的,任何一个框架做都不会完美。而微前端更专注于从宏观角度上构建一套完整的解决方案,如有对应的框架做应用动态模块加载、生命周期管理、沙箱管理等,有对应的研发平台做应用的版本管理,有对应的开发工具解决应用本地开发的问题。
从上面的分析也可以看到,即使 MF 的新特性或许能给我们带来新的思路,解决了以前比较难解的问题,但也存在一些缺陷,而且某些缺陷可能就成为技术选型的绊脚石。目前还处于相对不稳定、不完善的阶段吧,长期来说,相信官方也会持续优化,从这两年的改变就能看出来,优化还是蛮大的,值得长期关注。但也并不是只能等待官方来解决这些缺陷,因为这些问题都是可以解决的,要不要在 MF 的基础上自行解决,这就要考虑投入产出比的问题了。
无论怎样,MF 绝对是值得长期关注并投入时间去探索,相信它会与微前端很好地结合起来。

关注「Alibaba F2E」微信公众号把握阿里巴巴前端新动向
继续阅读
阅读原文