webpack 5 模块联邦实现微前端疑难问题解决

说明

webpack 5 新增 Module Federation(模块联邦)功能,他可以帮助将多个独立的构建组成一个应用程序,不同的构建可以独立的开发与部署。

借助模块联邦我们可以一定程度的实现微前端

概念

1. 什么是微前端?
  • 微前端将微服务理念扩展到前端开发,一般来讲一个微服务架构中会有多个后端团队开发不同的业务服务,而前端通常只有一个团队,集中维护一个 SPA 单页应用,随着时间累加,前端团队维护的 SPA 会随着业务增长越来越大,变得难以维护(项目启动耗时、CI\CD 耗时等);
  • 微前端可以帮助我们像后端一样将 SPA 应用按照业务拆分为多个可独立维护部署的应用,这样一方面我们可以实现,哪个业务改变就近更新哪个业务的前端;又可以帮助搭建从前端到后端单一业务领域的团队
  • 可以理解为微前端是一种将多个可独立交付的小型前端应用聚合为一个整体的架构风格
2. 什么样的产品适合微前端?
  • 大型前端项目
  • 多个项目间跨应用模块共享
  • 有拆分出多个独立的子系统,独立部署维护的需求
3. 模块联邦 module Federation 能干什么?
  • 模块联邦提供了可以在当前应用中远程加载其他服务器上应用的能力
  • 使用 module Federation 可以实现一个去中心话的应用部署群,每个应用都单独部署,每个应用都可以引用其他应用,也能被其他应用所引用(js 级别)
  • 借助第三方工具可实现跨技术栈开发 (react + vue 2)
  • 可以用来实现前端项目平滑迭代(旧项目是一个应用,新项目是一个应用,两者之间通过远程连接,这样我们可以逐渐丰富 新项目来替换旧项目的内容)
  • 又可以帮助搭建从前端到后端单一业务领域的团队
4. 什么是本地模块与远程模块,什么是 host \ remote?
  • host:消费其他 remote 的 webpack 构建(即使用远程模块的应用)
  • remote: 被 host 消费的 webpack 构建(即提供可用的模块给其他应用使用)
  • host 与 remote 是相对的,一个应用既可以是 host 有可以是 remote,因此 模块联邦实现的微前端又是去中心化的。
  • 本地模块即为我们本地开发时当前项目内的模块
  • 远程模块不属于当前构建,它是在运行时被加载进来的 remote 应用提供的模块
5. 模块联邦的负面影响
  • 运行时加载远程模块等逻辑,可能导致一定的性能问题
  • 本地开发需要开启多个端口的服务,比较麻烦
  • 按需加载第三方依赖比较难实现
  • 比起传统 spa 项目结构上有些复杂
  • 迭代时的版本控制需要更多关注

模块联邦 Api

// webpack.config.js
export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "app_two", // 当前应用名
      filename: "remoteEntry.js", // 外部应用引用当前应用模块时的加载入口
      exposes: {
        Search: "./src/Search" // 输出给外部应用使用的模块
      },
      remotes: {
        appOne: 'appOne@http://localhost:3003/remoteEntry.js' // 当前应用会用到的远程应用地址
      }
      shared: [ // 与远程模块共享的模块,与远程模块共同配置,这样在页面中就只会加载一次这个library, 用来避免重复加载第三方依赖
        "react", 
        "react-dom"
      ]
    })
  ]
};

// /index.js 注意,入口一定要动态引入模块
import('./bootstrap');

// /bootstrap.js
ReactDOM.render(
  <App/>
  document.getElementById('root'),
);

// /App.js 中使用远程应用的模块 import('远程应用/远程应用模块')
const AppOne = React.lazy(() => import('appOne/Button'));

shared 说明


/ **
 *应该在共享范围内共享的模块的高级配置。
 * /
