module federation v2原理

1,533 阅读7分钟

module federation v2原理

前言

我们在实际工作过程中会持续面临以下挑战:

  1. 由于公司业务需求的高速发展,各项目的模块积累成了一座座“大山”,致使项目的构建速度逐渐变慢
  2. 项目之间存在许多具有复用潜力的模块,然而,简单的复制粘贴代码可能会提升未来的维护难度和成本
  3. 在复杂的项目环境中,我们渴望能够将众多项目集成到一体,实现资源的有效共享和缜密复用

在此环境下,module federation应运而生,为我们提供了井然有序的解决方案。

什么是module federation?

module federation官网的描述:

模块联合是一种 JavaScript 应用去中心化的架构模式(类似于服务器端的微服务)。它允许您在多个 JavaScript 应用程序(或微前端)之间共享代码和资源。这可以帮助您:

  1. 减少代码重复
  2. 提高代码可维护性
  3. 降低应用程序的整体大小
  4. 增强应用程序的性能

module federationv1和v2的区别是什么?

Module Federation 2.0 与 Webpack5 内置的 Module Federation 不同,它不仅提供模块导出、加载和依赖共享等核心功能,还提供额外的动态类型提示、Manifest、Federation Runtime 和 Runtime Plugin System。这些特性使得 Module Federation 更适合用作大型 Web 应用程序中的微前端架构。

特点

  • 代码共享、依赖复用
  • 资源清单(Manifest)
  • module federation runtime
  • 运行时插件系统(tapable实现)
  • 动态类型提示
  • Chrome 开发工具
  • Rspack 和 Webpack 支持

基本用法

插件用法

我们可以在webpack中使用插件进行配置,配置方式与v1一致,但是相比于v1,v2能够对远程模块的类型声明进行拉取,并放到本地项目中的src/@mf-types目录下,对ts支持友好,并且将exposeloadRemote逻辑抽离到单独的@module-federation/runtime包中,只要使用的构建工具使用这个包实现对应的逻辑,即可使用。

接下来我们来看下实际使用

导出远程组件

在需要导入远程组件的项目(remote1)中安装@module-federation/enhanced

pnpm i @module-federation/enhanced

配置webpack.config.js

// ...
module.exports = {
  // ...常用webpack配置
  devServer: {
    port: 8081,
  },
  output: {
  // ...
    publicPath: 'http://localhost:8081/'
  },
  plugins: [
    // ...
    new ModuleFederationPlugin({
      filename: 'remoteEntry.js',
      name: pkg.name,
      exposes: {
        './Button': './src/exposes/Button',
      },
      shared: {
        react: {
          singleton: true,
          eager: true,
          requiredVersion: '18.2.0',
        },
        'react-dom': {
          singleton: true,
          eager: true,
          requiredVersion: '18.2.0',
        },
      },
    }),
  ],
};

暴露远程Button组件,例如remote1/src/exposes/Button/index.tsx

import { useState } from 'react';

export default function Button() {
  const [n, setN] = useState(0);
  return (
    <div>
      <h1>remote1-{n}</h1>
      <button onClick={() => setN(n + 1)}>+1</button>
    </div>
  );
}

这时当我们启动开发环境时,会发现remote1/dist下出现两个文件@mf-types.d.ts@mf-types.zip

引入远程组件

在需要引入远程组件的项目(host)中安装@module-federation/enhanced

pnpm i @module-federation/enhanced

配置webpack.config.js

// ...
const ModuleFederationPlugin = require('@module-federation/enhanced').ModuleFederationPlugin;

/**
 * @type {Configuration}
 */
module.exports = {
  // ...常用webpack配置
  devServer: {
    port: 8080,
  },
  plugins: [
    //...
    new ModuleFederationPlugin({
      name: pkg.name,
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/exposes/Button',
      },
      shared: {
        react: {
          eager: true,
          singleton: true,
        },
        'react-dom': {
          eager: true,
          singleton: true,
        },
      },
      remotes: {
        remote1: 'remote1@http://localhost:8081/remoteEntry.js',
      },
    }),
  ],
};

将项目入口文件改为动态引入的方式,例如host/src/index.ts

import('./bootstrap');

编写启动文件host/src/bootstrap.tsx

import App from './App';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);

host/src/App.tsx中使用远程组件

import React, { Suspense, lazy, useState } from 'react';
import Button from 'remote1/Button';
import ReactComponent from './component/ReactComponent';
import * as styles from './Button.module.css';


export default function App() {
  const [n, setN] = useState(0);

  return (
    <div>
      <Suspense fallback="loading">
        <Button />
      </Suspense>
    </div>
  );
}

当我们启动开发环境时,会发现多出一个目录host/@mf-types,这就是federation插件帮我们去拉取的类型声明,这时我们在host/tsconfig.json中添加如下配置就可以享受到远程模块的代码提示了

{
  "compilerOptions": {
    // ...
    "paths": {
      "*": ["./@mf-types/*"]
    }
  }
}

运行时用法(v2新增)

module federation v2允许我们在写运行时代码去加载远程模块,但是远程模块的导出还是交给了构建工具基于@module-federation/runtime去实现,下面我们来改造一下

导出远程组件

与插件导出方式保持一致

引入远程组件

修改host/webpack.config.js

const { Configuration } = require('webpack');
const ModuleFederationPlugin = require('@module-federation/enhanced').ModuleFederationPlugin;
const HtmlWebpackPlugin = require('html-webpack-plugin');
const pkg = require('./package.json');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const { resolve } = require('path');

/**
 * @type {Configuration}
 */
