微前端源码精讲
第14章 其他前沿方案
第14章 其他前沿方案
当所有人都在争论哪个框架更好时,真正的变革往往来自一个没人注意到的浏览器标准提案。
本章要点
- 深入理解 Garfish(字节跳动)的 Loader/Router/Sandbox 三层架构,以及它对乾坤设计哲学的继承与超越
- 掌握 Micro App(京东)基于 WebComponent 自定义元素的微前端实现路线及其设计取舍
- 理解 Import Maps 浏览器原生模块加载规范的工作机制,以及它如何改变微前端的依赖共享范式
- 把握 Server-Driven UI、Server Islands、边缘计算组合等前沿趋势与微前端的融合方向
本章涉及乾坤的源码以 v2.10.16 为准、import-html-entry 以 v1.17.0 为准;Garfish、Micro App 只核对其公开文档中的 API 名与版本事实,正文中标注"示意"的代码是为讲解原理写的伪代码、不是这两个框架的源码。
前面的章节中,我们花了大量篇幅剖析乾坤、single-spa、Module Federation 和 Wujie——它们是本专栏重点剖析的几种主流方案,各自代表一条典型路线。但微前端的版图远不止这四个名字。
字节跳动 Web Infra 团队开源的 Garfish,面向的是其内部多业务线、多实例同屏的超大规模场景;京东的 Micro App 另辟蹊径,用 WebComponent 自定义元素重新定义了子应用的加载与隔离范式;而浏览器原生的 Import Maps 规范,正在悄悄削弱"我们为什么需要一个微前端框架"这个根基性假设。
更远处,Server-Driven UI 和 Server Islands 等服务端驱动的架构模式,正在模糊前端微服务与后端微服务之间的边界。当 CDN 边缘节点可以在离用户几十毫秒的地方完成 HTML 片段的组合,当服务端可以动态决定每个 UI 区域加载哪个版本的哪个组件——传统意义上的"前端微前端"是否还有存在的必要?
这一章,我们不追求面面俱到的 API 文档式罗列,而是抓住每个方案的核心设计决策和本质差异点,帮助你在已有的架构认知框架上,快速定位这些方案的坐标。
下图展示了微前端各方案在"隔离强度"和"加载粒度"两个维度上的定位:
flowchart TB
subgraph StrongIsolation["强隔离 -- 运行时沙箱"]
Qiankun["乾坤\nProxy 沙箱 + HTML Entry"]
Wujie["Wujie\niframe + WebComponent"]
Garfish["Garfish\nProxy 沙箱 + 多实例路由"]
IFrame["原生 iframe\n最强隔离,体验差"]
end
subgraph LightIsolation["无内置隔离 -- 构建期契约"]
MF["Module Federation\n构建时声明、运行时共享\n不提供 JS/CSS 隔离"]
ImportMaps["Import Maps\n浏览器原生模块加载"]
end
subgraph ComponentLevel["组件级嵌入"]
MicroApp["Micro App (京东)\nWebComponent 自定义元素"]
WC["Web Components\n浏览器原生隔离"]
end
subgraph ServerDriven["服务端驱动"]
SI["Server Islands\n服务端片段组合"]
Edge["边缘计算\nCDN 级 HTML 拼接"]
end
style StrongIsolation fill:#ffebee,stroke:#c62828
style LightIsolation fill:#e3f2fd,stroke:#1565c0
style ComponentLevel fill:#e8f5e9,stroke:#2e7d32
style ServerDriven fill:#f3e5f5,stroke:#7b1fa2
14.1 Garfish(字节跳动):乾坤的继承者
14.1.1 从字节的痛点说起
2020 年前后(Garfish 的开源仓库建于 2020 年 12 月),字节跳动的前端团队面临一个现实问题:乾坤在中小规模场景下表现优秀,但当子应用数量增多、页面级别的动态组合需求出现时,乾坤的一些设计假设开始被挑战。
最典型的三个痛点:
- 预加载策略偏静态——乾坤 v2.10.16 的
prefetch只有几档(src/prefetch.ts:111的doPrefetchStrategy:true在首个子应用挂载后预加载其余、'all'立即全部预加载、应用名数组、或自定义函数返回criticalAppNames/minorAppsName),没有根据用户实际访问习惯自动加权的能力;Garfish 则会在用户端统计子应用打开次数、次数越多预加载权重越大(disablePreloadApp文档) - 路由与应用的绑定偏刚性——乾坤
start()默认singular: true、同一时刻只有一个子应用处于激活态,"同一个页面组合多个子应用的不同区域"要么关掉singular、要么改用loadMicroApp手动编排 - 沙箱性能在高频切换场景下不够理想——工作台类产品的用户会在子应用之间频繁切换,沙箱的创建和销毁开销变得不可忽视
Garfish 就是在这样的背景下诞生的。它的设计目标很明确:保留乾坤"运行时沙箱 + HTML Entry"的核心范式,在此基础上解决大规模、高频次、多区域的工程化问题。
14.1.2 三层架构:Loader、Router、Sandbox
Garfish 的架构可以用三个核心模块来概括。与乾坤将加载、路由、沙箱逻辑耦合在主流程中不同,Garfish 做了更清晰的分层:
┌─────────────────────────────────────────────────┐
│ Garfish 核心 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │
│ │ Loader │ │ Router │ │ Sandbox │ │
│ │ 资源加载 │ │ 路由管理 │ │ JS/CSS隔离 │ │
│ │ │ │ │ │ │ │
│ │ ·HTML解析│ │ ·路由劫持 │ │ ·Proxy沙箱 │ │
│ │ ·JS提取 │ │ ·激活规则 │ │ ·快照沙箱 │ │
│ │ ·CSS提取 │ │ ·多实例 │ │ ·样式隔离 │ │
│ │ ·预加载 │ │ ·嵌套路由 │ │ ·副作用收集 │ │
│ └─────────┘ └─────────┘ └──────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Plugin System (插件系统) │ │
│ │ 生命周期钩子 ─ 资源转换 ─ 沙箱扩展 │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Loader(资源加载器) 负责获取和解析子应用资源。来看核心流程:
// Garfish Loader 核心流程(示意,非 Garfish 源码)
interface AppInfo {
name: string;
entry: string; // 子应用入口 URL
activeWhen?: string; // 路由激活规则
cache?: boolean; // 是否启用缓存
}
class Loader {
private appCache: Map<string, AppCacheItem> = new Map();
private loadingMap: Map<string, Promise<AppResources>> = new Map();
async loadApp(appInfo: AppInfo): Promise<AppResources> {
const { name, entry, cache } = appInfo;
// 1. 命中缓存:直接返回
if (cache && this.appCache.has(name)) {
return this.appCache.get(name)!.resources;
}
// 2. 正在加载:复用 Promise,避免重复请求
if (this.loadingMap.has(name)) {
return this.loadingMap.get(name)!;
}
// 3. 发起加载
const loadPromise = this.fetchAndParse(entry);
this.loadingMap.set(name, loadPromise);
try {
const resources = await loadPromise;
if (cache) {
this.appCache.set(name, {
resources,
timestamp: Date.now(),
});
}
return resources;
} finally {
this.loadingMap.delete(name);
}
}
private async fetchAndParse(entry: string): Promise<AppResources> {
// 获取 HTML
const html = await fetch(entry).then(res => res.text());
// 解析 HTML,提取 JS 和 CSS 资源
const { scripts, styles, template } = parseHTML(html, entry);
// 并行加载所有 JS 和 CSS
const [jsContents, cssContents] = await Promise.all([
Promise.all(scripts.map(src => this.fetchScript(src))),
Promise.all(styles.map(href => this.fetchStyle(href))),
]);
return { template, jsContents, cssContents, scripts, styles };
}
}
与乾坤的 import-html-entry 相比,Garfish 的 Loader 有两个值得注意的差异:
- 去重加载——通过
loadingMap避免对同一个子应用的并发重复请求。公平地说,import-html-entry v1.17.0 也做了同样的事(src/index.js:335里embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)...)缓存的是 Promise);Garfish 真实的 Loader 同样按 URL 在loadingList里缓存 Promise(Garfish 官方仓库 web-infra-dev/garfish 的 packages/loader/src/index.ts:158-160),两者在这一点上并无本质差别。 - 细粒度缓存控制——Garfish 的
AppConfig.cache(默认true)按应用粒度开关的是应用实例缓存:开启时再次加载同一应用直接返回已有实例(Garfish 官方仓库 web-infra-dev/garfish 的 packages/core/src/garfish.ts:188-215),而不是全局的开或关。文档提醒,关闭它会严重影响子应用的加载速度,需要仔细权衡。
Router(路由管理器) 是 Garfish 与乾坤差异最大的模块:
// Garfish Router 的多实例路由匹配(示意,非 Garfish 源码;activeWhen / domGetter / basename / autoRefreshApp 是真实选项名)
interface RouterConfig {
// 支持多个子应用同时激活
apps: Array<{
name: string;
activeWhen: string | ((path: string) => boolean);
// 关键:指定子应用挂载到哪个 DOM 容器
domGetter: string | (() => HTMLElement);
}>;
// 路由拦截策略
autoRefreshApp?: boolean;
// 基础路径
basename?: string;
}
class GarfishRouter {
private apps: Map<string, RouterAppConfig> = new Map();
private activeApps: Set<string> = new Set();
async reroute(currentPath: string): Promise<void> {
const nextActiveApps = new Set<string>();
// 遍历所有注册的子应用,判断哪些应该被激活
for (const [name, config] of this.apps) {
if (this.matchRoute(currentPath, config.activeWhen)) {
nextActiveApps.add(name);
}
}
// 计算需要卸载的子应用
const appsToUnmount = [...this.activeApps]
.filter(name => !nextActiveApps.has(name));
// 计算需要挂载的子应用
const appsToMount = [...nextActiveApps]
.filter(name => !this.activeApps.has(name));
// 先卸载,再挂载(保证 DOM 清理在前)
await Promise.all(appsToUnmount.map(name => this.unmountApp(name)));
await Promise.all(appsToMount.map(name => this.mountApp(name)));
this.activeApps = nextActiveApps;
}
private matchRoute(
path: string,
rule: string | ((path: string) => boolean)
): boolean {
if (typeof rule === 'function') return rule(path);
// 支持通配符和前缀匹配
return path.startsWith(rule);
}
}
下图展示了 Garfish 与乾坤在路由模型上的关键差异:
flowchart TB
subgraph QiankunRouting["乾坤路由模型"]
QRoute["URL: /order/list"]
QRoute --> QApp["默认 singular: true\n同一时刻只激活一个子应用"]
QApp --> QContainer["#subapp-container\n订单子应用"]
end
subgraph GarfishRouting["Garfish 路由模型"]
GRoute["URL: /workspace"]
GRoute --> GNav["左侧导航子应用\ndomGetter: #nav"]
GRoute --> GContent["中间内容子应用\ndomGetter: #content"]
GRoute --> GPanel["右侧面板子应用\ndomGetter: #panel"]
GNav ~~~ GContent ~~~ GPanel
end
style QiankunRouting fill:#fff3e0,stroke:#e65100
style GarfishRouting fill:#e8f5e9,stroke:#2e7d32
多实例激活是 Garfish 路由设计的核心竞争力。在工作台类产品中,一个页面的左侧导航、中间内容区、右侧面板可能分别由三个不同的子应用提供。乾坤默认的 singular 模式处理这种场景需要关掉 singular 或改用 loadMicroApp 手动编排,而 Garfish 从路由层面原生支持(注意:Garfish 的快照沙箱不支持多实例,多实例要用默认的 vm 沙箱)。
Sandbox(沙箱系统) 在原理上与乾坤的 Proxy 沙箱一脉相承,但做了重要的性能优化:
// Garfish 沙箱的副作用收集机制(示意,非 Garfish 源码)
class GarfishSandbox {
private fakeWindow: Record<string, any>;
private proxyWindow: WindowProxy;
// 关键优化:副作用收集器
private sideEffects: Array<() => void> = [];
constructor() {
this.fakeWindow = Object.create(null);
this.proxyWindow = new Proxy(this.fakeWindow, {
get: (target, key: string) => {
// 优先从沙箱自身读取
if (key in target) return target[key];
// 回退到真实 window
const value = (window as any)[key];
// 如果是函数,需要绑定到真实 window(避免 this 指向问题)
if (typeof value === 'function' && !this.isConstructor(value)) {
return value.bind(window);
}
return value;
},
set: (target, key: string, value) => {
target[key] = value;
// 记录副作用,以便沙箱销毁时回收
this.sideEffects.push(() => {
delete target[key];
});
return true;
},
});
}
patchEventListener(): void {
const rawAddEventListener = window.addEventListener;
const rawRemoveEventListener = window.removeEventListener;
const listenerMap = new Map<string, Set<EventListener>>();
this.proxyWindow.addEventListener = (
type: string,
listener: EventListener,
options?: boolean | AddEventListenerOptions
) => {
if (!listenerMap.has(type)) {
listenerMap.set(type, new Set());
}
listenerMap.get(type)!.add(listener);
rawAddEventListener.call(window, type, listener, options);
};
// 沙箱销毁时,自动移除所有注册的事件监听
this.sideEffects.push(() => {
for (const [type, listeners] of listenerMap) {
for (const listener of listeners) {
rawRemoveEventListener.call(window, type, listener);
}
}
listenerMap.clear();
});
}
destroy(): void {
// 逆序执行,保证后添加的副作用先被清理
for (let i = this.sideEffects.length - 1; i >= 0; i--) {
this.sideEffects[i]();
}
this.sideEffects = [];
}
private isConstructor(fn: Function): boolean {
// 不能真的 new 一次来试(会产生副作用),参照乾坤 isConstructable 的思路看 prototype
const proto = (fn as any).prototype;
return !!proto && proto.constructor === fn && Object.getOwnPropertyNames(proto).length > 1;
}
}
副作用收集的设计思路是:与其在卸载时尝试"猜测"子应用做了哪些全局修改,不如在运行时就记录下每一个副作用,卸载时精确回收。 需要说明的是,乾坤的 ProxySandbox 走的也是这条路——src/sandbox/patchers/ 下的 interval、windowListener、historyListener、dynamicAppend 补丁同样是"运行时记录、卸载时回收",真正靠遍历 window 做 diff 的只有面向不支持 Proxy 浏览器的 SnapshotSandbox。Garfish 的差别在于把这套机制模块化成了沙箱配置里的 modules:每个 module 返回 { override, prepare, created, recover } 中的若干项,override 覆盖子应用执行上下文里的变量、recover 在应用卸载时清除副作用,用户可以用同一套接口扩展自己的副作用管理(文档也提醒:不在 recover 里清理就可能内存泄漏)。Garfish 的 sandbox 选项还有 snapshot(默认 false,即默认用 vm/Proxy 沙箱)和 strictIsolation(子应用渲染节点开启 closed 模式的 Shadow DOM)。
14.1.3 插件系统:Garfish 的扩展性设计
下图展示了 Garfish 插件系统的钩子执行时序,覆盖了子应用从加载到卸载的完整生命周期:
sequenceDiagram
participant Plugin as 插件系统
participant Loader as Loader
participant Sandbox as Sandbox
participant App as 子应用
Plugin->>Plugin: beforeLoad(appInfo)
Plugin-->>Loader: 返回 false 可阻止加载
Loader->>Loader: fetch HTML, 解析资源
Plugin->>Plugin: afterLoad(appInfo, appInstance)
Sandbox->>Sandbox: 创建沙箱
Plugin->>Plugin: beforeMount(appInfo, appInstance)
Plugin->>Plugin: beforeEval(appInfo, code, env, url, options)
Note over Plugin: 每段 script 执行前触发,可做审计/打点
Sandbox->>App: 在沙箱中执行 JS
Plugin->>Plugin: afterEval(appInfo, ...)
App->>App: 调用子应用 render 渲染到 DOM
Plugin->>Plugin: afterMount(appInfo, appInstance)
Plugin->>Plugin: beforeUnmount(appInfo, appInstance)
App->>App: unmount() 清理 DOM
Plugin->>Plugin: afterUnmount(appInfo, appInstance)
Garfish 的插件系统由独立的 @garfish/hooks 包实现——SyncHook、AsyncHook、SyncWaterfallHook、AsyncWaterfallHook 加一个 PluginSystem,形态和 Webpack 的 Tapable 很像;Router、Sandbox 这些框架自身的能力也都是以插件形式挂进去的。它提供了贯穿整个生命周期的钩子:
// Garfish 插件的形态(钩子名以 Garfish 文档为准,签名有精简)
import Garfish, { type interfaces } from 'garfish';
// 插件是一个「接收 Garfish 实例、返回插件对象」的函数
export function loggerPlugin(_args?: any) {
return function (Garfish: interfaces.Garfish): interfaces.Plugin {
return {
name: 'garfish-plugin-logger',
version: '1.0.0',
// 全局钩子:beforeBootstrap / bootstrap / beforeRegisterApp / registerApp
// 应用加载阶段:beforeLoad(返回 false 可阻止加载)/ afterLoad / errorLoadApp
beforeLoad(appInfo) {
console.log('子应用开始加载', appInfo.name);
},
// 代码执行阶段:每个 script(html 内的和动态创建的)执行前后都会触发
beforeEval(appInfo, code, env, url, options) {
performance.mark(`${appInfo.name}:eval:start`);
},
afterEval(appInfo, code, env, url, options) {
performance.mark(`${appInfo.name}:eval:end`);
},
// 挂载 / 卸载阶段:beforeMount / afterMount / errorMountApp / beforeUnmount / afterUnmount / errorUnmountApp
afterMount(appInfo, appInstance) {
console.log('子应用挂载完成', appInfo.name);
},
};
};
}
// 注册插件:run 时通过 plugins 传入,或调用 Garfish.usePlugin
Garfish.run({ plugins: [loggerPlugin()] });
beforeEval/afterEval 这对钩子值得关注——它们在子应用每一段 JS 代码执行前后触发,参数里带着代码文本、注入的环境变量和资源地址,是做性能打点、安全审计的天然切入口(文档把它们标为 sync, sequential,没有承诺通过返回值改写代码——要改代码还是得用 loader 层的能力)。乾坤没有提供对等的钩子:import-html-entry 的 execScripts 虽然有 beforeExec/afterExec 两个回调,但乾坤 2.x 没有把它们透出——src/loader.ts:347 调用 execScripts 时只传了 scopedGlobalVariables,能用的只有透传给 import-html-entry 的 getTemplate(改 HTML 模板)、fetch(自定义请求,可改写外链脚本的返回内容,内联脚本不经过它)和 excludeAssetFilter(跳过某些资源)。
🔥 深度洞察:Garfish 的定位
Garfish 并不是要"颠覆"乾坤,而是在字节跳动的超大规模场景中对乾坤范式的工程化升级。如果用汽车来类比:乾坤是一辆可靠的家用轿车,Garfish 是在同一底盘上改装的赛道版——发动机原理相同,但悬挂、空气动力学、冷却系统都针对高负载场景做了专项优化。选 Garfish 而非乾坤的核心理由不是"更好",而是"你的场景是否需要多实例同屏、插件化扩展和精细化缓存控制"。
14.1.4 Garfish 与乾坤的核心差异对照
| 维度 | 乾坤 (qiankun) | Garfish |
|---|---|---|
| 路由模型 | 默认 singular,一次激活一个子应用(loadMicroApp 可手动多实例) |
路由层原生支持同一路由激活多个子应用 |
| 沙箱策略 | ProxySandbox / SnapshotSandbox / LegacySandbox | vm(Proxy)沙箱 / 快照沙箱,副作用经 modules 的 recover 回收 |
| 插件系统 | 无官方插件机制 | @garfish/hooks 驱动的完整插件生命周期钩子 |
| 预加载 / 缓存 | prefetch 策略(true / 'all' / 名单 / 函数) |
按应用粒度的 cache 选项 + 按打开次数加权的预加载 |
| 代码执行钩子 | 无 | beforeEval / afterEval(每段脚本执行前后触发) |
| 实现语言 | TypeScript | TypeScript |
| 适用规模 | 单例路由模型,适合"一次一个子应用"的后台系统 | 多实例、多区域组合的大型工作台 |
| 社区生态 | 成熟、文档丰富 | 字节内部验证充分,外部社区较小 |
14.2 Micro App(京东):WebComponent 路线的实践
14.2.1 一个大胆的设问:如果微前端是一个 HTML 标签呢?
Micro App 的出发点是一个极其简洁的直觉:子应用的加载和渲染,本质上和加载一张图片或一个 iframe 没有区别——都是在 DOM 中嵌入一个外部资源。 那为什么不能像写 <img src="..."> 一样写 <micro-app src="...">?
<!-- 这是 Micro App 的理想使用形态 -->
<micro-app
name="order"
url="https://order.example.com"
></micro-app>
一行代码,一个自定义元素,完成子应用的加载、渲染和隔离。没有 registerMicroApps,没有 loadMicroApp,没有复杂的配置对象——主应用入口只需调用一次 microApp.start(),之后一切都是声明式的。这里没有写 baseroute:按 Micro App 1.x 文档,只有 router-mode 设为 native 或 native-scope 时才需要它,默认的 search 模式用不到。
14.2.2 自定义元素的生命周期映射
Micro App 的核心设计是将微前端的生命周期映射到 WebComponent 自定义元素的生命周期:
// Micro App 自定义元素定义(示意,非源码;observedAttributes 与真实源码一致)
class MicroAppElement extends HTMLElement {
// 子应用实例
private appInstance: MicroAppInstance | null = null;
// 声明需要监听的属性变化——真实源码只观察 name 和 url 两个属性
// data 走的是元素的 property(或 microApp.setData),不是 attribute
static get observedAttributes(): string[] {
return ['name', 'url'];
}
/**
* 当元素被插入 DOM 时触发
* 映射为:加载子应用 → 创建沙箱 → 挂载
*/
connectedCallback(): void {
const name = this.getAttribute('name')!;
const url = this.getAttribute('url')!;
const baseroute = this.getAttribute('baseroute') || ''; // 仅 native / native-scope 路由模式需要
this.appInstance = new MicroAppInstance({
name,
url,
baseroute,
container: this, // 自定义元素本身就是容器
});
// 开始加载和挂载
this.appInstance.start();
}
/**
* 当元素从 DOM 移除时触发
* 映射为:卸载子应用 → 销毁沙箱 → 清理资源
* (带 keep-alive 属性时只是隐藏,不卸载)
*/
disconnectedCallback(): void {
if (this.appInstance) {
this.appInstance.unmount();
this.appInstance = null;
}
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
): void {
if (name === 'url' && oldValue !== newValue && this.appInstance) {
// URL 变化触发子应用切换
this.appInstance.unmount();
this.appInstance = new MicroAppInstance({
name: this.getAttribute('name')!,
url: newValue!,
baseroute: this.getAttribute('baseroute') || '',
container: this,
});
this.appInstance.start();
}
}
}
// 注册自定义元素
customElements.define('micro-app', MicroAppElement);
这个设计的精妙之处在于:利用浏览器原生的自定义元素生命周期来驱动微前端的生命周期,无需手动管理。 当 React/Vue 的条件渲染把 <micro-app> 元素从 DOM 中移除时,disconnectedCallback 自动触发子应用卸载——不需要任何额外的卸载代码。
14.2.3 资源隔离:Shadow DOM 与样式作用域
Micro App 的 CSS 隔离默认并不依赖 Shadow DOM,而是在样式文本上做"属性前缀"作用域化(文档"样式隔离"一节的例子:.test {} 被改写成 micro-app[name=xxx] .test {})。早期版本提供过 shadowDOM 属性,但 1.x 的文档已经把它注释掉、并说明它"在 React 框架及一些 UI 库中的兼容不是很好……不建议使用"。下面用示意代码说明两种策略:
// 示意,非 Micro App 源码
class MicroAppInstance {
private shadowRoot: ShadowRoot | null = null;
private container: MicroAppElement;
private config: MicroAppConfig;
constructor(config: MicroAppConfig) {
this.config = config;
this.container = config.container;
}
async start(): Promise<void> {
// 1. 创建 Shadow DOM(如果配置了严格隔离)
if (this.config.shadowDom) {
this.shadowRoot = this.container.attachShadow({ mode: 'open' });
}
// 2. 获取子应用资源
const { template, scripts, styles } = await this.fetchResources();
// 3. 将模板和样式注入到容器中
const mountTarget = this.shadowRoot || this.container;
// Shadow DOM 模式:样式天然被限制在 shadowRoot 内部;否则加属性前缀
styles.forEach(cssText => {
const style = document.createElement('style');
style.textContent = this.shadowRoot ? cssText : this.scopeCSS(cssText);
mountTarget.appendChild(style);
});
// 注入 HTML 模板
const templateEl = document.createElement('div');
templateEl.innerHTML = template;
mountTarget.appendChild(templateEl);
// 4. 在沙箱环境中执行 JS
this.execScripts(scripts);
}
private scopeCSS(cssText: string): string {
const prefix = `micro-app[name="${this.config.name}"]`;
return cssText.replace(
// 匹配 CSS 选择器(简化版本)
/([^{}]+)\{/g,
(match, selector: string) => {
// 跳过 @规则(@media, @keyframes 等)
if (selector.trim().startsWith('@')) return match;
// 为每个选择器添加前缀
const scopedSelectors = selector
.split(',')
.map((s: string) => {
s = s.trim();
// 处理 :root, body, html 等全局选择器
if (/^(html|body|:root)/.test(s)) {
return `${prefix} ${s.replace(/^(html|body|:root)/, '')}`;
}
return `${prefix} ${s}`;
})
.join(', ');
return `${scopedSelectors} {`;
}
);
}
}
Micro App 的两种 CSS 隔离策略:
- CSS 作用域化模式——通过为所有 CSS 选择器添加
micro-app[name=xxx]属性前缀实现隔离,兼容性好,是默认模式(disable-scopecss属性或microApp.start({ disableScopecss: true })可关闭);它只能防止子应用样式外溢,主应用的样式仍会影响子应用 - Shadow DOM 模式——利用浏览器原生的样式隔离,隔离效果最强,但存在弹窗挂载、样式穿透等已知问题,1.x 文档已不再推荐
14.2.4 数据通信:属性传递与自定义事件
Micro App 的通信机制同样借鉴了 WebComponent 的惯用模式——属性传递向下,事件冒泡向上:
// 主应用 → 子应用:通过 data 属性传递(data 只接受对象,采用 === 严格对比,换新对象才会重新发送)
// React 主应用示例:React 会把未知 prop 当字符串 attribute 写下去,所以文档要求先引入 jsx-custom-event polyfill
/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event';
function MainApp() {
const [orderData, setOrderData] = useState({ userId: '123' });
return (
<div>
<micro-app
name="order"
url="https://order.example.com"
data={orderData}
/>
</div>
);
}
// 也可以走命令式 API:microApp.setData('order', { userId: '123' })
// 子应用 → 主应用:window.microApp.dispatch(异步,同一帧内多次调用会合并)
// 子应用内部
window.microApp?.dispatch({
type: 'order-created',
payload: { orderId: 'ORD-456' },
});
// 主应用监听:<micro-app> 元素上的 datachange 事件,或 microApp.addDataListener('order', cb)
const microAppEl = document.querySelector('micro-app[name="order"]');
microAppEl?.addEventListener('datachange', (event: CustomEvent) => {
const { type, payload } = event.detail.data;
if (type === 'order-created') {
console.log('新订单:', payload.orderId);
}
});
这种通信模型的优点是符合 Web 开发者的直觉:属性向下传、事件向上冒。不需要学习额外的 API,也不需要引入全局事件总线或状态管理库。
14.2.5 Micro App 的设计取舍
Micro App 的 WebComponent 路线并非没有代价。以下是它面临的核心挑战:
// 问题一:属性前缀作用域化下的弹窗
// 很多 UI 库(Ant Design、Element Plus)的弹窗默认挂载到 document.body
// Micro App 默认会把子应用对 document.body 的插入劫持到 <micro-app-body>(src/source/patch.ts:241-259),
// 所以多数情况下弹窗仍在 micro-app[name="order"] 这个前缀作用域里;
// 但元素作用域失效时(changelog 里多次修过"子应用元素逃逸到原生 body"),弹窗会挂到主应用 body 上,
// 子应用的样式对它不再生效
// 子应用中使用 Ant Design Modal
Modal.confirm({
title: '确认删除?',
getContainer: () => {
// 兜底思路:显式把容器指回子应用自己的渲染区域(Micro App 会渲染 <micro-app-head> / <micro-app-body>)
return document.querySelector(
'micro-app[name="order"] micro-app-body'
) || document.body;
},
});
// 问题二:浏览器兼容性
// 虽然 2026 年主流浏览器都支持 Custom Elements v1
// 但企业内网应用可能还有不支持它的旧浏览器,这时需要额外引入 Custom Elements 的 polyfill,会增加一些体积
// 注意 Micro App 还依赖 Proxy 且没有做兼容,IE 全系列无论如何都跑不了(README「兼容性如何?」)
// 问题三:与框架的集成
// React 对自定义元素的属性传递有特殊处理
// 需要区分 attribute(字符串)和 property(对象)
// React 19 已经改善了这一点,但旧版本需要特殊处理
🔥 深度洞察:声明式 vs 命令式的微前端
Micro App 和乾坤代表了微前端的两种编程范式。乾坤是命令式的——你需要手动注册应用、手动启动、手动管理生命周期。Micro App 是声明式的——你只需要在 DOM 中声明一个元素,一切自动发生。声明式的优点是心智模型简单:开发者不需要理解微前端的生命周期管理,只需要把
<micro-app>当成一个增强版的<iframe>来使用。但声明式的局限在于灵活性:当你需要在加载前做条件判断、在挂载后做异步初始化、在切换时做渐进式过渡动画时,命令式 API 提供的控制粒度更细。没有绝对的优劣,只有场景的匹配。
14.3 Import Maps:浏览器原生的模块加载
Import Maps 是 2021 到 2023 年间陆续在主流浏览器落地的一个标准(Chrome 89 于 2021 年 3 月、Firefox 108 于 2022 年 12 月、Safari 16.4 于 2023 年 3 月),它解决的问题看似简单:让浏览器原生支持"裸模块名"(bare specifier)的导入。浏览器(HTML 规范)解析 ES Module 时最初要求所有 import 必须是相对路径或绝对路径(import './utils.js' 或 import 'https://cdn.example.com/lib.js'),不能像 Node.js 那样写 import 'lodash'。Import Maps 用一个 JSON 配置把裸名映射到具体 URL,让浏览器也能理解 import 'lodash' 这种写法。放到微前端场景里,多个子应用可以通过一份公共的 import map 共享同一份 React、Vue 等依赖,不需要 Module Federation 那样的运行时版本协商。
14.3.1 一个被低估的浏览器标准
当微前端框架们在运行时沙箱、HTML 解析、JS 隔离等领域激烈竞争时,浏览器标准委员会悄悄推出了一个看似不起眼的规范——Import Maps。
<!-- 一个简单的 Import Maps 示例 -->
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.3.1/esm/react.production.min.js",
"react-dom": "https://cdn.example.com/react-dom@18.3.1/esm/react-dom.production.min.js",
"lodash/": "https://cdn.example.com/lodash-es@4.17.21/",
"@shared/utils": "https://shared.example.com/utils/v2.1.0/index.js"
}
}
</script>
<!-- 之后的 ES Module 可以直接使用裸模块标识符 -->
<script type="module">
import React from 'react';
import debounce from 'lodash/debounce.js';
import { formatDate } from '@shared/utils';
// 就像在 Node.js 中一样自然
</script>
Import Maps 做的事情很简单:建立模块标识符到 URL 的映射关系。 这意味着浏览器原生的 import 语句可以使用像 'react' 这样的裸模块标识符(bare specifiers),不再需要写完整的 URL 路径。
你可能会问:这和微前端有什么关系?
关系大了。微前端最核心的问题之一是依赖共享——多个子应用如何共用同一份 React 而不是各自打包一份?Module Federation 由构建工具在编译时把各方声明的版本区间写进产物、再由它自带的运行时协商出实际加载哪一份(见 §9.2.4);乾坤通过 externals + CDN 的约定来解决;而 Import Maps 提供了一个浏览器原生的、零构建工具依赖的、声明式的解决方案。
14.3.2 Import Maps 与微前端的依赖共享
来看一个完整的微前端依赖共享场景:
<!-- 主应用的 index.html -->
<!DOCTYPE html>
<html>
<head>
<!-- 全局 Import Map:定义所有共享依赖的版本和 URL -->
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.3.1/esm/react.production.min.js",
"react-dom": "https://cdn.example.com/react-dom@18.3.1/esm/react-dom.production.min.js",
"react-router-dom": "https://cdn.example.com/react-router-dom@6.20.0/esm/index.js",
"@design-system/": "https://cdn.example.com/design-system@3.0.0/"
},
"scopes": {
"https://order-app.example.com/": {
"react": "https://cdn.example.com/react@19.0.0/esm/react.production.min.js",
"react-dom": "https://cdn.example.com/react-dom@19.0.0/esm/react-dom.production.min.js"
}
}
}
</script>
</head>
<body>
<div id="main-app"></div>
<div id="sub-app-order"></div>
<div id="sub-app-product"></div>
<!-- 主应用 -->
<script type="module" src="/main-app/index.js"></script>
<!-- 子应用们:各自独立构建、独立部署 -->
<!-- 但共享同一份 React! -->
<script type="module" src="https://order-app.example.com/index.js"></script>
<script type="module" src="https://product-app.example.com/index.js"></script>
</body>
</html>
注意 scopes 字段——这是 Import Maps 最强大的特性之一。它允许不同路径下的模块使用不同版本的依赖。上面的配置表示:来自 https://order-app.example.com/ 的模块 import 'react' 时,会加载 React 19,而其他所有模块加载 React 18。
这正是微前端中"版本协商"问题的浏览器原生解法。
14.3.3 动态 Import Maps 与运行时控制
静态的 Import Maps 在页面加载时就确定了所有映射关系。但微前端场景往往需要动态加载子应用——在子应用被加载时才知道它需要什么依赖。
// 动态生成 Import Maps 的方案
class DynamicImportMapManager {
private registeredMaps: Map<string, Record<string, string>> = new Map();
registerApp(appName: string, dependencies: Record<string, string>): void {
this.registeredMaps.set(appName, dependencies);
}
generateAndInject(): void {
const mergedImports: Record<string, string> = {};
const scopes: Record<string, Record<string, string>> = {};
for (const [appName, deps] of this.registeredMaps) {
for (const [specifier, url] of Object.entries(deps)) {
if (!mergedImports[specifier]) {
// 首次注册的版本成为全局默认
mergedImports[specifier] = url;
} else if (mergedImports[specifier] !== url) {
// 版本冲突:使用 scopes 进行隔离
// 每个子应用有自己的 scope
const appScope = `/${appName}/`;
if (!scopes[appScope]) scopes[appScope] = {};
scopes[appScope][specifier] = url;
}
}
}
const importMap = { imports: mergedImports, scopes };
// 注入到 DOM
const script = document.createElement('script');
script.type = 'importmap';
script.textContent = JSON.stringify(importMap, null, 2);
// 旧规范看的是时间先后而非 DOM 位置:必须在任何 module script 开始加载之前注入,
// 插到已执行的 module script 前面并不能让它生效;这里的 DOM 位置只是保持可读
const firstModuleScript = document.querySelector('script[type="module"]');
if (firstModuleScript) {
firstModuleScript.before(script);
} else {
document.head.appendChild(script);
}
}
}
// 使用示例
const manager = new DynamicImportMapManager();
// 订单子应用需要 React 19
manager.registerApp('order', {
'react': 'https://cdn.example.com/react@19.0.0/esm/react.production.min.js',
'react-dom': 'https://cdn.example.com/react-dom@19.0.0/esm/react-dom.production.min.js',
});
// 商品子应用需要 React 18
manager.registerApp('product', {
'react': 'https://cdn.example.com/react@18.3.1/esm/react.production.min.js',
'react-dom': 'https://cdn.example.com/react-dom@18.3.1/esm/react-dom.production.min.js',
});
// 生成并注入合并后的 Import Map
manager.generateAndInject();
14.3.4 浏览器支持与 Polyfill 生态
截至 2026 年 9 月,Import Maps 的浏览器支持情况:
浏览器支持状态(2026 年 9 月):
┌──────────────────────────────────────────────────────────┐
│ Chrome 89+ ✅ 完全支持 │
│ Edge 89+ ✅ 完全支持 │
│ Firefox 108+ ✅ 完全支持 │
│ Safari 16.4+ ✅ 完全支持 │
│ Opera 76+ ✅ 完全支持 │
│ iOS Safari 16.4+✅ 完全支持 │
│ │
│ 全球覆盖率:约 96%(caniuse,2026 年 9 月数据) │
│ 不支持:IE 全系列、旧版移动端浏览器 │
└──────────────────────────────────────────────────────────┘
对于需要兼容旧浏览器的场景,社区提供了成熟的 polyfill:
// es-module-shims:最流行的 Import Maps polyfill
// 在不支持的浏览器中模拟 Import Maps 行为
// 1. 引入 polyfill(放在所有 script 之前)
// <script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
// 2. 默认是 polyfill 模式:照常写 type="importmap" / type="module",
// 原生支持 Import Maps 的浏览器直接走原生,polyfill 不介入
// 3. 也可以改用 "importmap-shim" / "module-shim" 类型进入 shim 模式
// 此时所有浏览器(包括原生支持的)都由 polyfill 处理,换来自定义 resolve/fetch 钩子等能力
// <script type="importmap-shim">
// { "imports": { "react": "..." } }
// </script>
// <script type="module-shim">
// import React from 'react'; // 在旧浏览器中也能工作
// </script>
// polyfill 的性能影响(定性)
interface PolyfillPerformance {
// 首次加载 polyfill 本身的开销:一个小体积脚本
polyfillSize: '体积很小';
// 模块解析的额外开销:polyfill 需要自己 fetch 并改写模块源码,比原生解析慢
moduleResolutionOverhead: '每个模块多一次文本处理';
// 对于原生支持 Import Maps 的浏览器
nativePerformanceImpact: 'polyfill 模式下直接走原生(polyfill 自动让位),shim 模式下不让位';
}
14.3.5 Import Maps 的局限性
Import Maps 并非万能。在微前端场景中,它有几个明确的局限:
// 局限一:没有 JS 隔离能力
// Import Maps 只解决模块映射,不提供沙箱
// 多个子应用共享同一个全局作用域
// 子应用 A
window.myGlobalVar = 'from A'; // 污染全局
// 子应用 B
console.log(window.myGlobalVar); // 'from A' —— 被污染了
// 局限二:没有 CSS 隔离能力
// Import Maps 不处理样式,CSS 冲突需要额外方案解决
// 局限三:Import Map 的注入时机受限
// 最初的规范:页面只能有一个 <script type="importmap">,且要在第一个 module script 之前
// 动态追加第二个会被浏览器忽略
// 2025 年起 Chrome 133 / Safari 18.4 按新规范支持多个 import map(按顺序合并)
// 但在老浏览器上,所有子应用的依赖映射仍必须在页面加载前确定
// 局限四:不支持改写已解析的映射
// 一个模块标识符一旦被解析过,后来追加的 import map 也不能再改它的指向
// 所以"运行时切换某个依赖的版本"靠 Import Map 本身做不到
// 解决方案:结合 Service Worker
// Service Worker 可以拦截模块请求并重定向
// 相当于一个运行时可修改的 Import Map
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
// 拦截模块请求,实现动态重映射
if (url.pathname.startsWith('/shared-modules/')) {
const moduleVersion = getVersionForCurrentApp(url.pathname);
const redirectUrl = `https://cdn.example.com${url.pathname}@${moduleVersion}`;
event.respondWith(fetch(redirectUrl));
}
});
🔥 深度洞察:Import Maps 不是微前端框架的替代品,而是基础设施层的补充
Import Maps 解决的是"多个独立构建的应用如何共享依赖"这一个问题,而微前端框架需要解决加载、隔离、通信、路由等一揽子问题。把 Import Maps 类比为微前端框架,就像把轮胎类比为汽车——轮胎是汽车不可或缺的组成部分,但单靠轮胎无法载人。Import Maps 的真正价值在于:它可以替代 Module Federation 中"依赖共享"这一个模块的功能,而且是零构建工具依赖的。 对于那些不需要 JS/CSS 隔离、只需要解决依赖共享问题的场景(比如同一团队维护的多个微应用),Import Maps 可能是比任何框架都更轻量的选择。
14.4 Server-Driven UI 与微前端的融合趋势
14.4.1 微前端的下一个战场在服务端
前面三节讨论的方案——Garfish、Micro App、Import Maps——都是纯客户端的解决方案。它们假设所有的微前端组合发生在浏览器中:主应用在浏览器中加载子应用的资源,在浏览器中创建沙箱,在浏览器中完成渲染。
但如果我们后退一步,重新审视微前端要解决的核心问题——让不同团队独立开发、独立部署的 UI 片段在同一个页面中组合——这个组合过程一定要发生在浏览器中吗?
答案是:不一定。而且在某些场景下,服务端组合有压倒性的优势。
14.4.2 Server Islands:Astro 引领的碎片化水合
Astro 的"岛屿"其实有两层。第一层是它最初的 Islands Architecture(客户端岛屿):页面的大部分内容在服务端渲染为静态 HTML,只有需要交互的"岛屿"区域在客户端水合(hydration),用 client:load、client:visible、client:idle 这类指令控制水合时机。第二层是 2024 年 7 月 Astro 4.12 以实验特性引入、Astro 5.0 转正的 Server Islands(服务端岛屿):给组件加上 server:defer,这个组件就会从首屏 HTML 里被换成一段占位内容,页面先以可缓存的静态 HTML 返回,再由一小段脚本向一个专门的端点请求这个岛屿渲染好的 HTML、把它换进来。
---
// page.astro:页面骨架是静态的、可以被 CDN 长期缓存
import Avatar from '../components/Avatar.astro';
import ProductList from '../components/ProductList.vue';
import Recommendations from '../components/Recommendations.astro';
---
<html>
<body>
<header>
<!-- Server Island:个性化头像延迟到服务端单独渲染,首屏先显示 fallback -->
<Avatar server:defer>
<div slot="fallback">加载中…</div>
</Avatar>
</header>
<main>
<!-- 客户端岛屿:Vue 团队维护,进入视口时才水合 -->
<ProductList client:visible />
<!-- Server Island:推荐结果由推荐服务动态渲染,慢也不会拖住首屏 -->
<Recommendations server:defer>
<div slot="fallback">正在生成推荐…</div>
</Recommendations>
</main>
<footer>
<!-- 纯静态 HTML,无需水合 -->
<p>© 2026 Example Corp</p>
</footer>
</body>
</html>
// 客户端岛屿的三种水合策略
interface HydrationStrategy {
// client:load — 页面加载时立即水合(用于导航栏等关键交互区域)
'client:load': '立即加载并水合';
// client:visible — 元素进入视口时才水合(用于折叠屏以下的内容)
'client:visible': '可见时水合,节省首屏开销';
// client:idle — 浏览器空闲时水合(用于非关键交互区域)
'client:idle': '空闲时水合,最低优先级';
}
Server Islands 与微前端的关联在于:每个岛屿可以由不同的团队维护、使用不同的框架、拥有不同的部署节奏、各自独立加载——一个慢岛屿不会拖住其他岛屿——这不正是微前端要解决的核心问题吗?
区别在于组合方式:传统微前端在客户端组合,Server Islands 的组合发生在服务端(静态骨架 + 按需渲染的服务端片段),客户端只负责把片段换进去。
14.4.3 边缘计算组合:CDN 节点上的微前端
更前沿的方向是在 CDN 边缘节点上进行 UI 片段的组合。Cloudflare Workers、Vercel Edge Functions、Deno Deploy 等边缘计算平台让这成为可能。
// 边缘计算组合示例(Cloudflare Workers 风格)
interface UIFragment {
name: string;
team: string;
endpoint: string; // 每个团队的服务端渲染端点
cacheTTL: number; // 缓存时间(秒)
fallback: string; // 降级 HTML
}
const fragments: UIFragment[] = [
{
name: 'header',
team: 'platform',
endpoint: 'https://header-service.internal/render',
cacheTTL: 3600, // 导航栏变化不频繁,缓存 1 小时
fallback: '<nav>Loading...</nav>',
},
{
name: 'product-list',
team: 'product',
endpoint: 'https://product-service.internal/render',
cacheTTL: 60, // 商品列表变化较频繁,缓存 1 分钟
fallback: '<div>Loading products...</div>',
},
{
name: 'recommendations',
team: 'ai',
endpoint: 'https://recommend-service.internal/render',
cacheTTL: 300, // 推荐结果缓存 5 分钟
fallback: '<div>Loading recommendations...</div>',
},
];
// 在边缘节点组合页面
async function handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
// 并行请求所有 UI 片段
const fragmentResults = await Promise.allSettled(
fragments.map(async (fragment) => {
// 先检查边缘缓存(edgeCache 为示意的缓存封装,Cloudflare 实际提供的是 caches.default 等 Cache API)
const cached = await edgeCache.get(
`${fragment.name}:${url.pathname}`
);
if (cached) return { name: fragment.name, html: cached };
try {
// 请求团队的渲染服务
const response = await fetch(fragment.endpoint, {
method: 'POST',
body: JSON.stringify({
path: url.pathname,
query: Object.fromEntries(url.searchParams),
userAgent: request.headers.get('user-agent'),
}),
signal: AbortSignal.timeout(2000), // 2秒超时
});
// fetch 只在网络层失败时 reject,5xx 也会正常返回——要自己检查,否则错误页会被当成片段缓存起来
if (!response.ok) throw new Error(`${fragment.name} 渲染服务返回 ${response.status}`);
const html = await response.text();
// 写入边缘缓存
await edgeCache.put(
`${fragment.name}:${url.pathname}`,
html,
{ ttl: fragment.cacheTTL }
);
return { name: fragment.name, html };
} catch (error) {
// 降级:返回 fallback HTML
return { name: fragment.name, html: fragment.fallback };
}
})
);
// 组装最终页面
const fragmentMap = new Map<string, string>();
for (const result of fragmentResults) {
if (result.status === 'fulfilled') {
fragmentMap.set(result.value.name, result.value.html);
}
}
const finalHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Example Store</title>
</head>
<body>
${fragmentMap.get('header') || ''}
<main>
${fragmentMap.get('product-list') || ''}
${fragmentMap.get('recommendations') || ''}
</main>
</body>
</html>
`;
return new Response(finalHtml, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
这种模式的威力在于:
- 性能极致化——边缘节点距用户最近、网络往返时延最短,在边缘完成组合省掉了浏览器里"先拉主应用、再拉各子应用资源"的串行等待
- 天然的容错——某个团队的渲染服务挂了?用 fallback HTML 兜底,其他片段不受影响
- 独立部署的终极形态——每个团队维护自己的渲染服务,连 JS bundle 都不需要共享
- SEO 友好——返回的是完整的 HTML,搜索引擎直接可见
14.4.4 Server-Driven UI:从组件到协议
Server-Driven UI(SDUI)将这个思路推向更极端:服务端不只是返回 HTML 片段,而是返回 UI 的结构化描述(通常是 JSON),由客户端的通用渲染器将其渲染为真实的 UI。
// Server-Driven UI 的数据协议示例
interface SDUIComponent {
type: string; // 组件类型
props: Record<string, any>; // 组件属性
children?: SDUIComponent[]; // 子组件
actions?: SDUIAction[]; // 交互行为
// 微前端扩展字段
_source?: {
team: string; // 提供此组件的团队
service: string; // 提供此组件的服务
version: string; // 组件版本
};
}
interface SDUIAction {
trigger: 'click' | 'submit' | 'visible';
type: 'navigate' | 'api_call' | 'update_state';
payload: Record<string, any>;
}
// 服务端返回的页面描述
const pageDescription: SDUIComponent = {
type: 'page',
props: { title: '商品详情' },
children: [
{
type: 'product-header',
props: { productId: 'SKU-123', showPrice: true },
_source: { team: 'product', service: 'product-bff', version: '3.2.1' },
},
{
type: 'review-list',
props: { productId: 'SKU-123', limit: 10, sortBy: 'recent' },
_source: { team: 'ugc', service: 'review-bff', version: '2.0.0' },
actions: [
{
trigger: 'click',
type: 'navigate',
payload: { url: '/reviews/SKU-123' },
},
],
},
{
type: 'recommendation-carousel',
props: { algorithm: 'collaborative-filtering', count: 8 },
_source: { team: 'ai', service: 'recommend-bff', version: '5.1.0' },
},
],
};
// 客户端通用渲染器
class SDUIRenderer {
private componentRegistry: Map<string, React.ComponentType<any>> = new Map();
register(type: string, component: React.ComponentType<any>): void {
this.componentRegistry.set(type, component);
}
/**
* 递归渲染 SDUI 描述为 React 元素
*/
render(node: SDUIComponent): React.ReactElement {
const Component = this.componentRegistry.get(node.type);
if (!Component) {
// 未知组件类型:渲染占位符
console.warn(`Unknown component type: ${node.type}`);
return React.createElement('div', {
className: 'sdui-placeholder',
'data-type': node.type,
});
}
const children = node.children?.map(
(child, index) => React.createElement(
React.Fragment,
{ key: index },
this.render(child)
)
);
return React.createElement(Component, {
...node.props,
actions: node.actions,
}, children);
}
}
SDUI 模式在微前端语境下的意义在于:UI 的"组合"从代码层面上移到了协议层面。 不同团队不再需要共享 JS bundle 或在同一个 DOM 中运行各自的 SPA——他们只需要遵循同一套 JSON 协议,各自提供组件的实现和数据描述。
14.4.5 融合趋势:边界在消融
下面这张图勾勒了微前端的演进轨迹和未来方向:
传统微前端 服务端驱动 未来融合
(客户端组合) (服务端组合) (自适应组合)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 浏览器中 │ │ 服务端/ │ │ 根据场景 │
│ 加载子应用│ ──────► │ 边缘节点 │ ──────► │ 自动选择 │
│ 运行时组合│ │ 组合HTML │ │ 组合策略 │
└──────────┘ └──────────┘ └──────────┘
代表方案: 代表方案: 可能形态:
· 乾坤 · Server Islands · 首屏在边缘组合
· Garfish · Edge Composition · 交互区域客户端水合
· Micro App · SDUI · 非关键内容懒加载
· single-spa · AI 驱动的动态优化
几个正在发生的融合趋势:
趋势一:首屏服务端组合 + 交互客户端增强。 页面的静态骨架在边缘节点组合(极快的 TTFB),然后需要交互的区域在客户端按需水合。这结合了两种范式的优势——服务端组合的速度和客户端组合的交互能力。
趋势二:元数据驱动的组合编排。 不再在代码中硬编码"哪个路由加载哪个子应用",而是从一个配置中心或 BFF 层动态获取组合规则。这让 A/B 测试、灰度发布、个性化体验成为架构的内在能力。
// 元数据驱动的组合编排示例
interface CompositionRule {
path: string;
fragments: Array<{
slot: string; // DOM 挂载位置
source: string; // 子应用/片段来源
strategy: 'ssr' | 'csr' | 'edge'; // 渲染策略
conditions?: {
// A/B 测试条件
abTest?: { experiment: string; variant: string };
// 灰度条件
canary?: { percentage: number };
// 用户画像条件
userSegment?: string[];
};
}>;
}
// 配置中心返回的组合规则
const rules: CompositionRule[] = [
{
path: '/product/:id',
fragments: [
{
slot: 'header',
source: 'https://header-service/render',
strategy: 'edge', // 在边缘节点渲染
},
{
slot: 'product-detail',
source: 'https://product-app.example.com',
strategy: 'ssr', // 服务端渲染
},
{
slot: 'reviews',
source: 'https://review-app.example.com',
strategy: 'csr', // 客户端渲染(传统微前端方式)
conditions: {
abTest: {
experiment: 'new-review-ui',
variant: 'treatment',
},
},
},
],
},
];
趋势三:AI 辅助的动态优化。 当用户的设备性能、网络状况、行为模式都可以被实时感知时,组合策略可以从静态配置变为动态决策:
// AI 驱动的动态组合策略(前瞻性设计)
interface DynamicCompositionContext {
// 用户设备信息
device: {
cpuCores: number;
memoryGB: number;
connectionType: 'slow-2g' | '2g' | '3g' | '4g' | '5g' | 'wifi';
};
// 用户行为信号
behavior: {
scrollSpeed: 'slow' | 'medium' | 'fast';
engagementScore: number; // 0-1
previousPages: string[];
};
}
function selectCompositionStrategy(
context: DynamicCompositionContext
): 'full-ssr' | 'partial-hydration' | 'full-csr' {
// 弱网 + 低端设备:全量 SSR,最小化客户端 JS
if (
context.device.connectionType === 'slow-2g' ||
context.device.memoryGB < 2
) {
return 'full-ssr';
}
// 高端设备 + 高参与度用户:部分水合,平衡体验与交互
if (
context.device.cpuCores >= 4 &&
context.behavior.engagementScore > 0.7
) {
return 'partial-hydration';
}
// 默认:全量客户端渲染(传统微前端模式)
return 'full-csr';
}
🔥 深度洞察:微前端的终极形态不是"框架",而是"策略"
回顾本章讨论的所有方案——Garfish 优化了客户端运行时组合,Micro App 用 WebComponent 简化了接入成本,Import Maps 提供了浏览器原生的依赖共享,Server Islands 和 Edge Composition 把组合搬到了服务端——你会发现一个清晰的趋势:微前端正在从"一个框架的选择"演化为"一组策略的编排"。 未来的微前端架构不会是"我们用乾坤"或"我们用 Module Federation"这样的单一选择,而是"首屏用 Edge Composition、交互区域用 Module Federation 共享组件、非关键模块用 Import Maps 懒加载"这样的混合策略。这意味着:理解每种方案的适用边界,比精通任何一种方案的 API 都更有价值。
14.4.6 方案定位全景图
让我们将本章讨论的方案,连同前面章节已经深入分析的方案,放在一张全景图中:
| 方案 | 组合位置 | 隔离能力 | 依赖共享 | 侵入性 | 最佳场景 |
|---|---|---|---|---|---|
| 乾坤 | 客户端 | 强(Proxy 沙箱) | 弱(externals) | 中 | 中型团队,存量应用微前端化 |
| Garfish | 客户端 | 强(vm/Proxy 沙箱) | 弱(externals) | 中 | 大型团队,多实例多区域场景 |
| Micro App | 客户端 | 中(with/Proxy 沙箱 + 属性前缀 CSS;可选 iframe 沙箱) | 弱 | 低 | 声明式偏好,接入成本敏感 |
| Module Federation | 客户端(构建时声明、运行时协商) | 无(信任模型) | 强(运行时共享) | 高 | 同技术栈,构建期可控 |
| Wujie | 客户端 | 强(同源 iframe,独立 realm,不是安全边界) | 弱 | 低 | 需要更彻底 JS 隔离的场景 |
| Import Maps | 浏览器原生 | 无 | 中(URL 映射) | 极低 | 轻量共享,同团队多应用 |
| Server Islands | 服务端 | 服务端片段互不影响(客户端岛屿仍共享 window) | N/A | 高 | 内容为主,首屏性能敏感 |
| Edge Composition | 边缘节点 | 天然隔离 | N/A | 高 | 全球部署,极致首屏性能 |
| SDUI | 服务端+客户端 | 协议隔离 | N/A | 极高 | 动态 UI,多端一致性 |
本章小结
- Garfish 是字节跳动对乾坤范式的工程化升级,核心优势在于多实例路由、副作用收集沙箱和插件化扩展,适合子应用数量多、页面组合复杂的大规模场景
- Micro App 用 WebComponent 自定义元素重新定义了微前端的接入方式,将微前端的生命周期映射为 DOM 元素的生命周期,实现了真正的声明式微前端
- Import Maps 是浏览器原生的模块映射规范,可以在零框架依赖的情况下解决微前端的依赖共享问题,但不提供 JS/CSS 隔离能力
- Server Islands 和边缘计算组合代表了微前端的服务端化趋势——当组合发生在服务端或边缘节点时,客户端的隔离问题大为缩小(仍在客户端水合的部分依旧共享同一个 window)
- SDUI 将 UI 的组合从代码层面上移到协议层面,为跨端一致性和动态化提供了新的可能
- 微前端的演进方向是从"单一框架选择"走向"多策略混合编排",理解每种方案的适用边界比精通任何单一方案更重要
思考题
-
方案对比:Garfish 的副作用收集机制和乾坤的
SnapshotSandbox(遍历window做快照对比)在实现原理上有何本质区别?乾坤的ProxySandbox又为什么也要靠 patchers 做"运行时记录、卸载时回收"(见 §14.1.2)?在什么场景下副作用收集的方案会优于快照对比方案?请从时间复杂度和空间复杂度两个角度分析。 -
架构设计:Micro App 选择了 WebComponent 自定义元素作为微前端的承载方式。如果让你设计一个新的微前端框架,你会选择自定义元素还是普通的
<div>容器?请列出至少三个决策因素。 -
实践应用:你的团队维护 5 个子应用,全部使用 React 18,部署在同一个域名下。请设计一个基于 Import Maps 的依赖共享方案,并分析它相比 Module Federation 的优势和劣势。
-
前沿思考:Server Islands 和传统微前端(如乾坤)可以共存于同一个项目中吗?请设计一个混合架构,让首屏内容通过 Server Islands 渲染,而需要复杂交互的区域通过客户端微前端加载。画出架构图并说明数据流。
-
开放讨论:本章最后提到"微前端的终极形态不是框架,而是策略"。你是否同意这个观点?如果微前端确实走向策略化,这对前端工程师的能力模型意味着什么?我们需要培养哪些新的技能?