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

简介

这一次接到需求需要做一个多个公司产品项目集合的前端工程,需要对前端模块拆分。

主要功能点如下:

使用 micro-app 和 Webpack 模块联邦实现微前端,提取公共依赖,提高子应用启动速度,减少打包后大小

实现懒启动子应用,开发者无需手动启动子应用,进入子应用页面以后后台自动启动子应用,离开子应用页面以后过段时间自动关闭后台进程。

实现开发小助手固钉,可定位页面元素到源代码并打开 VSCode ,以表格形式展示子应用描述,启动或关闭子应用。

仓库链接

目录介绍

pnpm-workspace.yaml

yaml 复制代码
packages:
  # 主应用
  - "apps/*"

![micro-table.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ccfa3b295da94671b81ba364dde20e3d~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=686&h=342&s=18946&e=png&b=fefefe)
  # 共享应用
  - "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);
        }
      });
    });
  }
}

结尾

仓库链接

相关推荐
葡萄糖o_o5 分钟前
ResizeObserver的错误
前端·javascript·html
AntBlack7 分钟前
Python : AI 太牛了 ,撸了两个 Markdown 阅读器 ,谈谈使用感受
前端·人工智能·后端
MK-mm25 分钟前
CSS盒子 flex弹性布局
前端·css·html
小小小小宇38 分钟前
CSP的使用
前端
sunbyte38 分钟前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | AnimatedNavigation(动态导航)
前端·javascript·vue.js·tailwindcss
ifanatic1 小时前
[每周一更]-(第147期):使用 Go 语言实现 JSON Web Token (JWT)
前端·golang·json
烛阴1 小时前
深入浅出地理解Python元类【从入门到精通】
前端·python
米粒宝的爸爸1 小时前
uniapp中vue3 ,uview-plus使用!
前端·vue.js·uni-app
JustHappy1 小时前
啥是Hooks?为啥要用Hooks?Hooks该怎么用?像是Vue中的什么?React Hooks的使用姿势(下)
前端·javascript·react.js
董先生_ad986ad1 小时前
C# 解析 URL URI 中的参数
前端·c#