微前端源码精讲

第17章 微前端性能工程

作者 杨艺韬 · 15,537 字

第17章 微前端性能工程

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

本章要点

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

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

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

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

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

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

flowchart TB
    subgraph Phase1["阶段1: 主应用加载"]
        P1_HTML["主应用 HTML ~50ms"] --> P1_JS["主应用 JS ~200ms"]
        P1_JS --> P1_Init["乾坤初始化 ~20ms"]
    end

    subgraph Phase2["阶段2: 子应用资源获取"]
        P2_HTML["fetch 子应用 HTML ~100ms"] --> P2_Parse["解析 HTML ~10ms"]
        P2_Parse --> P2_JS["下载子应用 JS ~150ms"]
        P2_Parse --> P2_CSS["下载子应用 CSS ~60ms"]
    end

    subgraph Phase3["阶段3: 沙箱与执行"]
        P3_Sandbox["创建 Proxy 沙箱 ~5ms"] --> P3_Exec["执行子应用 JS ~100ms"]
        P3_Exec --> P3_Boot["bootstrap ~30ms"]
        P3_Boot --> P3_Mount["mount 渲染 ~80ms"]
    end

    Phase1 -->|"用户点击导航"| Phase2
    Phase2 --> Phase3

    Phase2 -.->|"优化: prefetch\n将此阶段前置到空闲时间"| Prefetch["requestIdleCallback\n预加载"]
    P2_JS -.->|"优化: 公共依赖共享\n减少 JS 体积"| Shared["externals / MF shared"]

    style Phase1 fill:#e8f5e9,stroke:#2e7d32
    style Phase2 fill:#ffebee,stroke:#c62828
    style Phase3 fill:#fff3e0,stroke:#e65100
    style Prefetch fill:#e3f2fd,stroke:#1565c0

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

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

首屏性能是前端工程最重要的指标、没有之一——它直接影响用户对产品的第一印象、直接决定了跳出率和留存率Google 的研究显示——首屏加载时间每增加 1 秒、页面跳出率提高 32%;每增加 3 秒、跳出率提高 90%对于电商、SaaS、媒体这些依赖用户注意力的产品、首屏慢就是在慢慢失血微前端的首屏性能、比单体应用更挑战——因为它涉及”主应用外壳加载 + 子应用按需加载”两个串行阶段简单粗暴的实现会让微前端比单体应用慢 30-100%——因为多了一次网络往返去加载子应用入口所以微前端的性能工程、主要就是围绕”如何把这种天然劣势补回来、甚至反超”这个目标预加载是最重要的武器——它能把”串行的两阶段加载”变成”并行的重叠加载”、让用户感知不到子应用的额外加载时间

17.1.1 微前端的加载瀑布流

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

// 微前端子应用加载的完整瀑布流(以乾坤为例)
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 来预加载子应用资源。它的实现值得仔细研究——不仅因为它解决了首屏性能问题,更因为它展示了一种精妙的调度策略。

// 源码位置: 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);
    }
  );
}

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

sequenceDiagram
    participant Main as 主应用
    participant First as 首个子应用
    participant Idle as requestIdleCallback
    participant Network as 网络请求
    participant Cache as 资源缓存

    Main->>Main: start({ prefetch: true })
    Main->>First: 加载并挂载首个子应用

    Note over Main,First: 首屏渲染完成,不抢占带宽

    First-->>Main: single-spa:first-mount 事件

    Main->>Idle: 等待浏览器空闲 (第一级)
    Idle->>Network: importEntry(子应用B entry)
    Network-->>Idle: 获取 HTML,解析资源列表

    Idle->>Idle: 再次等待空闲 (第二级)
    Idle->>Network: getExternalStyleSheets()
    Idle->>Network: getExternalScripts()
    Network-->>Cache: CSS 和 JS 进入缓存

    Note over Cache: 用户导航到子应用B时,资源已在缓存中\n加载耗时从 ~300ms 降至 ~10ms

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

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

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

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

17.1.3 实战:定制预加载策略

一刀切的预加载策略”是初级工程师的做法、“场景化的预加载策略”是高级工程师的做法。**不同的业务场景、用户行为模式差异巨大——电商平台的用户大概率会从首页到商品详情到购物车;管理后台的用户大概率在几个核心模块之间切换;内容平台的用户大概率会看完一篇后看推荐的下一篇针对不同场景、预加载哪些子应用、以什么顺序、什么时机——都应该有差异化的策略。**这也是为什么乾坤把 prefetch 设计为一个函数、而不是一个简单的 boolean——函数让开发者可以注入任意的业务逻辑、根据用户角色、当前路由、网络环境动态决定预加载策略这种”把决策权交给开发者”的设计、让框架保持通用性、同时允许深度的场景化定制

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

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:资源预热的进阶方案

预加载”有不同的粒度——资源预加载、预执行、预挂载——它们各有收益和代价资源预加载最便宜——只是把 JS 和 CSS 文件下载到浏览器缓存、不消耗内存、不执行任何代码;预执行要把代码解析执行一遍、生成 JS 对象、占用内存;预挂载则更进一步、把子应用渲染到内存中(但不显示)、用户切过去只需要改个 display 属性三种方案的时间收益依次递增——从 500ms → 200ms → 80ms;但内存占用也依次递增——从几乎 0 → 10MB → 30-50MB选哪种、取决于你的优化目标——“每毫秒都重要”的产品(比如高频交互的 SaaS)适合激进预热;“内存敏感”的产品(比如移动端或低端设备)适合保守方案这种”时间 vs 空间”的取舍、在计算机系统设计的每一个层面都存在——从 CPU 缓存到数据库缓存到 CDN 缓存到本节讨论的前端预热——它们都是”用空间换时间”这个永恒主题的不同化身

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

flowchart LR
    subgraph NoPreload["无预加载"]
        NP["用户点击"] --> NP_Fetch["下载资源\n~300ms"]
        NP_Fetch --> NP_Exec["执行 JS\n~100ms"]
        NP_Exec --> NP_Mount["mount\n~80ms"]
        NP_Mount --> NP_Show["显示内容\n总计 ~500ms"]
    end

    subgraph Prefetch["prefetch 预加载"]
        PF["用户点击"] --> PF_Exec["执行 JS (已缓存)\n~100ms"]
        PF_Exec --> PF_Mount["mount\n~80ms"]
        PF_Mount --> PF_Show["显示内容\n总计 ~200ms"]
    end

    subgraph Prewarm["预热 (资源+执行)"]
        PW["用户点击"] --> PW_Mount["mount (已 bootstrap)\n~80ms"]
        PW_Mount --> PW_Show["显示内容\n总计 ~80ms"]
    end

    style NoPreload fill:#ffebee,stroke:#c62828
    style Prefetch fill:#fff3e0,stroke:#e65100
    style Prewarm fill:#e8f5e9,stroke:#2e7d32

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

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

/**
 * 子应用预热方案:不仅预加载资源,还预执行子应用代码
 * 注意:这是一个高级优化,可能增加内存开销
 */
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 懒加载的必要性与策略

**“预加载”和”懒加载”是一对看似对立、实际互补的策略——**预加载说”可能用到的先下载”、懒加载说”真用到才下载”——两者都有道理、关键看场景。**预加载适合”高概率使用”的资源——用户很可能会切到这个子应用、提前加载就能消除等待时间。**懒加载适合”低概率使用”的资源——用户可能一辈子都不会打开这个子应用、预加载是纯浪费好的性能工程师、应该能根据每个子应用的”使用概率”做差异化策略——高频的预加载、低频的懒加载、中间的根据用户行为数据动态调整这种”根据数据做差异化”的思维、比”一刀切地全部预加载”或”一刀切地全部懒加载”要高级得多

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

/**
 * 懒加载策略决策树
 */
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 公共依赖提取与共享策略

