探索webpack4与webpack5多项目公共代码复用架构

10,028 阅读17分钟

问题引入

先抛出一个尖锐问题:MPA 多页应用或微前端架构,如何处理页面的公共部分。

之所以说它尖锐,是因为不止我们公司,包括腾讯在内很多国内外一线技术团队都碰到了。

比如这篇博客:腾讯文档的困境

我们拿MPA应用举例,比如菜单部分,传统后端模板渲染时一般会通过

// .NET
 @Html.Partial("Header")

// Java
<%@ include file="header.jsp" %>

引入公共模板,这样在访问页面时会直接渲染公共部分。

但如果是现代化工程(比如 React),可前后端又未分离的MPA项目(页面仍由后端渲染,Dom 渲染交由 React 接管),我们就会将构建后的资源文件拷贝到后端工程里,通过在页面引入 script 与 style 进行渲染。

此时问题就暴露出来了,对于页头这种公共部分,是 React 渲染的组件。

常规做法是将公共部分作为组件直接构建进每个页面级项目,嗯,腾讯文档也是这么做的。

这样做会带来如下缺点:

  • 构建冗余,每个页面级项目构建时都会将其打包进去,无端浪费加载带宽。

比如 Header 部分单独构建体积为 400KB ,那么每个页面级构建结果都会在现有体积上增大 400KB (忽略公共库依赖,假设统一使用 DllReferencePlugin 处理)。没有丝毫夸张的成份,我们 Header 里有很多功能,加上 chunks 之后确实有将近 500KB

  • 如果公共部分做了修改,此时所有引用它的项目全部要重新构建发版!

尤其是对于 Header 这种每个页面都会使用的公共部分而言,只要做一丁点修改,所有页面级应用都必须重新构建发版。


比如下图中腾讯文档的通知中心:

通知中心


在 webpack5 发布之前,这似乎是一个不可能实现的事情!

腾讯文档的前端们也研究过这个问题,但是从文中描述的研究过程来看,主要是针对打包后的 __webpack_require__ 方法中埋入勾子做文章,并且一直没有提出有效的解决方案。

说实话,webpack 底层还是很复杂的,在不熟悉的情况下而且定制程度也不能确定,所以我们也是迟迟没有去真正做这个事情。

—— 摘自《腾讯文档的困境

但是,我们经过一系列的探索,在2019年7月利用 webpack4 现有特性完美解决了这个问题!巧合的是,Wepback 团队在最新的 V5 版本中也新增了 Module-Federation 这个 Feature,用于此场景。

下面开始正式上干货!

webpack4 解决方案

腾讯文档的小伙伴之所以不敢对 __webpack_require__ 动手无非就是因为它太复杂了,怕改动之后引发其它问题。

其实一开始他们的方向就错了,正所谓打蛇打七寸,如果没打中七寸就会引发一系列问题,或者迟迟不敢打。

所以,我们将目光移到”七寸“ 外部扩展(externals) 属性上来看一下(默认各位都已经知道它的作用了)。

正因为它是 webpack 内部(npm + 构建)与外部引用的桥梁,所以我认为在这里动刀子是最恰当不过的!


回顾 externalsumd

回忆一下,我们使用 externals 配置 CDN 第三方库,比如 React,配置如下:

externals: {
  'react-dom': 'ReactDOM',
  'react': 'React'
}

然后我们再看下 React 的CDN引用链接,一般我们使用的是 umd 构建版本,它会兼容 commonjscommonjs2amdwindow 等方案,在我们的浏览器环境中,它会绑定一个 React 变量到 window 上:

JoLAfI.png

externals 的作用在于:当 webpack 进行构建时,碰到 import React from 'react'import ReactDOM from 'react-dom' 导入语句时会避开 node_modules 而去 externals 配置的映射上去找,而这个映射值( ReactDOMReact )正是在 window 变量上找到的。

下面两张图可以证明这一点:

JoO4qU.png

JoXwWR.png

