Skip to content

第17章 微前端性能工程

"性能优化的最高境界,不是让代码跑得更快——是让用户感知不到等待的存在。"

本章要点

  • 理解微前端场景下首屏性能的特殊性:预加载与懒加载的工程权衡
  • 掌握公共依赖提取与共享的三大策略:externals、Module Federation shared、Import Maps
  • 深入分析 Proxy 沙箱的性能开销,建立基准测试方法论
  • 系统性优化 Core Web Vitals(LCP / FID / CLS)在微前端架构下的表现

微前端架构给团队带来了独立部署和技术栈自由的巨大收益——但天下没有免费的午餐。

想象这样一个场景:你的主应用加载完毕,用户点击导航切换到订单子应用。浏览器开始下载子应用的 HTML、解析其中的 JS 和 CSS 资源、创建 Proxy 沙箱、执行子应用的 bootstrap 生命周期、最后 mount 渲染到 DOM 上。这一系列动作的总耗时,决定了用户在点击之后看到内容之前,盯着白屏或 loading 动画的时长。

在单体 SPA 中,所有代码在首次加载时就已经被打包进 bundle——路由切换只是组件的替换,几乎是瞬时的。但微前端把这个"已打包"的前提打破了。每个子应用是一个独立的部署单元,它的资源需要在激活时从网络加载。这就像把一栋完整的大楼拆成了可拼接的模块化板房——灵活了,但拼接的时候需要时间。

本章的目标是:让这个"拼接时间"尽可能接近零。

下图展示了微前端子应用加载的完整瀑布流,标注了各阶段的典型耗时和优化切入点:

我们将从四个维度系统性地剖析微前端的性能工程:首屏加载策略、公共依赖共享、沙箱运行时开销、以及 Core Web Vitals 的针对性优化。每个维度都会深入源码实现,给出可落地的优化方案和真实的性能数据。

17.1 首屏性能:预加载 vs 懒加载的权衡

17.1.1 微前端的加载瀑布流

在分析优化策略之前,我们先精确地理解微前端子应用加载的完整链路:

typescript
// 微前端子应用加载的完整瀑布流(以乾坤为例)
interface LoadWaterfall {
  // 阶段 1: 主应用加载
  mainAppLoad: {
    html: number;        // 主应用 HTML 下载 ~50ms
    mainJs: number;      // 主应用 JS 下载+执行 ~200ms
    mainCss: number;     // 主应用 CSS 下载 ~80ms
    frameworkInit: number; // 乾坤框架初始化 ~20ms
  };

  // 阶段 2: 子应用资源获取(用户点击导航后触发)
  subAppFetch: {
    htmlFetch: number;   // 获取子应用 HTML ~100ms
    htmlParse: number;   // 解析 HTML 提取资源列表 ~10ms
    jsFetch: number;     // 下载子应用 JS ~150ms
    cssFetch: number;    // 下载子应用 CSS ~60ms
  };

  // 阶段 3: 沙箱与执行
  sandboxAndExec: {
    sandboxCreate: number; // 创建 Proxy 沙箱 ~5ms
    jsExec: number;        // 执行子应用 JS ~100ms
    bootstrap: number;     // 子应用 bootstrap 生命周期 ~30ms
    mount: number;         // 子应用 mount 渲染 ~80ms
  };

  // 总耗时: 约 800-1200ms(首次加载,无缓存)
  // 用户感知: 从"点击导航"到"看到内容"的时间
}

这个瀑布流揭示了三个关键瓶颈:

  1. 网络阶段是最大瓶颈:子应用的 HTML + JS + CSS 下载占总耗时的 40% 以上
  2. 串行依赖严重:必须先下载 HTML 才能解析出 JS/CSS 地址,再下载执行
  3. 沙箱创建和 JS 执行不可并行:沙箱必须在 JS 执行之前准备好

优化策略的核心思路就是:打破串行瓶颈,将资源获取前置。

