浅谈模块联邦

242 阅读7分钟

MF(Module Federation)是什么

Module Federation,中文翻译为"模块联邦",webpack的新功能,模块联邦可以在多个 Webpack 编译产物之间共享模块、依赖、页面甚至应用。模块联邦让Webpack达到了线上Runtime的效果,能让代码直接在项目间利用CDN直接共享,引用者不再需要本地安装Npm包、构建再发布。

官方解释: 多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。

简单说,MF 实际上就是可以把多个无单独依赖的、单独部署的应用合为一个,这听着像是微前端。但是它的场景不止是应用,MF 支持的粒度更细,能做到更多事情。

模块共享的方式

NPM

whiteboard_exported_image.png

如下图所示,正常的代码共享需要将依赖作为 Lib 安装到项目,进行 Webpack 打包构建再上线,如下图: 对于项目APP1 与 APP2, 需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中。

UMD

UMD 是 (Universal Module Definition) 通用模块定义的缩写,UMD 将 commonjs、amd 规范集于一身,并支持导出全局变量使用。

whiteboard_exported_image (1).png

`UMD` 方案存在一些缺陷,其导出的全局变量,容易造成冲突,而且多个 UMD 模块如果相互依赖,那么加载顺序就需要手动去维护,`tree shaking` 也不是很好做。

Git SubmitModule

whiteboard_exported_image (2).png

git 提供的**子模块**做代码复用,任何一个git repo 都可以通过引用其它 repo(子模块)来做模块复用,在主项目中通过`git commit id` 来管理子项目的版本。会带来一些问题,多人协作中子项目版本很容易造成冲突,且每次都需要手动更新子模块引用,并更新之后也需要重新部署发布。

Monorepo

Monorepo 是一种项目代码管理方式,指单个仓库中管理多个项目,可以帮助改善代码共享、简化版本控制并实现更快的开发周期。

whiteboard_exported_image (3).png

这种方式每次更新Common还是需要重新构建发布,也没有实际解决NPM重复安装问题。

Module Federation

主角模块联邦,webpack5的新特性。

whiteboard_exported_image (4).png

可以看到是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。让应用具备模块化输出能力,其实也开辟了一种新的应用形态,即 `“中心应用”`,这个中心应用可以用于在线动态分发 Runtime 子模块,并不直接提供给用户使用。

MF与微前端的对比

MF 解决的问题其实和微前端有些类似,都是将一个应用拆分成多个子应用,每个应用都可以独立开发、部署,但是他们也有一些区别,比如微前端需要一个中心应用(简称基座)去承载子应用,而 MF 不需要,因为任何一个应用都可以作为中心应用,其次就是 MF 可以实现应用之间的依赖共享。MF也可以成为微前端只不过是模块的粒度以及使用场景不同。

MF实践

三个概念

首先,要理解三个重要的概念:

  • Webpack构建(容器):一个独立项目通过Webpack 打包编译而产生资源包。
  • Remote(远程模块): 一个暴露模块供其他Webpack构建消费的webpack构建。
  • Host(宿主模块):一个消费其他remote模块的Webpack构建。

一言蔽之, 一个Webpack构建可以是Remote(即服务的提供方),也可以是Host(即服务的消费方),也可以同时扮演服务提供者和服务消费者,完全看项目的架构。

项目实战

下面我们来看下A/B的代码

项目A的结构目录如下

├─ appA-*
│ ├─ src
│ │ ├─ components
│ │ │ └─ Example1.jsx
│ │ │ └─ Example2.jsx
│ │ ├─ utils
│ │ │ └─ index.js
│ │ └─ app.jsx
│ ├─ index.js
│ ├─ bootstrap.js
│ ├─ package.json
│ ├─ webpack.config.js

项目B的结构目录如下

├─ appB-*
│ ├─ src
│ │ ├─ components
│ │ │ └─ Exposes.jsx
│ │ └─ app.jsx
│ ├─ index.js
│ ├─ bootstrap.js
│ ├─ package.json
│ ├─ webpack.config.js

项目A、B的差异主要在App.js中import的组件不同,两者的index.js、bootstrap.js都是一样的。

现在我们看看在接入Module Federation之前的Webpack配置。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /.(js|jsx)$/,
        exclude: /node_modules/,
        use: ['babel-loader']
      }
    ]
  },
  resolve: {
    extensions: ['*', '.js', '.jsx']
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ],
  devServer: {
    static: {
      directory: path.join(__dirname, 'public'),
    },
    compress: true,
    port: 3367, // appA 3366 | appB 3367
    open: false
  },
};
配置参数

模块联邦本身是一个普通的 Webpack 插件 ModuleFederationPlugin,该插件有几个重要参数:

  • name: 当前应用名,要全局唯一
  • remotes: 用于为其他项目的名称指定映射规则
  • exposes: 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用
  • shared: 可以让远程加载的模块对应依赖改为使用本地项目资源,需要注意的是主项目的shared集合需要包含远程项目的shared
