微前端项目架构探索

446 阅读12分钟

微前端常见疑问

  1. 微前端到底解决什么问题,什么样的项目和需求背景才适合使用微前端

答:

解决的问题: 微前端本质是将大型前端应用按功能或团队边界进行拆分,各个子系统独立开发、独立部署、运行时集成,从而解决传统大型前端项目面临的一系列组织、技术和交付效率问题。

什么时候考虑微前端架构:

  1. 项目体量大、功能复杂
  2. 各业务模块生命周期不同,频繁独立迭代
  3. 需要渐进式重构或技术栈共存(如 Vue + React)
  4. 期望主子系统之间独立部署、降级互不影响

也就是说当一个系统中存在多个需要独立迭代独立模块或其他独立系统时,以及需要将其他外部系统集成进当前系统时,微前端就可以发挥较大价值,让开发者可以无需关心各系统技术栈的情况下方便的进行集成与独立开发和独立部署。

  1. 微前端架构可以给项目带来什么优势

答:

  1. 未来的高可扩展性:各个子应用的独立开发、部署与发布、未来接入其他独立业务模块的准备(如原本在主应用中运行的独立模块需要被拿出去另做独立项目运行
  2. 作为大型项目,各独立业务模块可以分团队独立开发与独立部署
  1. 微前端 对比 iframe 的优势是什么?

答:

iframe的缺点:

  1. 首当其冲是 iframe 的性能问题,虽然 iframe 提供的原生浏览器隔离方案完美的解决了js、样式隔离,但每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,首屏加载效果差,对于C端应用来说难以接受。
  2. UI 不同步,DOM 结构不共享,导致一些特定场景展示很难处理(如屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中)
  3. 全局上下文完全隔离,内存变量不共享。如iframe 内外系统的通信、数据同步等需求实现复杂。

微前端的优点:

其实就是针对性的解决了上述问题

  1. 通过共享资源可以使得子应用的本身构建产物体积降低,提高首次加载速度,且可以进行子应用缓存,不需要每次加载都是一次资源的完全重新加载。
  2. 在使用 with + proxy 隔离模式时,子应用本身挂载在主应用当中,子应用可以直接访问主应用的DOM元素和全局变量。
  3. 便捷的数据通信能力,提供了主子应用之间互相访问数据与事件监听的能力,并提供了管理子应用完整生命周期的方法。

image.png

image.png

  1. 微前端想要性能达到最优推荐使用 with 沙箱

答:

with 沙箱的性能要明显优于 iframe 沙箱

目前来看微前端架构下的最佳性能方案为:子应用external掉与主应用相同的依赖并共享主应用的缓存资源减小子应用自身打包体积 + 使用 with 隔离模式在主应用中加载子应用

image.png

  1. 将其他独立项目的在线地址作为子应用放到主应用加载时,如果这个子应用项目没有 externals 掉与主应用共用的依赖,那么微前端加载和 iframe 的加载性能是不是就差不多了?

答:

是的,如果子应用并没有针对性的拆包和共享主应用资源,那么在首次加载时的性能和使用 iframe 加载的性能是差不太多的,区别在于再次打开时可以利用缓存以及直接使用主应用的数据和缓存达到加速效果。

微前端架构设计思路:

最终的目标:

让其他开发者可以以最低的成本上手项目开发,并保证项目各个模块之间信息传递与性能表现的稳定。 当明确了技术栈框架及对应框架的如何使用之后,剩下的就是结合自己业务场景设计如何让开发者在这套技术框架下能够以最低成本的上手开发,可以只需要了解基本方法的使用就可以正常进行开发。

如果设计一个微前端架构的项目,都需要考虑哪些点?

应用状态管理层面:

  1. 主应用如何管理各个子应用的状态,并轻松的拿到各子应用的内部状态信息

    a. 子应用挂载时的初始化以及卸载时的状态数据清理

    b. 获取是否所有子应用都已经加载完成

数据通信层面:

  1. 主应用如何把想要暴露给子应用的方法和数据方便的注册到子应用当中
  2. 主应用如何方便的获取子应用提供的方法和数据
  3. 子应用如何方便的使用主应用下发的数据
  4. 子应用如何暴露自身数据给主应用
  5. 子应用与子应用之间的状态共享
  6. 数据传递的方式标准化
  7. 共享状态的同步机制

粗略简答:

主应用中维护 MicroappHostService 类 和 MicroAppManager 类,MicroappHostService中维护主应用要下发给所有子应用的方法与变量,并将其作为参数传递给 MicroAppManager 类 得到其实例化。

MicroAppManager 类中维护记录子应用挂载的 Map 变量,子应用挂载成功的回调函数,将主应用下发的方法和变量及一些变更子应用状态的自定义回调函数下发给对应子应用的注册方法,当子应用注销时调用的清除对应状态的注销方法。

子应用中维护 ClientManager 类,包含监听主应用下发数据事件,处理与存储主应用下发数据的方法,获取主应用下发数据的方法。

将 标签封装成 MicroAppItem 组件,将 MicroappHostService 实例作为参数传入。

MicroAppItem 组件的 useEffect 调用 microAppService.microappManager.registerMicroapp(microappName, {config: initConfigRef.current,});microAppService.microappManager.unregisterMicroapp(microappName);

  1. MicroAppItem 组件通过 加载子应用,子应用挂载时调用 ClientManager 实例的 mount 方法,监听主应用下发的数据。
  2. MicroAppItem 挂载完成时调用 microAppService.microappManager.registerMicroapp下发主应用数据
  3. 子应用收到主应用下发数据后进行处理并存储,存储成功后调用挂载其中主应用下发的挂载成功回调函数,告知主应用更新当前子应用的挂载状态
  4. 各个子应用的数据共享可以通过将位于主应用的 zustand 全局状态下发给每个子应用,让子应用使用的都是同一个 zustand 全局变量

image.png

开发层面:

  1. 如何方便的调试子应用、支持热更新

答:

子应用在开发模式启动开发服务器时,每次更新构建工具都会自动重新构建产物,只要保证子应用 url 访问的资源永远是最新的构建产物即可实现热更新。

  1. 如何保证一个包含众多子应用的主应用顺利启动并加载所有子应用

答:

在开启主应用之前,通过包管理工具打包构建所有子应用,并将每个子应用构建产物软连接到一个专门存放构建产物的文件夹当中,必须所有子应用全部构建成功主应用才能过继续启动。

每次加载微前端子应用时会结合环境变量,是否启动开发服务器,来决定 标签加载的地址是开发服务器地址还是构建产物文件路径。

  1. 如何保证生产环境与开发环境一致性

答:

开发环境与生产环境尽量保持使用一致的微前端隔离模式

  1. 跨项目开发体验一致性规范

    1. 封装一套 CLI 或模板,统一各子应用的开发体验,比如 lint、测试、构建流程

项目性能层面:

  1. 如何提取主应用与子应用之间的公用包,减少构建体积
  2. 模块联邦的使用

扩展层面:

  1. 子应用的独立开发、构建、发布

  2. 其他项目的低成本接入

  3. 子应用间的功能复用

具体设计实现概览:

MicroappHostService - 全局服务实例

主应用中想要暴露给所有子应用的变量和方法都维护在 MicroappHostService 类中的 cosmoApi 变量中,

MicroappHostService 类中使用 microappManager 变量接收了 MicroAppManager 类的实例,MicroAppManager 类是主应用管理所有子应用状态的中枢,在实例化时接收了 microApp(micro-app 框架实例) 与 cosmoApi 变量

在使用 MicroappItem 组件加载对应微前端子应用时将 MicroappHostService 实例当作 prop 传递进去,使得子应用中可以访问到主应用暴露的所有方法以及调用 MicroAppManager 中的方法。

export class MicroappHostService {
  // 子应用状态管理、注册卸载方法,维护在 MicroAppManager 类中
  public microappManager: MicroAppManager;
  
  location$ = new BehaviorSubject<Location | undefined>(undefined);
  navigate$ = new BehaviorSubject<NavigateFunction | undefined>(undefined);

  constructor() {
    // 构建要暴露给子应用的 API 集合
    const mainAppExposeDate: PartialCosmoApi = {
      location$: this.location$,
      navigate$: this.navigate$,
      zustandStore: {
        useLayoutStore,
        useAchievementStore,
        // 更多 store...
      },
      requestApi: {
        sendClientUpdateHome,
        request,
        requestSSE,
        uploadFile,
      },
      methodFromMaster: {
        showFullScreenIframe,
        toStudentRoom,
        // 更多方法...
      },
      bridgeApi: {
        openInBrowser,
        toastClose,
        // 更多 API...
      },
      service: {
        pagServiceInternal: PagServiceInternal,
      },
    };
     // 将主应用中所有想要暴露给子应用使用的方法交给 MicroAppManager 管理并分发给各个被加载的子应用
    this . microappManager = new  MicroAppManager ({
microApp, // 主应用微前端实例
mainAppExposeDate, // 暴露的方法
});
  }
}
// 全局唯一实例,作为使用封装的 MicroappItem 的必传参数
export const microappService = new MicroappHostService();

在子应用挂载时调用实例的 microappService.microappManager . registerMicroapp 方法,注册子应用自身基本信息到实例中,监听主应用事件,这样在主应用中就可以通过 MicroappHostService 实例获取各个子应用的注册状态。

在子应用卸载时,调用实例的 registerMicroapp 方法,清除子应用自身相关信息,解绑相关事件。

MicroAppManager - 子应用管理器

主应用当中全局唯一,用于管理所有微前端子应用的状态和附加信息,并下发主应用希望传递给子应用的数据,主要内容如下:

  1. 当前子应用的注册(初始化当前子应用相关状态)与卸载(清除当前子应用的状态)

    1. 在子应用挂载时调用实例的 microappService.microappManager . registerMicroapp 方法,注册子应用自身基本信息到实例中,同时通过 micro-app 将主应用下发的内容发送到该子应用当中,交由子应用自己处理
    2. 卸载时调用实例的 microappService.microappManager . unregisterMicroapp 方法,清除相关存储状态
  2. 当前子应用是否就绪

  3. 主应用中的所有微前端子应用是否全部就绪

  4. 通过 micro-app 的 setData 方法将主应用数据下发给当前子应用,交由子应用自己处理来自主应用的数据

  5. 对额外信息的整合与处理

export class MicroAppManager {
  private cosmoApi: PartialCosmoApi;
  protected microappHandles: Map<string, TMicroappHandle> = new Map();

  // 所有子应用的 ready 状态表
  private readyApps: Record<string, true> = {};

  // 是否所有子应用都 ready
  private allAppsReady = false;

  // 所有 ready 状态变化的回调
  private onAllAppsReadyCallbacks: (() => void)[] = [];

  // 每个子应用 ready 的监听器
  private perAppReadyCallbacks: Record<string, (() => void)[]> = {};

  constructor(args: Args) {
    this.cosmoApi = args.cosmoApi;
  }

  /**
   * 注册一个子应用
   */
  public registerMicroapp(appName: string, options?: TRegisterOptions) {
    if (this.microappHandles.has(appName)) return;

    this.allAppsReady = false;

    const baseHandle: IMicroappHandleBase = {
      appName,
      disposers: [],
      isClientReady: false,
    };

    // 子应用挂载成功后调用,告知当前类子应用挂载完成
    const onClientReady = () => {
      if (baseHandle.isClientReady) return; // 防止重复触发
      baseHandle.isClientReady = true;

      this.readyApps[appName] = true;

      // 执行所有绑定在这个 appName 上的回调
      (this.perAppReadyCallbacks[appName] || []).forEach(fn => fn());

      this.updateAllAppsReady();
    };

    // 暴露给子应用的接口
    const clientApi: IClientApi = {
      clientReady: onClientReady,
      extensions: this.extensionManager,
      message: this.messageManager.registerMicroApp(baseHandle),
      config: options?.config ?? {},
      globalPersistContext: this.globalPersistContext,
    };

    // 将 baseHandle 注册到 microappHandles 中
    const extendedHandle = this.extendMicroappHandle(baseHandle, options);
    this.microappHandles.set(appName, extendedHandle);

    // 构建初始化消息(主应用发给子应用)
    const extra = this.extendInitMessage(extendedHandle, options);
    const internalMessage: InternalInitMessage<TInternalInitMessage> = {
      type: '__micro_app_bridge_init__',
      api: clientApi,
      extra,
    };

    this.microAppInstance.setData(appName, { ...internalMessage });

    // 添加销毁函数
    baseHandle.disposers.push(() => {
      delete this.perAppReadyCallbacks[appName];
    });
  }

  /**
   * 子应用调用:监听自己 ready 的回调(替代 rxjs 的 subscribe)
   */
  public onAppReady(appName: string, cb: () => void) {
    if (this.readyApps[appName]) {
      cb(); // 已经 ready 就立即调用
      return;
    }

    if (!this.perAppReadyCallbacks[appName]) {
      this.perAppReadyCallbacks[appName] = [];
    }
    this.perAppReadyCallbacks[appName].push(cb);
  }

  /**
   * 子应用调用:监听所有子应用 ready 的回调
   */
  public onAllAppsReady(cb: () => void) {
    if (this.allAppsReady) {
      cb(); // 已经 ready 就立即调用
    } else {
      this.onAllAppsReadyCallbacks.push(cb);
    }
  }

  /**
   * 子应用卸载时调用
   */
  public unregisterMicroapp(appName: string) {
    const handle = this.microappHandles.get(appName);
    if (!handle) return;

    this.microappHandles.delete(appName);
    handle.disposers.forEach(dispose => dispose());
    this.microAppInstance.clearData(appName);

    // 更新 ready 状态
    delete this.readyApps[appName];
    delete this.perAppReadyCallbacks[appName];
    if (!handle.isClientReady) {
      this.updateAllAppsReady();
    }
  }

  /**
   * 检查是否所有子应用都 ready
   */
  private updateAllAppsReady() {
    const allAppNames = Array.from(this.microappHandles.keys());
    const readyAppNames = Object.keys(this.readyApps);

    const isAllReady = allAppNames.length > 0 && allAppNames.every(name => readyAppNames.includes(name));
    this.allAppsReady = isAllReady;

    if (isAllReady) {
      this.onAllAppsReadyCallbacks.forEach(cb => cb());
      this.onAllAppsReadyCallbacks = []; // 清空 once 回调
    }
  }
}

微前端子应用挂载初始化

在子应用的组件挂载完成时,借助 micro-app 内置方法接收并处理由主应用下发的内容,使得子应用中可以获取并使用主应用暴露的变量和方法。

  1. 获取维护组件挂载和卸载类的实例,并调用其中的 mount 方法,启动微前端通信监听主应用下发的数据
window.mount = () => {
  // 1. 获取客户端管理器实例
  const clientBridge = getClient();
  // 2. 启动微前端通信 --- 主要是执行 mount 方法中的
  // window.microApp?.addDataListener(this.dataListener, true);
  clientBridge.mount();
  
    // 3. 等待主应用数据就绪
    // clientApi$ 是 getClient() 获取的对象实例身上的 clientApi$ 属性,用于存储来自主应用下发的数据
  // 当 clientApi$ 有值时,说明微前端通信已经启动成功,子应用成功接收到了来自主应用下发的数据
  // 数据准备完成,这个时候子应用可以进行挂载了
  clientBridge.clientApi$.pipe(first(Boolean)).subscribe(api => {
    root = createRoot(document.getElementById('root')!);
    root.render(
      <StrictMode>
        <App />
      </StrictMode>,
    );

    /**
      6. 通知主应用子应用已就绪
     * 通知微前端 host 自己的 ready 状态。
     * 如果需要,可以改到更靠后的时机上报 ready,比如 App 首次渲染后
     */
    api.clientReady$.next(true);
  });
};

ClientManager 类中的核心变量与方法

  1. clientApi$ 变量: 用于存储由主应用下发的所有变量与方法
  2. mount 方法: 初始化各个状态、并调用 micro-app 监听主应用传递数据的方法,用来接收并处理主应用下发的数据
  3. dataListener 方法: 处理主应用下发的数据
  4. unmount 方法: 销毁各个状态、移除 micro-app 监听方法
export class ClientManager {
  clientApi$ = new BehaviorSubject<TClientApi | undefined>(undefined);
  
  protected unmount$ = new Subject<void>();
  protected mounted = false;
  protected disposers: (() => void)[] = [];

  constructor() {
    this.dataListener = this.dataListener.bind(this);
  }

  // 这个方法用来整合主应用下发的变量和方法
  // 最后返回的 exposedApi 包含所有主应用下发数据,最终存入当前子应用中 ClientManager 单例中的 clientApi$ 内
  extendClientApiInit(clientApi: IClientApi, initMessage: InternalInitMessage) {
    const api = initMessage.api; // 这里的 api 是主应用下发的所有方法
    const { ...otherCosmoApi } = api; 

    const exposedApi: ICosmoClientApi = {
      ...clientApi,
      ...otherCosmoApi,
    };

    return exposedApi;
  }
  
  public mount() {
    this.mounted = true;
    this.disposers.forEach(disposer => disposer());
    this.disposers.length = 0;
    this.unmount$.next();
    this.unmount$.complete();
    this.unmount$ = new Subject<void>();
    this.clientApi$.complete();
    this.clientApi$ = new BehaviorSubject<TClientApi | undefined>(undefined);
    // 子应用接收并处理由主应用下发的方法
    window.microApp?.addDataListener(this.dataListener, true);
  }

  public unmount() {
    window.microApp?.removeDataListener(this.dataListener);
    this.clientApi$.complete();
    this.unmount$.next();
    this.unmount$.complete();
    this.unmount$ = new Subject<void>();
    this.mounted = false;
    this.disposers.forEach(disposer => disposer());
    this.disposers.length = 0;
  }
    // 子应用接收并处理由主应用下发的方法
  private dataListener(data: InternalInitMessage<TInternalInitMessage>) {
    if (data.type === '__micro_app_bridge_init__') {
      // data.api 是当前子应用自身相关 api
      const api = data.api;

      const baseApi: IClientApi = {
        ...api,
      };
      // extendClientApiInit 做的事情其实就是类似结构拍平
      const extendedApi = this.extendClientApiInit(baseApi, data.extra);
      this.clientApi$.next(extendedApi); // 拍平后的数据存入 clientApi$
    }
  }
}