17.1.2 乾坤的 prefetchApps 实现

乾坤提供了 prefetchApps API 来预加载子应用资源。它的实现值得仔细研究——不仅因为它解决了首屏性能问题,更因为它展示了一种精妙的调度策略。

typescript
// 源码位置: qiankun/src/prefetch.ts
// 乾坤的预加载策略实现

import { importEntry } from 'import-html-entry';

/**
 * 预加载策略的核心:利用浏览器空闲时间预取子应用资源
 * 关键洞察:使用 requestIdleCallback 而非立即加载,
 * 确保预加载不影响当前页面的首屏渲染
 */
function prefetch(
  entry: string,
  opts?: ImportEntryOpts
): void {
  // 不是立即加载,而是等浏览器空闲
  if (!navigator.onLine) {
    // 离线环境下跳过预加载——这是一个容易忽略的边界条件
    return;
  }

  requestIdleCallback(async () => {
    // importEntry 会获取 HTML 并解析出 JS/CSS 资源列表
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(
      entry,
      opts
    );

    // 再次利用 requestIdleCallback 分批加载静态资源
    requestIdleCallback(() => getExternalStyleSheets());
    requestIdleCallback(() => getExternalScripts());
  });
}

/**
 * 根据配置决定预加载哪些子应用
 */
export function doPrefetchStrategy(
  apps: AppMetadata[],
  prefetchStrategy: PrefetchStrategy,
  importEntryOpts?: ImportEntryOpts
): void {
  // 策略类型判断
  if (Array.isArray(prefetchStrategy)) {
    // 精确指定需要预加载的子应用列表
    const appsToPrefetch = apps.filter((app) =>
      prefetchStrategy.includes(app.name)
    );
    prefetchAfterFirstMounting(appsToPrefetch, importEntryOpts);
  } else if (typeof prefetchStrategy === 'function') {
    // 自定义预加载策略函数——最大灵活度
    const {
      criticalAppNames = [],
      minorAppNames = [],
    } = prefetchStrategy(apps);

    // 关键子应用立即预加载
    prefetchImmediately(
      apps.filter((app) => criticalAppNames.includes(app.name)),
      importEntryOpts
    );

    // 次要子应用等第一个子应用挂载后再预加载
    prefetchAfterFirstMounting(
      apps.filter((app) => minorAppNames.includes(app.name)),
      importEntryOpts
    );
  } else if (prefetchStrategy === true) {
    // 默认策略:第一个子应用挂载后,预加载所有其他子应用
    prefetchAfterFirstMounting(apps, importEntryOpts);
  }
}

/**
 * 核心:等第一个子应用挂载完成后再预加载其他子应用
 * 这避免了预加载与首屏渲染争抢带宽
 */
function prefetchAfterFirstMounting(
  apps: AppMetadata[],
  opts?: ImportEntryOpts
): void {
  // 监听第一个子应用挂载完成的事件
  if (window.__POWERED_BY_QIANKUN__FIRST_APP_MOUNTED__) {
    apps.forEach(({ entry }) => prefetch(entry, opts));
    return;
  }

  // 订阅首次挂载事件
  window.addEventListener(
    'single-spa:first-mount',
    function listener() {
      // 获取所有未加载的子应用
      const notLoadedApps = apps.filter(
        (app) => getAppStatus(app.name) === NOT_LOADED
      );
      notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
      window.removeEventListener('single-spa:first-mount', listener);
    }
  );
}

下图展示了乾坤预加载策略的两级调度时序:

这段代码有三个设计精妙之处:

第一,两级 requestIdleCallback 调度。 第一级等待浏览器空闲后获取 HTML 并解析资源列表,第二级再在空闲时分别加载 JS 和 CSS。这确保了预加载永远不会阻塞用户的正常交互。

