微前端源码精讲

第17章 微前端性能工程

作者 杨艺韬 · 14,920 字 · 发布于 · 更新于

第17章 微前端性能工程

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

本章要点

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

本章引用的 qiankun 源码以 v2.10.16 为准(src/prefetch.ts、src/sandbox/proxySandbox.ts、src/sandbox/index.ts、src/apis.ts),import-html-entry 以 v1.17.0 为准,single-spa 以 v6.0.3 为准;Module Federation 的 shared 协商逻辑以 module-federation/core commit 80e9597 的 packages/runtime-core/src/shared/index.ts 与 packages/runtime-core/src/utils/share.ts 为准。


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

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

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

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

下图展示了微前端子应用加载的完整瀑布流和各阶段的优化切入点(各阶段的耗时随项目、网络和设备差别很大,这里不标具体数字):

flowchart TB
    subgraph Phase1["阶段1: 主应用加载"]
        P1_HTML["主应用 HTML"] --> P1_JS["主应用 JS"]
        P1_JS --> P1_Init["乾坤初始化"]
    end
    subgraph Phase2["阶段2: 子应用资源获取"]
        P2_HTML["fetch 子应用 HTML"] --> P2_Parse["解析 HTML"]
        P2_Parse --> P2_JS["下载子应用 JS"]
        P2_Parse --> P2_CSS["下载子应用 CSS"]
    end
    subgraph Phase3["阶段3: 沙箱与执行"]
        P3_Sandbox["创建 Proxy 沙箱"] --> P3_Exec["执行子应用 JS"]
        P3_Exec --> P3_Boot["bootstrap"]
        P3_Boot --> P3_Mount["mount 渲染"]
    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 2017 年发布在 Think with Google 上的移动落地页研究(分析了 90 多万个移动广告落地页)给出的是一条非线性曲线:加载时间从 1 秒变到 3 秒,跳出概率相对上升 32%;变到 5 秒上升 90%;变到 10 秒上升 123%。注意这里的基准始终是 1 秒,不是「每多 1 秒涨 32%」——把它读成线性会高估前一秒的收益、低估后几秒的代价。

微前端在这件事上天生吃亏:它的加载链路是「主应用外壳 + 子应用按需加载」两个串行阶段,比单体应用多一次网络往返去取子应用入口。所以微前端的性能工程基本上就围绕一个目标——把这个串行劣势重叠回去。预加载是其中最重要的一件武器:它把两阶段的串行等待变成并行,让用户感知不到子应用的额外加载时间。

17.1.1 微前端的加载瀑布流

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

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

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

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

  // 总耗时: 三个阶段之和(首次加载、无缓存时最长)
  // 用户感知: 从"点击导航"到"看到内容"的时间
}

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

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

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

17.1.2 乾坤的 prefetch 策略实现

乾坤的预加载入口是 start({ prefetch })(默认 true);另外还导出了一个手动 API prefetchApps——它就是 src/prefetch.ts 里 prefetchImmediately 的别名(见 src/index.ts)。这段实现值得仔细研究——不仅因为它解决了首屏性能问题,更因为它展示了一种精妙的调度策略。

// 源码位置: qiankun/src/prefetch.ts(v2.10.16,删去了开发态日志与 requestIdleCallback 的 MessageChannel/setTimeout 兜底)
import { importEntry } from 'import-html-entry';
import { getAppStatus, NOT_LOADED } from 'single-spa';

// 弱网判定:开了省流量,或者不在 wifi/以太网上且 effectiveType 是 2g/3g
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /([23])g/.test(navigator.connection.effectiveType))
  : false;


function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  if (!navigator.onLine || isSlowNetwork) {
    // 离线或弱网直接不预加载——这是一个容易忽略的边界条件
    return;
  }

  requestIdleCallback(async () => {
    // importEntry 会获取 HTML 并解析出 JS/CSS 资源列表
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    // 再次利用 requestIdleCallback 分批加载静态资源
    requestIdleCallback(getExternalStyleSheets);
    requestIdleCallback(getExternalScripts);
  });
}