公共依赖共享、是微前端性能优化里收益最大的一个环节——做对了、总包体积能降低 50-70%;做错了、不仅没降反而增加了风险这个话题的难点不在技术层面、在取舍层面**——你在”每个子应用独立打包依赖”和”多个子应用共享一份依赖”之间必须选一个、但两者各有代价**。独立打包——每个子应用有自己的 React、lodash、antd、总包变大但版本自由;共享一份——总包减小但必须统一版本、任何子应用升级都可能影响其他子应用这种”没有银弹”的取舍、在性能工程里极其常见——优化往往不是”A 比 B 好”、而是”A 用在场景 X、B 用在场景 Y本节讨论的三种策略(externals + CDN、Module Federation shared、Import Maps)、就是三种不同的取舍方案——理解它们各自适合的场景、比记住它们的 API 细节更重要

17.2.1 问题的本质

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

// 典型的资源浪费场景
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

externals + CDN”是最古老、最简单、最广泛使用的依赖共享方案——它在 2010 年代的 jQuery 时代就已经普及核心思路是”把公共依赖从 bundle 里排除、转而通过 script 标签全局加载”——这让所有子应用都访问同一个全局变量(window.React、window.Vue、window.antd)、自然达到共享效果这种方案的优势是”零运行时开销 + CDN 缓存命中率高 + 实现极简”;劣势是”必须所有子应用使用同一版本、升级一致性强约束。**为什么我们要在 Module Federation 出现之后还讨论这个”老方案”?**因为在很多实际项目里、externals + CDN 反而是最合适的——如果你的所有子应用本来就用同一版本的 React、没有版本分歧的需求、那 externals 的简单性就是巨大优势、完全没必要引入 Module Federation 的复杂性工程决策永远不是”用最新最酷的技术”、而是”用最适合场景的技术

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

// webpack.config.js — 每个子应用的配置
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
    'react-router-dom': 'ReactRouterDOM',
    'antd': 'antd',
    'moment': 'moment',
  },
};
<!-- 主应用 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 缓存命中率高,跨站点共享
  • 主应用和子应用加载同一份代码,没有版本冲突风险

致命缺陷

// 问题 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 配置提供了一种编译时协商的依赖共享方案——这是一个根本性的范式提升。

// 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',
        },
      },
    }),
  ],
};

Module Federation 的 shared 在运行时的工作原理值得深入理解:

// Module Federation shared 模块的运行时协商过程(简化)
// 源码位置: webpack/lib/sharing/ConsumeSharedModule.js

/**
 * 当子应用需要使用 react 时,运行时会执行以下协商逻辑:
 */
function resolveSharedModule(
  moduleId: string,   // 如 'react'
  requiredVersion: string,
  singleton: boolean
): Module {
  // 步骤 1: 检查全局共享作用域中是否已有该模块
  const scope = __webpack_share_scopes__['default'];
  const existingModule = scope[moduleId];

  if (existingModule) {
    if (singleton) {
      // singleton 模式: 直接使用已加载的版本
      // 如果版本不满足要求,打印警告但仍使用已加载版本
      if (!satisfies(existingModule.version, requiredVersion)) {
        console.warn(
          `Unsatisfied version ${existingModule.version} ` +
          `of shared singleton module ${moduleId} ` +
          `(required ${requiredVersion})`
        );
      }
      return existingModule;
    } else {
      // 非 singleton 模式: 如果版本满足要求,复用;否则加载自己的版本
      if (satisfies(existingModule.version, requiredVersion)) {
        return existingModule;
      }
      // 版本不满足,回退到自己的 bundled 版本
      return loadOwnVersion(moduleId);
    }
  }

  // 步骤 2: 共享作用域中没有该模块,加载自己的版本并注册到共享作用域
  const ownModule = loadOwnVersion(moduleId);
  scope[moduleId] = ownModule;
  return ownModule;
}

Module Federation shared 的核心优势

  • 版本协商是自动的:不需要手动管理 CDN 版本号
  • 支持多版本共存:非 singleton 模式下,不同子应用可以使用不同版本
  • 按需加载:只有真正被使用的模块才会被加载
  • 编译时检查:版本冲突在构建时就能发现

17.2.4 策略三:Import Maps — 浏览器原生方案

Import Maps 是浏览器原生支持的模块映射方案,为微前端依赖共享提供了零运行时开销的新可能:

<!-- 在主应用 HTML 中声明 Import Map -->
<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18.2.0",
    "react-dom": "https://esm.sh/react-dom@18.2.0",
    "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
    "react-router-dom": "https://esm.sh/react-router-dom@6.20.0"
  }
}
</script>
// 子应用使用原生 ESM import,浏览器自动解析到 Import Map 中的地址
// 注意:子应用需要以 ESM 格式构建
import React from 'react';        // → https://esm.sh/react@18.2.0
import ReactDOM from 'react-dom'; // → https://esm.sh/react-dom@18.2.0

// 子应用的构建配置(以 Vite 为例)
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: ['react', 'react-dom', 'react-router-dom'],
      output: {
        format: 'esm',  // 必须是 ESM 格式
      },
    },
  },
});

Import Maps 的局限

// 局限 1: 不支持动态映射——Import Map 一旦声明就不可修改
// 这意味着无法根据子应用的需求动态调整依赖版本

// 局限 2: 与乾坤沙箱的兼容问题
// 乾坤通过 Proxy 拦截 window 上的属性访问
// 但 ESM import 是引擎级别的静态解析,不经过 Proxy
// 这可能导致子应用绕过沙箱直接访问全局模块

// 局限 3: 不支持版本协商
// 如果两个子应用需要不同版本的同一个库,Import Maps 无法处理
// 只能映射到一个确定的 URL

// 局限 4: 浏览器兼容性(2026 年基本不再是问题)
// Chrome 89+, Firefox 108+, Safari 16.4+

17.2.5 三种策略的对比与选型

interface SharedDependencyStrategy {
  name: string;
  versionFlexibility: 'none' | 'negotiated' | 'none';
  runtimeOverhead: 'zero' | 'minimal' | 'zero';
  sandboxCompatibility: 'fragile' | 'good' | 'fragile';
  buildToolRequirement: 'any' | 'webpack/rspack' | 'esm-capable';
  bestFor: string;
}

const strategies: SharedDependencyStrategy[] = [
  {
    name: 'Webpack externals + CDN',
    versionFlexibility: 'none',        // 所有子应用必须用同一版本
    runtimeOverhead: 'zero',            // 纯 CDN,无运行时协商
    sandboxCompatibility: 'fragile',    // 依赖全局变量,沙箱兼容需小心
    buildToolRequirement: 'any',        // 任何构建工具都支持 externals
    bestFor: '技术栈统一、版本一致的项目',
  },
  {
    name: 'Module Federation shared',
    versionFlexibility: 'negotiated',   // 运行时版本协商
    runtimeOverhead: 'minimal',         // 有少量运行时协商代码(~5KB)
    sandboxCompatibility: 'good',       // 不依赖全局变量,与沙箱正交
    buildToolRequirement: 'webpack/rspack', // 需要 MF 插件支持
    bestFor: '新项目、需要渐进升级的项目',
  },
  {
    name: 'Import Maps',
    versionFlexibility: 'none',         // 静态映射,不支持协商
    runtimeOverhead: 'zero',            // 浏览器原生,零开销
    sandboxCompatibility: 'fragile',    // ESM 绕过 Proxy 沙箱
    buildToolRequirement: 'esm-capable', // 需要 ESM 格式输出
    bestFor: '不使用运行时沙箱的微前端方案',
  },
];

深度洞察:共享依赖的”不可能三角”

微前端的公共依赖共享存在一个”不可能三角”:版本自由度运行时零开销完美沙箱隔离——你最多只能同时满足两个。externals 方案牺牲版本自由度换取零开销和可控的沙箱行为;Module Federation 牺牲一点运行时开销换取版本协商和沙箱兼容;Import Maps 牺牲沙箱隔离换取零开销和原生体验。理解这个”不可能三角”,你就不会再纠结于”哪个方案最好”——而是根据项目约束选择放弃哪个角

17.3 沙箱的性能开销与优化

沙箱的性能开销、是微前端里最容易被误解的话题——很多工程师听说”Proxy 会有性能开销”就望而生畏、放弃使用沙箱;另一些工程师则完全忽视开销、在高频场景里踩了大坑。**正确的认知应该是——Proxy 沙箱的开销存在、但在绝大多数场景下可以忽略——37-77ns 的单次访问开销、对于业务代码的”偶尔”访问完全不构成瓶颈;但在 Canvas 渲染、大规模数据处理、高频事件处理这些”每秒数万次访问”的场景下、开销就会累积成问题性能工程的核心原则是”在真正的瓶颈上投入”——不要过度优化不是瓶颈的地方、也不要忽视真正的瓶颈本节将通过基准测试量化开销、识别真实的瓶颈场景、提供针对性的优化手段——让你对沙箱性能有一个准确、数据驱动的认知