第二,首屏优先原则。 prefetchAfterFirstMounting 等待第一个子应用完全挂载后才开始预加载其他子应用——这意味着用户看到首屏内容不会有任何延迟,预加载是"隐形"的。

第三,自定义策略函数。 通过传入函数,开发者可以根据业务优先级将子应用分为 criticalAppNamesminorAppNames,实现精细化的预加载控制。

17.1.3 实战:定制预加载策略

理论很美好,但落地时需要根据具体业务场景选择策略。以下是三种典型场景的最佳实践:

typescript
import { registerMicroApps, start } from 'qiankun';

// 场景 1: 电商平台——基于用户行为的预测性预加载
start({
  prefetch: (apps) => {
    // 从首页出发,用户最可能访问商品详情页
    // 数据来源: 埋点分析,70% 的用户下一步点击商品
    return {
      criticalAppNames: ['product-detail'],
      minorAppNames: ['shopping-cart', 'user-center', 'order-list'],
    };
  },
});

// 场景 2: 企业后台——基于角色的预加载
start({
  prefetch: (apps) => {
    const userRole = getCurrentUserRole();

    if (userRole === 'admin') {
      // 管理员最常访问系统设置和用户管理
      return {
        criticalAppNames: ['system-settings', 'user-management'],
        minorAppNames: apps
          .map((a) => a.name)
          .filter(
            (n) => !['system-settings', 'user-management'].includes(n)
          ),
      };
    }

    if (userRole === 'operator') {
      // 运营人员最常访问数据看板和内容管理
      return {
        criticalAppNames: ['dashboard', 'content-management'],
        minorAppNames: ['order-management', 'customer-service'],
      };
    }

    // 默认策略
    return { criticalAppNames: [], minorAppNames: apps.map((a) => a.name) };
  },
});

// 场景 3: 移动端 H5——网络感知的保守策略
start({
  prefetch: (apps) => {
    const connection = (navigator as any).connection;

    if (connection) {
      // 4G/WiFi 环境: 积极预加载
      if (connection.effectiveType === '4g') {
        return {
          criticalAppNames: apps.map((a) => a.name),
          minorAppNames: [],
        };
      }

      // 3G 环境: 只预加载最关键的一个子应用
      if (connection.effectiveType === '3g') {
        return {
          criticalAppNames: [apps[0]?.name].filter(Boolean),
          minorAppNames: [],
        };
      }

      // 2G/slow-2g 环境: 完全不预加载,节省带宽
      return { criticalAppNames: [], minorAppNames: [] };
    }

    // 无法检测网络: 采用保守策略
    return { criticalAppNames: [], minorAppNames: apps.map((a) => a.name) };
  },
});

17.1.4 超越 prefetch:资源预热的进阶方案

下图对比了三种不同级别的预加载策略及其对用户感知延迟的影响:

乾坤的 prefetchApps 只解决了资源下载的问题——JS 和 CSS 被缓存到浏览器中,但子应用的 JS 并没有被执行。当用户真正切换到子应用时,仍然需要经历 JS 执行、bootstrap、mount 的过程。

对于极致性能要求的场景,我们可以实现"预热"——在后台完成子应用的 JS 执行甚至 bootstrap:

typescript
/**
 * 子应用预热方案:不仅预加载资源,还预执行子应用代码
 * 注意:这是一个高级优化,可能增加内存开销
 */
class MicroAppPrewarmer {
  private prewarmedApps = new Map<
    string,
    {
      scripts: string[];
      styles: string[];
      execScripts: () => Promise<any>;
      bootstrapped: boolean;
    }
  >();