function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  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);
  });
}

// 对外以 prefetchApps 的名字导出
export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  apps.forEach(({ entry }) => prefetch(entry, opts));
}


export function doPrefetchStrategy(
  apps: AppMetadata[],
  prefetchStrategy: PrefetchStrategy,
  importEntryOpts?: ImportEntryOpts,
) {
  const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));

  if (Array.isArray(prefetchStrategy)) {
    // 精确指定需要预加载的子应用列表——也是等首个子应用挂载后再预加载
    prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
  } else if (isFunction(prefetchStrategy)) {
    (async () => {
      // 自定义策略函数(可以是 async)——最大灵活度
      // 注意字段名是 minorAppsName,不是 minorAppNames
      const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
      // 关键子应用立即预加载
      prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
      // 次要子应用等第一个子应用挂载后再预加载
      prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
    })();
  } else {
    switch (prefetchStrategy) {
      case true:
        // 默认策略:第一个子应用挂载后,预加载所有其他子应用
        prefetchAfterFirstMounted(apps, importEntryOpts);
        break;
      case 'all':
        // 不等首屏,立刻预加载全部
        prefetchImmediately(apps, importEntryOpts);
        break;
      default:
        break;
    }
  }
}

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

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网络阶段基本消失

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

第一,两级 requestIdleCallback 调度。 第一级等待浏览器空闲后获取 HTML 并解析资源列表,第二级再在空闲时分别加载 JS 和 CSS。这样预加载的启动时机都落在浏览器空闲时段,尽量不和当前页面的渲染、交互争抢主线程。

第二,首屏优先原则。 prefetchAfterFirstMounted 监听 single-spa 的 single-spa:first-mount 事件(src/lifecycles/mount.js:42-45 在首个应用 mount 完成后派发),等第一个子应用完全挂载后才开始预加载其他子应用——这意味着预加载不会和首个子应用的加载争抢带宽,对首屏来说基本是"隐形"的。

第三,自定义策略函数。 通过传入函数(可以是 async),开发者可以根据业务优先级将子应用分为 criticalAppNames 和 minorAppsName,实现精细化的预加载控制——注意第二个字段在源码里就是 minorAppsName 这个拼法,写成 minorAppNames 会被静默忽略。

第四,弱网与离线直接跳过。 prefetch 在 navigator.onLine 为假、或 navigator.connection 显示开了省流量 / 处于非 wifi、以太网的 2g/3g 网络时什么都不做——这一层判断是内置的,业务策略函数不必重复实现。另外 prefetch: 'all' 会跳过「等首屏」这一步、立刻预加载全部子应用。

17.1.3 实战:定制预加载策略

不同业务场景的用户行为差异很大:电商平台的用户大概率从首页到商品详情再到购物车,管理后台的用户大概率在几个核心模块之间切换,内容平台的用户大概率看完一篇接着看推荐的下一篇。预加载哪些子应用、以什么顺序、在什么时机,都应该因场景而异。乾坤把 prefetch 设计成也可以传一个函数,而不只是 boolean,就是为了让开发者按用户角色、当前路由等条件自己决定预加载清单。

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

import { registerMicroApps, start } from 'qiankun';

// 场景 1: 电商平台——基于用户行为的预测性预加载
start({
  prefetch: (apps) => {
    // 从首页出发,用户最可能访问商品详情页
    // 假设埋点显示:多数用户下一步会点进商品详情
    return {
      criticalAppNames: ['product-detail'],
      minorAppsName: ['shopping-cart', 'user-center', 'order-list'],
    };
  },
});

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

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

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

    // 默认策略
    return { criticalAppNames: [], minorAppsName: 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),
          minorAppsName: [],
        };
      }

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

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

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

场景 3 里 2G/3G 分支其实是多余的——上一节看到 prefetch 自己就会在弱网(省流量、或非 wifi/以太网下的 2g/3g)和离线时跳过;这段代码真正有价值的是 4G/WiFi 分支把所有子应用提到 criticalAppNames,绕开「等首个子应用挂载」这一步。如果只是想「全部立刻预加载」,直接写 start({ prefetch: 'all' }) 即可。

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

