一文看透 Module Federation 下

简介: 一文看透 Module Federation

它们如何共享依赖?

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 保存的信息。
图片.png
其中 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))

我们再看看 app2app2RemoteEntry.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!由于这是引用关系,所以 app1app2共用了一个的 sharedScope

这里注意的是 app2 也调用了自己的 __webpack_require__.I,也会 register 自己的共享依赖,那么最终的 webpack_require.S 会是怎样呢?

如果 app2也是使用16.14.0 版本的 react 的话,那么 webpack_require.S 是不变的,还是跟上面 app1的一样,如果 app2使用的是 16.13.0 版本的 react  的话,那么会增加一个版本信息。
图片.png

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 去实现了,如下图所示。

图片.png

所有公共依赖均可作为一个应用,子应用依赖公共依赖,公共依赖之间也会相互依赖,比如 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 className="atom-app">
      ...
    </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的数量降至为只有一个,也就是建一个库应用存放所有的公共依赖,缺陷就是解决不了依赖有多个版本并存的问题,因为在库应用里装不了两个版本的依赖,如果不需要解决多版本的问题,这种方式比较好一点,这也是目前在极致优化本地项目构建速度的时候采取的方案,依赖关系如下图所示。

图片.png


总结


从上面的内容,已经知道了如何使用 MF,清楚了它的原理,了解了它的应用场景,现在总结一下它的优缺点。

优点

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

缺点

  • 为了实现依赖共享,资源需要各种异步加载,可能会对页面的性能造成负面影响。
  • 依赖的远程应用需要显式配置其资源路径,存在版本控制的话,存在和 NPM 包管理一样的问题。
  • 引用远程应用模块的时候,没有类型提示,存在代码质量问题。
  • 缺乏官方工具支持多个应用一起启动、一起开发


思考


MF 极致地发挥出模块动态加载与依赖自动管理的优势,使得我们对于应用的拆分和代码的复用有了新的思路。

回到前言,它与微前端方案结合起来的可能性有吗?答案是必须有的,而且是互补的。MF 专注于在应用间的代码共享依赖共享,从原生构建上解决模块之间的依赖关系无可质疑是最适合的,任何一个框架做都不会完美。而微前端更专注于从宏观角度上构建一套完整的解决方案,如有对应的框架做应用动态模块加载、生命周期管理、沙箱管理等,有对应的研发平台做应用的版本管理,有对应的开发工具解决应用本地开发的问题。

从上面的分析也可以看到,即使 MF 的新特性或许能给我们带来新的思路,解决了以前比较难解的问题,但也存在一些缺陷,而且某些缺陷可能就成为技术选型的绊脚石。目前还处于相对不稳定、不完善的阶段吧,长期来说,相信官方也会持续优化,从这两年的改变就能看出来,优化还是蛮大的,值得长期关注。但也并不是只能等待官方来解决这些缺陷,因为这些问题都是可以解决的,要不要在 MF 的基础上自行解决,这就要考虑投入产出比的问题了。

无论怎样,MF 绝对是值得长期关注并投入时间去探索,相信它会与微前端很好地结合起来。

相关文章
|
7月前
|
前端开发 JavaScript
CommonJS 和 ES6 Module:一场模块规范的对决(下)
CommonJS 和 ES6 Module:一场模块规范的对决(下)
CommonJS 和 ES6 Module:一场模块规范的对决(下)
|
7月前
|
JavaScript 前端开发 开发者
CommonJS 和 ES6 Module:一场模块规范的对决(上)
CommonJS 和 ES6 Module:一场模块规范的对决(上)
|
JavaScript 前端开发 编译器
CommonJS与ES6 Module的本质区别
文章主要讨论了CommonJS和ES6 Module两种JavaScript模块系统的核心区别,包括动态与静态解决依赖方式,值拷贝与动态映射,以及如何处理循环依赖的问题。
243 0
|
JavaScript 前端开发
每天3分钟,重学ES6-ES12(十八)ES Module(二)
每天3分钟,重学ES6-ES12(十八)ES Module
84 0
|
JavaScript 前端开发
每天3分钟,重学ES6-ES12(十八)ES Module(一)
每天3分钟,重学ES6-ES12(十八)ES Module
87 0
|
前端开发 JavaScript
每天3分钟,重学ES6-ES12系列文章汇总
每天3分钟,重学ES6-ES12系列文章汇总
70 0
|
存储 JSON 安全
es学习笔记1-es概念
es学习笔记1-es概念
91 0
|
缓存 JavaScript 算法
每天3分钟,重学ES6-ES12(十八) CJS
每天3分钟,重学ES6-ES12(十八) CJS
96 0
|
前端开发 JavaScript 计算机视觉
Module Federation最佳实践
Module Federation[1]官方称为模块联邦,模块联邦是webpack5支持的一个最新特性,多个独立构建的应用,可以组成一个应用,这些独立的应用不存在依赖关系,可以独立部署,官方称为微前端。
640 0
Module Federation最佳实践
|
前端开发 容器
一文看透 Module Federation 上
一文看透 Module Federation
1429 2
一文看透 Module Federation 上