  async prewarm(
    appName: string,
    entry: string
  ): Promise<void> {
    // 阶段 1: 获取并解析子应用资源(等同于 prefetch)
    const {
      template,
      getExternalScripts,
      getExternalStyleSheets,
      execScripts,
    } = await importEntry(entry);

    // 阶段 2: 下载所有外部资源
    const [scripts, styles] = await Promise.all([
      getExternalScripts(),
      getExternalStyleSheets(),
    ]);

    // 阶段 3: 在后台创建临时沙箱并执行 JS
    // 注意:此处创建的沙箱是临时的,真正挂载时会使用正式沙箱
    const tempSandbox = createTempSandbox();
    const appExports = await execScripts(tempSandbox.proxy);

    // 阶段 4: 调用 bootstrap 生命周期
    if (appExports.bootstrap) {
      await appExports.bootstrap();
    }

    this.prewarmedApps.set(appName, {
      scripts,
      styles,
      execScripts,
      bootstrapped: true,
    });

    console.log(
      `[Prewarmer] ${appName} 预热完成,切换时可节省约 200-400ms`
    );
  }

  isPrewarmed(appName: string): boolean {
    return this.prewarmedApps.get(appName)?.bootstrapped ?? false;
  }
}

// 使用示例
const prewarmer = new MicroAppPrewarmer();

// 主应用首屏渲染完成后,预热高优先级子应用
window.addEventListener('single-spa:first-mount', () => {
  requestIdleCallback(() => {
    prewarmer.prewarm('product-detail', '//cdn.example.com/product/');
  });
});

17.1.5 懒加载的必要性与策略

预加载不是万能药。以下场景中,懒加载反而是更好的选择:

typescript
/**
 * 懒加载策略决策树
 */
interface LazyLoadDecision {
  // 条件 1: 子应用数量多(>10个),不可能全部预加载
  manySubApps: boolean;
  // 条件 2: 移动端或弱网环境,带宽珍贵
  limitedBandwidth: boolean;
  // 条件 3: 子应用体积大(>500KB gzipped)
  largeSubApps: boolean;
  // 条件 4: 某些子应用使用频率极低
  rarelyUsedApps: boolean;
}

function shouldLazyLoad(decision: LazyLoadDecision): boolean {
  // 任何一个条件为 true,都应该考虑懒加载(至少部分子应用)
  return Object.values(decision).some(Boolean);
}

// 混合策略:核心子应用预加载 + 低频子应用懒加载
const microApps = [
  // 高频核心子应用 → 预加载
  { name: 'dashboard', entry: '//cdn/dashboard/', preload: true },
  { name: 'order',     entry: '//cdn/order/',     preload: true },

  // 中频子应用 → 首屏后预加载
  { name: 'product',   entry: '//cdn/product/',   preload: 'afterMount' },
  { name: 'user',      entry: '//cdn/user/',      preload: 'afterMount' },

  // 低频子应用 → 纯懒加载,不预加载
  { name: 'settings',  entry: '//cdn/settings/',  preload: false },
  { name: 'reports',   entry: '//cdn/reports/',    preload: false },
  { name: 'audit-log', entry: '//cdn/audit/',      preload: false },
];

深度洞察:预加载的隐性成本

预加载看起来是纯收益——提前加载资源,用户切换时更快。但实际上,预加载有三个隐性成本:1)带宽竞争——预加载的请求可能与当前页面的 API 调用和图片加载竞争带宽,尤其在 HTTP/1.1 环境下(每个域名只有 6 个并发连接);2)内存占用——预加载的 JS 和 CSS 会驻留在浏览器内存中,大量预加载可能导致低端设备的内存压力;3)缓存失效浪费——如果子应用频繁更新,预加载的资源可能在用户真正访问前就已经过期,白白浪费了带宽。最佳实践:不要对所有子应用都开启预加载,基于访问概率做优先级排序。80/20 法则在这里同样适用——通常 20% 的子应用承载了 80% 的流量。

17.2 公共依赖提取与共享策略

17.2.1 问题的本质

微前端架构下,每个子应用独立构建、独立部署。这意味着如果主应用和五个子应用都使用了 React 18,用户的浏览器会下载六份 React 代码。