预加载有不同的粒度:资源预加载、预执行、预挂载,各有收益和代价。资源预加载最便宜,只是把 JS 和 CSS 文件取回来缓存(乾坤是由 import-html-entry 把文本存在内存里),不执行任何代码,内存只多出这份文本;预执行要把代码解析执行一遍,生成 JS 对象,占用内存;预挂载更进一步,把子应用渲染到内存中(但不显示),用户切过去只需要改个 display 属性。三种方案的时间收益依次递增(依次省掉下载、执行、bootstrap 这几段),内存占用也依次递增(从一份脚本和样式文本,到常驻一份已执行的模块图,再到常驻一整棵已渲染的组件树)。"每毫秒都重要"的产品(比如高频交互的 SaaS)适合激进预热,"内存敏感"的产品(比如面向移动端或低端设备)适合保守方案。

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

flowchart LR
    subgraph NoPreload["无预加载"]
        NP["用户点击"] --> NP_Fetch["下载资源"]
        NP_Fetch --> NP_Exec["执行 JS"]
        NP_Exec --> NP_Mount["mount"]
        NP_Mount --> NP_Show["显示内容\n三段耗时之和"]
    end
    subgraph Prefetch["prefetch 预加载"]
        PF["用户点击"] --> PF_Exec["执行 JS (已缓存)"]
        PF_Exec --> PF_Mount["mount"]
        PF_Mount --> PF_Show["显示内容\n省掉下载"]
    end
    subgraph Prewarm["预热 (资源+执行)"]
        PW["用户点击"] --> PW_Mount["mount (已 bootstrap)"]
        PW_Mount --> PW_Show["显示内容\n只剩 mount"]
    end
    style NoPreload fill:#ffebee,stroke:#c62828
    style Prefetch fill:#fff3e0,stroke:#e65100
    style Prewarm fill:#e8f5e9,stroke:#2e7d32