为什么我要花这么多篇幅去铺垫这个 externals 呢?因为这就是桥梁,连接外部模块的桥梁!

让我们大胆的做一个设想:最理想的情况,我的公共部分就一个 Header 组件!假如将它独立构建成一个 umd 包,以 externals 的形式配置,通过 import Header from 'header'; 导入,然后作为组件使用,怎么样?

我做过试验了,没有任何问题!!!

但是,最理想的情况并不存在,概率低到跟中福利彩票差不多。

我们多数情况是这样的:

import { PredefinedRole, PredefinedClient } from '@core/common/public/enums/base';
import { isReadOnlyUser } from '@core/common/public/moon/role';
import { setWebICON } from '@core/common/public/moon/base';
import ErrorBoundary from '@core/common/public/wrapper/errorBoundary';
import OutClick from '@core/common/public/utils/outClick';
import { combine } from '@core/common/entry/fetoolkit';
import { getExtremePoint } from '@core/common/public/utils/map';
import { cookie } from '@core/common/public/utils/storage';
import Header from '@common/containers/header/header';
import { ICommonStoreContainer } from '@common/interface/store';
import { cutTextForSelect } from '@common/public/moon/format';
import { withAuthority } from '@common/hoc';
......

诸如此类的引用方式遍布几十个项目之中,尤其是别名(alias)的使用,更是让引用情况多达几十种!

PS:我们是 monorepo 架构,@core/common 是公共依赖项目,工具方法、枚举、axios实例、公共组件、菜单等都在这里面维护,所以我们才想方设法将这个项目独立构建。

externals 上面的配置方式只支持转换下面这种情况,它只是完全匹配了模块名:

import React from 'react'; => 'react': 'React' => e.exports = React;
import ReactDom from 'react-dom'; => 'react-dom': 'ReactDOM' => e.exports = ReactDOM;

第三方库名称后面是不能跟 / 路径的!比如下面这种就不支持:

import xxx from 'react/xxx';

柳暗花明

我当时认为 webpack 开发人员不太可能在 api 上这么死板,肯定有隐藏入口才对。果不其然!细读了下官方文档,让我找到了一丝端倪:它还支持函数

J5olT0.png

函数的功能在于:可以自由控制任何 import 语句!

我们可以试着在这个函数里打印一下入参 request 的值,结果如下图所示:

J7Sefg.png