declare interface SharedConfig  {
 / **
  *直接在异步请求后面包含提供的和后备模块。这也允许在初始加载中使用此共享模块。所有可能的共享模块也都需要急切。
  * /
 eager?: boolean;
/ **
  *应提供共享范围的提供的模块。如果在共享作用域中找不到共享模块或版本无效,则还充当回退模块。默认为属性名称。
  * /
 import?: DevTool;
/ **
  *软件包名称,用于从描述文件中确定所需的版本。仅当无法根据请求自动确定包名称时才需要。
  * /
 packageName?: string;
/ **
  *共享范围中来自模块的版本要求。
  * /
 requiredVersion?: DevTool;
/ **
  *在共享范围内的此键下查找模块。
  * /
 shareKey?: string;
/ **
  *共享范围名称。
  * /
 shareScope?: string;
/ **
  *在共享范围内仅允许共享模块的单个版本(默认情况下处于禁用状态)。
  * /
 singleton?: boolean;
/ **
  *如果版本无效,则不接受共享模块(默认为是,如果本地后备模块可用并且共享模块不是单例,否则为no,如果未指定所需的版本,则无效)。
  * /
 strictVersion?: boolean;
/ **
  *所提供模块的版本。将替换较低的匹配版本,但不会替换较高的版本。
  * /
 version?: DevTool;
}

开发中遇到的问题

1. 怎么仿照微服务中的服务发现,实现对不同应用版本的运行时管理?

由于 关于“远程依赖应用的引用”是在 build 打包时,打包到代码中的固定值(url),为了通过文件名区分是否有更新,我们需要给remoteEntry.[contenthash].js 加上 hash,;这个时候如果, “远程依赖应用”有版本更新,那么使用这个“远程依赖应用“的应用也要更新(否则拿到的时是期的资源),如此一来我们 “只发布有改动的应用” 这个目标就没办法达成了。

为了实现这个目标,最好是将 remoteEntry 的确切地址(url)在项目运行时注入,这样就避免了改动一个应用,其他应用也跟着更新的窘境
参考案例

除此之外还可以借助 __webpack_init_sharing__ __webpack_share_scopes__ 实现 “动态远程容器” 参考 通过这套方案我们可以实现:项目线上运行时,动态决定要渲染哪些远程应用模块

2. 模块联邦对 Tree shaking 有什么影响
  • 因为模块联邦中的各个应用是各自打包的,没有办法综合所有应用来做 Tree shaking(仅能各自应用各自 Tree-shaking );
  • 这样会造成 应用间对于某个依赖的冗余引用,例如多个应用都使用了 Antd 的 Button 组件,就会在每个应用中都打包一份 Button 组件
  • 如果把依赖提取为公共依赖,则只能全量引用,同样造成代码体积过大(可以成为公共依赖的都需要是全量的,例如 react react-dom 等);
  • 考虑到以上问题,我们可以使用一个麻烦一点的方案:再增加一个”库应用“,这个应用专门用来做需要 Tree-shaking 依赖的打包,所有关于这个依赖的引用都要指向这个项目中,麻烦点在于需要确定是否能都通过脚本实现自动化追加,以及 TS 项目中类型检查怎么办
  • 注意: 模块联邦中不可使用 webpack 的 module.noParse 来处理工具库的解析,否则会引发模块引用的 报错
3. 模块联邦对 context 使用有什么影响?
  • 使用 context, 我们可以避免通过中间元素来向下传递 props
  • Context 在模块联邦的模块(应用)之间无法自动传递
  • 因此需要在每个应用的入口处重新提供一个 Provider 来将与上层同样的context 数据传递下去
4. 模块联邦对状态管理有什么影响?
  • 与 context 类似,例如 redux 这样的状态管理工具内部也是使用的 context 实现
  • 以 redux 为例,一个 SPA 项目中我们会借助 react-redux 提供的 Provider 来将业务组件包裹,然后通过 connect 将 store 中的状态释放到被包裹的组件(及其子组件)中。
  • 对于 模块联邦中的各个应用来说,即使一个应用充当了另外一个含有 Provider 应用的子组件,store 也不会传递下去,这是因为两个应用是分别构建的。
  • 为了所有模块联邦中的应用都共用一套状态,我们可以在每个应用的顶层都通过 Provider 包裹一下,然后给每个应用都传入 store 这样就可以实现连接了
