Module Federation 你的浪漫我来懂

381次阅读  |  发布于3年以前

前言 我们在实际开发中,经历过许多次的模块共享的场景。最常见的场景例如我们将代码封装后根据版本和环境的不同发布到公共平台或者私有平台,供不同项目进行使用,npm 工程化就是其中最日常的实践。

【通关目标: 在页面中插入轮播图模块】

NPM 方式-share lib

将轮播图代码打包发布到 NPM 包。主项目中通过 package.json 依赖加载到本地进行编译打包。 【biu~通关成功,当前一星】

当投入生产时,多个项目对于被引入的轮播图代码都没有进行共享,他们是各自独立的。如果二次封装的某个模块进行更改并且要求全部同步……亦或者后期迭代或者定制化需求——

小孙 : “啊!!那岂不是要手动去一一修改?” 代码爸爸慈祥一笑,看着二傻子痛苦加班。

Module Federation-share subApp

初初查看到一些资料的时候,脑海中浮现一些远古项目:项目中通过 iframe 引入别的网页作为一个块进行展示,当然这也是微前端的一种实现,但是受 postMessage 通信机制、刷新后退、安全问题、SEO、Cookie、速度慢等影响,目前还有许多难以解决的点,一旦复杂度提升,后期迭代就可能需要更多的成本去维护项目或者妥协功能。

Module Federation 的出现使得多部门协作开发变得更便捷。多个单独的构建形成一个应用程序。这些单独的构建彼此之间不应该有依赖关系,因此可以单独开发和部署它们,它们可以随时被应用,再各自成为新的应用块。官网对于这块概念拆解成Low-level conceptsHigh-level concepts

让我们来结合配置项来更详细了解作者的整个设计过程。

Low-level concepts - 代码中的分子与原子

PS: 这里的引用不是语法中的引用哦

这里小孙想引用一下化学中的分子与原子的关系。这张图要侧重说明的是本地模块远程模块

A container is created through a container entry, which exposes asynchronous access to the specific modules. The exposed access is separated into two steps:

  1. loading the module (asynchronous) 加载模块(异步)
  2. evaluating the module (synchronous) 执行模块(同步)

加载模块将在 chunk 加载期间完成。执行模块将在与其他(本地和远程)的模块交错执行期间完成。

我们来找个例子配合看一下整个设计过程:

一个例子介绍 MF 的常规用法

例子把 App3 的组件引入到 App2 的组件,然后 App1 再引入 App2 的这个二次封装的组件。这个例子还是非常接近目前常见的开发场景。

/* 业务代码如下 */

// App1 webpack-config
new ModuleFederationPlugin({
    // ...other config
    name: "app1",
    remotes: {
      app2: `app2@${getRemoteEntryUrl(3002)}`,
    },
})

// App2 webpack-config
new ModuleFederationPlugin({
    // ...other config
    name: "app2",
    filename: "remoteEntry.js",
    exposes: {
      "./ButtonContainer": "./src/ButtonContainer",
    },
    remotes: {
      app3: `app3@${getRemoteEntryUrl(3003)}`,
    },
})

// App3 webpack-config
new ModuleFederationPlugin({
  // ...other config
  name: "app3",
  filename: "remoteEntry.js",
  exposes: {
    "./Button": "./src/Button",
  },
}),

加载模块相关代码解析

// from App1 的 remoteEntry.js 中__webpack_modules__某个对象的 value
"webpack/container/entry/app3":((__unused_webpack_module, exports, __webpack_require__) => {
  var moduleMap = {
    "./Button": () => {
      return Promise.all([__webpack_require__.e("webpack_sharing_consume_default_react_react-_0085"), __webpack_require__.e("src_Button_js")]).then(() => (() => ((
        __webpack_require__( /*! ./src/Button */
          "./src/Button.js")))));
    }
  };
  // get 方法作用:获取 Scope
  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;
  };
  // init 方法作用:初始化作用域对象 并把依赖存储到 shareScope 中
  var init = (shareScope, initScope) => {
    if (!__webpack_require__.S) return;
    var oldScope = __webpack_require__.S["default"];
    var name = "default"
    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)
  });
})


// App main.js 后面有详细代码 这边简单介绍一下就是一个 JSONP 的下载
__webpack_require__.I = (name, initScope) => {})

