微前端源码精讲

第14章 其他前沿方案

作者 杨艺韬 · 12,176 字 · 发布于 · 更新于

第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 月),字节跳动的前端团队面临一个现实问题:乾坤在中小规模场景下表现优秀,但当子应用数量增多、页面级别的动态组合需求出现时,乾坤的一些设计假设开始被挑战。

最典型的三个痛点:

  1. 预加载策略偏静态——乾坤 v2.10.16 的 prefetch 只有几档(src/prefetch.ts:111 的 doPrefetchStrategy:true 在首个子应用挂载后预加载其余、'all' 立即全部预加载、应用名数组、或自定义函数返回 criticalAppNames/minorAppsName),没有根据用户实际访问习惯自动加权的能力;Garfish 则会在用户端统计子应用打开次数、次数越多预加载权重越大(disablePreloadApp 文档)
  2. 路由与应用的绑定偏刚性——乾坤 start() 默认 singular: true、同一时刻只有一个子应用处于激活态,"同一个页面组合多个子应用的不同区域"要么关掉 singular、要么改用 loadMicroApp 手动编排
  3. 沙箱性能在高频切换场景下不够理想——工作台类产品的用户会在子应用之间频繁切换,沙箱的创建和销毁开销变得不可忽视

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 有两个值得注意的差异:

  1. 去重加载——通过 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),两者在这一点上并无本质差别。
  2. 细粒度缓存控制——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 隔离策略:

  1. CSS 作用域化模式——通过为所有 CSS 选择器添加 micro-app[name=xxx] 属性前缀实现隔离,兼容性好,是默认模式(disable-scopecss 属性或 microApp.start({ disableScopecss: true }) 可关闭);它只能防止子应用样式外溢,主应用的样式仍会影响子应用
  2. 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' },
  });
}

这种模式的威力在于:

  1. 性能极致化——边缘节点距用户最近、网络往返时延最短,在边缘完成组合省掉了浏览器里"先拉主应用、再拉各子应用资源"的串行等待
  2. 天然的容错——某个团队的渲染服务挂了?用 fallback HTML 兜底,其他片段不受影响
  3. 独立部署的终极形态——每个团队维护自己的渲染服务,连 JS bundle 都不需要共享
  4. 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 的组合从代码层面上移到协议层面,为跨端一致性和动态化提供了新的可能
  • 微前端的演进方向是从"单一框架选择"走向"多策略混合编排",理解每种方案的适用边界比精通任何单一方案更重要

思考题

  1. 方案对比:Garfish 的副作用收集机制和乾坤的 SnapshotSandbox(遍历 window 做快照对比)在实现原理上有何本质区别?乾坤的 ProxySandbox 又为什么也要靠 patchers 做"运行时记录、卸载时回收"(见 §14.1.2)?在什么场景下副作用收集的方案会优于快照对比方案?请从时间复杂度和空间复杂度两个角度分析。

  2. 架构设计:Micro App 选择了 WebComponent 自定义元素作为微前端的承载方式。如果让你设计一个新的微前端框架,你会选择自定义元素还是普通的 <div> 容器?请列出至少三个决策因素。

  3. 实践应用:你的团队维护 5 个子应用,全部使用 React 18,部署在同一个域名下。请设计一个基于 Import Maps 的依赖共享方案,并分析它相比 Module Federation 的优势和劣势。

  4. 前沿思考:Server Islands 和传统微前端(如乾坤)可以共存于同一个项目中吗?请设计一个混合架构,让首屏内容通过 Server Islands 渲染,而需要复杂交互的区域通过客户端微前端加载。画出架构图并说明数据流。

  5. 开放讨论:本章最后提到"微前端的终极形态不是框架,而是策略"。你是否同意这个观点?如果微前端确实走向策略化,这对前端工程师的能力模型意味着什么?我们需要培养哪些新的技能?