likes
comments
collection

Webpack5 模块联邦(Module Federation)实践

作者站长头像
站长
· 阅读数 16

微前端背景

概念

微前端概念是从微服务概念扩展而来的,摒弃大型单体方式,将前端整体分解为小而简单的块,这些块可以独立开发、测试和部署,同时仍然聚合为一个产品出现在客户面前。可以理解微前端是一种将多个可独立交付的小型前端应用聚合为一个整体的架构风格。 Webpack5 模块联邦(Module Federation)实践

特点

  • 技术栈无关:主应用不限制接入应用的技术栈,子应用具备完全自主权。
  • 独立性强:独立开发、独立部署,子应用仓库独立。
  • 状态隔离:运行时每个子应用之间状态隔离。

适用场景

  • 技术栈切换又不想重构已有业务。
  • 历史包袱项目,比如历史项目内部强耦合,但是又运行稳定的。(既要保证旧项目的稳定且不变,又能将新需求聚合为一个产品)
  • 旧项目的业务页面在新项目中复用,开发周期短。举例:假设JQ写了很多用户信息相关的页面,每个页面的初始化只要传用户ID就能获取其它数据,再用React写新项目时要复用这些页面的场景。

实现方案

  • 基于single-spa的微前端方案qiankun
  • 基于webcomponent+qiankun sandbox的微前端方案micro-app
  • 基于webcomponent容器+iframe沙箱的微前端方案wujie
  • 基于webpack5的微前端方案module federation

ModuleFederation 概述

本文的主角,作为webpack5内置核心特性之一的module federation

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

Webpack5 模块联邦(Module Federation)实践 对微前端而言,这张图就是一个完美的主应用,因为所有子应用都可以利用runtime方式复用主应用npm包和模块,更好的集成到主应用中。

问:什么是runtime方式? 答:运行时加载不同联合模块。因为用npm包的方式需要在本地编译时处理依赖关系,并打到对应的包中。

ModuleFederation 实践

目录结构

Webpack5 模块联邦(Module Federation)实践

Container App

首先创建container项目,该项目导出其它两个应用程序app1app2

// container/webpack.config.js
const webpack = require("webpack");
const { ModuleFederationPlugin } = webpack.container;

const HtmlWebpackPlugin = require("html-webpack-plugin");
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");

const deps = require("./package.json").dependencies;
const buildDate = new Date().toLocaleString();
require("dotenv").config({ path: "./.env" });

module.exports = (env, argv) => {
  const isProduction = argv.mode === "production";

  return {
    entry: "./src/index.ts",
    mode: process.env.NODE_ENV || "development",
    devServer: {
      port: 3000,
      open: true,
      headers: { "Access-Control-Allow-Origin": "*" },
    },
    resolve: {
      extensions: [".ts", ".tsx", ".js"],
    },
    module: {
      rules: [
        {
          test: /\.(js|jsx|tsx|ts)$/,
          loader: "babel-loader",
          exclude: /node_modules/,
          options: {
            cacheDirectory: true,
            babelrc: false,
            presets: [
              [
                "@babel/preset-env",
                { targets: { browsers: "last 2 versions" } },
              ],
              "@babel/preset-typescript",
              "@babel/preset-react",
            ],
            plugins: [
              "react-hot-loader/babel",
              ["@babel/plugin-proposal-class-properties", { loose: true }],
              [
                "@babel/plugin-proposal-private-property-in-object",
                { loose: true },
              ],
              ["@babel/plugin-proposal-private-methods", { loose: true }],
            ],
          },
        },
      ],
    },
    plugins: [
      new webpack.EnvironmentPlugin({ BUILD_DATE: buildDate }),
      new webpack.DefinePlugin({ "process.env": JSON.stringify(process.env) }),
      new ModuleFederationPlugin({
        name: "container",
        // 将其它项目的 name 映射到当前项目中
        remotes: {
          app1: isProduction ? process.env.PROD_APP1 : process.env.DEV_APP1,
          app2: isProduction ? process.env.PROD_APP2 : process.env.DEV_APP2,
        },
        // 是非常重要的参数,制定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。
        shared: {
          ...deps,
          react: { singleton: true, eager: true, requiredVersion: deps.react },
          "react-dom": {
            singleton: true,
            eager: true,
            requiredVersion: deps["react-dom"],
          },
          "react-router-dom": {
            singleton: true,
            eager: true,
            requiredVersion: deps["react-router-dom"],
          },
        },
      }),
      new HtmlWebpackPlugin({ template: "./public/index.html" }),
      new ForkTsCheckerWebpackPlugin(),
    ],
  };
};