乾坤的 prefetch 只解决了资源下载的问题——JS 和 CSS 的文本被 import-html-entry 缓存在内存里(scriptCache / styleCache),但子应用的 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
    // 注意:此处创建的沙箱是临时的,真正挂载时会使用正式沙箱;
    // qiankun 没有公开导出沙箱类,createTempSandbox 需要自行实现(或直接传 window 不隔离)
    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} 预热完成,切换时只剩 mount 这一段`
    );
  }

  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);
}

// 混合策略:核心子应用预加载 + 低频子应用懒加载
// 注意:preload 是这里自定义的元数据字段,不是乾坤 RegistrableApp 的配置项,
// 需要自己在 start({ prefetch: fn }) 里把它翻译成 criticalAppNames / minorAppsName
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)缓存失效浪费——如果子应用频繁更新,预加载的资源可能在用户真正访问前就已经过期,白白浪费了带宽。最佳实践:不要对所有子应用都开启预加载,基于访问概率做优先级排序——访问量往往集中在少数几个子应用上,先看自己的埋点数据再定名单。

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

17.2.1 问题的本质

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

// 典型的资源浪费场景
// 以 react@18.2.0 + react-dom@18.2.0 的 UMD 生产构建为例:
// 两者合计约 140KB(min)、约 45KB(gzip);antd、echarts 这类 UI/图表库只会更大
const subAppBundles = {
  'main-app':     { 'react + react-dom': '~140KB', antd: '...' },
  'order-app':    { 'react + react-dom': '~140KB', antd: '...' },
  'product-app':  { 'react + react-dom': '~140KB', antd: '...' },
  'user-app':     { 'react + react-dom': '~140KB', antd: '...' },
  'dashboard':    { 'react + react-dom': '~140KB', echarts: '...' },
  'settings':     { 'react + react-dom': '~140KB' },
};

// 仅 react + react-dom 就下载了 6 次,其中 5 次是冗余
// 总计: 约 140KB * 6 ≈ 840KB,冗余约 140KB * 5 = 700KB
// gzipped 后: 冗余约 45KB * 5 = 225KB(仍然不可忽视)

17.2.2 策略一:Webpack externals + CDN

externals + CDN 是最早也最简单的依赖共享方案:把公共依赖从 bundle 里排除,通过 script 标签全局加载,所有子应用访问同一个全局变量(window.React、window.Vue、window.antd),自然达到共享。优势是没有运行时协商、实现简单;劣势是所有子应用必须用同一版本,升级要一起升。如果所有子应用本来就用同一版本的 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 加载,所有子应用共享 -->
  <!-- 示意:各库的 UMD 包可能还有自己的前置依赖(如 antd 5 需先引入 dayjs),按各库文档补齐并排好顺序 -->
  <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 缓存命中率高,同一站点内的各子应用复用同一份缓存(主流浏览器已按站点分区 HTTP 缓存,不再跨站点共享)
  • 主应用和子应用加载同一份代码,没有版本冲突风险

致命缺陷:

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

// 问题 2: 沙箱兼容性——乾坤的 Proxy 沙箱会拦截全局变量访问
// 子应用中 `import React from 'react'` 被编译为对全局变量的直接引用(`module.exports = React;`)
// 在乾坤里这个裸标识符要经 with(proxy) 查找,需要确保代理正确转发
// 实际工程中,这里是 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 模块的运行时协商过程(简化)
// 源码位置: module-federation/core @80e9597
//   packages/runtime-core/src/utils/share.ts:354  → getRegisteredShare(选版本)
//   packages/runtime-core/src/shared/index.ts:214 → SharedHandler.loadShare(加载并登记)


async function loadShare(pkgName: string, shareInfo: Shared) {
  const { singleton, requiredVersion, strictVersion } = shareInfo.shareConfig;
  // 步骤 0: 先把本应用声明的 shared 登记进共享作用域(initializeSharing),
  //         所以「作用域里有没有这个包」看的是所有已 init 的应用
  const versions = shareScopeMap[scope][pkgName]; // { '18.2.0': {...}, '18.3.0': {...} }

  if (versions) {
    // 步骤 1: 按 strategy 选一个候选版本——
    //   'version-first'(默认): 版本号最高的那份
    //   'loaded-first'        : 已经加载过的优先,其次才比版本号
    const candidate = findShareFunction(strategy)(shareScopeMap, scope, pkgName);

    if (singleton) {
      // singleton 模式: 直接使用候选版本,不满足要求只 warn;
      // 只有 strictVersion: true 才会抛错——绝不会为了满足某个消费者再加载一份
      if (typeof requiredVersion === 'string' && !satisfy(candidate, requiredVersion)) {
        const msg = `Version ${candidate} of shared singleton module ${pkgName} ` +
          `does not satisfy the requirement of ${shareInfo.from} which needs ${requiredVersion}`;
        strictVersion ? error(msg) : warn(msg);
      }
      return use(versions[candidate]);
    }

    // 非 singleton 模式: 候选版本满足就用它;否则在作用域里找任意一份满足的版本
    if (requiredVersion === false || requiredVersion === '*' || satisfy(candidate, requiredVersion)) {
      return use(versions[candidate]);
    }
    for (const [v, shared] of Object.entries(versions)) {
      if (satisfy(v, requiredVersion)) return use(shared);
    }
  }

  // 步骤 2: 作用域里没有可用的版本,加载自己打包的那份(shareInfo.get),
  //         并以 setShared 登记到共享作用域供后来者复用
  const factory = await shareInfo.get();
  setShared({ pkgName, shared: shareInfo, lib: factory, loaded: true, from: shareInfo.from });
  return factory;
}

Module Federation shared 的核心优势:

  • 版本协商是自动的:不需要手动管理 CDN 版本号
  • 支持多版本共存:非 singleton 模式下,不同子应用可以使用不同版本
  • 按需加载:只有真正被使用的模块才会被加载
  • 构建时推断、运行时暴露:requiredVersion 缺省时由构建插件从最近的 package.json 推断并写进产物(ConsumeSharedPlugin 的 getRequiredVersionFromDescriptionFile),协商本身发生在运行时,冲突以警告(strictVersion: true 时报错)的形式暴露

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: 映射基本是静态的——一个 specifier 一旦被解析过就不能再改;
// Chrome 133 起允许页面里有多个 importmap 并合并,但也只能「追加」,做不到按子应用切换版本

// 局限 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',         // 带一段运行时协商代码
    sandboxCompatibility: 'good',       // 共享模块不经 window.React 这类全局变量传递,与沙箱冲突少
    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 沙箱的开销是存在的,但在多数场景下可以忽略:单次访问多出的只是一次陷阱函数调用加几次判断,业务代码"偶尔"访问 window 不构成瓶颈;但在 Canvas 渲染、大规模数据处理、高频事件处理这类每秒数万次访问的场景下,开销会累积成问题。本节用基准测试量化开销,识别真正的瓶颈场景,再给出针对性的优化手段。

17.3.1 Proxy 沙箱的运行时开销

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

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

// 源码位置: qiankun/src/sandbox/proxySandbox.ts(v2.10.16,简化:删去了 speedy/测试分支与 defineProperty/ownKeys 等陷阱)
export default class ProxySandbox implements SandBox {
  /** window 值变更记录 */
  private updatedValueSet = new Set<PropertyKey>();
  proxy: WindowProxy;
  sandboxRunning = true;

  constructor(name: string, globalContext = window) {
    // fakeWindow 不是空对象:window 上所有 non-configurable 的属性会先被拷贝进去
    const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext, false);

    const proxy = new Proxy(fakeWindow, {
      set: (target, p, value) => {
        if (this.sandboxRunning) {
          this.registerRunningApp(name, proxy);
          // 白名单(System、__cjsWrapper 等)直接写回真 window;其余始终写入 fakeWindow
          if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
            globalContext[p] = value;
          } else {
            target[p] = value;
          }
          this.updatedValueSet.add(p);
          return true;
        }
        // 沙箱失活后的写入直接忽略(返回 false 在严格模式下会抛 TypeError)
        return true;
      },

      get: (target, p) => {
        this.registerRunningApp(name, proxy);

        if (p === Symbol.unscopables) return unscopables;
        // window / self / globalThis 都返回 proxy 本身,堵住逃逸口
        if (p === 'window' || p === 'self' || p === 'globalThis') return proxy;
        // 主应用不在 iframe 里时,top / parent 也指回 proxy
        if (p === 'top' || p === 'parent') {
          return globalContext === globalContext.parent ? proxy : globalContext[p];
        }
        if (p === 'hasOwnProperty') return hasOwnProperty;
        if (p === 'document') return this.document;
        if (p === 'eval') return eval;

        // 优先从 fakeWindow 读取(子应用设置的值),否则从真 window 读取
        const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext;
        const value = actualTarget[p];

        if (isPropertyFrozen(actualTarget, p)) return value;
        // 非原生全局属性直接返回,不做 rebind
        if (!isNativeGlobalProp(p as string) && !useNativeWindowForBindingsProps.has(p)) return value;

        // 原生函数(fetch、atob、console…)要绑回真 window,否则会抛 Illegal invocation;
        // rebindTarget2Fn 内部用 WeakMap 缓存绑定结果,同一个函数只 bind 一次
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        return rebindTarget2Fn(boundTarget, value);
      },

      // with(proxy) 里的裸标识符先走 has 陷阱:返回 true 才会落到 get 上
      has(target, p) {
        return p in cachedGlobalObjects || p in target || p in globalContext;
      },

      deleteProperty: (target, p) => {
        this.registerRunningApp(name, proxy);
        if (target.hasOwnProperty(p)) {
          delete target[p];
          this.updatedValueSet.delete(p);
        }
        return true;
      },
    });

    this.proxy = proxy;
  }
}

有两处细节直接关系到本节的性能讨论。第一,get 陷阱返回的 Symbol.unscopables 不是摆设:子应用代码被 import-html-entry 包进 with(window){...} 执行,unscopables 里列出的 ES2015 内置全局(Array、Promise、Object……)会被 with 跳过、直接落到真正的全局作用域——源码注释说得很直白:「不可能被覆写的变量出于性能原因要逃出 proxy 沙箱」。这张表是层层做减法得来的(src/sandbox/proxySandbox.ts:83-98):cachedGlobals 以 ES2015 全局为底、并入 window、self、globalThis、hasOwnProperty 和 requestAnimationFrame,再剔掉 document、top、parent、eval 这四个每次访问都要记录的名字,unscopables 再在此基础上剔掉 window、self、globalThis、hasOwnProperty——这几个是沙箱自己要改写的,一旦逃出去就会泄漏到真正的全局作用域。第二,v2.8.0 引入、v2.9.0 起默认开启的 speedy 模式(sandbox.speedy,src/loader.ts:317 里 speedy !== false 即为开)把同一份 cachedGlobals 作为 scopedGlobalVariables 传给 execScripts,让这些标识符在函数作用域层面就被声明为局部变量、连 with 的查找都省掉。也就是说,乾坤自己已经把「最热、又绝不会被子应用改写」的那批全局访问挪出了 Proxy 路径。

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

做基准测试有几条原则:(1) 隔离单一变量(测 Proxy 开销就只测 Proxy,不要和其他操作混在一起);(2) 跑足够多次(单次测量有噪音,循环次数要多到结果稳定);(3) 用合适的计时工具(performance.now() 比 Date.now() 精度高);(4) 警惕 JIT 优化(V8 会对热代码做特殊优化,第一次和第十次可能差异很大)。本节的 benchmark 给出的是"直接访问 vs 经过 Proxy"的相对差距,绝对数字请以自己机器上的实测为准(V8 在 2017 年的博客《Optimizing ES2015 proxies in V8》里专门优化过 Proxy 陷阱的调用路径,不同版本引擎的结果差别不小)。

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



// 测试 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('bench');
  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('bench');
  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('bench');
  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)}%`,
  };
}

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