所有的 import 引用都打印出来了!所以,我们可以随意操纵 @common@core/common 相关的引用!比如:

    function(context, request, callback) {
        if (/^@common\/?.*$/.test(request) && !isDevelopment) {
          return callback(
            null,
            request.replace(/@common/, '$common').replace(/\//g, '.')
          );
        }
        if (/^@moon$/.test(request) && !isDevelopment) {
          return callback(null, '$common.Moon');
        }
        if (/^@http$/.test(request) && !isDevelopment) {
          return callback(null, '$common.utils.http');
        }
        callback();
      }

这里解释一下,callback 是一个回调函数(这也意味着它支持异步判断),它的第一个参数目的不明,文档没有明说;第二个参数是个字符串,将会去 window 上执行此表达式,比如 $common.Moon,它就会去找 window.$common.Moon

所以,以上代码目的就很明了了:将 @common 替换成 $common, 将引用路径中的 / 替换为 . 改为去 window 上查找。

变量名不允许以 @ 符号开头,所以我将 library 的值换成了 $common

那么,现在构建页面级项目已经可以将公共部分剥离,让它自动去 window 上寻找了,可此时 window 上还没有 $common 对象呢!

独立构建公共项目

首先,上一节末尾,我们的需求很明确,需要构建一个 $common 对象在 window 上,关于这一点我们可以使用 umdwindowglobal 形式进行构建。但是,$common 上要有一系列的子属性,要能根据 import 的路径进行层级设计,比如:

import $http, { Api } from '@http';
import Header from '@common/containers/header/header';
import { CommonStore } from '@common/store';
import { timeout } from '@packages/@core/common/public/moon/base';
import * as Enums2 from '@common/public/enums/enum';
import { Localstorage } from '@common/utils/storage';

我们就需要 $common 具备如下结构:

J7AwTA.png

那么,该如何构建这种层级结构的 $common 对象呢?答案很简单,针对编译入口导出一个相应结构的对象即可!

直接贴代码吧:

// webpack.config.js
    output: {
      filename: "public.js",
      chunkFilename: 'app/public/chunks/[name].[chunkhash:8].js',
      libraryTarget: 'window',
      library: '$common',
      libraryExport: "default",
    },
    entry: "../packages/@core/common/entry/index.tsx",
// @core/common/entry/index.tsx
import * as baseEnum from '../public/enums/base';
import * as Enum from '../public/enums/enum';
import * as exportExcel from '../public/enums/exportExcel';
import * as message from '../public/enums/message';
import commonStore from '../store';
import * as client from '../public/moon/client';
import * as moonBase from '../public/moon/base';
import AuthorityWrapper from '../public/wrapper/authority';
import ErrorBoundary from '../public/wrapper/errorBoundary';
import * as map from '../containers/map';
import pubsub from '../public/utils/pubsub';
import * as format from '../public/moon/format';
import termCheck from '../containers/termCheck/termCheck';
import filterManage from '../containers/filterManage/filterManage';
import * as post from '../public/utils/post';
import * as role from '../public/moon/role';
import resourceCode from '../public/moon/resourceCode';
import outClick from '../public/utils/outClick';
import newFeature from '../containers/newFeature';
import * as exportExcelBusiness from '../business/exportExcel';
import * as storage from '../public/utils/storage';
import * as _export from '../public/utils/export';
import * as _map from '../public/utils/map';
import * as date from '../public/moon/date';
import * as abFeature from '../public/moon/abFeature';
import * as behavior from '../public/moon/behavior';
import * as _message from '../public/moon/message';
import * as http from '../public/utils/http';
import Moon from '../public/moon';
import initFeToolkit from '../initFeToolkit';
import '../containers/header/style.less';
import withMonthPicker from '../public/hoc/searchBar/withMonthPicker';
import withDateRangePickerWeek from '../public/hoc/searchBar/withDateRangePickerWeek';
import withDateRangePickerClear from '../public/hoc/searchBar/withDateRangePickerClear';
import MessageCenterPush from '../public/moon/messageCenter/messageCenterPush';

import { AuthorityBusiness, ExportExcelBusiness, FeedbackBusinessBusiness,
  FilterManageBusiness, HeaderBusiness, IAuthorityBusinessProps,
  IExportExcelBusiness, IFeedbackBusiness, IFilterManageBusinessProps,
  IHeaderBusinessProps, IMustDoBusinessProps, INewFeatureBusinessProps,
  MustDoBusiness, NewFeatureBusiness } from '../business';

import {
  Header, FeedBack, MustDoV1, MustDoV2, Weather,
  withSearchBarCol, withAuthority,
  withIconFilter, withExportToEmail, withSelectExport, withPageTable, withVisualEventLog
} from '../async';

const enums = {
  base: baseEnum,
  enum: Enum,
  exportExcel,
  message
};

const business = {
  exportExcel: exportExcelBusiness,
  feedback: FeedbackBusinessBusiness,
  filterManage: { FilterManageBusiness },
  header: { HeaderBusiness },
  mustDo: { MustDoBusiness },
  newFeature: { NewFeatureBusiness },
  authority: { AuthorityBusiness },
};

const containers = {
  map,
  feedback: FeedBack,
  newFeature,
  weather: Weather,
  header: { header: Header },
  filterManage: { filterManage },
  termCheck: { termCheck },
  mustdo: {
    mustdoV1: { mustDo: MustDoV1 },
    mustdoV2: { mustDo: MustDoV2 },
  }
};

const utils = {
  pubsub,
  post,
  outClick,
  storage,
  http,
  export: _export,
  map: _map
};

const hoc = {
  exportExcel: {
    withExportToEmail: withExportToEmail,
    withSelectExport: withSelectExport
  },
  searchBar: {
    withDateRangePickerClear: withDateRangePickerClear,
    withDateRangePickerWeek: withDateRangePickerWeek,
    withMonthPicker: withMonthPicker,
    withSearchBarCol: withSearchBarCol,
  },
  wo: {
    withVisualEventLog: withVisualEventLog
  },
  withAuthority: withAuthority,
  withIconFilter: withIconFilter,
  withPageTable: withPageTable,
  withVisualEventLog,
  withSearchBarCol,
  withMonthPicker,
  withDateRangePickerWeek,
  withDateRangePickerClear,
  withSelectExport,
  withExportToEmail,
};

export default {
  enums,
  utils,
  business,
  containers,
  hoc,
  initFeToolkit,
  store: commonStore,
  Moon: Moon,
  wrapper: {
    authority: AuthorityWrapper,
    errorBoundary: ErrorBoundary,
  },
  public: {
    enums,
    hoc,
    moon: {
      date,
      client,
      role,
      MessageCenterPush,
      resourceCode,
      format,
      abFeature,
      behavior,
      message: _message,
      base: moonBase,
    }
  }
};

代码虽然有些长,但是没有任何阅读难度。我们的目的就是构建这么一个导出对象,它的层级结构穷举了所有的 import 路径可能性!

而且我们一旦新增了公共文件给其它项目使用,就必须维护进这个文件,因为它才是真正的入口!

这个文件这么长,一方面是因为公共功能确实非常多,另一方面也是因为我们使用了 webpack 的 alias 功能,导致引用方式五花八门,穷举出来的可能性稍微有点多(比如 withSearchBarCol 就有两种导入方式,所以结构里面出现了两次)。所以,大家如果要使用这套方案,建议定个规范控制一下比较好。

组合使用

公共部分独立构建完成了,页面应用也将它们抽离了,那么如何配合使用呢?

J7nwTg.png

直接按顺序引用即可!

如何调试

有细心的童鞋可能就会问了,这样子页面应用引用的是打包后的 public.js,实际开发的时候开发环境怎么调试呢?

J7uJN4.png

页面应用构建或运行时,我加了 isDevelopment 变量去控制,只有构建生产环境时才抽离。否则直接调用 callback() 原样返回,不作任何操作。

这样,在开发环境写代码的时候,实际引用的还是 node_modules 下的本地项目。

对于 monorepo 构架的本地项目依赖, lerna 建立的是软连接。

其实,能用 webpack4 现有特性做到这程度,还是很不容易的,毕竟人家国内外一线技术团队都为这事头疼了好几年呢!

接下来,让我们来看看 webpack5 这个让他们眼前一亮的解决方案吧!

webpack5 解决方案

Module Federation

webpack5 给我们带来了一个内置 plugin: ModuleFederationPlugin

作者对它的定义如下:

Module federation allows a JavaScript application to dynamically load code from another application — in the process, sharing dependencies, if an application consuming a federated module does not have a dependency needed by the federated code — Webpack will download the missing dependency from that federated build origin.

Module Federation 使 JavaScript 应用得以从另一个 JavaScript 应用中动态地加载代码 —— 同时共享依赖。如果某应用所消费的 federated module 没有 federated code 中所需的依赖,Webpack 将会从 federated 构建源中下载缺少的依赖项。

术语解释

几个术语

  • Module federation: 与 Apollo GraphQL federation 的想法相同 —— 但适用于在浏览器或者 Node.js 中运行的 JavaScript 模块。

  • host:在页面加载过程中(当 onLoad 事件被触发)最先被初始化的 Webpack 构建;

  • remote:部分被 “host” 消费的另一个 Webpack 构建;

  • Bidirectional(双向的) hosts:当一个 bundle 或者 webpack build 作为一个 host 或 remote 运行时,它要么消费其他应用,要么被其他应用消费——均发生在运行时(runtime)。

  • 编排层(orchestration layer):这是一个专门设计的 Webpack runtime 和 entry point,但它不是一个普通的应用 entry point,并且只有几 KB。

配置解析

先列出使用方式给大家看一下吧,待会儿我们再深挖细节:

// app1 webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
...
plugins: [
   new ModuleFederationPlugin({
      name: "app1",
      library: { type: "var", name: "app1" },
      remotes: {
        app2: "app2"
      },
      shared: ["react", "react-dom"]
    }),
]

// app1 App.tsx
import * as React from "react";
import Button from 'app2/Button';

const RemoteButton = React.lazy(() => import("app2/Button"));
const RemoteTable = React.lazy(() => import("app2/Table"));

const App = () => (
  <div>
    <h1>Typescript</h1>
    <h2>App 1</h2>
    <Button />
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
      <RemoteTable />
    </React.Suspense>
  </div>
);

export default App;

// app2 webpack.config.js
...
plugins: [
  new ModuleFederationPlugin({
      name: "app2",
      library: { type: "var", name: "app2" },
      filename: "remoteEntry.js",
      exposes: {
        Button: "./src/Button",
        Table: "./src/Table"
      },
      shared: ["react", "react-dom"]
    })
]

这里演示了如何在 app1 中使用 app2 共享的 ButtonTable 组件。

稍微解释下这几个配置项的意义:

  • ModuleFederationPlugin 来自于 webpack/lib/container/ModuleFederationPlugin,是一个 plugin

  • 不论是 host 或是 remote 都需要初始化 ModuleFederationPlugin 插件。

  • 任何模块都能担当 hostremote 或两者同时兼具。

  • name 必填项,未配置 filename 属性时会作为当前项目的编排层( orchestration layer )文件名

  • filename 可选项,编排层文件名,如果未配置则使用 name 属性值。

  • library 必填项,定义编排层模块结构与变量名称,与 outputlibraryTarget 功能类似,只不过是只针对编排层。

  • exposes 可选项(共享模块必填)对外暴露项,键值对,key 值为 app1 (被共享模块)中引用 import Button from 'app2/Button'; 中后半截路径,value 值为 app2 项目中的实际路径。

  • remote 键值对,含义类似于 externalkey 值为 import Button from 'app2/Button'; 中的前半截,value 值为 app2 中配置的 library -> name,也就是全局变量名。

  • shared 共享模块,用于共享第三方库。比方说 app1 先加载,共享 app2 中某个组件,而 app2 中这个组件依赖 react。当加载 app2 中这个组件时,它会去 app1shared 中查找有没有 react 依赖,如果有就优先使用,没有再加载自己的( fallback

最后在 app1 中的 index.html 中引入

    <script src="http://app2/remoteEntry.js"></script>

即可。

有了以上这些配置, app1 中便可以自由的引入并使用 app2/Buttonapp2/Table 了。

构建文件剖析

那么,ModuleFederationPlugin 是怎么实现这个神奇的黑魔法的呢?

答案就在以下这段构建后的代码中:

__webpack_require__.e = (chunkId) => {
 	return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
 		__webpack_require__.f[key](chunkId, promises);
 		return promises;
 	}, []));
 };