/* 在消费模块执行的操作 from consumes   */
// 确认好 loaded 以后调用原子的 Scope
var get = (entry) => {
  entry.loaded = 1;
  return entry.get()
};
// 消费模块再执行原子的异步初始化行为 在这个模块还会处理后面提到的一个疑问 公共模块的版本问题
//__webpack_require__.S[scopeName] 取出 scopeName 对应的 scope
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);
});
  • 执行 init 初始化以后,收集的依赖存储到 shareScope 中,并初始化作用域。
  • 中间会涉及到 版本号处理,关联关系,公共模块等处理,拼数据挂载到__webpack_require__上使用。
  • 调用时通过通过 JSONP 的远程加载模块(异步行为),相关代码如下:
// from App1 main.js
/***/ "webpack/container/reference/app2":
/*!*******************************************************!*\
  !*** external "app2@//localhost:3002/remoteEntry.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("//localhost:3002/remoteEntry.js", (event) => {
  //...这个方法根据 JSONP 加载远程脚本 
 }, "app2");
}).then(() => (app2));

// __webpack_require__.l 定义如下
// 对 IE 和 ES module 单独处理 如果是 ES module,取 module[default]的值
// 这边特别定义 inProgress 去监控多个 url 的回调状态,这段设计挺有意思的
__webpack_require__.l = (url, done, key, chunkId) => {
    if(inProgress[url]) { inProgress[url].push(done); return; }
    var script, needAttach;
    if(key !== undefined) {
     var scripts = document.getElementsByTagName("script");
     for(var i = 0; i < scripts.length; i++) {
      var s = scripts[i];
      if(s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
     }
    }
    if(!script) {
     needAttach = true;
     script = document.createElement('script');

     script.charset = 'utf-8';
     script.timeout = 120;
     if (__webpack_require__.nc) {
      script.setAttribute("nonce", __webpack_require__.nc);
     }
     script.setAttribute("data-webpack", dataWebpackPrefix + key);
     script.src = url;
    }
    inProgress[url] = [done];
    var onScriptComplete = (prev, event) => {
     // avoid mem leaks in IE.
     script.onerror = script.onload = null;
     clearTimeout(timeout);
     var doneFns = inProgress[url];
     delete inProgress[url];
     script.parentNode && script.parentNode.removeChild(script);
     doneFns && doneFns.forEach((fn) => (fn(event)));
     if(prev) return prev(event);
    }
    ;
    var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
    script.onerror = onScriptComplete.bind(null, script.onerror);
    script.onload = onScriptComplete.bind(null, script.onload);
    needAttach && document.head.appendChild(script);
   };
  })();

前面说了单个具象化的加载模块和执行模块的代码,现在说说分子与原子之间的代码关系,如何知晓并加载原子代码:

// 每个分子 main.js 中 例如 App1 只引入了 App2 的
var __webpack_modules__ = ({
 "webpack/container/reference/app2":": ()
  ....
})

// 如果是有原子代码的 查看 remotes loading 模块
// 执行后找到//localhost:3002/remoteEntry.js 的文件 再异步执行里面的原子代码
__webpack_require__.l("//localhost:3002/remoteEntry.js", (event) => {
  if (typeof app2 !== "undefined") return resolve();
  var errorType = event && (event.type === 'load' ? 'missing' :
                            event.type);
  var realSrc = event && event.target && event.target.src;
  __webpack_error__.message = 'Loading script failed.\n(' +
    errorType + ': ' + realSrc + ')';
  __webpack_error__.name = 'ScriptExternalLoadError';
  __webpack_error__.type = errorType;
  __webpack_error__.request = realSrc;
  reject(__webpack_error__);
}, "app2");

// 然后加载相关 chuck 的时候根据枚举进行 get 调用 
var chunkMapping = {"app2/ButtonContainer": ["webpack/container/remote/app2/ButtonContainer"]};
var idToExternalAndNameMapping = {
  "webpack/container/remote/app2/ButtonContainer": ["default","./ButtonContainer","webpack/container/reference/app2"]
};

__webpack_require__.f.remotes = (chunkId, promises) => {
  if(__webpack_require__.o(chunkMapping, chunkId)) {
    chunkMapping[chunkId].forEach((id) => {
      var getScope = __webpack_require__.R;
      if(!getScope) getScope = [];
      // 获取渲染时候的 moduleName
      var data = idToExternalAndNameMapping[id];
      if(getScope.indexOf(data) >= 0) return;
      getScope.push(data);
      if(data.p) return promises.push(data.p);
      var onError = (error) => {
        // 处理错误然后给一个标志数据表示错误 太长不看
        data.p = 0;
      };
      var handleFunction = (fn, arg1, arg2, d, next, first) => {
        // 异步执行方法 太长不看
      }
      var onExternal = (external, _, first) => (external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError());
      // 核心代码 本质是调用 get 方法
      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);
    });
  }
}

// 调用 get 以后 下载下面这个文件再做具象化的处理
// 在打包后的代码中 import 相关的原子模块 异步加载
(self["webpackChunk_nested_app2"] = self["webpackChunk_nested_app2"] || []).push([["src_bootstrap_js"], {
 "./src/App.js": "...",
  "./src/ButtonContainer.js": "...",
  "./src/bootstrap.js":"..."
}])

High-level concepts - 双向共享和推断

前面说了容器的概念,再深入拓展一个过去常有的场景: 暂不考虑抽离公共逻辑的基础上,组件 A 和组件 B 都互相需要移植一部分功能,你刷刷刷复制对应代码过去,后期每次迭代都需要同时更新组件 A 和组件 B 中的对应内容,那如果这个纬度是两个项目呢?

疑问一:例子中的 import("./bootstrap")作为入口是为什么

看看打包后做了什么:

(self["webpackChunk_nested_app2"] = self["webpackChunk_nested_app2"] || []).push([["src_bootstrap_js"], {
 "./src/App.js": "...",
  "./src/ButtonContainer.js": "...",
  "./src/bootstrap.js":"..."
}])

//每个模块大概处理几件事
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    // exports module
    __webpack_require__.r(__webpack_exports__)
    // 处理不同 module 类型之间的差异 如果是 ES module 取这个 default 的值
    __webpack_require__.d(__webpack_exports__, {
      /* harmony export */
      "default": () => (__WEBPACK_DEFAULT_EXPORT__)
      /* harmony export */
    });
    //...具体组件逻辑 或者 import 原子部分的代码~触发后续的回调钩子去初始化 scoped
   // 最后挂载
   react_dom__WEBPACK_IMPORTED_MODULE_2___default().render( /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().createElement(
   _App__WEBPACK_IMPORTED_MODULE_0__.default, null), document.getElementById("root"));  
 }),