基准测试告诉我们:单次 Proxy 访问多出的是常数级的一次陷阱调用,在正常的业务逻辑中几乎不可感知。但有几类场景需要警惕:

// 场景 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 访问——几乎可忽略
// 但如果每帧涉及成百上千次属性访问(粒子系统、大表格重算),开销就会被放大到可测量的程度

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

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

  // 只在 resize 时更新缓存(只改变量,不要再次调用 animateOptimized,否则会叠出多条动画循环)
  window.addEventListener('resize', () => {
    // 这个事件频率很低,Proxy 开销可忽略
    width = window.innerWidth;
    height = window.innerHeight;
  });
}

// 场景 2: 第三方库的内部高频操作
// 某些库(如 canvas 渲染库、物理引擎)可能在内部循环中频繁访问全局变量
// 是否真的如此要用 Performance 面板录一段看 flame chart,不要凭库的名气猜

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

// ✅ 优化: 把函数引用拿出循环——只经过一次 Proxy get,拿到的已经是绑定到原生 window 的函数
// 注意: 乾坤没有 __QIANKUN_RAW_WINDOW__ 之类的「裸 window」出口,
// proxy 上读 window / self / globalThis 返回的仍是 proxy 本身(proxySandbox.ts 的 get 陷阱);
// 顺带一提,定时器里乾坤只 patch 了 setInterval / clearInterval(patchers/interval.ts),
// setTimeout 本身没有被再包一层
const rawSetTimeout = setTimeout;
const rawClearTimeout = clearTimeout;
for (let i = 0; i < 1000; i++) {
  const id = rawSetTimeout(() => {}, 0);
  rawClearTimeout(id);
}

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


