Appearance
第17章 微前端性能工程
"性能优化的最高境界,不是让代码跑得更快——是让用户感知不到等待的存在。"
本章要点
- 理解微前端场景下首屏性能的特殊性:预加载与懒加载的工程权衡
- 掌握公共依赖提取与共享的三大策略:externals、Module Federation shared、Import Maps
- 深入分析 Proxy 沙箱的性能开销,建立基准测试方法论
- 系统性优化 Core Web Vitals(LCP / FID / CLS)在微前端架构下的表现
微前端架构给团队带来了独立部署和技术栈自由的巨大收益——但天下没有免费的午餐。
想象这样一个场景:你的主应用加载完毕,用户点击导航切换到订单子应用。浏览器开始下载子应用的 HTML、解析其中的 JS 和 CSS 资源、创建 Proxy 沙箱、执行子应用的 bootstrap 生命周期、最后 mount 渲染到 DOM 上。这一系列动作的总耗时,决定了用户在点击之后看到内容之前,盯着白屏或 loading 动画的时长。
在单体 SPA 中,所有代码在首次加载时就已经被打包进 bundle——路由切换只是组件的替换,几乎是瞬时的。但微前端把这个"已打包"的前提打破了。每个子应用是一个独立的部署单元,它的资源需要在激活时从网络加载。这就像把一栋完整的大楼拆成了可拼接的模块化板房——灵活了,但拼接的时候需要时间。
本章的目标是:让这个"拼接时间"尽可能接近零。
下图展示了微前端子应用加载的完整瀑布流,标注了各阶段的典型耗时和优化切入点:
我们将从四个维度系统性地剖析微前端的性能工程:首屏加载策略、公共依赖共享、沙箱运行时开销、以及 Core Web Vitals 的针对性优化。每个维度都会深入源码实现,给出可落地的优化方案和真实的性能数据。
17.1 首屏性能:预加载 vs 懒加载的权衡
17.1.1 微前端的加载瀑布流
在分析优化策略之前,我们先精确地理解微前端子应用加载的完整链路:
typescript
// 微前端子应用加载的完整瀑布流(以乾坤为例)
interface LoadWaterfall {
// 阶段 1: 主应用加载
mainAppLoad: {
html: number; // 主应用 HTML 下载 ~50ms
mainJs: number; // 主应用 JS 下载+执行 ~200ms
mainCss: number; // 主应用 CSS 下载 ~80ms
frameworkInit: number; // 乾坤框架初始化 ~20ms
};
// 阶段 2: 子应用资源获取(用户点击导航后触发)
subAppFetch: {
htmlFetch: number; // 获取子应用 HTML ~100ms
htmlParse: number; // 解析 HTML 提取资源列表 ~10ms
jsFetch: number; // 下载子应用 JS ~150ms
cssFetch: number; // 下载子应用 CSS ~60ms
};
// 阶段 3: 沙箱与执行
sandboxAndExec: {
sandboxCreate: number; // 创建 Proxy 沙箱 ~5ms
jsExec: number; // 执行子应用 JS ~100ms
bootstrap: number; // 子应用 bootstrap 生命周期 ~30ms
mount: number; // 子应用 mount 渲染 ~80ms
};
// 总耗时: 约 800-1200ms(首次加载,无缓存)
// 用户感知: 从"点击导航"到"看到内容"的时间
}这个瀑布流揭示了三个关键瓶颈:
- 网络阶段是最大瓶颈:子应用的 HTML + JS + CSS 下载占总耗时的 40% 以上
- 串行依赖严重:必须先下载 HTML 才能解析出 JS/CSS 地址,再下载执行
- 沙箱创建和 JS 执行不可并行:沙箱必须在 JS 执行之前准备好
优化策略的核心思路就是:打破串行瓶颈,将资源获取前置。
17.1.2 乾坤的 prefetchApps 实现
乾坤提供了 prefetchApps API 来预加载子应用资源。它的实现值得仔细研究——不仅因为它解决了首屏性能问题,更因为它展示了一种精妙的调度策略。
typescript
// 源码位置: qiankun/src/prefetch.ts
// 乾坤的预加载策略实现
import { importEntry } from 'import-html-entry';
/**
* 预加载策略的核心:利用浏览器空闲时间预取子应用资源
* 关键洞察:使用 requestIdleCallback 而非立即加载,
* 确保预加载不影响当前页面的首屏渲染
*/
function prefetch(
entry: string,
opts?: ImportEntryOpts
): void {
// 不是立即加载,而是等浏览器空闲
if (!navigator.onLine) {
// 离线环境下跳过预加载——这是一个容易忽略的边界条件
return;
}
requestIdleCallback(async () => {
// importEntry 会获取 HTML 并解析出 JS/CSS 资源列表
const { getExternalScripts, getExternalStyleSheets } = await importEntry(
entry,
opts
);
// 再次利用 requestIdleCallback 分批加载静态资源
requestIdleCallback(() => getExternalStyleSheets());
requestIdleCallback(() => getExternalScripts());
});
}
/**
* 根据配置决定预加载哪些子应用
*/
export function doPrefetchStrategy(
apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts
): void {
// 策略类型判断
if (Array.isArray(prefetchStrategy)) {
// 精确指定需要预加载的子应用列表
const appsToPrefetch = apps.filter((app) =>
prefetchStrategy.includes(app.name)
);
prefetchAfterFirstMounting(appsToPrefetch, importEntryOpts);
} else if (typeof prefetchStrategy === 'function') {
// 自定义预加载策略函数——最大灵活度
const {
criticalAppNames = [],
minorAppNames = [],
} = prefetchStrategy(apps);
// 关键子应用立即预加载
prefetchImmediately(
apps.filter((app) => criticalAppNames.includes(app.name)),
importEntryOpts
);
// 次要子应用等第一个子应用挂载后再预加载
prefetchAfterFirstMounting(
apps.filter((app) => minorAppNames.includes(app.name)),
importEntryOpts
);
} else if (prefetchStrategy === true) {
// 默认策略:第一个子应用挂载后,预加载所有其他子应用
prefetchAfterFirstMounting(apps, importEntryOpts);
}
}
/**
* 核心:等第一个子应用挂载完成后再预加载其他子应用
* 这避免了预加载与首屏渲染争抢带宽
*/
function prefetchAfterFirstMounting(
apps: AppMetadata[],
opts?: ImportEntryOpts
): void {
// 监听第一个子应用挂载完成的事件
if (window.__POWERED_BY_QIANKUN__FIRST_APP_MOUNTED__) {
apps.forEach(({ entry }) => prefetch(entry, opts));
return;
}
// 订阅首次挂载事件
window.addEventListener(
'single-spa:first-mount',
function listener() {
// 获取所有未加载的子应用
const notLoadedApps = apps.filter(
(app) => getAppStatus(app.name) === NOT_LOADED
);
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
window.removeEventListener('single-spa:first-mount', listener);
}
);
}下图展示了乾坤预加载策略的两级调度时序:
这段代码有三个设计精妙之处:
第一,两级 requestIdleCallback 调度。 第一级等待浏览器空闲后获取 HTML 并解析资源列表,第二级再在空闲时分别加载 JS 和 CSS。这确保了预加载永远不会阻塞用户的正常交互。
第二,首屏优先原则。 prefetchAfterFirstMounting 等待第一个子应用完全挂载后才开始预加载其他子应用——这意味着用户看到首屏内容不会有任何延迟,预加载是"隐形"的。
第三,自定义策略函数。 通过传入函数,开发者可以根据业务优先级将子应用分为 criticalAppNames 和 minorAppNames,实现精细化的预加载控制。
17.1.3 实战:定制预加载策略
理论很美好,但落地时需要根据具体业务场景选择策略。以下是三种典型场景的最佳实践:
typescript
import { registerMicroApps, start } from 'qiankun';
// 场景 1: 电商平台——基于用户行为的预测性预加载
start({
prefetch: (apps) => {
// 从首页出发,用户最可能访问商品详情页
// 数据来源: 埋点分析,70% 的用户下一步点击商品
return {
criticalAppNames: ['product-detail'],
minorAppNames: ['shopping-cart', 'user-center', 'order-list'],
};
},
});
// 场景 2: 企业后台——基于角色的预加载
start({
prefetch: (apps) => {
const userRole = getCurrentUserRole();
if (userRole === 'admin') {
// 管理员最常访问系统设置和用户管理
return {
criticalAppNames: ['system-settings', 'user-management'],
minorAppNames: apps
.map((a) => a.name)
.filter(
(n) => !['system-settings', 'user-management'].includes(n)
),
};
}
if (userRole === 'operator') {
// 运营人员最常访问数据看板和内容管理
return {
criticalAppNames: ['dashboard', 'content-management'],
minorAppNames: ['order-management', 'customer-service'],
};
}
// 默认策略
return { criticalAppNames: [], minorAppNames: apps.map((a) => a.name) };
},
});
// 场景 3: 移动端 H5——网络感知的保守策略
start({
prefetch: (apps) => {
const connection = (navigator as any).connection;
if (connection) {
// 4G/WiFi 环境: 积极预加载
if (connection.effectiveType === '4g') {
return {
criticalAppNames: apps.map((a) => a.name),
minorAppNames: [],
};
}
// 3G 环境: 只预加载最关键的一个子应用
if (connection.effectiveType === '3g') {
return {
criticalAppNames: [apps[0]?.name].filter(Boolean),
minorAppNames: [],
};
}
// 2G/slow-2g 环境: 完全不预加载,节省带宽
return { criticalAppNames: [], minorAppNames: [] };
}
// 无法检测网络: 采用保守策略
return { criticalAppNames: [], minorAppNames: apps.map((a) => a.name) };
},
});17.1.4 超越 prefetch:资源预热的进阶方案
下图对比了三种不同级别的预加载策略及其对用户感知延迟的影响:
乾坤的 prefetchApps 只解决了资源下载的问题——JS 和 CSS 被缓存到浏览器中,但子应用的 JS 并没有被执行。当用户真正切换到子应用时,仍然需要经历 JS 执行、bootstrap、mount 的过程。
对于极致性能要求的场景,我们可以实现"预热"——在后台完成子应用的 JS 执行甚至 bootstrap:
typescript
/**
* 子应用预热方案:不仅预加载资源,还预执行子应用代码
* 注意:这是一个高级优化,可能增加内存开销
*/
class MicroAppPrewarmer {
private prewarmedApps = new Map<
string,
{
scripts: string[];
styles: string[];
execScripts: () => Promise<any>;
bootstrapped: boolean;
}
>();
async prewarm(
appName: string,
entry: string
): Promise<void> {
// 阶段 1: 获取并解析子应用资源(等同于 prefetch)
const {
template,
getExternalScripts,
getExternalStyleSheets,
execScripts,
} = await importEntry(entry);
// 阶段 2: 下载所有外部资源
const [scripts, styles] = await Promise.all([
getExternalScripts(),
getExternalStyleSheets(),
]);
// 阶段 3: 在后台创建临时沙箱并执行 JS
// 注意:此处创建的沙箱是临时的,真正挂载时会使用正式沙箱
const tempSandbox = createTempSandbox();
const appExports = await execScripts(tempSandbox.proxy);
// 阶段 4: 调用 bootstrap 生命周期
if (appExports.bootstrap) {
await appExports.bootstrap();
}
this.prewarmedApps.set(appName, {
scripts,
styles,
execScripts,
bootstrapped: true,
});
console.log(
`[Prewarmer] ${appName} 预热完成,切换时可节省约 200-400ms`
);
}
isPrewarmed(appName: string): boolean {
return this.prewarmedApps.get(appName)?.bootstrapped ?? false;
}
}
// 使用示例
const prewarmer = new MicroAppPrewarmer();
// 主应用首屏渲染完成后,预热高优先级子应用
window.addEventListener('single-spa:first-mount', () => {
requestIdleCallback(() => {
prewarmer.prewarm('product-detail', '//cdn.example.com/product/');
});
});17.1.5 懒加载的必要性与策略
预加载不是万能药。以下场景中,懒加载反而是更好的选择:
typescript
/**
* 懒加载策略决策树
*/
interface LazyLoadDecision {
// 条件 1: 子应用数量多(>10个),不可能全部预加载
manySubApps: boolean;
// 条件 2: 移动端或弱网环境,带宽珍贵
limitedBandwidth: boolean;
// 条件 3: 子应用体积大(>500KB gzipped)
largeSubApps: boolean;
// 条件 4: 某些子应用使用频率极低
rarelyUsedApps: boolean;
}
function shouldLazyLoad(decision: LazyLoadDecision): boolean {
// 任何一个条件为 true,都应该考虑懒加载(至少部分子应用)
return Object.values(decision).some(Boolean);
}
// 混合策略:核心子应用预加载 + 低频子应用懒加载
const microApps = [
// 高频核心子应用 → 预加载
{ name: 'dashboard', entry: '//cdn/dashboard/', preload: true },
{ name: 'order', entry: '//cdn/order/', preload: true },
// 中频子应用 → 首屏后预加载
{ name: 'product', entry: '//cdn/product/', preload: 'afterMount' },
{ name: 'user', entry: '//cdn/user/', preload: 'afterMount' },
// 低频子应用 → 纯懒加载,不预加载
{ name: 'settings', entry: '//cdn/settings/', preload: false },
{ name: 'reports', entry: '//cdn/reports/', preload: false },
{ name: 'audit-log', entry: '//cdn/audit/', preload: false },
];深度洞察:预加载的隐性成本
预加载看起来是纯收益——提前加载资源,用户切换时更快。但实际上,预加载有三个隐性成本:1)带宽竞争——预加载的请求可能与当前页面的 API 调用和图片加载竞争带宽,尤其在 HTTP/1.1 环境下(每个域名只有 6 个并发连接);2)内存占用——预加载的 JS 和 CSS 会驻留在浏览器内存中,大量预加载可能导致低端设备的内存压力;3)缓存失效浪费——如果子应用频繁更新,预加载的资源可能在用户真正访问前就已经过期,白白浪费了带宽。最佳实践:不要对所有子应用都开启预加载,基于访问概率做优先级排序。80/20 法则在这里同样适用——通常 20% 的子应用承载了 80% 的流量。
17.2 公共依赖提取与共享策略
17.2.1 问题的本质
微前端架构下,每个子应用独立构建、独立部署。这意味着如果主应用和五个子应用都使用了 React 18,用户的浏览器会下载六份 React 代码。
typescript
// 典型的资源浪费场景
const subAppBundles = {
'main-app': { react: '130KB', reactDom: '120KB', antd: '350KB' },
'order-app': { react: '130KB', reactDom: '120KB', antd: '350KB' },
'product-app': { react: '130KB', reactDom: '120KB', antd: '350KB' },
'user-app': { react: '130KB', reactDom: '120KB', antd: '350KB' },
'dashboard': { react: '130KB', reactDom: '120KB', echarts: '400KB' },
'settings': { react: '130KB', reactDom: '120KB' },
};
// 仅 react + react-dom 就重复了 6 次
// 总计: 130 * 6 + 120 * 6 = 1500KB 的冗余下载
// gzipped 后约: 45KB * 6 = 270KB 冗余(仍然不可忽视)17.2.2 策略一:Webpack externals + CDN
最经典的方案。将公共依赖从 bundle 中排除,通过 CDN 的 <script> 标签全局注入。
javascript
// webpack.config.js — 每个子应用的配置
module.exports = {
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
'react-router-dom': 'ReactRouterDOM',
'antd': 'antd',
'moment': 'moment',
},
};html
<!-- 主应用 index.html — 全局注入公共依赖 -->
<!DOCTYPE html>
<html>
<head>
<!-- 公共依赖通过 CDN 加载,所有子应用共享 -->
<script src="https://cdn.example.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://cdn.example.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdn.example.com/react-router-dom@6.20.0/dist/umd/react-router-dom.production.min.js"></script>
<script src="https://cdn.example.com/antd@5.12.0/dist/antd.min.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>优势:
- 实现简单,零运行时开销
- CDN 缓存命中率高,跨站点共享
- 主应用和子应用加载同一份代码,没有版本冲突风险
致命缺陷:
typescript
// 问题 1: 版本锁定——所有子应用必须使用完全相同的版本
// 如果 order-app 需要 React 18.2 而 product-app 需要 React 18.3
// externals 方案无法处理
// 问题 2: 沙箱兼容性——乾坤的 Proxy 沙箱会拦截全局变量访问
// 子应用中 `import React from 'react'` 被编译为 `const React = window.React`
// 但在 Proxy 沙箱中 window 是代理对象,需要确保代理正确转发
// 实际工程中,这里是 bug 高发区
// 问题 3: UMD 格式依赖——不是所有库都提供 UMD 格式
// ESM-only 的库无法通过这种方式共享
// 问题 4: 加载顺序——script 标签必须按依赖顺序排列
// antd 依赖 react 和 react-dom,必须在它们之后加载
// 维护这个顺序在依赖增多时变得脆弱17.2.3 策略二:Module Federation shared 配置
Module Federation 的 shared 配置提供了一种编译时协商的依赖共享方案——这是一个根本性的范式提升。
javascript
// host-app/webpack.config.js — 主应用(Host)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
shared: {
react: {
singleton: true, // 只允许加载一个版本
requiredVersion: '^18.0.0',
eager: true, // 主应用立即加载,不做异步拆分
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: true,
},
antd: {
singleton: true,
requiredVersion: '^5.0.0',
},
// 非 singleton 模式:允许多版本共存
lodash: {
singleton: false, // 允许不同子应用使用不同版本
requiredVersion: '^4.17.0',
},
},
}),
],
};
// order-app/webpack.config.js — 子应用(Remote)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'orderApp',
filename: 'remoteEntry.js',
exposes: {
'./OrderList': './src/components/OrderList',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
// 注意:子应用不设置 eager,使用异步加载
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
antd: {
singleton: true,
requiredVersion: '^5.0.0',
},
},
}),
],
};