5. 模块联邦中 Typescript 类型检查怎么用,eslint 会有影响么?

如果我们的项目使用 lerna 做一个monorepo 的仓库,每个子 package 代表着一个 应用,预期这些应用都会用 TS 编写,而且 TS 的配置应该是一样的,所以,我按照如下配置

  • 整个仓库只有最外层会有 tsconfig.json 以及 .eslintrc.js 配置文件,所有子package 中都会使用外层的配置
  • .eslintrc.js 中引用正确的 ts 的配置文件路径即可
  • 因为应用之间的模块引用会是一个类似webpack alias 的形式,引用的远程模块是跨包的,也需要ts 提供的属性提示,所以我们要给 tsconfig.json 中配置一下路径解析,否则 ts 找不到模块位置
  • 如下 如果请求模块 @module/library/antd 则会映射到 /modules/library/antd/index 这个文件上,ts 的 paths 会提供一定的路径解析支持,但是没办法更细致的解析(如:没有办法把 @module/library/antd 解析到 /modules/library/src/antd.ts)因此,我们开发时就不要在每个子 package 中创建 src 文件夹了,而是直接展开写,这样我们就不用在增加远程模块时频繁的改动 tsconfig.json 文件了
  // tsconfig.json 配置
{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
          "@shell/*": ["./shells/*"],
          "@module/*": ["./modules/*"]
        },
    }
}

// remote 模块 /modules/library/antd/index.tsx
export { Button } from 'antd';
export { Row } from 'antd';

// host 中调用 remote 模块
import { Button,Row } from '@module/library/antd';
6. 模块联邦对 code split 有什么影响?
  • Runtime chunk 不会被提取出来了(会造成解析错误)runtimeChunk 用来将运行时代码提取出来,避免因运行时代码改变导致的其他chunk hash 改变,从而影响浏览器缓存的问题。
7. 模块联邦对样式文件有什么要求?
  • 每个普通应用在 build 后都会生成各自的 main-style.css 如果使用了 css-module 则不用担心样式覆盖和引用问题
  • 如果是 global(或者不使用 css-module) 的样式则存在覆盖问题,因此建议使用 css-module (dev 开发和 build 两个环境都会样式覆盖,行为表现一致,这样可以确保,我们开发时什么表现,build 到生成环境后还是什么表现)
  • 如果是 ”库应用“ (例如 antd)的打包则需要注意,收到 babel-plugin-import 插件的影响 在 ”库应用“ 中类似 export {Button} from ‘antd’; 这种写法,会导致 样式文件输出不了 ISSUE
  • 目前没找到更好的方案,只能先用下边的方案来做
// Error1: 会导致不输出样式文件
// export { Button, Row } from 'antd'; 
 
// Error2: 不能直接在 import {Button} 后 export {Button}; webpack 会报错
// import { Button, Row } from 'antd'; 
// export { Button, Row }; 


// Error3: export default 可以实现, 但是使用时不方便
// 例如只能这样使用:
//     import antd from '@module/library/antd';
//     const {Button} = antd;
// export default { Button, Row }; 

// 可实现方案,编写库应用时有点繁琐,但是使用时简单 例: import {Button} from '@module/library/antd';
import { Button as _Button, Row as _Row } from 'antd';
 
export const Button = _Button;
export const Row = _Row;
8. 模块联邦对 路由有什么影响?
  • 基本没什么影响,react-router 的 Hash 路由可以正常使用
9. DLL 与模块联邦结合?
  • 两者可以结合使用
  • 但是因为 DLL 与其他业务代码是分开打包的,使用时又是一起使用的,这样会有一个问题:DLL 打包模块中的 chunkid moduleId与 业务代码打包模块中的 chunkid 重复了,这会导致模块识别报错。解决的方法是将 DLL 打包的 moduleId chunkId 生成方式由 (deterministic => named), 减少重复的可能