const HOT_PROPERTIES = new Set(['innerWidth', 'innerHeight', 'devicePixelRatio']);
const hotPropertyCache = new Map<PropertyKey, any>();

// proxySandbox.ts get 陷阱里、在「const actualTarget = ...」之前插入:
//   if (HOT_PROPERTIES.has(p) && hotPropertyCache.has(p)) return hotPropertyCache.get(p);
// 在拿到 value 之后插入:
//   if (HOT_PROPERTIES.has(p)) hotPropertyCache.set(p, value);

// 在 resize / orientationchange 时清除缓存(这两个事件频率很低)
window.addEventListener('resize', () => hotPropertyCache.clear());
window.addEventListener('orientationchange', () => hotPropertyCache.clear());


function createOptimalSandbox(appName: string): SandBox {
  // 检测 Proxy 性能(阈值需按目标设备实测标定)
  const proxyPerf = measureProxyPerformance();

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

  return new ProxySandbox(appName);
}


// 方式 A:全局关闭(所有基于路由的子应用都不再有沙箱)
start({ sandbox: false });

// 方式 B:只对手动加载的这个子应用关闭
loadMicroApp(
  {
    name: 'high-perf-app',
    entry: '//cdn.example.com/canvas-app/',
    container: '#container',
  },
  {
    // 告诉乾坤不为这个子应用创建沙箱——但子应用自己要保证不污染全局环境
    sandbox: false,
  },
);


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 });
    });
  }
}