__webpack_require__.e(/* import() */ "src_bootstrap_tsx").then(__webpack_require__.bind(__webpack_require__, 601));

这是 app1 的启动代码,__webpack_require__.e 为入口,查找 src_bootstrap_tsx 入口模块依赖,去哪查找?

Object.keys(__webpack_require__.f).reduce((promises, key) => {
 	__webpack_require__.f[key](chunkId, promises);
 	return promises;
 }, [])

这里遍历了 f 对象上所有的方法。

下面贴出了 f 对象上绑定的所有三个方法 overridables remotes j

/******/ 	/* webpack/runtime/overridables */
/******/ 	(() => {
/******/ 		__webpack_require__.O = {};
/******/ 		var chunkMapping = {
/******/ 			"src_bootstrap_tsx": [
/******/ 				471,
/******/ 				14
/******/ 			]
/******/ 		};
/******/ 		var idToNameMapping = {
/******/ 			"14": "react",
/******/ 			"471": "react-dom"
/******/ 		};
/******/ 		var fallbackMapping = {
/******/ 			471: () => {
/******/ 				return __webpack_require__.e("vendors-node_modules_react-dom_index_js").then(() => () => __webpack_require__(316))
/******/ 			},
/******/ 			14: () => {
/******/ 				return __webpack_require__.e("node_modules_react_index_js").then(() => () => __webpack_require__(784))
/******/ 			}
/******/ 		};
/******/ 		__webpack_require__.f.overridables = (chunkId, promises) => {
/******/ 			if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/ 				chunkMapping[chunkId].forEach((id) => {
/******/ 					if(__webpack_modules__[id]) return;
/******/ 					promises.push(Promise.resolve((__webpack_require__.O[idToNameMapping[id]] || fallbackMapping[id])()).then((factory) => {
/******/ 						__webpack_modules__[id] = (module) => {
/******/ 							module.exports = factory();
/******/ 						}
/******/ 					}))
/******/ 				});
/******/ 			}
/******/ 		}
/******/ 	})();