typescript
// 典型的资源浪费场景
const subAppBundles = {
  'main-app':     { react: '130KB', reactDom: '120KB', antd: '350KB' },
  'order-app':    { react: '130KB', reactDom: '120KB', antd: '350KB' },
  'product-app':  { react: '130KB', reactDom: '120KB', antd: '350KB' },
  'user-app':     { react: '130KB', reactDom: '120KB', antd: '350KB' },
  'dashboard':    { react: '130KB', reactDom: '120KB', echarts: '400KB' },
  'settings':     { react: '130KB', reactDom: '120KB' },
};

// 仅 react + react-dom 就重复了 6 次
// 总计: 130 * 6 + 120 * 6 = 1500KB 的冗余下载
// gzipped 后约: 45KB * 6 = 270KB 冗余(仍然不可忽视)

17.2.2 策略一:Webpack externals + CDN

最经典的方案。将公共依赖从 bundle 中排除,通过 CDN 的 <script> 标签全局注入。

javascript
// webpack.config.js — 每个子应用的配置
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
    'react-router-dom': 'ReactRouterDOM',
    'antd': 'antd',
    'moment': 'moment',
  },
};
html
<!-- 主应用 index.html — 全局注入公共依赖 -->
<!DOCTYPE html>
<html>
<head>
  <!-- 公共依赖通过 CDN 加载,所有子应用共享 -->
  <script src="https://cdn.example.com/react@18.2.0/umd/react.production.min.js"></script>
  <script src="https://cdn.example.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
  <script src="https://cdn.example.com/react-router-dom@6.20.0/dist/umd/react-router-dom.production.min.js"></script>
  <script src="https://cdn.example.com/antd@5.12.0/dist/antd.min.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

优势

  • 实现简单,零运行时开销
  • CDN 缓存命中率高,跨站点共享
  • 主应用和子应用加载同一份代码,没有版本冲突风险

致命缺陷

typescript
// 问题 1: 版本锁定——所有子应用必须使用完全相同的版本
// 如果 order-app 需要 React 18.2 而 product-app 需要 React 18.3
// externals 方案无法处理

// 问题 2: 沙箱兼容性——乾坤的 Proxy 沙箱会拦截全局变量访问
// 子应用中 `import React from 'react'` 被编译为 `const React = window.React`
// 但在 Proxy 沙箱中 window 是代理对象,需要确保代理正确转发
// 实际工程中,这里是 bug 高发区

// 问题 3: UMD 格式依赖——不是所有库都提供 UMD 格式
// ESM-only 的库无法通过这种方式共享

// 问题 4: 加载顺序——script 标签必须按依赖顺序排列
// antd 依赖 react 和 react-dom,必须在它们之后加载
// 维护这个顺序在依赖增多时变得脆弱

17.2.3 策略二:Module Federation shared 配置

Module Federation 的 shared 配置提供了一种编译时协商的依赖共享方案——这是一个根本性的范式提升。

javascript
// host-app/webpack.config.js — 主应用(Host)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'hostApp',
      shared: {
        react: {
          singleton: true,        // 只允许加载一个版本
          requiredVersion: '^18.0.0',
          eager: true,            // 主应用立即加载,不做异步拆分
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.0.0',
          eager: true,
        },
        antd: {
          singleton: true,
          requiredVersion: '^5.0.0',
        },
        // 非 singleton 模式:允许多版本共存
        lodash: {
          singleton: false,       // 允许不同子应用使用不同版本
          requiredVersion: '^4.17.0',
        },
      },
    }),
  ],
};

// order-app/webpack.config.js — 子应用(Remote)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'orderApp',
      filename: 'remoteEntry.js',
      exposes: {
        './OrderList': './src/components/OrderList',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: '^18.0.0',
          // 注意:子应用不设置 eager,使用异步加载
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.0.0',
        },
        antd: {
          singleton: true,
          requiredVersion: '^5.0.0',
        },
      },
    }),
  ],
};

基于 VitePress 构建