如何利用 Webpack Module Federation 实现微前端 (Micro-Frontends) 架构下的模块共享和版本兼容?

各位观众老爷们,晚上好!我是你们的老朋友,今天咱们来聊聊微前端这个话题,更具体地说,是用Webpack Module Federation来搞定微前端架构下的模块共享和版本兼容问题。保证让各位听完之后,感觉打开了新世界的大门,以后再也不怕微前端带来的各种奇葩问题了。

啥是微前端?——别告诉我你还不知道!

先简单过一下微前端的概念。想象一下,你正在做一个超大型的网站,功能多到爆炸,一个人根本搞不定。传统的做法是,整个团队一起维护一个巨大的代码库,然后每天都在merge代码的时候互相伤害。

微前端就是把这个庞然大物拆分成若干个小的、自治的前端应用,每个应用都可以独立开发、独立部署、独立运行。就像一个航母战斗群,每艘船各司其职,但又能协同作战。

Webpack Module Federation:微前端的瑞士军刀

Module Federation 是 Webpack 5 引入的一个强大的功能,它可以让不同的 Webpack 构建的应用之间共享代码,而不需要将这些代码打包到同一个 bundle 中。 简单来说,它可以让一个应用“暴露”自己的部分模块,让其他应用“消费”这些模块。

它就像一个模块共享平台,各个微前端应用可以把自己需要的模块拿过来用,而不需要重复造轮子。

Module Federation 的基本概念

在深入代码之前,我们需要先了解几个关键概念:

  • Host (宿主): 宿主应用,它会加载和运行其他应用提供的远程模块。
  • Remote (远程): 提供可共享模块的应用。
  • Shared Modules (共享模块): 被 Host 和 Remote 共享的模块,例如 React, Vue, 或者一些工具库。

实战演练:搭建一个简单的微前端架构

为了更好地理解 Module Federation,我们来搭建一个简单的微前端架构,包含一个 Host 应用和两个 Remote 应用。

项目结构

micro-frontends/
├── host/            # 宿主应用
├── remote1/         # 远程应用 1
└── remote2/         # 远程应用 2

1. Host 应用 (host/)

  • webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');