10. React 技术栈与 Vue 技术栈结合能否实现
  • 借助 vuera 可以实现在 react 项目中引用 vue 组件,或者在 Vue 项目中引入 React 组件,但是优越 vuera 这个项目缺少维护,目前仅在 vue 2.x 中试验成功。
  • 再结合模块联邦,我们可以实现某个远程应用通过 Vue 技术栈实现(单独一个 仓库维护),这样可以保证其他远程应用的干净
11. 模块联邦对日常 dev 开发会有什么影响?
  • 模块联邦项目中,可能需要有多个应用需要同时启动,联合调试,且需要 HMR 来帮助刷新浏览器,例如:我测试的项目中会有一个 host 主应用,其余有若干个远程应用
  • webpack 支持 MultiCompiler (即多个 webpack 配置文件一起执行)但是 webpack-dev-server 对 MultiCompiler 的支持却不太好
  • 截止 2021.04.07 webpack-dev-server 的最新发行版本还是 v3.x.x ,这个版本有一个问题,就是对 MultiCompiler 支持不好(会导致只有一个 compiler 有 HMR 支持,其他构建没有实时刷新),而 webpack-dev-server@4.0.0-beta.2 虽然支持 MultiCompiler,但是对于模块联邦会出现模块解析失败问题
  • 经过多次尝试最终提供一个解决方案如下
    • 主应用通过 webpack-dev-server@4.0.0-beta.2 来实现源码编译、源码改动监控、编译后文件映射到页面、页面实时刷新
    • 其他应用 直接使用 webpack 的 watch 来实现源码编译、源码改动监控, 至于编译后文件映射到页面、页面实时刷新 这两项则借助注意用的 DevServer 实例顺便实现
    • 注意:非主应用的实时刷新的全量的,主应用的实时刷新是增量的
// 1. 前提:所有构建的 webpacCofnig 都要禁用 webpack.HotModuleReplacementPlugin()因为现阶段的 HMR 只能支持一个构建,多个构建会出错
// webpack.config.js
const cofnig = {
    plugins: [
        // new webpack.HotModuleReplacementPlugin(), // 禁用
    ]
}

// 2. 主应用开启 hmr
// /shells/admin/bootstrap.js 中加入如下内容
if (module.hot) {
  module.hot.accept();
}

// 3. 除主应用外,其他应用通过 MultiCompiler 一起构建, 并通过 webpack watch 监控源码变化实时编译到指定目录(buildPath)
const compiler = webpack([module1Config, module2config, ...]);
compiler.watch({}, (err, stats) => {
  console.log(stats);
});

// 4. 主应用借助 webpack-dev-server 来实现 源码监控、实时编译、文件映射到页面、页面实时刷新
const server = new WebpackDevServer(webpack(hostConfig), {
    transportMode:'ws',
    hot: true,
    // 5. 借助主应用 webpack-dev-server 的 static 配置,将其他应用的构建结果纳入“监控及映射到页面”的范围
    static: [
        {directory: path.join(buildPath, module1Static), publicPath: path.join(publicPath, module1Static)},
        {directory: path.join(buildPath, module2Static), publicPath: path.join(publicPath, module2Static)},
    ],
});

server.listen(customPort, customHost);
  • 遗留问题1:经过如上配置后留下一个问题:webpack-dev-server 提供的 proxy 只能在 主应用中使用,其他应用中没法使用。但是,还使用这个方案的原因是:我们的 proxy mock server 代理应该不会使用 webpack-dev-server 实现,所以这个功能是低概率使用的,而且即使使用也不会上到生成环境,影响不大。
  • 遗留问题2:非主应用的应用由于是通过 webpack 直接构建出来的,其编译结果会存在真实的文件(即在项目中生成一个编译后结果的文件夹,而 webpack-dev-server 会将编译后结果存放到虚拟内存中,虚拟内存要比真正文件读写性能要好)
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