17.3.1 Proxy 沙箱的运行时开销

乾坤的 ProxySandbox 是微前端运行时隔离的核心机制。它通过 ES6 Proxy 拦截子应用对 window 对象的所有属性访问和修改。但 Proxy 并非零成本的——每次属性访问都经过一层拦截函数,在高频操作场景下,这个开销可能成为性能瓶颈。

让我们先看看 ProxySandbox 的核心实现,然后进行基准测试:

// 源码位置: qiankun/src/sandbox/proxySandbox.ts(简化版)
class ProxySandbox {
  private updatedValueSet = new Set<PropertyKey>();
  private fakeWindow: Window;
  public proxy: WindowProxy;

  constructor() {
    const rawWindow = window;
    // 创建一个裸对象作为 fakeWindow
    this.fakeWindow = this.createFakeWindow(rawWindow);

    const proxy = new Proxy(this.fakeWindow, {
      get: (target, prop: string) => {
        // 拦截属性读取
        // 某些属性必须从原始 window 获取(如 document、location)
        if (prop === 'window' || prop === 'self' || prop === 'globalThis') {
          return proxy;
        }
        if (prop === 'document' || prop === 'location') {
          return rawWindow[prop];
        }
        if (prop === 'hasOwnProperty') {
          return (key: string) =>
            target.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
        }

        // 优先从 fakeWindow 读取(子应用设置的值)
        // 否则从原始 window 读取
        const value = prop in target ? target[prop] : rawWindow[prop];

        // 如果是函数且需要绑定 this
        if (typeof value === 'function' && !this.isBound(value)) {
          return value.bind(rawWindow);
        }
        return value;
      },

      set: (target, prop: string, value) => {
        // 拦截属性写入——始终写入 fakeWindow,不污染原始 window
        target[prop] = value;
        this.updatedValueSet.add(prop);
        return true;
      },

      has: (target, prop) => {
        return prop in target || prop in rawWindow;
      },

      deleteProperty: (target, prop: string) => {
        if (target.hasOwnProperty(prop)) {
          delete target[prop];
          this.updatedValueSet.delete(prop);
        }
        return true;
      },
    });

    this.proxy = proxy;
  }
}

17.3.2 基准测试:量化 Proxy 的开销

Benchmark”(基准测试)是性能工程的显微镜——它让你能精确量化”某个操作到底耗时多少做 benchmark 的正确姿势有几个原则——(1) 隔离单一变量(测 Proxy 开销就只测 Proxy、不要和其他操作混在一起);(2) 跑足够多次取平均(单次测量有噪音、跑 10000 次才有统计意义);(3) 使用合适的精度工具(performance.now() 比 Date.now() 精度高);(4) 警惕 JIT 优化(V8 会对热代码做特殊优化、第一次和第十次可能差异很大)**。这些原则、让基准测试从”拍脑袋的数字”变成”可信的数据本节的 benchmark 就是按这些原则设计的——结果显示 Proxy 单次访问 37-77ns、这个数字在 V8 里已经相当接近”理论最优”——V8 对 Proxy 做了大量 JIT 优化

空谈开销没有意义,让我们用基准测试来量化:

/**
 * Proxy 沙箱性能基准测试
 * 测试环境: Chrome 120, M1 MacBook Pro, 16GB RAM
 */

// 测试 1: 属性读取性能
function benchmarkPropertyRead() {
  const iterations = 1_000_000;

  // 基线: 直接访问 window
  const t0 = performance.now();
  for (let i = 0; i < iterations; i++) {
    const val = window.innerWidth;
    const val2 = window.document;
    const val3 = window.location;
  }
  const directTime = performance.now() - t0;

  // 对比: 通过 Proxy 沙箱访问
  const sandbox = new ProxySandbox();
  const proxyWindow = sandbox.proxy;

  const t1 = performance.now();
  for (let i = 0; i < iterations; i++) {
    const val = proxyWindow.innerWidth;
    const val2 = proxyWindow.document;
    const val3 = proxyWindow.location;
  }
  const proxyTime = performance.now() - t1;

  return {
    directTime: `${directTime.toFixed(2)}ms`,
    proxyTime: `${proxyTime.toFixed(2)}ms`,
    overhead: `${((proxyTime / directTime - 1) * 100).toFixed(1)}%`,
  };
}

// 测试 2: 属性写入性能
function benchmarkPropertyWrite() {
  const iterations = 1_000_000;

  const t0 = performance.now();
  const obj: Record<string, number> = {};
  for (let i = 0; i < iterations; i++) {
    obj[`prop_${i % 100}`] = i;
  }
  const directTime = performance.now() - t0;

  const sandbox = new ProxySandbox();
  const proxyWindow = sandbox.proxy as any;

  const t1 = performance.now();
  for (let i = 0; i < iterations; i++) {
    proxyWindow[`prop_${i % 100}`] = i;
  }
  const proxyTime = performance.now() - t1;

  return {
    directTime: `${directTime.toFixed(2)}ms`,
    proxyTime: `${proxyTime.toFixed(2)}ms`,
    overhead: `${((proxyTime / directTime - 1) * 100).toFixed(1)}%`,
  };
}

// 测试 3: 函数调用(经过 bind 转换)
function benchmarkFunctionCall() {
  const iterations = 1_000_000;

  const t0 = performance.now();
  for (let i = 0; i < iterations; i++) {
    window.setTimeout;  // 仅访问函数引用
  }
  const directTime = performance.now() - t0;

  const sandbox = new ProxySandbox();
  const proxyWindow = sandbox.proxy as any;

  const t1 = performance.now();
  for (let i = 0; i < iterations; i++) {
    proxyWindow.setTimeout; // Proxy get + bind
  }
  const proxyTime = performance.now() - t1;

  return {
    directTime: `${directTime.toFixed(2)}ms`,
    proxyTime: `${proxyTime.toFixed(2)}ms`,
    overhead: `${((proxyTime / directTime - 1) * 100).toFixed(1)}%`,
  };
}

/**
 * 典型测试结果(Chrome 120, M1 MacBook Pro):
 *
 * ┌──────────────────┬────────────┬────────────┬──────────┐
 * │ 测试项            │ 直接访问    │ Proxy 访问  │ 额外开销  │
 * ├──────────────────┼────────────┼────────────┼──────────┤
 * │ 属性读取 (100万次) │ 12.3ms     │ 48.7ms     │ +296%    │
 * │ 属性写入 (100万次) │ 18.1ms     │ 62.4ms     │ +245%    │
 * │ 函数引用 (100万次) │ 11.8ms     │ 89.2ms     │ +656%    │
 * └──────────────────┴────────────┴────────────┴──────────┘
 *
 * 关键解读:
 * - 单次 Proxy 属性读取约 49ns,单次直接读取约 12ns
 * - 差距是 ~37ns/次——对于绝大多数应用来说可以忽略
 * - 函数引用开销较大(因为 bind),但实际场景中函数引用很少在热循环中执行
 * - 真正需要关注的是:是否有代码在高频循环中大量访问 window 属性
 */

17.3.3 哪些场景下沙箱开销真正成为问题

**“识别真实瓶颈”是性能优化最难的一步——很多工程师凭直觉觉得”这里可能慢”、花大量精力优化、结果发现根本不是瓶颈;而真正的瓶颈却一直没被发现专业的做法是”先用 Chrome DevTools Performance 录制一段操作、看 flame chart 里哪个函数占用时间最多”——数据会告诉你真相。**对于 Proxy 沙箱的开销、真正会成为问题的场景很特定——高频循环(每秒数万次)+ 大量 window 属性访问 = 可测量的性能退化如果你的代码不在这种场景下、基本不需要担心沙箱开销这种”用数据识别真瓶颈、对症下药”的方法、比”盲目优化所有可能的瓶颈”要高效一百倍