深度洞察:沙箱开销的"二八直觉"

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

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

Core Web Vitals 是 Google 在 2020 年定义的一组用户体验核心指标:LCP(最大内容绘制)、INP(交互响应,2024 年 3 月起取代最初的 FID)、CLS(布局稳定)。从 2021 年起,Core Web Vitals 成为 Google 搜索排名的信号之一。微前端在这三项上各有特有的挑战:LCP 常被子应用加载延迟拖慢,CLS 常因子应用挂载时的布局变化恶化,INP 常因子应用初始化时的 JS 阻塞变差。

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

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


interface MicroFrontendCWVChallenges {
  LCP: {
    // Largest Contentful Paint — 最大内容绘制
    challenge: '子应用的主要内容需要等加载完成后才能渲染';
    typicalImpact: '首屏多出一段串行加载,通常是最显著的劣化';
    rootCause: '串行加载链路: 主应用加载 → 框架初始化 → 子应用资源下载 → 渲染';
  };

  INP: {
    // Interaction to Next Paint — 交互到下一次绘制(2024-03 取代 FID;
    // FID 只量首次交互的输入延迟,INP 量整个访问期间交互的完整延迟,取接近最差的一次)
    challenge: '子应用 JS 执行阻塞主线程';
    typicalImpact: '子应用 bundle 执行期间的长任务直接计入';
    rootCause: '子应用的 JS bundle 需要在主线程解析执行,阻塞用户交互';
  };

  CLS: {
    // Cumulative Layout Shift — 累积布局偏移
    challenge: '子应用挂载时的 DOM 插入导致布局跳动';
    typicalImpact: '容器被撑开一次就可能越过 0.1 的 Good 阈值';
    rootCause: '容器元素的高度在子应用挂载前后发生变化';
  };
}

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

LCP(Largest Contentful Paint)记录的是最大的主要内容块渲染完成的时刻——不是白屏消失(那是 FP),也不是 DOM 解析完成(那是 DOMContentLoaded)。Google 给 LCP 定的阈值是 2.5 秒(Good)和 4 秒(Poor)。

LCP 衡量的是页面最大内容元素的渲染时间。在微前端中,子应用的主要内容通常就是 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);
}



// 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 INP 优化:减少子应用 JS 的主线程阻塞

INP 衡量的是"用户点下去之后,页面多久能给出下一帧反馈"。主线程被长任务占用时,所有用户交互都要排队,微前端的 JS 更多、解析执行更耗时,这个问题更突出。核心手段是把长任务切成小任务,中间用 scheduler.yield() 或 setTimeout 让出主线程,给浏览器响应交互的机会。

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

/**
 * 策略 1: JS 执行分片——用 scheduler 拆分长任务
 */
class ScriptExecutor {
  