It's still a valid a approach to wrap your entry point with import("./bootstrap"). When doing so, make sure to inline the entry chunk into the HTML for best performance (no double round trip).

This is now the recommended approach. The old "fix" no longer works as remotes could provide shared modules to the app, which requires an async step before using shared modules. Maybe we provide some flag for the entry option in future to do this automatically.

文章里写到在开启 MF 中共享模块时,入口采用异步边界可以有效规避掉双重更新造成的性能加载问题。官方文档对此还提供了这种做法的缺陷案例【以下来自官网】:

1.通过 ModuleFederationPlugin 将依赖的 eager 属性设置为 true:

new ModuleFederationPlugin({
    // ...other config
    shared: {
        eager: true,
    }
});  
// webpack beta.16 升级到 webpack beta.17 可能类似报错 Uncaught Error: Module "./Button" does not exist in container.

2.更改 exposes:Uncaught TypeError: fn is not a function

new ModuleFederationPlugin({
  exposes: {
-   'Button': './src/Button'
+   './Button':'./src/Button'
  }
});

// 此处错误可能是丢失了远程容器,请确保在使用前添加它。
// 如果已为试图使用远程服务器的容器加载了容器,但仍然看到此错误,则需将主机容器的远程容器文件也添加到 HTML 中。

疑问二:包的版本选择

在我们目前应用到的许多场景中,就对私有库的自定义组件做过本地的二次封装,由于代码是单向更新的,在移植项目的过程中就存在许多难以规避的问题,Module Federation 通过设置singleton: true 开启公共模块可以一定程度解决这个问题。但是如果两方项目所需的版本号不一致是按照什么依据呢?

