简介
这一次接到需求需要做一个多个公司产品项目集合的前端工程,需要对前端模块拆分。
主要功能点如下:
使用 micro-app 和 Webpack 模块联邦实现微前端,提取公共依赖,提高子应用启动速度,减少打包后大小
实现懒启动子应用,开发者无需手动启动子应用,进入子应用页面以后后台自动启动子应用,离开子应用页面以后过段时间自动关闭后台进程。
实现开发小助手固钉,可定位页面元素到源代码并打开 VSCode ,以表格形式展示子应用描述,启动或关闭子应用。
目录介绍
pnpm-workspace.yaml
yaml
packages:
# 主应用
- "apps/*"

# 共享应用
- "micro/*"
# 共享模块
- "shared/*"
# 脚本模块
- "scripts"
bash
.
├── 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 里导入,也就是说
项目中的导入语句
tsx
import { createRoot } from "react-dom/client"
变成
tsx
import { createRoot } from "modules/react-dom/client"
那么如何才能在 modules 中暴露出三方模块供外部使用呢?换句话说,我们在 modules 中需要怎么写代码?
我们可以在 modules/src 目录下创建我们需要暴露出去的模块,比如说 micro/modules/src/react/index.ts 这个文件
ts
export * from "react";
export { default } from "react";
然后在我们的 apps/main 应用项目里配置下 tsconfig.json 文件:
json
{
"extends": "@shared/tsconfig",
"compilerOptions": {
"paths": {
"modules/*": ["../../micro/modules/src/*"],
}
}
}
这样我们就能在 main 这个主应用的代码中使用 modules 里的三方模块导入语句有智能提示了。
除此之外,我们还需要使用 webpack 提供的 ModuleFederationPlugin 插件,将 modules 项目设置为模块提供方,将主应用或者其他微应用作为消费者,从而实现模块联邦。
作为消费者(主应用以及其他微应用),我定义了一个 ModuleFederationConsumerPlugin 模块联邦消费者的插件
ts
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`
}
});
}
}
这里区分了开发环境和线上环境,启动应用的开发服务器时,我们总是会启动 modules 的开发服务器,因为它是模块提供者。
下面是作为提供者的 modules 模块使用到的插件:
ts
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}=========================`);
}
}
这里我们拿到 modules 项目中的所有 src 中的代码文件,然后生成一个 exposes 对象作为选项传入 ModuleFederationPlugin 这个插件,从而暴露了在 modules 项目中的所有在 src 中写的那些文件,可以看一下 exposes 长什么样子:
bash
==================ModuleFederationProviderPlugin=========================
{
exposes: {
'./app-manifests': './src/app-manifests.ts',
'.': './src/index.tsx',
'./@micro-zoe/micro-app': './src/@micro-zoe/micro-app.ts',
'./ahooks': './src/ahooks/index.ts',
'./antd': './src/antd/index.ts',
'./antd-token-previewer': './src/antd-token-previewer/index.ts',
'./classnames': './src/classnames/index.ts',
'./common': './src/common/index.ts',
'./lodash-es': './src/lodash-es/index.ts',
'./react': './src/react/index.ts',
'./react/jsx-dev-runtime': './src/react/jsx-dev-runtime.ts',
'./react/jsx-runtime': './src/react/jsx-runtime.ts',
'./react-dev-inspector': './src/react-dev-inspector/index.ts',
'./react-dom/client': './src/react-dom/client.ts',
'./react-dom': './src/react-dom/index.ts',
'./rxjs': './src/rxjs/index.ts',
'./@ant-design/icons': './src/@ant-design/icons/index.ts',
'./@pnpm/types': './src/@pnpm/types/index.ts',
'./@shared/common': './src/@shared/common/index.ts',
'./@micro-zoe/micro-app/polyfill/jsx-custom-event': './src/@micro-zoe/micro-app/polyfill/jsx-custom-event.ts'
}
}
==================ModuleFederationProviderPlugin=========================
这里的 modules 也是作为一个应用需要部署在线上,只不过它只作为模块提供方而没有任何的业务功能。
MicroApp 的接入过程
在 modules 项目中暴露出 MicroApp 相关 API
前面说到,我们项目的所有三房模块都从 modules 暴露出来,首先在 modules 项目中安装 MicroApp
pnpm -F=modules i @micro-zoe/micro-app
我们需要创建一个 micro/modules/src/@micro-zoe/micro-app/index.ts 文件:
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 这个文件:
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:
ts
import('./bootstrap');
micro/login/src/bootstrap.tsx:
tsx
import { createRoot } from 'modules/react-dom/client';
createRoot(document.getElementById('root')!).render(<h1>login</h1>);
主应用
apps/main/src/index.ts:
ts
import('./bootstrap');
apps/main/src/bootstrap.tsx
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'}
{/* 这里是微应用 login 的开发服务器端口 */}
url={'http://localhost:5003'}
/>
);
修改 Webpack 配置
首先我们需要配置开发服务器跨域,用来支持应用间的资源访问:
在 webpack-dev-server 的配置中,我们需要增加响应头部:
js
headers: {
'Access-Control-Allow-Origin': '*',
}
然后还需要配置应用的 output.publicPath,比如 login 这个微应用模块:
ts
output: {
publicPath: isDev ? 'http://localhost:5003/' : '/micro/login/'
}
配置 publicPath 的作用是,在微应用被嵌入在其他应用时,自身的资源路径得以拼接正确。
启动过程
我们首先启动 modules,然后启动微应用 login,最后是主应用 main。
优化
之前的过程对开发体验不友好,各种配置一大堆,维护困难,下面我们来做一些工程优化
应用端口汇总文件
我们在项目根目录写一个 ports.json 来存放项目中应用的端口号:
json
{
"@apps/main": 8000,
"@micro/login": 8001,
"@micro/modules": 8002
}
这个 key 就是应用的 package.json 中的 name 字段
在主应用中增加微应用管理工具
实现功能如下:
通过网页来控制子应用的启动和关闭,同时提供一个按钮来在新窗口中打开子应用
实现这个功能我们需要在前端网页通知 NodeJS 新开一个进程来执行启动命令。
我们可以扩展 WebpackDevServer 中间件,网页发送一个消息到后台,后台生成一个命令行命令,然后再执行,就可以了。
中间件的实现
首先我们抽象出 AppProcess 作为应用启动进程这一个功能的类
ts
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 类来作为应用名称到进程对象的映射表:
ts
class AppMap extends Map<IAppName, AppProcess> {
}
socket 处理流程:
ts
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;
}
});
这样我们就扩展了 Webpack 开发服务器,提供了应用启动控制的功能。
前端向开发服务器通信功能的实现
我们抽象出一个 MicroAppDevServer 的类,来实现启动和关闭微应用的功能。
ts
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);
}
});
});
}
}