  async executeWithYielding(
    scripts: string[],
    execScript: (code: string) => void
  ): Promise<void> {
    for (const script of scripts) {
      // 估算脚本执行时间
      const estimatedTime = this.estimateExecutionTime(script);

      if (estimatedTime > 50) {
        // 超过 50ms 就是长任务:在它开始前先让出主线程,让积压的输入先得到处理
        // (分片的粒度是「脚本之间」,单个脚本自身的执行无法再拆开)
        await this.yieldToMain();
      }

      // 在沙箱中执行脚本(execScript 由调用方提供,例如包一层 import-html-entry 的 execScripts)
      execScript(script);
    }
  }

  
  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 {
    // 纯占位估算,上线前应替换为按实测(performance.measure)得到的映射
    return (script.length / 10240) * 5;
  }
}


async function loadSubAppNonBlocking(
  appName: string,
  entry: string,
  container: HTMLElement
): Promise<void> {
  // 步骤 1: 立即显示骨架屏
  showSkeleton(appName, 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 秒,然后强制执行
    );
  });
}


// 子应用自身的 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 主要来自子应用异步加载时的容器尺寸变化:主应用渲染时容器是 0 高度,子应用挂载后突然变成 1000px,下面的内容被推移。优化思路是给子应用容器预留尺寸(min-height 或 aspect-ratio),让子应用挂载后只是填充预留空间。

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



// CSS 方案: 为子应用容器设置最小高度
const containerStyles = `
  /* 方案 A: 固定最小高度 */
  .micro-app-container {
    min-height: 600px;  
    contain: layout;    /* CSS Containment: 隔离布局影响(不含尺寸,见策略 2 的说明) */
  }

  
  .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;  
  }
`;


const advancedContainerStyles = `
  .micro-app-container {
    
    contain: layout style;

    
    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 超过阈值,触发告警
          // (简化:这里把所有偏移一路累加;正式的 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 {
    // 识别当前活跃的微应用:乾坤为每个子应用生成的包裹 div 的 id 是
    // __qiankun_microapp_wrapper_for_<snakeCase(name)>__、并带 data-name 属性(src/utils.ts:142 getDefaultTplWrapper / :164 getWrapperId)
    return document.querySelector(
      'div[id^="__qiankun_microapp_wrapper_for_"]'
    )?.getAttribute('data-name') ?? 'unknown';
  }
}


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 综合性能监控方案

性能监控体系应该满足几个要求:(1) 覆盖关键指标(LCP、INP、CLS、TTFB 等);(2) 按用户维度(地理、设备、浏览器)切片;(3) 在微前端场景下按子应用切片;(4) 支持实时和历史对比;(5) 指标异常时自动告警。

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


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 秒,建议启用预加载(start({ prefetch }) / 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;
  }

  // 乾坤的包裹 div:id 为 __qiankun_microapp_wrapper_for_<name>__,带 data-name 属性
  private static WRAPPER = 'div[id^="__qiankun_microapp_wrapper_for_"]';

  private getActiveApp(): string {
    return (
      document
        .querySelector(MicroFrontendPerformanceMonitor.WRAPPER)
        ?.getAttribute('data-name') ?? 'main'
    );
  }

  private isElementInSubApp(element: Element | null): boolean {
    if (!element) return false;
    return !!element.closest(MicroFrontendPerformanceMonitor.WRAPPER);
  }

  private identifyAppForElement(element: Element | null): string {
    if (!element) return 'unknown';
    const container = element.closest(MicroFrontendPerformanceMonitor.WRAPPER);
    return container?.getAttribute('data-name') ?? '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)是 Tim Kadlec 在 2013 年的文章《Setting a Performance Budget》里提出并推广的实践:像管理财务预算一样管理性能指标,在 CI/CD 里给指标设硬上限,任何提交导致超标就阻断合并,把性能退化从"事后发现"变成"事前拦截"。没有预算,性能会在一次次"只多了几 KB"的提交里慢慢退化。微前端因为多团队,更需要明确分配:每个子应用有自己的预算,主应用有整体预算,加起来不超过总预算。

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


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)性能预算应该在架构设计阶段就确定,而不是上线后才开始关注。先定义"多快才够快",再选择能达到这个标准的架构方案。


本章小结

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

思考题

  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 中完成子应用资源的预处理和缓存管理,从而消除运行时的加载延迟?