// 前提情况 App1 是 host App2 是 remote App1 中引用 App2 的组件
// App1 package.json:
"dependencies": {
  "mf-test-ssy": "^1.0.0"
}
// App2 package.json:
"dependencies": {
  "mf-test-ssy": "^2.0.0"
}

// webpack-config-common 部分:
new ModuleFederationPlugin({
  // ...other config
  shared: { 
    react: { singleton: true }, 
    "react-dom": { singleton: true },
    "mf-test-ssy":{ singleton: true }, 
  },
}),

这里小孙简单写了个 demo 尝试模拟这个问题,以basic-host-remote 案例为基础,自己发布了两个不同版本的 npm 包,分别引入 v1.0.0 和 v2.0.0 查看一下结果。

可以看到 host 展示的 Npm 版本虽然低于 remote 中 Npm 的版本,但是展示的还是 remote 中较高的版本的代码。

然后互换 App1 和 App2 的 npm 版本:

// App1 package.json:
"dependencies": {
  "mf-test-ssy": "^2.0.0"
}
// App2 package.json:
"dependencies": {
  "mf-test-ssy": "^1.0.0"
}

可以看到此时 App2 还是以低版本展示为主,App1 还是以本地的引用版本为主,开启共享的差异性并不大。 共享中的模块请求(from 官网中文站):

  • 只在使用时提供
  • 会匹配构建中所有使用的相等模块请求
  • 将提供所有匹配模块
  • 将从图中这个位置的 package.json 提取 requiredVersion
  • 当你有嵌套的 node_modules 时,可以提供和使用多个不同的版本

如何解决? => 自动推断的设置

packageName 选项允许通过设置包名来查找所需的版本。默认情况下,它会自动推断模块请求,当想禁用自动推断时,请将 requiredVersion 设置为 false。

疑问三:共享模块是什么程度的共享

借此猜测某些库是不是也只会一次实例化,实验继续 UP!! Npm 中的构造函数逻辑更改如下:初始化成功的例子在 window 下挂载上数据,并且每次初始化后打印值递增。两份代码都更新成 V5.0.0,我们看一下效果:

看似没有问题对不对,小孙复检的时候猛然惊醒,这是两个项目,window 各自为政,这个例子这样设计本身就是大错特错。但是没关系,虽然小孙不靠谱,但是 webpack 靠谱呀。

从加载文件到源码

这里先贴会影响打包的业务代码:

/*
** App1 app.js
*/
import React from "react";
import Test from "mf-test-ssy"
// 这里用了 App2 的 button 组件代码
const RemoteButton = React.lazy(() => import("app2/Button"));
const App = () => (
  <div>
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
    </React.Suspense>
  </div>
);
export default App;
/*
** App2 Html-Script 注意这里是编译后动态生成的。
*/
<script defer src="remoteEntry.js"></script>

把 remoteEntry 打包后的代码,把 相关部分截取出来:

/* webpack/runtime/sharing */
//前面暂且忽略一些定义以及判空
//存放 scope
__webpack_require__.S = {};
var initPromises = {};
var initTokens = {};
//初始化 scope,最后把数据拼成一个大对象
__webpack_require__.I = (name, initScope) => {
  if(!initScope) initScope = [];
  // handling circular init calls
  var initToken = initTokens[name];
  if(!initToken) initToken = initTokens[name] = {};
  if(initScope.indexOf(initToken) >= 0) return;
  initScope.push(initToken);
  // only runs once
  if(initPromises[name]) return initPromises[name];
  // creates a new share scope if needed
  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 warn = (msg) => (typeof console !== "undefined" && console.warn && console.warn(msg));
  var uniqueName = "@basic-host-remote/app2";
  //注册共享模块
  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 = [];
  //根据 chunkId 的名称注册共享模块
  switch(name) {
    case "default": {
      register("mf-test-ssy", "6.0.0", () => (__webpack_require__.e("node_modules_mf-test-ssy_index_js").then(() => (() => (__webpack_require__(/*! ./node_modules/mf-test-ssy/index.js */ "./node_modules/mf-test-ssy/index.js"))))));
      register("react-dom", "16.14.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react-_76b1")]).then(() => (() => (__webpack_require__(/*! ./node_modules/react-dom/index.js */ "./node_modules/react-dom/index.js"))))));
      register("react", "16.14.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules_react_index_js"), __webpack_require__.e("node_modules_object-assign_index_js-node_modules_prop-types_checkPropTypes_js")]).then(() => (() => (__webpack_require__(/*! ./node_modules/react/index.js */ "./node_modules/react/index.js"))))));
    }
      break;
  }
  if(!promises.length) return initPromises[name] = 1;
  return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
};
})();

