使用 MicroApp 和模块联邦搭建微前端项目

1,626 阅读4分钟

使用 MicroApp 和模块联邦搭建微前端项目

简介

最近接到一个需求,要求开发一个集合多个公司产品项目的前端工程,需要对前端模块进行拆分。本文将介绍如何使用 micro-app 和 Webpack 模块联邦实现微前端架构,主要功能点包括:

  1. 使用 micro-app 和 Webpack 模块联邦实现微前端架构,提取公共依赖,提高子应用启动速度,减少打包后的体积。
  2. 实现子应用的懒加载启动:开发者无需手动启动子应用,进入子应用页面后自动启动,离开页面后一段时间自动关闭后台进程。
  3. 实现开发辅助工具:可定位页面元素到源代码并打开 VSCode,以表格形式展示子应用描述,控制子应用的启动或关闭。

项目仓库: micro-app-module-federation-template

项目结构

工作区配置

pnpm-workspace.yaml:

packages:
  # 主应用
  - "apps/*"

  # 共享应用
  - "micro/*"

  # 共享模块
  - "shared/*"

  # 脚本模块
  - "scripts"

目录结构

.
├── README.md
├── package.json
├── pnpm-workspace.yaml
├── pnpm-lock.yaml
├── nx.json
├── apps # 主应用列表
│   └── main
│       └── package.json # @apps/main
├── micro # 微应用列表
│   ├── login # 登录页面
│   │   └── package.json # @micro/login
│   └── modules # 作为 Webpack 模块联邦的提供者
│       └── package.json # @micro/modules
├── scripts # 项目工程化文件
│   └── package.json
└── shared # 公共模块
    ├── common # 应用公共模块,如 utils 等
    │   └── package.json
    └── tsconfig # 项目 tsconfig 公共配置
        └── package.json # @shared/tsconfig

模块联邦的实现

模块提供者配置

micro/modules 项目作为 Webpack 的模块联邦提供者,也就是项目公共依赖提供方。我们希望项目中的其他应用在使用三方依赖时从 modules 里导入,例如:

// 原始导入语句
import { createRoot } from "react-dom/client";

// 变为
import { createRoot } from "modules/react-dom/client";

在 modules 中暴露三方模块的方法:

  1. 在 modules/src 目录下创建需要暴露的模块,例如 micro/modules/src/react/index.ts:
export * from "react";
export { default } from "react";
  1. 在主应用 apps/main 的 tsconfig.json 中配置路径映射:
{
  "extends": "@shared/tsconfig",
  "compilerOptions": {
    "paths": {
      "modules/*": ["../../micro/modules/src/*"]
    }
  }
}
  1. 使用 webpack 的 ModuleFederationPlugin 插件,将 modules 项目设置为模块提供方:
import { cwd } from "process";
import { container } from "webpack";
import { moduleFederationUtils } from "../../../utils/module-federation";

export class ModuleFederationProviderPlugin extends container.ModuleFederationPlugin {
  constructor() {
    const exposes = moduleFederationUtils.filePathsToExposes(
      moduleFederationUtils.resolveCodeFiles(cwd())
    );
    super({
      name: "modules",
      filename: "remoteEntry.js",
      exposes,
    });

    console.log(
      `==================${ModuleFederationProviderPlugin.name}=========================`
    );
    console.log({ exposes });
    console.log(
      `==================${ModuleFederationProviderPlugin.name}=========================`
    );
  }
}

消费者配置

为主应用和其他微应用创建一个模块联邦消费者插件:

import ports from "root/ports.json";
import { container } from "webpack";

export class ModuleFederationConsumerPlugin extends container.ModuleFederationPlugin {
  constructor(options: { isDev: boolean }) {
    super({
      remotes: {
        modules: options.isDev
          ? `modules@http://localhost:${ports["@micro/modules"]}/remoteEntry.js`
          : `modules@/micro/modules/remoteEntry.js`,
      },
    });
  }
}

MicroApp 的接入过程

在 modules 项目中暴露 MicroApp 相关 API

  1. 安装 MicroApp: pnpm -F=modules i @micro-zoe/micro-app

  2. 创建 micro/modules/src/@micro-zoe/micro-app/index.ts:

export * from "@micro-zoe/micro-app";
export { default } from "@micro-zoe/micro-app";
  1. 创建 micro/modules/src/@micro-zoe/micro-app/polyfill/jsx-custom-event.ts:
export * from "@micro-zoe/micro-app/polyfill/jsx-custom-event";
export { default } from "@micro-zoe/micro-app/polyfill/jsx-custom-event";

微应用 login 配置

micro/login/src/index.ts:

import("./bootstrap");

micro/login/src/bootstrap.tsx:

import { createRoot } from "modules/react-dom/client";