/******/ 	/* webpack/runtime/remotes loading */
/******/ 	(() => {
/******/ 		var chunkMapping = {
/******/ 			"src_bootstrap_tsx": [
/******/ 				341,
/******/ 				980
/******/ 			]
/******/ 		};
/******/ 		var idToExternalAndNameMapping = {
/******/ 			"341": [
/******/ 				731,
/******/ 				"Button"
/******/ 			],
/******/ 			"980": [
/******/ 				731,
/******/ 				"Table"
/******/ 			]
/******/ 		};
/******/ 		__webpack_require__.f.remotes = (chunkId, promises) => {
/******/ 			if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/ 				chunkMapping[chunkId].forEach((id) => {
/******/ 					if(__webpack_modules__[id]) return;
/******/ 					var data = idToExternalAndNameMapping[id];
/******/ 					promises.push(Promise.resolve(__webpack_require__(data[0]).get(data[1])).then((factory) => {
/******/ 						__webpack_modules__[id] = (module) => {
/******/ 							module.exports = factory();
/******/ 						}
/******/ 					}))
/******/ 				});
/******/ 			}
/******/ 		}
/******/ 	})();

/******/ 	/* webpack/runtime/jsonp chunk loading */
__webpack_require__.f.j = (chunkId, promises) => {
  ...
/******/ 	})();