基准测试告诉我们:单次 Proxy 访问的开销约为 37-77ns。这在正常的业务逻辑中几乎不可感知。但有几类场景需要警惕:

// 场景 1: 高频动画中访问 window 属性
// ❌ 问题代码
function animate() {
  // 每帧都通过 Proxy 访问 window.innerWidth
  const width = window.innerWidth;  // 在沙箱中这是 proxy.innerWidth
  const height = window.innerHeight;
  element.style.transform = `translate(${width / 2}px, ${height / 2}px)`;
  requestAnimationFrame(animate);
}
// 60fps 下每秒 120 次 Proxy 访问——开销约 5μs/帧,几乎可忽略
// 但如果动画逻辑更复杂,涉及数十次属性访问,可能累积到 50-100μs/帧

// ✅ 优化: 在循环外缓存值
function animateOptimized() {
  // 一次性读取并缓存
  const width = window.innerWidth;
  const height = window.innerHeight;

  function frame() {
    // 使用缓存的值,不再触发 Proxy
    element.style.transform = `translate(${width / 2}px, ${height / 2}px)`;
    requestAnimationFrame(frame);
  }
  frame();

  // 只在 resize 时更新缓存
  window.addEventListener('resize', () => {
    // 这个事件频率很低,Proxy 开销可忽略
    animateOptimized();
  });
}

// 场景 2: 第三方库的内部高频操作
// 某些库(如 canvas 渲染库、物理引擎)在内部循环中频繁访问全局变量
// 典型案例: Three.js 的渲染循环、ECharts 的大数据量图表渲染

// 场景 3: 大量定时器的创建和清除
// ❌ 问题代码
for (let i = 0; i < 1000; i++) {
  const id = setTimeout(() => {}, 0);  // 每次都经过 Proxy
  clearTimeout(id);
}

// ✅ 优化: 批量操作或使用原生引用
const rawSetTimeout = window.__QIANKUN_RAW_WINDOW__?.setTimeout ?? setTimeout;
for (let i = 0; i < 1000; i++) {
  const id = rawSetTimeout(() => {}, 0);  // 绕过 Proxy
  clearTimeout(id);
}

17.3.4 沙箱性能优化的四种手段

性能优化的四种手段”——这个清单是经过大量生产实践总结出来的”武器库”——不是理论、是实战属性访问缓存、快照降级、选择性关闭沙箱、raw 引用直通——这四个手段各自针对一种特定类型的性能瓶颈。**掌握它们的关键不是”背下来”、而是”知道在什么场景用哪个”——这种”场景-手段”的映射能力、是一个经验丰富的性能工程师最宝贵的资产。**记住一个原则——性能优化永远要有针对性、不能”把所有手段都堆上去”——过度优化往往比不优化更糟糕、它会让代码变复杂、维护成本变高、反而引入新的 bug

/**
 * 优化手段 1: 属性访问缓存
 * 对频繁访问的属性做一级缓存,避免每次都走 Proxy 拦截
 */
class OptimizedProxySandbox extends ProxySandbox {
  // 频繁访问的属性白名单缓存
  private hotPropertyCache = new Map<string, any>();
  private readonly HOT_PROPERTIES = new Set([
    'document', 'location', 'navigator', 'performance',
    'innerWidth', 'innerHeight', 'devicePixelRatio',
  ]);

  createProxy() {
    const { hotPropertyCache, HOT_PROPERTIES } = this;

    return new Proxy(this.fakeWindow, {
      get: (target, prop: string) => {
        // 热属性走缓存
        if (HOT_PROPERTIES.has(prop) && hotPropertyCache.has(prop)) {
          return hotPropertyCache.get(prop);
        }

        const value = this.originalGet(target, prop);

        // 将热属性存入缓存
        if (HOT_PROPERTIES.has(prop)) {
          hotPropertyCache.set(prop, value);
        }

        return value;
      },
    });
  }

  // 在 resize/orientationchange 等事件时清除缓存
  invalidateCache() {
    this.hotPropertyCache.clear();
  }
}

/**
 * 优化手段 2: 快照沙箱用于低端设备
 * 在不支持 Proxy 或 Proxy 性能差的设备上,使用快照沙箱
 */
function createOptimalSandbox(): Sandbox {
  // 检测 Proxy 性能
  const proxyPerf = measureProxyPerformance();

  if (proxyPerf.overhead > 500) {
    // Proxy 开销过大(低端设备),降级到快照沙箱
    console.warn(
      '[Sandbox] Proxy overhead too high, falling back to SnapshotSandbox'
    );
    return new SnapshotSandbox();
  }

  return new ProxySandbox();
}

/**
 * 优化手段 3: 沙箱逃逸(有意为之的"不隔离")
 * 对于性能敏感的子应用,可以选择不使用沙箱
 */
registerMicroApps([
  {
    name: 'high-perf-app',
    entry: '//cdn.example.com/canvas-app/',
    container: '#container',
    props: {
      // 告诉乾坤不为这个子应用创建沙箱
      sandbox: false,
      // 但需要子应用自己保证不污染全局环境
    },
  },
]);

/**
 * 优化手段 4: Web Worker 沙箱——将隔离移出主线程
 * 实验性方案:在 Worker 中运行子应用的逻辑部分
 */
class WorkerSandbox {
  private worker: Worker;
  private callbackMap = new Map<number, Function>();
  private callId = 0;

  constructor(scriptUrl: string) {
    this.worker = new Worker(scriptUrl);
    this.worker.onmessage = (event) => {
      const { id, result } = event.data;
      const callback = this.callbackMap.get(id);
      if (callback) {
        callback(result);
        this.callbackMap.delete(id);
      }
    };
  }

  // 在 Worker 中执行代码,主线程零隔离开销
  execute(code: string): Promise<any> {
    return new Promise((resolve) => {
      const id = this.callId++;
      this.callbackMap.set(id, resolve);
      this.worker.postMessage({ id, code });
    });
  }
}

深度洞察:沙箱开销的”80/20 法则”

在实际生产环境中,沙箱性能问题遵循严格的 80/20 法则:80% 的子应用完全不会感受到 Proxy 沙箱的开销——因为普通的业务逻辑(表单提交、列表渲染、API 调用)不涉及高频的 window 属性访问。真正受影响的是那 20% 的特殊场景:Canvas 密集渲染、大数据量图表、WebGL 应用、复杂动画引擎。正确的策略不是”优化沙箱让它更快”,而是”识别哪些子应用需要沙箱、哪些不需要”。 性能工程的核心从来不是让一切都变快,而是找到真正的瓶颈。

17.4 LCP / FID / CLS 在微前端场景下的优化

Core Web Vitals 是 Google 在 2020 年定义的一组”用户体验核心指标”——LCP(最大内容绘制)、FID/INP(交互响应)、CLS(布局稳定)它们的定义、不是凭空而来、是基于大规模用户研究得出的”最能影响用户感知的三个性能维度。**更重要的是——**从 2021 年开始、Core Web Vitals 已经成为 Google 搜索排名的正式因子——一个 Core Web Vitals 得分差的网站、在搜索结果里的排名会被降低这让”优化 Core Web Vitals”从”用户体验改进”上升为”业务增长的必需。**微前端的 Core Web Vitals 优化、有一些独特的挑战——LCP 经常被”子应用加载延迟”拖慢;CLS 经常因为”子应用挂载时的布局变化”恶化;FID/INP 经常因为”子应用初始化时的 JS 阻塞”下降本节针对微前端特有的这些挑战、给出具体的优化手段

17.4.1 Core Web Vitals 与微前端的特殊挑战

Google 的 Core Web Vitals 已经成为衡量 Web 应用用户体验的行业标准。但微前端架构给这三个指标带来了独特的挑战:

/**
 * Core Web Vitals 在微前端场景下的特殊挑战
 */
interface MicroFrontendCWVChallenges {
  LCP: {
    // Largest Contentful Paint — 最大内容绘制
    challenge: '子应用的主要内容需要等加载完成后才能渲染';
    typicalImpact: '增加 500-2000ms';
    rootCause: '串行加载链路: 主应用加载 → 框架初始化 → 子应用资源下载 → 渲染';
  };