module.exports = {
  // ...常用webpack配置
  plugins: [
    //...
    // new ModuleFederationPlugin({
    //   name: pkg.name,
    //   filename: 'remoteEntry.js',
    //   exposes: {
    //     './Button': './src/exposes/Button',
    //   },
    //   shared: {
    //     react: {
    //       eager: true,
    //       singleton: true, // only a single version of the shared module is allowed
    //     },
    //     'react-dom': {
    //       eager: true,
    //       singleton: true, // only a single version of the shared module is allowed
    //     },
    //   },
    //   remotes: {
    //     remote1: 'remote1@http://localhost:8080/remote1/remoteEntry.js',
    //   },
    // }),
  ],
};

修改一下host/index.ts

// import('./bootstrap');
import "./bootstrap"

修改host/App.tsx

import pkg from '../package.json';
import React, { ComponentType, Suspense, lazy } from 'react';
import ReactDOM from 'react-dom';
import { init, loadRemote } from '@module-federation/enhanced/runtime';
const host = init({
  name: pkg.name,
  remotes: [
    {
      name: 'remote1',
      entry: 'http://localhost:8081/remoteEntry.js',
      // 加载远程模块时
      shareScope: 'default',
    },
  ],
  shared: {
    react: {
      version: '18.2.0',
      lib: () => React,
      // 指定注册的作用域
      scope: 'default',
      shareConfig: {
        singleton: true,
        eager: true,
        requiredVersion: '18.2.0',
      },
    },
    'react-dom': {
      version: '18.2.0',
      lib: () => ReactDOM,
      // 指定注册的作用域
      scope: 'default',
      shareConfig: {
        singleton: true,
        requiredVersion: '18.2.0',
        eager: true,
      },
    },
  },
});

const Button = lazy(() => loadRemote('remote1/Button') as Promise<{ default: ComponentType }>);

export default function App() {
  return (
    <div>
      <Suspense fallback="loading">
        <Button />
      </Suspense>
    </div>
  );
}

访问http://localhost:8080我们可以看到效果跟v1是一致的

image.png

以上就是module federation的基本用法

module federation原理

其实模块联邦并没有多黑科技,本质上模块联邦就是通过scriptdynamic import的方式对远程文件进行引用,并对这种能力进行增强(shareScope共享、shareScope资源复用)。 接下来我们看下这份remote端的webpack.config.jsonmodule federation配置

const { Configuration } = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const pkg = require('./package.json');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const { resolve } = require('path');
const ModuleFederationPlugin = require('@module-federation/enhanced').ModuleFederationPlugin;
/**
 * @type {Configuration}
 */
module.exports = {
  //...webpack常用配置
  plugins: [
    new ModuleFederationPlugin({
      filename: 'remoteEntry.js',
      name: pkg.name,
      exposes: {
        './Button': './src/exposes/Button',
      },
      // 指定接收host端共享的shareScope名称,如果不写则为default
      shareScope: 'default',
      shared: {
        react: {
          singleton: true,
          eager: true,
          // 往本地作用域中名为self的shareScope注册
          shareScope: 'self',
        },
        'react-dom': {
          singleton: true,
          eager: true,
          // 往本地作用域中名为self的shareScope注册
          shareScope: 'self',
        },
      },
    }),
  ],
};

这份配置配置中remote端会在全局共享空间中开辟一份属于自己的空间,并在里面注册一个名为defaultscope去承接host端share过来的scope,然后会把自身的reactreact-dom注册到指定好的名为selfscope里面,然后自身的reactreact-dom就只能消费self scope中的包,所以这就会出现react多实例的报错!

接下来我们再看看host端引用remote的方式

const host = init({
  name: pkg.name,
  remotes: [
    {
      name: 'remote',
      entry: 'http://localhost:8081/remoteEntry.js',
      // 将本地作用域中的`default Scope`共享出去
      shareScope: 'default',
    },
  ],
  shared: {
    react: {
      version: '18.3.0',
      lib: () => React,
      // 往本地作用域中名为default的shareScope注册
      scope: 'default',
      shareConfig: {
        singleton: true,
        eager: true,
        requiredVersion: '18.3.0',
      },
    },
    'react-dom': {
      version: '18.3.0',
      lib: () => ReactDOM,
      // 往本地作用域中名为default的shareScope注册
      scope: 'default',
      shareConfig: {
        singleton: true,
        eager: true,
        requiredVersion: '18.3.0',
      },
    },
  },
});

const Button = lazy(() => {
  debugger;
  return loadRemote('remote1/Button') as Promise<{ default: ComponentType }>;
});

这里host端往自身的本地作用域中开辟了一个名为default的作用域,并将reactreact-dom注册进default scope中,随后引入了remote模块,同时将自身的default scope共享给了remote

我们来看下这张图

模块联邦原理图.png 在模块联邦中,host端会创建一个名为federation的实例,shareScope的默认值设为default。然后,host会在全局对象__SHARE__中创建自己的作用范围(scope),如__SHARE__={host: {}}。然后注册reactreact-dom,并附加版本号,如__SHARE__={host: {react: {": {...}}, "react-dom": {"18.3.0": {...}}}}

在加载remote模块时,remote本身也会创建自己federation的实例,由于指定了shareScope: defaulthost会将自己的default scope分享给remote模块,由于remote中的module federation plugin指定了shareScope: 'default',remote也会使用default来接受host的共享作用域。然后,remote按照相同的步骤将其自己所共享的reactreact-dom注册到它自己指定好的的self scope中。

至此remote模块就可以开始消费这些已共享的模块。需要特别注意的是,由于remote模块是将reactreact-dom共享到self scope中,因此只能在自己的self scope中消费这些模块。这就导致了hostremote使用的不是同一个react实例。

总结

本文主要讲述了模块联邦的主要概念、v1与v2的区别,以及模块联邦如何去拉取远程模块、以及shareScope是如何运作,大道至简,以最少的代码阐述通熟易懂的概念。

参考文档