最后一个 f.j 方法就不贴细节了,是 wepback4 时代就有的 jsonp 加载。

我们主要关注 f.remotesf.overridables 两个 webpack5 新增的方法。Zack Jackson (作者)选择在这儿动刀子,确实很精妙。与 external 不同(external 是构建时与外界的联系入口) ,这儿是构建后与外界联系的入口。

我们待会儿就能看到,实际上真正跟外界打交道的方式与我上一节在 webpack4 中探讨的方式一模一样,都是通过全局变量去打通引用。

先说下上段代码中 reduce 的作用:它主要是遍历上面这三个方法,挨个去查找某依赖是否存在

overridables

shared 公共第三方依赖, reactreact-dom 等公共依赖会有此处进行解析。app1 在构建时,会独立构建出这两个文件,app2 里的 exposes 模块在加载时会优先查找 app1 下的 shared 依赖,若有,则直接使用,若无,则使用自身的。

remotes

remotes 依赖,会将配置中的 remotes 键值对生成在 idToExternalAndNameMapping 变量中,然后最关键的一点在于:

YZNCTK.png

YZpdmT.png

贴两张图,我们来一一分析:

首先,前面说会 __webpack_require__.e 会挨个查找 overridables remotes j 三个方法,当查找到 remotes 时会如上图所示,进入 remotes 方法。