  FID: {
    // First Input Delay — 首次输入延迟(被 INP 替代但概念相同)
    challenge: '子应用 JS 执行阻塞主线程';
    typicalImpact: '增加 100-500ms';
    rootCause: '子应用的 JS bundle 需要在主线程解析执行,阻塞用户交互';
  };

  CLS: {
    // Cumulative Layout Shift — 累积布局偏移
    challenge: '子应用挂载时的 DOM 插入导致布局跳动';
    typicalImpact: 'CLS 增加 0.05-0.3';
    rootCause: '容器元素的高度在子应用挂载前后发生变化';
  };
}

17.4.2 LCP 优化:缩短子应用的”可见时间”

LCP(Largest Contentful Paint)的深层含义、是”用户感觉页面可用的那一刻——不是”白屏消失的那一刻”(那是 FP)、也不是”DOM 加载完成的那一刻”(那是 DOMContentLoaded)——而是”最大的主要内容块渲染完成的那一刻”**。这个定义非常贴近用户的真实感知——用户真正关心的不是浏览器做完了所有工作、而是”我能看到我要的内容了Google 的研究发现——LCP 时间超过 2.5 秒、用户开始感到”不耐烦”;超过 4 秒、很多用户会选择关闭页面。**所以优化 LCP 的本质、不是”让浏览器尽早完成渲染”、而是”让用户尽早看到有意义的内容”——骨架屏、占位图、关键内容优先加载——都是围绕这个目标设计的从技术实现到产品体验的这种价值链条、让性能优化从”工程师的快乐”变成”用户的价值”——这是性能工程最有意义的一面

LCP 衡量的是页面最大内容元素的渲染时间。在微前端中,子应用的主要内容通常就是 LCP 元素——这意味着子应用的完整加载链路直接决定了 LCP 值。

/**
 * 策略 1: 骨架屏——让 LCP 元素提前出现
 * 核心思路: 在子应用加载完成之前,先渲染一个骨架屏
 * 骨架屏本身可以成为 LCP 元素(如果它足够大)
 */

// 主应用中为每个子应用定义骨架屏
const skeletonMap: Record<string, string> = {
  'order-app': `
    <div class="skeleton-container" style="min-height:600px;">
      <div class="skeleton-header" style="height:48px;background:#f0f0f0;margin-bottom:16px;border-radius:4px;"></div>
      <div class="skeleton-table">
        ${Array(5).fill(`
          <div style="display:flex;gap:16px;margin-bottom:12px;">
            <div style="flex:1;height:20px;background:#f0f0f0;border-radius:4px;"></div>
            <div style="flex:2;height:20px;background:#f0f0f0;border-radius:4px;"></div>
            <div style="flex:1;height:20px;background:#f0f0f0;border-radius:4px;"></div>
          </div>
        `).join('')}
      </div>
    </div>
  `,
  'dashboard-app': `
    <div class="skeleton-container" style="min-height:600px;">
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
        <div style="height:200px;background:#f0f0f0;border-radius:8px;"></div>
        <div style="height:200px;background:#f0f0f0;border-radius:8px;"></div>
        <div style="height:300px;background:#f0f0f0;border-radius:8px;grid-column:span 2;"></div>
      </div>
    </div>
  `,
};

// 在子应用容器中插入骨架屏
function showSkeleton(appName: string, container: HTMLElement): void {
  const skeleton = skeletonMap[appName];
  if (skeleton) {
    container.innerHTML = skeleton;
    // 标记骨架屏出现时间,用于性能追踪
    performance.mark(`skeleton-shown:${appName}`);
  }
}

// 子应用挂载后清除骨架屏
function hideSkeleton(container: HTMLElement): void {
  // 使用 fade-out 动画避免视觉跳动
  container.style.transition = 'opacity 0.2s ease-out';
  container.style.opacity = '0';
  setTimeout(() => {
    container.innerHTML = '';
    container.style.opacity = '1';
  }, 200);
}

/**
 * 策略 2: SSR/SSG 预渲染子应用首屏
 * 在服务端预渲染子应用的首屏 HTML,直接嵌入主应用的 HTML 响应中
 */

// Node.js 中间件:根据路由预渲染对应子应用的首屏
async function microAppSSRMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const route = req.path;

  // 根据路由确定需要预渲染哪个子应用
  const appConfig = matchMicroApp(route);
  if (!appConfig) {
    next();
    return;
  }

  try {
    // 获取子应用的预渲染 HTML(可以缓存)
    const prerenderedHtml = await fetchPrerenderedContent(
      appConfig.ssrEndpoint,
      route
    );

    // 将预渲染内容注入主应用模板
    const mainTemplate = await getMainAppTemplate();
    const finalHtml = mainTemplate.replace(
      '<!-- MICRO_APP_PLACEHOLDER -->',
      `<div id="${appConfig.container}" data-prerendered="true">
        ${prerenderedHtml}
      </div>`
    );

    res.send(finalHtml);
  } catch (error) {
    // SSR 失败时降级为客户端渲染
    console.error(`SSR failed for ${appConfig.name}:`, error);
    next();
  }
}

/**
 * 策略 3: 关键 CSS 内联
 * 将子应用的关键 CSS 内联到主应用 HTML 中,避免 CSS 下载阻塞渲染
 */
function inlineCriticalCSS(
  appName: string,
  container: HTMLElement
): void {
  // 预先提取并内联子应用的首屏关键 CSS
  const criticalCSS = getCriticalCSSForApp(appName);
  if (criticalCSS) {
    const style = document.createElement('style');
    style.setAttribute('data-micro-app', appName);
    style.textContent = criticalCSS;
    document.head.appendChild(style);
  }
}

17.4.3 FID / INP 优化:减少子应用 JS 的主线程阻塞

FID 和 INP 的本质、是”用户点下去之后、浏览器多久能响应”——这是用户感知最强的性能维度之一一个 LCP 慢 500ms 的页面、用户可能还能忍;但一个点击按钮后 500ms 才有反应的页面、用户会立刻觉得”这个网站坏了主线程阻塞是 FID/INP 变差的头号杀手——JavaScript 是单线程的、主线程被一个长任务占用时、所有用户交互都会被阻塞微前端的 JS bundle 更大、解析执行更耗时、所以 FID/INP 优化的挑战也更大优化的核心手段、是”让 JS 执行变成分片的小任务”——不要一次性跑完几百毫秒的代码、而是切成几十个几毫秒的小任务、中间留出让浏览器响应用户交互的机会scheduler.yield()setTimeout 这些看似简单的 API、在性能优化里的意义巨大——它们是”把长任务拆分成短任务”的关键工具**。

FID(First Input Delay)及其后继指标 INP(Interaction to Next Paint)衡量用户交互的响应速度。在微前端中,子应用的 JS 执行是主线程阻塞的主要来源。

/**
 * 策略 1: JS 执行分片——用 scheduler 拆分长任务
 */
class ScriptExecutor {
  /**
   * 将大的 JS 执行任务拆分为多个小任务
   * 每个小任务之间让出主线程,允许浏览器处理用户输入
   */
  async executeWithYielding(
    scripts: string[],
    sandbox: ProxySandbox
  ): Promise<void> {
    for (const script of scripts) {
      // 估算脚本执行时间
      const estimatedTime = this.estimateExecutionTime(script);

      if (estimatedTime > 50) {
        // 超过 50ms 的长任务需要分片
        await this.yieldToMain();
      }

      // 在沙箱中执行脚本
      sandbox.exec(script);
    }
  }

  /**
   * 让出主线程的通用方法
   * 优先使用 scheduler.yield()(Chrome 115+)
   * 降级到 MessageChannel(比 setTimeout 更快)
   */
  private yieldToMain(): Promise<void> {
    // 优先使用 Scheduler API(如果可用)
    if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
      return (globalThis as any).scheduler.yield();
    }

    // 降级到 MessageChannel
    return new Promise((resolve) => {
      const channel = new MessageChannel();
      channel.port1.onmessage = () => resolve();
      channel.port2.postMessage(undefined);
    });
  }

  private estimateExecutionTime(script: string): number {
    // 粗略估算: 每 10KB 约 5ms(经验值,实际取决于代码复杂度)
    return (script.length / 10240) * 5;
  }
}

/**
 * 策略 2: 子应用 JS 异步加载——不阻塞主应用的交互
 */