createRoot(document.getElementById("root")!).render(<h1>login</h1>);

主应用配置

apps/main/src/index.ts:

import("./bootstrap");

apps/main/src/bootstrap.tsx:

/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from "modules/@micro-zoe/micro-app/polyfill/jsx-custom-event";
import microApp from "modules/@micro-zoe/micro-app";
import { createRoot } from "modules/react-dom/client";

import { DevTools } from "./components/dev-tools";
import { MicroApp } from "./components/micro-app";

// 需要保留,否则 eslint 自动修复会把导入语句去掉
jsxCustomEvent;

microApp.start({
  lifeCycles: {
    error(e) {
      console.log("error", e);
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <micro-app
    name={"login"}
    url={"http://localhost:5003"} // 微应用 login 的开发服务器端口
  />
);

Webpack 配置修改

  1. 配置开发服务器跨域支持:
headers: {
  'Access-Control-Allow-Origin': '*',
}
  1. 配置应用的 output.publicPath,以 login 微应用为例:
output: {
  publicPath: isDev ? "http://localhost:5003/" : "/micro/login/";
}

启动顺序

  1. 启动 modules
  2. 启动微应用 login
  3. 启动主应用 main

构建优化

为了实现合并各个应用的 dist 文件夹,我们需要进行以下步骤:

  1. 修改 Webpack 的 output.publicPath 配置:
output: {
  publicPath: `/${this.packageJson.name.replace("@", "")}/`;
}
  1. 实现汇聚各个应用的输出目录:
import cpy from "cpy";
import fsx from "fs-extra";
import fs from "fs/promises";

await fsx.emptyDir(pathUtils.resolveWorkspaceRoot("dist"));

// 拷贝所有主应用的 dist 文件夹
const apps = (await fs.readdir("./apps")).filter(
  (x) => !(x.startsWith(".") || x.startsWith("_"))
);
await Promise.all(
  apps.map(async (x) => {
    await cpy(
      path.join(pathUtils.workspaceRoot, `./apps/${x}/dist/**`),
      pathUtils.resolveWorkspaceRoot(`./dist/apps/${x}`)
    );
  })
);

// 拷贝所有微应用 dist 文件夹
const microApps = (await fs.readdir("./micro")).filter(
  (x) => !(x.startsWith(".") || x.startsWith("_"))
);
await Promise.all(
  microApps.map(async (x) => {
    await cpy(
      path.join(pathUtils.workspaceRoot, `./micro/${x}/dist/**`),
      pathUtils.resolveWorkspaceRoot(`./dist/micro/${x}`)
    );
  })
);

log.info(`create ${pathUtils.resolveWorkspaceRoot("dist")}`);

开发体验优化

应用端口汇总文件

在项目根目录创建 ports.json 存放项目中应用的端口号:

{
  "@apps/main": 8000,
  "@micro/login": 8001,
  "@micro/modules": 8002
}

微应用管理工具

实现一个网页工具来控制子应用的启动和关闭,并提供在新窗口中打开子应用的功能。

中间件实现
  1. 抽象 AppProcess 类作为应用启动进程:
class AppProcess {
  private appName: IAppName;
  running: boolean = false;
  private process?: ExecaChildProcess<string>;

  constructor(appName: IAppName) {
    this.appName = appName;
  }

  async runStart(afterCreate?: (process: ExecaChildProcess) => void) {
    if (this.running) {
      return;
    }

    this.running = true;
    const cmd = `pnpm -F ${this.appName} dev`;
    const childProcess = execa.command(cmd, {
      cwd: pathUtils.workspaceRoot,
    });

    this.process = childProcess;
    afterCreate?.(childProcess);
    return childProcess;
  }

  close() {
    this.process?.kill();
    this.log("已关闭");
    this.running = false;
  }
}
  1. 创建 AppMap 类作为应用名称到进程对象的映射表:
class AppMap extends Map<IAppName, AppProcess> {}
  1. 实现 socket 处理流程:
socket.on("message", async (buffer) => {
  const rawData = JSON.parse(buffer.toString());
  const { type, data } = rawData;

  switch (type) {
    case EAppDevSocketType.StartApp: {
      const appName = data as IAppName;
      if (!appMap.has(appName)) {
        appMap.set(appName, new AppProcess(appName));
      }

      // appMap
      const appProcess = appMap.get(appName)!;
      appMap.set(appName, appProcess);

      const afterCreate = (childProcess: ExecaChildProcess) => {
        const onMessage = (message: string) => {
          if (message.includes("started successfully")) {
            console.log(
              chalk.cyan(
                `Dev socket: 当前正在运行的微应用: ${appMap.getRunningAppNames()}`
              ) + EOL
            );
            socket.send(
              JSON.stringify({
                type: EAppDevSocketType.AppStarted,
                data: appName,
              })
            );
          }
        };

        childProcess.stdout!.on("data", (buffer) => {
          const message = buffer.toString();
          appProcess.log(message);
          onMessage(message);
        });

        childProcess.stderr!.on("data", (buffer) => {
          const message = buffer.toString();
          appProcess.logError(message);
          onMessage(message);
        });
      };

      await appProcess.runStart(afterCreate);
      appMap.set(appName, appProcess);
      break;
    }

    case EAppDevSocketType.CloseApp: {
      const appName = data as IAppName;
      appMap.get(appName)?.close();
      console.log(
        chalk.cyan(
          `Dev socket: 当前正在运行的微应用: ${appMap.getRunningAppNames()}`
        ) + EOL
      );
      socket.send(
        JSON.stringify({
          type: EAppDevSocketType.AppClosed,
          data: appName,
        })
      );
      break;
    }

    default:
      break;
  }
});
前端通信功能实现

抽象 MicroAppDevServer 类实现启动和关闭微应用的功能:

import { MicroUtils } from "modules/@shared/common";
import { Subject, fromEvent } from "modules/rxjs";
import { EAppDevSocketType } from "scripts/types";

/**
 * 微前端应用启动控制服务,仅在开发模式下使用
 */
export class MicroAppDevServer {
  private static instance: MicroAppDevServer;

  static get() {
    if (this.instance) {
      return this.instance;
    }

    return (this.instance = new MicroAppDevServer());
  }

  startedApp$ = new Subject<IAppName>();
  closedApp$ = new Subject<IAppName>();

  private ws: WebSocket;
  private isConnected: boolean;

  private constructor() {
    this.ws = new WebSocket(
      `ws://localhost:${MicroUtils.getAppPort("@apps/main")}/ws`
    );

    this.isConnected = false;

    fromEvent(this.ws, "open").subscribe(() => {
      this.isConnected = true;
    });

    fromEvent<MessageEvent>(this.ws, "message").subscribe((ev) => {
      const rawData = JSON.parse(ev.data);
      const { type, data } = rawData;

      switch (type) {
        case EAppDevSocketType.AppStarted:
          this.startedApp$.next(data);
          break;

        case EAppDevSocketType.AppClosed:
          this.closedApp$.next(data);
          break;

        default:
          break;
      }
    });
  }

  waitConnected() {
    return new Promise((rs) => {
      if (this.isConnected) {
        rs(null);
      }

      setTimeout(() => {
        if (this.isConnected) {
          rs(null);
        }
      }, 200);
    });
  }

  send(type: string, data: ISafeAny) {
    this.waitConnected().then(() => {
      this.ws.send(
        JSON.stringify({
          type,
          data,
        })
      );
    });
  }

  start(appName: IAppName) {
    return new Promise((resolve) => {
      this.send(EAppDevSocketType.StartApp, appName);
      const subs = this.startedApp$.subscribe((x) => {
        if (x === appName) {
          subs.unsubscribe();
          resolve(null);
        }
      });
    });
  }

  close(appName: IAppName) {
    return new Promise((resolve) => {
      this.send(EAppDevSocketType.CloseApp, appName);
      const subs = this.closedApp$.subscribe((x) => {
        if (x === appName) {
          subs.unsubscribe();
          resolve(null);
        }
      });
    });
  }
}

使用 WebpackDevServer API 启动微应用

封装应用启动类

创建 WebpackRunner 类提供 serve 和 build 方法:

import path from "path";

import chalk from "chalk";
import fsx from "fs-extra";
import { Subject } from "rxjs";
import { webpack } from "webpack";
import WebpackDevServer from "webpack-dev-server";

import { invariantUtils } from "../../../utils/invariant";
import { pathUtils } from "../../../utils/paths";

import { setupMiddlewares } from "./middleware";

import type { WebpackConfiguration } from "../configuration/base";
import type { PackageManifest } from "@pnpm/types";
import type { Port } from "webpack-dev-server";

export class WebpackRunner {
  private server?: WebpackDevServer;

  started$ = new Subject<Port>();
  stopped$ = new Subject();
  portsWritten$ = new Subject<Port>();

  isServing = false;

  packageJson: PackageManifest;

  constructor(
    private projectDir: string,
    private devConfiguration: WebpackConfiguration,
    private buildConfiguration: WebpackConfiguration,
    private port?: Port
  ) {
    this.packageJson = fsx.readJSONSync(
      path.resolve(projectDir, "package.json")
    );

    // 写入 ports.json
    this.started$.subscribe(async (port) => {
      await fsx.ensureFile(pathUtils.resolveWorkspaceRoot("ports.json"));
      let json;
      try {
        json = await fsx.readJSON(pathUtils.resolveWorkspaceRoot("ports.json"));
      } catch (error) {
        json = {};
      }

      json[this.packageJson.name] = port;
      await fsx.writeJSON(pathUtils.resolveWorkspaceRoot("ports.json"), json);
      this.portsWritten$.next(port);
    });
  }

  build() {
    const compiler = webpack(
      this.buildConfiguration.merge({
        output: {
          publicPath: `/micro/${this.packageJson.name.replace("@micro/", "")}/`,
        },
        context: this.projectDir,
      })
    );
    compiler.run(function (err, stats) {
      if (err) {
        throw err;
      }

      process.stdout.write(
        (stats || "").toString({
          colors: true,
          modules: false,
          children: false,
          chunks: false,
          chunkModules: false,
        }) + "\n\n"
      );
    });
  }

  serve() {
    this.isServing = true;
    this.createWebpackDevServer(this.devConfiguration).then((x) =>
      x.startCallback(this.startServerCallback)
    );
  }

  stopServe() {
    invariantUtils.define(this.server);
    this.server.stopCallback(this.stopServerCallback);
  }

  private async createWebpackDevServer(configuration: WebpackConfiguration) {
    const host = "localhost";
    this.port =
      this.port ?? (await WebpackDevServer.getFreePort("auto", "localhost"));

    const devServerConfig: WebpackDevServer.Configuration = {
      port: this.port,
      host,
      https: false,
      static: false,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods":
          "GET, POST, PUT, DELETE, PATCH, OPTIONS",
        "Access-Control-Allow-Headers":
          "X-Requested-With, content-type, Authorization",
      },
      client: {
        logging: "warn",
        progress: true,
        overlay: {
          errors: true,
          warnings: false,
        },
      },
      devMiddleware: {
        stats: "errors-only",
      },
      setupMiddlewares,
    };

    const config = configuration.merge({
      output: {
        publicPath: `http://localhost:${this.port}/`,
      },
      context: this.projectDir,
    });
    const compiler = webpack(config);
    this.server = new WebpackDevServer(devServerConfig, compiler);
    return this.server;
  }

  private stopServerCallback = () => {
    this.isServing = false;
    this.stopped$.next(null);

    this.server!.logger.info(`${chalk.green(this.packageJson.name)} stopped.`);
  };

  private startServerCallback = async () => {
    const { server, packageJson, port } = this;

    invariantUtils.define(server);
    invariantUtils.define(port);

    this.started$.next(port);

    const host = "localhost";
    const localAddress = `http://${host}:${port}/`;
    const ipv4Address = `http://${
      (await WebpackDevServer.internalIP("v4")) || host
    }:${port}`;

    server.logger.info(
      `${chalk.green("🎉 本地地址:")}${chalk.cyanBright(localAddress)}`
    );
    server.logger.info(
      `${chalk.green("🎉 网络地址:")}${chalk.cyanBright(ipv4Address)}`
    );
    server.logger.info(
      `${chalk.green(packageJson.name)} started successfully.`
    );
  };
}
修改中间件处理流程

更新 AppMap 类和处理逻辑:

class AppMap extends Map<IAppName, Project> {
  getRunningAppNames() {
    return Array.from(this.keys()).filter((name) => this.get(name)?.isServing);
  }
}

// 处理逻辑
switch (type) {
  case EAppDevSocketType.StartApp: {
    const appName = data as IAppName;
    if (!appMap.has(appName)) {
      appMap.set(
        appName,
        ProjectFactory.create(undefined, pathUtils.getAppProjectDir(appName))
      );
    }

    const appProject = appMap.get(appName)!;
    await appProject.serve();

    appProject.started$.pipe(take(1)).subscribe(() => {
      socket.send(
        JSON.stringify({
          type: EAppDevSocketType.AppStarted,
          data: appName,
        })
      );
      logRunningApps();
    });
    break;
  }

  case EAppDevSocketType.CloseApp: {
    const appName = data as IAppName;
    const appProject = appMap.get(appName);
    invariantUtils.define(appProject);
    appProject.stopServe();

    appProject.stopped$.pipe(take(1)).subscribe(() => {
      logRunningApps();
      socket.send(
        JSON.stringify({
          type: EAppDevSocketType.AppClosed,
          data: appName,
        })
      );
    });
    break;
  }

  case EAppDevSocketType.RunningApps: {
    socket.send(
      JSON.stringify({
        type: EAppDevSocketType.RunningApps,
        data: appMap.getRunningAppNames(),
      })
    );
    break;
  }

  default:
    break;
}

结语

通过以上步骤,我们成功搭建了一个基于 MicroApp 和模块联邦的微前端项目。这种架构不仅提高了开发效率,还增强了项目的可维护性和扩展性。希望本文能为您在微前端开发中提供有价值的参考。

完整代码请参考 GitHub 仓库