前端闲聊系列(5):微前端 和 Module Federation

901 阅读5分钟

本文不会对太多概念展开,具体可参考这里

为什么要用微前端

编程中的很多问题都可以用分治法来解决,比如归并排序,比如react中的组件,都是将复杂问题简化成多个简单部分然后解决后组合,最终完成要解决的问题。

微前端使用的场景也一样,要么是一个很大且难维护的应用需要按照一定依据拆开单独维护和发布(但仍然是一个应用),要么是本来单独的老项目不想重新开发,而要嵌入到当前的应用中。
总而言之,微前端方案解决的就是拆分和合并的问题。

目前的解决方案

使用npm包

这种是打包时合并,区别于其他的运行时合并,这种方式升级时需要将所有依赖的项目重新打包,不适合解决当前问题。

采用路由分发

即在不同路由时访问不同单独部署的应用,传统的多页面应用可以归为此类,或者在多个spa项目间路由。
这种方式在跳转时需要刷新,但也是改动最小风险最低的。

使用iframe

iframe是浏览器提供的隔离不同应用的方案,但为了安全隔离的程度太高了,可控性差,比如url不同步,浏览器刷新,iframe url状态丢失,前进后退按钮无法使用,其他比如弹窗全屏,通信问题、加载性能问题等。

qiankun 和 single-spa

其中前者是后者的封装,就是在各个子应用在全局变量上挂生命周期,然后在路由切换的时机去执行它们,从而切换不同应用,这种方案比较常见,但是又要引入新的一套方案,修改成本比较大。

Module Federation

看标题就知道这是本文的主角,不同于前面三种应用级别的拆分,这是webpack提供的组件级别的拆分,在目前项目中选择这种方案的原因是

  • 能满足目前的项目需要,主要是将当前逐渐复杂的项目的较大的新业务拆出去开发和部署
  • 修改较小
  • 有webpack背书,本身也对webpack比较熟悉

后面会对这个方案做一下深入介绍

Module Federation

这种方式就是通过全局变量,可以运行其他应用暴露出来的bundle,比如某个函数或组件。

工作细节

在这个架构中,不同应用分为host和remote,即host可以使用引用remote暴露的bundle,每个应用都可以是remote或host,也可以同时是两者。
remote可以通过share字段指定共享模块,当host使用时会先检查host本身有没有,如果有则用本身的,否则去下载remote的bundle。

比如以下配置,其中app1是host,app2是remote

 new ModuleFederationPlugin({
      name: "app1",
      remotes: {
        app2: `app2@localhost:3031}`,
      },
      shared: { react: { singleton: true }, "react-dom": { singleton: true } },
    }),
    new ModuleFederationPlugin({
      name: "app2",
      filename: "remoteEntry.js",
      exposes: {
        "./Button": "./src/Button",
      },
      shared: { react: { singleton: true }, "react-dom": { singleton: true } },
    }),

其中app2会生成一个remoteEntry.js被app1应用引用,其代码大致如下

//暴露全局变量app2
var app2
//用来映射expose的模块
var moduleMap = {
	"./Button": () => {
		return Promise.all([__webpack_require__.e(199), __webpack_require__.e(131)]).then(() => (() => ((__webpack_require__(80179)))));
	}
};
//init,被host用于将remote模块注入

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);
};
//get,用于在host获取remote expose的模块
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;
};

__webpack_require__.d(exports, {
	get: () => (get),
	init: () => (init)
});

在使用get获取具体的模块时使用了__webpack_require__.e,相关源码如下

(() => {
        __webpack_require__.f = {};
       //在这里定义用来获取对应chunk
        __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__.f相关的方法是consumes
__webpack_require__.f.consumes = function(chunkId, promises) {

 if(__webpack_require__.o(chunkMapping, chunkId)) {
 	chunkMapping[chunkId].forEach(function(id) {
 //如果host已经有则直接使用,否则去remote安装
 		if(__webpack_require__.o(installedModules, id)) return promises.push(installedModules[id]);
 		var onFactory = function(factory) {
 			installedModules[id] = 0;
 			__webpack_modules__[id] = function(module) {
 				delete __webpack_module_cache__[id];
 				module.exports = factory();
 			}
 		};
 		try {
 			var promise = moduleToHandlerMapping[id]();
 			if(promise.then) {
 				promises.push(installedModules[id] = promise.then(onFactory).catch(onError));
 			} else onFactory(promise);
 		} catch(e) { onError(e); }
 	});
 }
}

  1. host加载自己的bundle main.js,其中使用jsonp加载对应remote提供的remoteEntry.js
  2. 在remote中暴露了全局变量,host将remote注入后可以获取对应模块,其中的共享模块使用前会检查自身有没有,如果没有再去加载remote的内容

注意事项

以下是使用过程中遇到的问题

远程模块的类型怎么确定

可以参考这个issue

我这边因为使用了yarn workspace,使用的方法是修改tsconfig

 "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "host/*": ["../admin-main/src/*"]
    }
  },

如果想显式获取对应类型,可以使用typeof import(...)

怎么共享状态

host可以向remote传props,可以是常规value,也可以是store,然后注入新的reducer,参考这个demo

注意context不能传到remote组件中。

样式和全局变量怎么隔离

不能隔离,如果是使用的antd,可以修改前缀prefixCls

注意对remote模块加载错误进行错误处理

要么使用import()要么使用error boundary

不要单独创建runtime chunk

见这个issue

没必要也不要自定义splitChunks

见这个issue

会造成react fast refresh无效

如果import了远程资源,会在修改文件后不能应用对应修改,对策是当修改当前应用时,使用babel插件将远程资源替换成了一个本地模块,具体用法可以见下一篇文章。

参考