async function loadSubAppNonBlocking(
  entry: string,
  container: HTMLElement
): Promise<void> {
  // 步骤 1: 立即显示骨架屏
  showSkeleton(container);

  // 步骤 2: 下载子应用资源(不阻塞主线程)
  const { template, execScripts, getExternalStyleSheets } =
    await importEntry(entry);

  // 步骤 3: CSS 先加载——不阻塞交互,但防止 FOUC
  await getExternalStyleSheets();

  // 步骤 4: JS 在 requestIdleCallback 中执行
  await new Promise<void>((resolve) => {
    requestIdleCallback(
      async () => {
        await execScripts();
        resolve();
      },
      { timeout: 3000 } // 最多等 3 秒,然后强制执行
    );
  });
}

/**
 * 策略 3: 子应用代码分割——只加载当前路由需要的代码
 */
// 子应用自身的 webpack 配置
// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // 将子应用按路由拆分
        orderList: {
          test: /[\\/]pages[\\/]order-list/,
          name: 'order-list',
          priority: 10,
        },
        orderDetail: {
          test: /[\\/]pages[\\/]order-detail/,
          name: 'order-detail',
          priority: 10,
        },
        // 公共代码单独打包
        common: {
          minChunks: 2,
          name: 'common',
          priority: 5,
        },
      },
    },
  },
};

// 子应用的路由配置——使用动态 import 实现路由级代码分割
const routes = [
  {
    path: '/order/list',
    component: () => import(
      /* webpackChunkName: "order-list" */
      './pages/OrderList'
    ),
  },
  {
    path: '/order/:id',
    component: () => import(
      /* webpackChunkName: "order-detail" */
      './pages/OrderDetail'
    ),
  },
];

17.4.4 CLS 优化:消除子应用挂载时的布局偏移

CLS(Cumulative Layout Shift)是最”反直觉”的性能指标——它不是关于速度、而是关于稳定性想象你正在点击一个按钮、就在点下去的瞬间、一个广告加载出来把按钮挤到下面、你的点击落到了广告上——这种”点错了”的挫败感、CLS 捕捉的就是这种糟糕体验。**CLS 虽然看起来是个”小问题”、但对用户信任的伤害巨大——一个经常出现布局跳动的网站、用户会产生”不可靠”的印象、下次就不愿意再访问。**微前端的 CLS 挑战、主要来自”子应用异步加载时的容器尺寸变化”——主应用渲染时容器是 0 高度、子应用挂载后容器突然变成 1000px、下面的内容被推移、CLS 飙升优化的思路是”给子应用容器预留固定高度”——通过 CSS 的 min-height 或 aspect-ratio、让容器在子应用加载前就有稳定的尺寸、子应用挂载后只是填充这个预留空间、不引起布局变化

CLS(Cumulative Layout Shift)在微前端中有一个经典的触发场景:子应用容器在子应用挂载前没有确定的高度,挂载后内容撑开容器,导致下方的元素被推移。

/**
 * 策略 1: 容器尺寸预留——在子应用加载前就固定容器高度
 */

// CSS 方案: 为子应用容器设置最小高度
const containerStyles = `
  /* 方案 A: 固定最小高度 */
  .micro-app-container {
    min-height: 600px;  /* 基于子应用的典型高度 */
    contain: layout;    /* CSS Containment: 隔离布局影响 */
  }

  /* 方案 B: 使用 aspect-ratio(如果内容比例可预测) */
  .micro-app-container--dashboard {
    aspect-ratio: 16 / 9;
    width: 100%;
  }

  /* 方案 C: 使用 CSS Grid 预留空间 */
  .app-layout {
    display: grid;
    grid-template-rows: 60px 1fr;  /* 导航栏 + 内容区 */
    min-height: 100vh;
  }
  .app-layout__content {
    overflow: auto;  /* 子应用内容不影响外部布局 */
  }
`;

/**
 * 策略 2: contain: layout 和 content-visibility
 * 利用 CSS Containment 告诉浏览器子应用容器是一个独立的布局上下文
 */
const advancedContainerStyles = `
  .micro-app-container {
    /* contain: layout 告诉浏览器:
       这个元素内部的布局变化不会影响外部 */
    contain: layout style;

    /* content-visibility: auto 告诉浏览器:
       屏幕外的子应用内容可以跳过渲染 */
    content-visibility: auto;

    /* 配合 contain-intrinsic-size 提供预估尺寸
       避免 content-visibility 导致的高度为 0 */
    contain-intrinsic-size: 0 600px;
  }
`;

/**
 * 策略 3: 监控 CLS 并自动修复
 */
class CLSMonitor {
  private observer: PerformanceObserver | null = null;
  private clsValue = 0;
  private clsEntries: PerformanceEntry[] = [];

  start(): void {
    this.observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        // 只关注非用户输入引起的布局偏移
        if (!entry.hadRecentInput) {
          this.clsValue += entry.value;
          this.clsEntries.push(entry);

          // 如果 CLS 超过阈值,触发告警
          if (this.clsValue > 0.1) {
            this.reportCLSIssue(entry);
          }
        }
      }
    });

    this.observer.observe({ type: 'layout-shift', buffered: true });
  }

  private reportCLSIssue(entry: any): void {
    // 定位导致布局偏移的元素
    const sources = entry.sources?.map((source: any) => ({
      node: source.node?.tagName,
      previousRect: source.previousRect,
      currentRect: source.currentRect,
    }));

    console.warn('[CLS Monitor] 检测到微前端布局偏移:', {
      clsValue: this.clsValue.toFixed(4),
      shiftedElements: sources,
      timestamp: entry.startTime,
    });

    // 上报到监控系统
    reportToMonitoring({
      type: 'cls_issue',
      value: this.clsValue,
      sources,
      microApp: this.getCurrentMicroApp(),
    });
  }

  private getCurrentMicroApp(): string {
    // 识别当前活跃的微应用
    return document.querySelector(
      '[data-qiankun-app]'
    )?.getAttribute('data-qiankun-app') ?? 'unknown';
  }
}

/**
 * 策略 4: 子应用切换的平滑过渡
 * 避免子应用卸载-加载之间的空白期导致 CLS
 */
class SmoothTransition {
  private currentContainer: HTMLElement | null = null;
  private pendingContainer: HTMLElement | null = null;

  async switchApp(
    fromApp: string | null,
    toApp: string,
    container: HTMLElement
  ): Promise<void> {
    // 步骤 1: 创建新容器(在当前容器下方,不可见)
    this.pendingContainer = document.createElement('div');
    this.pendingContainer.style.cssText =
      'position:absolute;top:0;left:0;width:100%;opacity:0;pointer-events:none;';
    container.style.position = 'relative';
    container.appendChild(this.pendingContainer);

    // 步骤 2: 在新容器中加载新子应用
    await mountMicroApp(toApp, this.pendingContainer);

    // 步骤 3: 获取新容器的实际高度
    const newHeight = this.pendingContainer.scrollHeight;

    // 步骤 4: 平滑过渡高度
    container.style.height = `${container.scrollHeight}px`;
    container.style.transition = 'height 0.3s ease-out';

    // 步骤 5: 交叉淡入淡出
    requestAnimationFrame(() => {
      container.style.height = `${newHeight}px`;

      // 淡出旧应用
      if (this.currentContainer) {
        this.currentContainer.style.transition = 'opacity 0.2s ease-out';
        this.currentContainer.style.opacity = '0';
      }

      // 淡入新应用
      this.pendingContainer!.style.transition = 'opacity 0.3s ease-in';
      this.pendingContainer!.style.opacity = '1';
      this.pendingContainer!.style.position = 'relative';
      this.pendingContainer!.style.pointerEvents = 'auto';

      // 步骤 6: 过渡完成后清理
      setTimeout(() => {
        if (this.currentContainer) {
          unmountMicroApp(fromApp!);
          this.currentContainer.remove();
        }
        container.style.height = 'auto';
        container.style.transition = '';
        this.currentContainer = this.pendingContainer;
      }, 300);
    });
  }
}

17.4.5 综合性能监控方案

