微前端源码精讲
第17章 微前端性能工程
第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 渲染
};
// 总耗时: 三个阶段之和(首次加载、无缓存时最长)
// 用户感知: 从"点击导航"到"看到内容"的时间
}
这个瀑布流揭示了三个关键瓶颈:
- 网络阶段是最大瓶颈:子应用的 HTML + JS + CSS 下载通常是总耗时里最大的一块
- 串行依赖严重:必须先下载 HTML 才能解析出 JS/CSS 地址,再下载执行
- 沙箱创建和 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 的性能预算机制和生产环境的实时监控来持续守护
思考题
-
实践应用:你的微前端项目有 8 个子应用,平均 bundle 大小 180KB(gzipped),首次切换到子应用需要 1.8 秒。请设计一个完整的预加载策略,说明哪些子应用预加载、何时触发、如何处理弱网场景。
-
方案对比:分析 Webpack externals + CDN 和 Module Federation shared 在以下场景中的优劣:a)所有子应用使用相同版本的 React;b)三个子应用使用 React 18,两个使用 React 17;c)需要在乾坤 Proxy 沙箱环境中运行。
-
性能分析:某个微前端项目的 CLS 得分为 0.25(远超 0.1 的 "Good" 阈值),已确认原因是子应用挂载时容器高度变化。请提出至少三种不同层次的解决方案,并说明各自的优缺点。
-
深度思考:本章提出"性能是架构决策的结果"。如果你正在设计一个全新的微前端架构,且性能预算要求 LCP < 1500ms、CLS < 0.05,你会选择什么技术方案?请说明选择理由和必须放弃的特性。
-
开放讨论:随着 Edge Computing 和 Service Worker 的成熟,你认为微前端的性能优化范式是否会发生根本性变化?例如,是否可以在 Service Worker 中完成子应用资源的预处理和缓存管理,从而消除运行时的加载延迟?