配置exposes/remotes

现在修改webpack配置,引入ModuleFederationPlugin,让项目B引入项目A的Example1和Example2组件和utils方法,并且也让B组件成为Remote让A消费B的Exposes组件。

// appA的配置
const { ModuleFederationPlugin } = require("webpack").container;

new ModuleFederationPlugin({
    // 唯一ID,用于标记当前服务
    name: "appA",
    // 提供给其他服务器加载的文件
    filename: "remoteEntry.js",
    // 需要暴露的模块,引用时候通过`${name}/${expose}`引入
    exposes: {
        "./Example1": "./src/components/Example1",
        "./Example2": "./src/components/Example2",
        "./utils": "./src/utils/index",
    },
    remotes: {
        appB: "appB@http://localhost:3367/remoteEntry.js",
    },
    shared: ['react', 'react-dom']
}),

// appB的配置
const { ModuleFederationPlugin } = require("webpack").container;

new ModuleFederationPlugin({
    name: "appB",
    filename: "remoteEntry.js",
    // 引入appA的服务
    remotes: {
        appA: "appA@http://localhost:3366/remoteEntry.js",
    },
    exposes: {
        './Exposes': './src/components/exposes'
    },
    shared: ['react', 'react-dom']
})

修改A/B项目消费引入的组件。

// appA
import React from 'react';
import Exposes from 'appB/Exposes';

export default function App() {
  return <h1>
    App(A)
    <Exposes />
  </h1>;
}

// appB
import React, { useEffect } from 'react';
import Example1 from 'appA/Example1'
import Example2 from 'appA/Example2'
import { get } from 'appA/utils' // get方法引用了lodash的get方法

export default function App() {
  useEffect(() => { get() }, [])
  return <h1>
    App(B)
    <Example1 count="100" />
    <Example2 />
  </h1>;
}

其中在B项目没有安装lodash的情况下,B项目直接复用了A项目的lodash,解决了需要重复安装依赖问题。

配置shared

除了前面提到的模块引入和模块暴露相关的配置外,还有个shared配置,主要是用来避免项目出现多个公共依赖。使用ModuleFederation时,要配置shared来避免多个项目出现重复依赖的性能问题。将公共依赖配置到shared中,同时确保两个项目都配置了shared。否则,会导致报错。如果不配置shared,项目A会直接使用自己的依赖。

为什么需要多了bootstrap文件

一般在我们的项目中 index.js 作为入口文件里面应该存放的是 bootstrap.js 中的代码,这里却将代码单独抽离出来放到 bootstrap.js 中,同时在 index.js 中使用import('./bootstrap')来异步加载 bootstrap.js,这是为什么呢?

appB/src/bootstrap.js

import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

appB/src/index.js

import('./bootstrap')

如果 bootstrap.js 不是异步加载的话而是直接打包在 index.js 里面,那么import Example1 from 'appA/Example1'就会被立即执行了,但是此时组件的资源根本没有被下载下来,所以就会报错。

如果我们开启了shared功能的话,那么 import React from 'react'这句被同步执行也会报错,这时候还没有初始化好共享的依赖。

bootstrap.js文件也是官方的推荐做法,总结下来大致就是在通过在index.js异步导入bootstrap.js,确保了在初始化你的应用程序代码前,Webpack有足够的时间加载和设置好所有的共享依赖,如果同步执行Webpack没有机会先加载所有必要的共享模块。这个“等待过程”就像是你先告诉Webpack:“我需要这些共享模块,请先确认它们都加载并准备好了”。

微前端轮子

EMP | 下一代微前端构建方案

联邦模块优劣势

优点

  • 简化开发流程且仅需要发布一次就能在所有使用后的项目中生效
  • 支持运行时加载,无需将共享的代码打包进每一个消费方应用,可以减少打包时的代码体积。
  • 能够减少重复第三方依赖。
  • 支持在项目中直接导出某个模块,直接单独打包。

缺点

  • 由于是 webpack5 的新特性,所以一些 webpack4 的项目要使用的话需要先迁移到 webpack5
  • 为了支持加载remote模块对runtime做了大量改造,在运行时要做的事情也陡然增加,会对我们页面的运行时性能造成负面影响。
  • 运行时共享也是一把双刃剑,如何去做版控以及控制共享模块的影响是需要去考虑的问题。
  • 远程模块TS类型提示也是需要考虑的问题。

结语&&参考

  • MF有很多想象空间,值得继续探索和留意。但它并不是万金油,还是需要结合场景去选择。

Module Federation | webpack 中文文档

为什么说 webpack 的 Module Federation 天生是模块级的微前端