这段代码所做的就是根据配置项将模块生成内部对应的 modules,定义了一个 scope 去存储所有的 module,然后注册了共享模块等操作。全部挂载在__webpack_require__上,这样处理以方便后续 require 的方式引入进来。对应最最最核心的源码:

// 四大天王镇宅 
sharing: {
   // 处理分子原子关系的依赖
  get ConsumeSharedPlugin() {
   return require("./sharing/ConsumeSharedPlugin");
  },
    // 处理 provide 依赖
  get ProvideSharedPlugin() {
   return require("./sharing/ProvideSharedPlugin");
  },
    // 我是入口 让我来调用 并且我实现了共享
  get SharePlugin() {
   return require("./sharing/SharePlugin");
  },
  get scope() {
   return require("./container/options").scope;
  }
},
// from /webpack-master/lib/sharing/SharePlugin.js
class SharePlugin {
 /**
  * @param {SharePluginOptions} options options
  */
 constructor(options) {
  /** @type {[string, SharedConfig][]} */
    // 处理 options 格式 模块二次封装 
  const sharedOptions = parseOptions(...太长不看);
  /** @type {Record<string, ConsumesConfig>[]} */
    // 定义 Host 消费 remote 的信息 后面会根据这个关联去加载前面说的原子的初始化以及 scoped
  const consumes = sharedOptions.map(([key, options]) => ({
   [key]: {
    import: options.import,
    shareKey: options.shareKey || key,
    shareScope: options.shareScope,
    requiredVersion: options.requiredVersion,
    strictVersion: options.strictVersion,
    singleton: options.singleton,
    packageName: options.packageName,
    eager: options.eager
   }
  }));
  /** @type {Record<string, ProvidesConfig>[]} */
    // 核心代码 处理
  const provides = sharedOptions
   .filter(([, options]) => options.import !== false)
   .map(([key, options]) => ({
    [options.import || key]: {
     shareKey: options.shareKey || key,
     shareScope: options.shareScope,
     version: options.version,
     eager: options.eager
    }
   }));
  this._shareScope = options.shareScope;
  this._consumes = consumes;
  this._provides = provides;
 }

 /**
  * Apply the plugin
  * @param {Compiler} compiler the compiler instance
  * @returns {void}
  */
 apply(compiler) {
    // 处理分子原子关系的依赖
  new ConsumeSharedPlugin({
   shareScope: this._shareScope,
   consumes: this._consumes
  }).apply(compiler);
    // 处理 provider 依赖
  new ProvideSharedPlugin({
   shareScope: this._shareScope,
   provides: this._provides
  }).apply(compiler);
 }
}
module.exports = SharePlugin;

总结

每一个分子跟原子的爱恨纠葛终有一个文件去划分好主次,虽然异步加载分离打包,但是爱永不失联。 每一个公共分享的时刻,runtime 在各自心中,就像共同孕育同一个孩子,生了一次不会生第两次。 但是—— 共享模块中 remote 版本大,按照较大的算,如果 remote 版本小,按照我本地说了算。

其他:MF 生态

ExternalTemplateRemotesPlugin

有需求在构建中使用上下文处理处理动态 Url 的,且需要解决缓存失效问题的,可以看一下这个插件。

from https://github.com/module-federation/module-federation-examples/issues/566

  • Dynamic URL, have the ability to define the URL at runtime instead of hard code at build time.
  • Cache invalidation.
// from webpack.config
plugins: [
    new ModuleFederationPlugin({
        //...config
        remotes: {
          'my-remote-1': 'my-remote-1@[window.remote-1-domain]/remoteEntry.js?[getRandomString()]',
        },
    }),
    new ExternalTemplateRemotesPlugin(), //no parameter,
]

参考资料

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8