性能优化的 90% 是监控、10% 是优化”——这是一句被资深性能工程师反复验证的经验之谈没有准确的监控、你不知道哪里慢;没有对比的基准、你不知道优化有没有效果;没有长期的趋势、你不知道代码什么时候开始退化好的性能监控体系、应该满足几个要求(1) 覆盖所有关键指标(LCP、FID、CLS、TTI 等);(2) 按用户维度(地理、设备、浏览器)切片;(3) 在微前端场景下按子应用维度切片;(4) 支持实时和历史对比;(5) 在指标异常时能自动告警。**本节介绍的监控 SDK、是这些要求的一个具体实现——但更重要的是它背后的思路——把性能当成一个”持续工程”来对待、而不是”发版前冲刺一下”的一次性工作

最后,性能优化不能靠猜测——需要完整的监控体系来发现问题、验证优化效果。

/**
 * 微前端专属的性能监控 SDK
 */
class MicroFrontendPerformanceMonitor {
  private metrics: Map<string, any[]> = new Map();

  /**
   * 追踪子应用的完整加载链路
   */
  trackAppLoad(appName: string): {
    markFetchStart: () => void;
    markFetchEnd: () => void;
    markExecStart: () => void;
    markExecEnd: () => void;
    markMountStart: () => void;
    markMountEnd: () => void;
    report: () => AppLoadMetrics;
  } {
    const marks: Record<string, number> = {};

    return {
      markFetchStart: () => {
        marks.fetchStart = performance.now();
      },
      markFetchEnd: () => {
        marks.fetchEnd = performance.now();
      },
      markExecStart: () => {
        marks.execStart = performance.now();
      },
      markExecEnd: () => {
        marks.execEnd = performance.now();
      },
      markMountStart: () => {
        marks.mountStart = performance.now();
      },
      markMountEnd: () => {
        marks.mountEnd = performance.now();
      },
      report: () => {
        const metrics: AppLoadMetrics = {
          appName,
          fetchDuration: marks.fetchEnd - marks.fetchStart,
          execDuration: marks.execEnd - marks.execStart,
          mountDuration: marks.mountEnd - marks.mountStart,
          totalDuration: marks.mountEnd - marks.fetchStart,
          timestamp: Date.now(),
        };

        // 存储并上报
        this.storeMetrics(appName, metrics);
        return metrics;
      },
    };
  }

  /**
   * 追踪 Core Web Vitals(微前端增强版)
   */
  trackCoreWebVitals(): void {
    // LCP
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1] as any;
      const lcpValue = lastEntry.startTime;
      const lcpElement = lastEntry.element?.tagName;
      const activeApp = this.getActiveApp();

      this.reportMetric('lcp', {
        value: lcpValue,
        element: lcpElement,
        microApp: activeApp,
        // 区分: LCP 元素属于主应用还是子应用
        isSubAppContent: this.isElementInSubApp(lastEntry.element),
      });
    }).observe({ type: 'largest-contentful-paint', buffered: true });

    // INP (Interaction to Next Paint)
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        if (entry.interactionId) {
          this.reportMetric('inp', {
            value: entry.duration,
            target: entry.target?.tagName,
            microApp: this.getActiveApp(),
            // 追踪交互发生在哪个子应用中
            interactionType: entry.name,
          });
        }
      }
    }).observe({ type: 'event', buffered: true, durationThreshold: 16 });

    // CLS(按子应用归因)
    let sessionCLS = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        if (!entry.hadRecentInput) {
          sessionCLS += entry.value;
          this.reportMetric('cls', {
            value: entry.value,
            cumulativeValue: sessionCLS,
            sources: entry.sources?.map((s: any) => ({
              element: s.node?.tagName,
              microApp: this.identifyAppForElement(s.node),
            })),
          });
        }
      }
    }).observe({ type: 'layout-shift', buffered: true });
  }

  /**
   * 生成性能报告
   */
  generateReport(): PerformanceReport {
    const allMetrics = Object.fromEntries(this.metrics);

    return {
      summary: {
        averageAppLoadTime: this.calculateAverage('appLoad', 'totalDuration'),
        p95AppLoadTime: this.calculatePercentile('appLoad', 'totalDuration', 95),
        lcpValue: this.getLatestMetric('lcp')?.value,
        clsValue: this.getLatestMetric('cls')?.cumulativeValue,
        inpValue: this.calculatePercentile('inp', 'value', 75),
      },
      perAppMetrics: this.getPerAppBreakdown(),
      recommendations: this.generateRecommendations(),
      timestamp: Date.now(),
    };
  }

  private generateRecommendations(): string[] {
    const recommendations: string[] = [];
    const report = this.generateBasicStats();

    if (report.averageLoadTime > 2000) {
      recommendations.push(
        '子应用平均加载时间超过 2 秒,建议启用预加载(prefetchApps)'
      );
    }

    if (report.clsValue > 0.1) {
      recommendations.push(
        'CLS 超过 0.1,建议为子应用容器设置 min-height 和 contain: layout'
      );
    }

    if (report.largestBundle > 500 * 1024) {
      recommendations.push(
        `子应用 ${report.largestBundleApp} 的 bundle 超过 500KB,` +
        '建议进行代码分割或提取公共依赖'
      );
    }

    if (report.proxyOverhead > 100) {
      recommendations.push(
        '检测到 Proxy 沙箱开销较高,建议对高频访问属性启用缓存'
      );
    }

    return recommendations;
  }

  private getActiveApp(): string {
    return (
      document
        .querySelector('[data-qiankun-app].active')
        ?.getAttribute('data-qiankun-app') ?? 'main'
    );
  }

  private isElementInSubApp(element: Element | null): boolean {
    if (!element) return false;
    return !!element.closest('[data-qiankun-app]');
  }

  private identifyAppForElement(element: Element | null): string {
    if (!element) return 'unknown';
    const container = element.closest('[data-qiankun-app]');
    return container?.getAttribute('data-qiankun-app') ?? 'main';
  }

  private storeMetrics(key: string, data: any): void {
    if (!this.metrics.has(key)) {
      this.metrics.set(key, []);
    }
    this.metrics.get(key)!.push(data);
  }

  private reportMetric(type: string, data: any): void {
    this.storeMetrics(type, data);
  }

  private calculateAverage(key: string, field: string): number {
    const entries = this.metrics.get(key) ?? [];
    if (entries.length === 0) return 0;
    return entries.reduce((sum, e) => sum + (e[field] ?? 0), 0) / entries.length;
  }

  private calculatePercentile(
    key: string,
    field: string,
    percentile: number
  ): number {
    const entries = this.metrics.get(key) ?? [];
    if (entries.length === 0) return 0;
    const sorted = entries.map((e) => e[field] ?? 0).sort((a, b) => a - b);
    const index = Math.ceil((percentile / 100) * sorted.length) - 1;
    return sorted[index];
  }

  private getLatestMetric(key: string): any {
    const entries = this.metrics.get(key) ?? [];
    return entries[entries.length - 1];
  }

  private getPerAppBreakdown(): Record<string, any> {
    return {};
  }

  private generateBasicStats(): any {
    return {
      averageLoadTime: this.calculateAverage('appLoad', 'totalDuration'),
      clsValue: this.getLatestMetric('cls')?.cumulativeValue ?? 0,
      largestBundle: 0,
      largestBundleApp: '',
      proxyOverhead: 0,
    };
  }
}

// 使用
const monitor = new MicroFrontendPerformanceMonitor();
monitor.trackCoreWebVitals();

// 在子应用生命周期中埋点
registerMicroApps(
  apps.map((app) => ({
    ...app,
    props: {
      ...app.props,
      performanceTracker: monitor.trackAppLoad(app.name),
    },
  }))
);

17.4.6 性能预算与持续集成

性能预算”(Performance Budget)是 Google 工程师 Tim Kadlec 在 2015 年左右提出的实践——像管理财务预算一样管理性能指标。**核心思想是——在 CI/CD 里设置性能指标的硬上限、任何提交导致指标超标就阻断合并这种做法把”性能退化”从”事后被动发现”变成了”事前主动拦截。**为什么需要性能预算?因为如果没有它、性能会不可避免地慢慢退化——每个工程师都会说”我加的这个功能只增加了 5KB、问题不大”、但一年下来 200 个工程师每人加 5KB、就是 1MB 的膨胀性能预算的本质、是用”制度性的约束”来对抗”个体决策的短视”——让每个工程师在加功能的同时都必须考虑”我占用了多少性能预算微前端的性能预算、因为”多团队”特性、更需要明确的分配和管理——每个子应用有自己的预算、主应用有整体的预算、加起来不超过总预算