此时的 chunkId 变量值是 src_bootstrap_tsx,那么,首层会遍历 341980 ,然后通过这两个值,查找 idToExternalAndNameMapping ,从而找到 341 的值为 [731, "Button"]980 的值为 [731, "Table"]

图中高亮的这行代码 __webpack_require__(data[0]).get(data[1]) 目的就是取 731 这个模块,再调用它的 get 方法,参数为 Button | Table,去取 Button 或 Table 组件。

那么问题来了,这个 731 是什么模块? 它上面为什么会有 get 方法呢?

继续看上面第二张图,我高亮了 731 这个模块,它的内部引用了 907 模块,并 overridereact react-dom 两个模块,指向 14471 (这两个值正好来自于 overridables 方法里定义的 idToNameMapping 映射)。

907 模块正是引用了全局变量 app2

为什么 app2 这个变量上会存在 get 方法呢?我们构建 app2 时可并没有定义这个方法,让我们移步来看下 app2 的构建结果:

YZu8SK.png

点开 remoteEntry.js ,答案揭晓:

YZuaTA.png

ModuleFederationPlugin 会在编排层上定义两个方法 getoverride,其中:

get 用于查找自身的 moduleMap 映射(来自于 exposes 配置),正是这个全局变量 app2 + 它的 get 方法连接了两个毫不相关的模块!

override 则用于查找 shared 第三方依赖,这里也极其精妙,为什么这么说呢?在前文贴的代码中,我们将目光放在 app1 的编排层中,找到 __webpack_require__.O 对象,它定义在 overridables 方法运行时,其初始值为 {},但又在 __webpack_require__.f.overridables 正式执行时是空的。这就使得 app1 在执行时是直接使用的 fallbackMapping (也就是本地自身第三方依赖)。

YZ80HO.png

而前面提到的 731 模块中正好使用 app2 提供的 override 方法将 reactreact-domapp1 中的引用复写到了 app2内部,我们将目光移到 app2 的编排层(所有的编排层代码都是一致的),app2 中的 overridables 就使用了 __webpack_require__.O 中的 reactreact-dom 依赖!

YZJy6I.png

可以看到,app2 中的 override 方法将外部调用传入的 app1 中的第三方依赖复写到了 __webpack_require__.O 变量上!

这也正是作者为何强调几乎没有任何依赖冗余的原因所在:

There is little to no dependency duplication. Through the shared option — remotes will depend on host dependencies, if the host does not have a dependency, the remote will download its own. No code duplication, but built-in redundancy.

截至2010/05/13,发现 webpack5 的这块插件代码还在不停合并新的提交到 master 分支。稍微看了一眼最近两次提交,发现打包层面改动还比较大(配置项暂时没有变化),所以以上打包结果的代码仅供参考,大致明白原理即可,我今天测试的构建结果代码已经不一样了。

总结

ModuleFederationPlugin 给我们带来了无限的想象空间,应用场景很多,例如微前端上微应用的依赖共享,模块共享等。

我能想到的两点缺陷:

  • 其一在于针对要暴露出去的模块需要额外的 exposes 配置(对于本文前一节中我们自身的场景并不合适,entry 导出结构太复杂了),而且必须通知所有使用的模块正确配置;

  • 其二则是本地依赖调试时,在配置了 npm link 或 lerna 本地依赖之后,还需要针对 remotes 配置同名的 webpack aliastsconfig paths,略有些繁琐。

但是,将 wepback4webpack5 这两种解决方案结合起来之后按场景使用,就近乎完美了!

噢,忘了提一嘴,使用了这两种方案之后,编译性能提升非常大,因为公共部分直接跳过,不必再进行编译了;而针对分离的共享文件也可以做缓存,加载性能也随之提升了。数据就不贴了,各自的应用场景不同,心中明了即可。

参考资料

Webpack 5 Module Federation: A game-changer in JavaScript architecture