module.exports = {
  mode: 'development',
  devServer: {
    port: 3000,
  },
  entry: './src/index.js',
  output: {
    publicPath: 'http://localhost:3000/', // 重点:需要设置 publicPath
  },
  module: {
    rules: [
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host', // 必须唯一
      remotes: {
        remote1: 'remote1@http://localhost:3001/remoteEntry.js', // 远程应用 1
        remote2: 'remote2@http://localhost:3002/remoteEntry.js', // 远程应用 2
      },
      shared: {
        react: { singleton: true, requiredVersion: '17.0.2' },
        'react-dom': { singleton: true, requiredVersion: '17.0.2' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  resolve: {
      extensions: ['.js', '.jsx'],
  },
};
  • src/index.js
import React from 'react';
import ReactDOM from 'react-dom';

import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));
  • src/App.js
import React, { Suspense } from 'react';

const RemoteComponent1 = React.lazy(() => import('remote1/Component'));
const RemoteComponent2 = React.lazy(() => import('remote2/Component'));

const App = () => (
  <div>
    <h1>Host Application</h1>
    <Suspense fallback={<div>Loading Remote Component 1...</div>}>
      <RemoteComponent1 />
    </Suspense>
    <Suspense fallback={<div>Loading Remote Component 2...</div>}>
      <RemoteComponent2 />
    </Suspense>
  </div>
);

export default App;
  • public/index.html
<!DOCTYPE html>
<html>
<head>
  <title>Host Application</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

2. Remote 应用 1 (remote1/)

  • webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');

module.exports = {
  mode: 'development',
  devServer: {
    port: 3001,
  },
  entry: './src/index.js',
  output: {
    publicPath: 'http://localhost:3001/', // 重点:需要设置 publicPath
  },
  module: {
    rules: [
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote1', // 必须唯一
      filename: 'remoteEntry.js',
      exposes: {
        './Component': './src/Component', // 暴露的模块
      },
      shared: {
        react: { singleton: true, requiredVersion: '17.0.2' },
        'react-dom': { singleton: true, requiredVersion: '17.0.2' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  resolve: {
    extensions: ['.js', '.jsx'],
  },
};
  • src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Component from './Component';

ReactDOM.render(<Component />, document.getElementById('root'));
  • src/Component.js
import React from 'react';

const Component = () => (
  <div>
    <h2>Remote Component 1</h2>
    <p>This component is from remote1.</p>
  </div>
);

export default Component;
  • public/index.html
<!DOCTYPE html>
<html>
<head>
  <title>Remote Application 1</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

3. Remote 应用 2 (remote2/)

  • webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');

module.exports = {
  mode: 'development',
  devServer: {
    port: 3002,
  },
  entry: './src/index.js',
  output: {
    publicPath: 'http://localhost:3002/', // 重点:需要设置 publicPath
  },
  module: {
    rules: [
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote2', // 必须唯一
      filename: 'remoteEntry.js',
      exposes: {
        './Component': './src/Component', // 暴露的模块
      },
      shared: {
        react: { singleton: true, requiredVersion: '17.0.2' },
        'react-dom': { singleton: true, requiredVersion: '17.0.2' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  resolve: {
      extensions: ['.js', '.jsx'],
  },
};
  • src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Component from './Component';

ReactDOM.render(<Component />, document.getElementById('root'));
  • src/Component.js
import React from 'react';

const Component = () => (
  <div>
    <h2>Remote Component 2</h2>
    <p>This component is from remote2.</p>
  </div>
);

export default Component;
  • public/index.html
<!DOCTYPE html>
<html>
<head>
  <title>Remote Application 2</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

运行项目

  1. 分别进入 host, remote1, remote2 目录,运行 npm installyarn install 安装依赖。
  2. 分别启动三个应用:
    • cd host && npm start (访问 http://localhost:3000)
    • cd remote1 && npm start (访问 http://localhost:3001)
    • cd remote2 && npm start (访问 http://localhost:3002)

如果一切顺利,你将在 http://localhost:3000 看到 Host 应用,其中包含了 Remote 应用 1 和 Remote 应用 2 的组件。

模块共享:shared 配置

shared 配置是 Module Federation 的核心之一,它允许我们指定哪些模块应该在 Host 和 Remote 之间共享。

在上面的例子中,我们共享了 reactreact-dom。这样做的好处是:

  • 避免重复加载: 如果 Host 和 Remote 都打包了 react,那么浏览器就需要加载两份 react 代码,浪费带宽和内存。
  • 保持状态一致: 如果 Host 和 Remote 使用不同的 react 版本,可能会导致状态管理出现问题。

shared 配置的详细选项:

  • singleton: 如果设置为 true,则只允许加载一个版本的共享模块。这对于像 react 这样的库非常重要,因为多个版本的 react 可能会导致冲突。
  • requiredVersion: 指定共享模块的最低版本要求。如果 Host 或 Remote 提供的版本低于这个要求,Module Federation 会抛出一个错误。
  • strictVersion: 如果设置为 true,则必须使用完全相同的版本。否则,Module Federation 会抛出一个错误。
  • eager: 默认情况下,共享模块是按需加载的。如果设置为 true,则会立即加载共享模块。
  • import: 允许指定共享模块的实际导入路径。

版本兼容:requiredVersion 的威力

版本兼容性是微前端架构中一个非常重要的问题。如果不同的微前端应用使用了不兼容的模块版本,可能会导致各种奇怪的问题。

Module Federation 的 requiredVersion 配置可以帮助我们解决这个问题。它可以让我们指定共享模块的最低版本要求。

例如,如果 Remote 应用 1 使用了 [email protected],而 Host 应用使用了 [email protected],那么 Host 应用在加载 Remote 应用 1 的模块时,会抛出一个错误,提示版本不兼容。

更灵活的版本控制策略

除了 requiredVersion,Module Federation 还提供了一些更灵活的版本控制策略,例如:

  • 版本范围 (Version Ranges): 可以使用版本范围来指定允许的版本范围。例如,react: { requiredVersion: '^16.0.0' } 表示允许使用 [email protected] 或更高版本。
  • 语义化版本控制 (Semantic Versioning): Module Federation 支持语义化版本控制,可以根据语义化版本规则来选择合适的版本。
  • 自定义版本解析器 (Custom Version Resolver): 可以自定义版本解析器,根据自己的需求来选择合适的版本。

高级技巧:动态加载远程模块

上面的例子中,我们是在 Host 应用的 webpack.config.js 文件中静态地指定了 Remote 应用的地址。这种方式的缺点是,如果 Remote 应用的地址发生变化,我们就需要修改 Host 应用的 webpack.config.js 文件,并重新构建 Host 应用。

为了解决这个问题,我们可以使用动态加载远程模块的方式。

// 动态加载远程模块
const loadRemoteModule = (remoteUrl, scope, module) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = remoteUrl;
    script.type = 'text/javascript';
    script.async = true;

    script.onload = () => {
      const proxy = {
        get: (request) => window[scope].get(request),
        init: (arg) => {
          try {
            return window[scope].init(arg);
          } catch (e) {
            console.log('remote container already initialized');
          }
        },
      };
      resolve(proxy.get(module).then((factory) => factory()));
    };

    script.onerror = (error) => {
      console.error('Failed to load remote module:', error);
      reject(error);
    };

    document.head.appendChild(script);
  });
};

// 在组件中使用
import React, { useState, useEffect, Suspense } from 'react';

const DynamicRemoteComponent = ({ remoteUrl, scope, module }) => {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    loadRemoteModule(remoteUrl, scope, module)
      .then((RemoteComponent) => {
        setComponent(() => RemoteComponent);
      })
      .catch((error) => {
        console.error('Failed to load remote component:', error);
      });
  }, [remoteUrl, scope, module]);

  if (!Component) {
    return <div>Loading...</div>;
  }

  return <Component />;
};

// 使用 DynamicRemoteComponent
const App = () => (
  <div>
    <h1>Host Application</h1>
    <Suspense fallback={<div>Loading Remote Component...</div>}>
      <DynamicRemoteComponent remoteUrl="http://localhost:3001/remoteEntry.js" scope="remote1" module="./Component" />
    </Suspense>
  </div>
);

export default App;

使用动态加载远程模块的方式,我们可以将 Remote 应用的地址配置在 Host 应用的运行时环境中,而不需要修改 webpack.config.js 文件。

Module Federation 的优缺点

优点:

  • 代码共享: 避免重复造轮子,减少代码冗余。
  • 独立部署: 每个微前端应用都可以独立部署,互不影响。
  • 技术栈无关: 可以使用不同的技术栈来开发不同的微前端应用。
  • 增量升级: 可以逐步升级微前端应用,而不需要一次性升级整个系统。

缺点:

  • 配置复杂: Module Federation 的配置比较复杂,需要一定的学习成本。
  • 运行时依赖: 需要在运行时加载远程模块,可能会影响性能。
  • 版本管理: 需要仔细管理共享模块的版本,避免版本冲突。
  • 类型定义共享: 需要额外的方式共享typescript 类型定义, 比如使用 npm link 或者 publish 到 npm.

Module Federation 的适用场景

Module Federation 适用于以下场景:

  • 大型的、复杂的 Web 应用。
  • 需要独立部署和独立维护的多个前端应用。
  • 需要共享代码的多个前端应用。
  • 需要使用不同技术栈的前端应用。

总结

Module Federation 是一个强大的工具,可以帮助我们构建灵活、可扩展的微前端架构。但是,它也需要一定的学习成本和配置成本。在选择使用 Module Federation 之前,我们需要仔细评估其优缺点,并根据自己的实际情况做出决定。

希望今天的讲座能够帮助大家更好地理解 Module Federation,并在实践中灵活运用它。如果大家还有什么问题,欢迎随时提问。

感谢大家的观看! 下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注