性能优化不是一次性的工作——它需要持续的监控和守护。

/**
 * 微前端性能预算配置
 * 集成到 CI/CD 流水线中,每次提交自动检测
 */
interface PerformanceBudget {
  // 子应用级别的预算
  perSubApp: {
    maxBundleSize: '200KB gzipped';     // 单个子应用的 JS bundle
    maxCSSSize: '50KB gzipped';          // 单个子应用的 CSS
    maxLoadTime: '2000ms';               // 首次加载时间上限
    maxMountTime: '500ms';               // mount 生命周期时间上限
  };

  // 整体预算
  overall: {
    maxLCP: '2500ms';                    // Google 的 "Good" 阈值
    maxINP: '200ms';                     // Google 的 "Good" 阈值
    maxCLS: '0.1';                       // Google 的 "Good" 阈值
    maxTotalSharedDeps: '300KB gzipped'; // 公共依赖总体积
    maxConcurrentApps: 3;                // 同时加载的子应用数量上限
  };
}

// CI 检测脚本示例
async function checkPerformanceBudget(): Promise<{
  passed: boolean;
  violations: string[];
}> {
  const violations: string[] = [];

  // 检查每个子应用的 bundle 大小
  for (const app of microApps) {
    const stats = await getBuildStats(app.name);

    if (stats.jsSize > 200 * 1024) {
      violations.push(
        `${app.name}: JS bundle ${(stats.jsSize / 1024).toFixed(0)}KB ` +
        `exceeds budget of 200KB`
      );
    }

    if (stats.cssSize > 50 * 1024) {
      violations.push(
        `${app.name}: CSS ${(stats.cssSize / 1024).toFixed(0)}KB ` +
        `exceeds budget of 50KB`
      );
    }
  }

  // 检查公共依赖总体积
  const sharedDepsSize = await getSharedDependenciesSize();
  if (sharedDepsSize > 300 * 1024) {
    violations.push(
      `Shared dependencies: ${(sharedDepsSize / 1024).toFixed(0)}KB ` +
      `exceeds budget of 300KB`
    );
  }

  return {
    passed: violations.length === 0,
    violations,
  };
}

深度洞察:性能是架构决策的结果

本章讨论的所有性能问题——子应用加载延迟、公共依赖冗余、沙箱运行时开销、Core Web Vitals 劣化——它们的根因都不是”代码写得不好”,而是微前端架构本身引入的结构性开销。这意味着:1)性能优化的上限由架构决定——在乾坤 + Proxy 沙箱的架构下,你无法消除 Proxy 拦截的开销,只能最小化它;2)最大的性能优化往往来自架构调整而非代码优化——从乾坤切换到 Module Federation 可能一次性消除沙箱开销和依赖冗余问题;3)性能预算应该在架构设计阶段就确定,而不是上线后才开始关注。先定义”多快才够快”,再选择能达到这个标准的架构方案。


本章小结

  • 首屏性能:乾坤的 prefetchApps 通过 requestIdleCallback 两级调度实现非侵入式预加载,自定义策略函数支持基于业务优先级和网络环境的精细控制。预加载不是万能的——需要基于访问概率排序,遵循 80/20 法则
  • 公共依赖共享:externals + CDN、Module Federation shared、Import Maps 三种方案各有取舍,核心矛盾是”版本自由度 vs 运行时开销 vs 沙箱兼容”的不可能三角
  • 沙箱开销:Proxy 沙箱的单次属性访问开销约 37-77ns,对 80% 的业务场景可忽略。真正需要关注的是 Canvas 渲染、大数据量图表等高频访问场景,优化手段包括属性缓存、快照降级、选择性关闭沙箱
  • Core Web Vitals:LCP 优化靠骨架屏和 SSR 预渲染缩短可见时间;FID/INP 优化靠 JS 执行分片和 scheduler.yield() 减少主线程阻塞;CLS 优化靠容器尺寸预留和 contain: layout 隔离布局影响
  • 持续监控:性能优化不是一次性工作,需要集成到 CI/CD 的性能预算机制和生产环境的实时监控来持续守护

性能工程、是软件工程里最需要”数据思维”的一个分支——它拒绝主观判断、只认数字一个优化到底有没有效果?不是”感觉快了”、是”P95 延迟从 2.3s 降到 1.1s一个架构决策会不会影响性能?不是”理论上应该影响不大”、是”跑一次基准测试、看数字怎么变化这种”凡事让数据说话”的文化、在性能工程领域被奉为圭臬本章讨论的每一个优化手段、都不是来自”我觉得这样快”的直觉、而是来自”在真实场景测量后发现的瓶颈这种”以数据驱动优化”的思维方式、和”拍脑袋优化”有本质区别——前者能持续带来真实收益、后者可能花了大量精力却毫无效果

与我们在《Claude Code 源码》第 16 章讨论的上下文管理、在《Tokio 源码》第 18 章讨论的性能优化、在《Vue 3 源码》的编译优化章节——都展现了”性能工程”这门学科在不同领域的具体应用。**它们共同告诉我们一件事——性能不是”一次性做对”、而是”持续度量、持续改进”的过程一个系统今天性能良好、不代表下个季度还良好——代码会腐坏、依赖会更新、用户数据会增长、浏览器行为会变化——只有持续的监控和优化、才能让性能在长时间尺度上保持优秀这是为什么顶级互联网公司都有专职的性能工程师、甚至性能团队——不是一次性任务、是长期工作

微前端的性能工程、比单体应用多了一个维度——“多团队协作下的性能单体应用的性能可以由一个团队全权负责;微前端的性能涉及多个团队、每个团队的代码都可能影响整体性能。**这带来了”性能责任归属”的新挑战——当 LCP 变差、是谁的锅?需要准确归因、才能让正确的团队采取行动本章讨论的性能监控的子应用维度拆分、性能预算的独立管理、持续集成的性能门禁——都是针对这个”多团队性能协作”问题的工程解决方案

读完本章、作为微前端书的最后一章、你应该对”如何构建一个高性能的微前端系统”有了完整的认知——从架构层面的方案选型、到实现层面的各种优化手段、到运维层面的持续监控和守护——每一层的知识都不可或缺这种”系统化的性能思维”、是能让你在职业生涯中长期受益的能力——不只是微前端、任何性能敏感的系统都能用到这套思路

我们的微前端之旅、到这里告一段落从第 1 章的”为什么需要微前端”、到第 17 章的”如何让微前端跑得快”——我们走过了从架构思想到技术细节、从源码分析到工程实践的完整路径感谢你陪伴这本书走到最后愿你在自己的微前端项目里、少走弯路、多有收获

思考题

  1. 实践应用:你的微前端项目有 8 个子应用,平均 bundle 大小 180KB(gzipped),首次切换到子应用需要 1.8 秒。请设计一个完整的预加载策略,说明哪些子应用预加载、何时触发、如何处理弱网场景。

  2. 方案对比:分析 Webpack externals + CDN 和 Module Federation shared 在以下场景中的优劣:a)所有子应用使用相同版本的 React;b)三个子应用使用 React 18,两个使用 React 17;c)需要在乾坤 Proxy 沙箱环境中运行。

  3. 性能分析:某个微前端项目的 CLS 得分为 0.25(远超 0.1 的 “Good” 阈值),已确认原因是子应用挂载时容器高度变化。请提出至少三种不同层次的解决方案,并说明各自的优缺点。

  4. 深度思考:本章提出”性能是架构决策的结果”。如果你正在设计一个全新的微前端架构,且性能预算要求 LCP < 1500ms、CLS < 0.05,你会选择什么技术方案?请说明选择理由和必须放弃的特性。

  5. 开放讨论:随着 Edge Computing 和 Service Worker 的成熟,你认为微前端的性能优化范式是否会发生根本性变化?例如,是否可以在 Service Worker 中完成子应用资源的预处理和缓存管理,从而消除运行时的加载延迟?