使用 MicroApp 和模块联邦搭建微前端项目
简介
最近接到一个需求,要求开发一个集合多个公司产品项目的前端工程,需要对前端模块进行拆分。本文将介绍如何使用 micro-app 和 Webpack 模块联邦实现微前端架构,主要功能点包括:
- 使用 micro-app 和 Webpack 模块联邦实现微前端架构,提取公共依赖,提高子应用启动速度,减少打包后的体积。
- 实现子应用的懒加载启动:开发者无需手动启动子应用,进入子应用页面后自动启动,离开页面后一段时间自动关闭后台进程。
- 实现开发辅助工具:可定位页面元素到源代码并打开 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 中暴露三方模块的方法:
- 在 modules/src 目录下创建需要暴露的模块,例如 micro/modules/src/react/index.ts:
export * from "react";
export { default } from "react";
- 在主应用 apps/main 的 tsconfig.json 中配置路径映射:
{
"extends": "@shared/tsconfig",
"compilerOptions": {
"paths": {
"modules/*": ["../../micro/modules/src/*"]
}
}
}
- 使用 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
-
安装 MicroApp:
pnpm -F=modules i @micro-zoe/micro-app -
创建 micro/modules/src/@micro-zoe/micro-app/index.ts:
export * from "@micro-zoe/micro-app";
export { default } from "@micro-zoe/micro-app";
- 创建 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 配置修改
- 配置开发服务器跨域支持:
headers: {
'Access-Control-Allow-Origin': '*',
}
- 配置应用的 output.publicPath,以 login 微应用为例:
output: {
publicPath: isDev ? "http://localhost:5003/" : "/micro/login/";
}
启动顺序
- 启动 modules
- 启动微应用 login
- 启动主应用 main
构建优化
为了实现合并各个应用的 dist 文件夹,我们需要进行以下步骤:
- 修改 Webpack 的 output.publicPath 配置:
output: {
publicPath: `/${this.packageJson.name.replace("@", "")}/`;
}
- 实现汇聚各个应用的输出目录:
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
}
微应用管理工具
实现一个网页工具来控制子应用的启动和关闭,并提供在新窗口中打开子应用的功能。
中间件实现
- 抽象 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;
}
}
- 创建 AppMap 类作为应用名称到进程对象的映射表:
class AppMap extends Map<IAppName, AppProcess> {}
- 实现 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 仓库。