前言
一直在听说 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
从消费者和生产者的角度看 Container,Container 又可被称作 Host 或 Remote。
- Host:消费方,它动态加载并运行其他 Container 的代码。
- Remote:提供方,它暴露属性(如组件、方法等)供 Host 使用
可以知道,这里的 Host 和 Remote 是相对的,因为 一个 Container 既可以作为 Host,也可以作为 Remote。
Shared
一个 Container 可以 Shared 它的依赖(如 react、react-dom)给其他 Container 使用,也就是共享依赖。
使用实践
下面以一个简单的例子来介绍一下如何使用 MF 的功能。
效果演示
有两个应用分别为 app1
和 app2
,app2
共享它的 Hello 组件给 app1
使用,它们共享一份 react 和 react-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 组件的代码,并且只下载了 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
当前应用的别名,当应用作为 Remote 给 host 使用的时候,作为引用前缀,import xx from name/expose。
filename
当前应用作为 Remote 给 Host 使用的时候,提供的远程模块入口文件名,比如上面 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),value 为 Remote 应用的资源地址,使用 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 则为该三方依赖的属性配置项。常用的有 singleton 或requiredVersion。
- singleton:是否开启单例模式,如果开启的话,共享的依赖则只会加载一次(优先取版本高的)。
- requiredVersion:指定共享依赖的版本。
比如 singleton 为 true,app1
的 react 版本为 16.13.0,app2
的 react 版本为 16.14.0,那么 app1
和 app2
将会共同使用 16.14.0 的 react 版本,也就是 app2
提供的 react。
如果这时 app1
配置的 react 版本 requiredVersion 为 16.13.0,那么 app1
将会使用 16.13.0,app2
将会使用 16.14.0,相当于它们都没有共享依赖,各自下载自己的 react 版本。
工作原理
从上面的一个简单例子可以快速知道 MF 的使用方法,下面来介绍下具体的工作原理。
这部分内容有点枯燥,如果不想了解的话可快速跳过这一节,如果继续了解的话,建议对照着运行代码来查看
构建上有什么不同?
在没有使用 MF 之前,app1
和 app2
的构建如下:
使用 MF 之后,对应的构建如下:
对比两张图,我们可以看出打包文件发生了变化,在新的打包文件中,我们发现新增了 remoteEntry-chunk
、shared-chunk
、expose-chunk
以及 async-chunk
。
其中remoteEntry-chunk
、shared-chunk
、expose-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.js
、app2RemoteEntry.js
。
shared-chunk
是当前应用开启了 shared(共享依赖)功能后生成的,比如 shared 指定共享 react 和 react-dom,那么在构建的时候 react 模块和 react-dom 模块会被分离为新的 shared-chunk
,比如vendors-node_modules__react_16_14_0_react_index_js.js
和 vendors-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
里是这样引用 app2
的 Hello 组件的,背后发生了什么呢?
我们来看看这段代码的构建结果:
可以看到 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
,也就是我们在配置 app1
ModuleFederationPlugin 的时候指定的 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) });
它暴露了 get
和 init
方法,我们回到上面 __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
模块。