应用中必须要配置环境变量,来关联其它应用。

// container/.env.development
DEV_APP1="app1@http://localhost:3001/remoteEntry.js"
DEV_APP2="app2@http://localhost:3002/remoteEntry.js"
// container/.env.production
PROD_APP1="app1@http://ogz-microfrontend-app-1.s3-website.eu-central-1.amazonaws.com/remoteEntry.js"
PROD_APP2="app2@http://ogz-microfrontend-app-2.s3-website.eu-central-1.amazonaws.com/remoteEntry.js"
// container/App.tsx
import React, { lazy } from "react";
import { Routes, Route } from "react-router-dom";
import { ContainerApp } from "./components/ContainerApp";

// 通过 webpack 关联其它应用,然后按需加载
const CounterAppOne = lazy(() => import("app1/CounterAppOne"));
const CounterAppTwo = lazy(() => import("app2/CounterAppTwo"));

const App = () => (
  <Routes>
    <Route
      path="/"
      element={
        <ContainerApp
          CounterAppOne={CounterAppOne}
          CounterAppTwo={CounterAppTwo}
        />
      }
    />
    <Route path="app1/*" element={<CounterAppOne />} />
    <Route path="app2/*" element={<CounterAppTwo />} />
  </Routes>
);

export default App;

展示效果: Webpack5 模块联邦(Module Federation)实践

Webpack5 模块联邦(Module Federation)实践

app1应用和app2应用下的两个组件已经无缝的应用到了container中。

App-1

// app1/webpack.config.js
const deps = require("./package.json").dependencies;
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  entry: "./src/index.ts",
  mode: "development",
  devServer: {
    port: 3001,
    open: true,
    headers: { "Access-Control-Allow-Origin": "*" },
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx|tsx|ts)$/,
        loader: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "app1",
      filename: "remoteEntry.js",
      // 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用。
      exposes: {
        "./CounterAppOne": "./src/components/CounterAppOne",
      },
      shared: {
        ...deps,
        react: { singleton: true, eager: true, requiredVersion: deps.react },
        "react-dom": {
          singleton: true,
          eager: true,
          requiredVersion: deps["react-dom"],
        },
        "react-router-dom": {
          singleton: true,
          eager: true,
          requiredVersion: deps["react-router-dom"],
        },
      },
    }),
    new HtmlWebpackPlugin({ template: "./public/index.html" }),
  ],
};
// app1/src/components/CounterAppOne
import React, { useState } from "react";
import { Link, useLocation } from "react-router-dom";

import { Text, Button, Flex } from "@chakra-ui/react";

const Counter = () => {
  const location = useLocation();
  const [count, setCount] = useState(0);

  return (
    <Flex color="#000" gap="1rem" direction="column">
      <Text>
        Add by one each click <strong>APP-1</strong>
      </Text>
      <Text>Your click count : {count} </Text>
      <Button onClick={() => setCount(count + 1)}>Click me</Button>
      {location.pathname !== "/" && (
        <Button as={Link} to="/">
          Back to container
        </Button>
      )}
    </Flex>
  );
};

export default Counter;

app2应用与app1应用的webpack基础配置基本一致,需要暴露出的组件会存在不同。其次就是根据业务开发相应的内容。

总结

  • 对于module federation,官方解释就是模块联邦,主要依赖内部webpack提供的一个插件ModuleFederationPlugin,将内部的组件共享给其它应用使用。可以说是继externals后最终的运行时代码复用解决方案。
  • module federation解决了什么样的问题,允许一个应用A加载另外一个应用B,并且依赖共享,且两个独立的应用之间互不影响。

更多关于MDF信息参考 webpack官方文档 了解更多共享模块的方式以及优缺点 精读《Webpack5 新特性 - 模块联邦》 本文示例github地址 code example