跪拜 Guibai
← Back to the summary

PavilionMfe: A 500-Line Micro-Frontend Core That Tracks Side Effects Instead of Proxying window

Building a Micro-Frontend Framework from Scratch: Unveiling the Design of PavilionMfe

A micro-frontend framework based on Module Federation, with a core of 500 lines handling sandbox isolation, CSS scoping, and lifecycle management.

Why Build Another Micro-Frontend Framework?

There are already quite a few micro-frontend solutions on the market—qiankun, micro-app, wujie, the native Module Federation approach... each has its own design philosophy. But we encountered several pain points in real production projects:

  1. qiankun's sandbox performance overhead: Every sub-application switch requires a full activate / deactivate, which isn't smooth enough for multi-tab scenarios requiring frequent switching.
  2. Specificity pollution in CSS isolation solutions: Shadow DOM is too closed-off, CSS Modules require code modifications, and BEM naming conventions rely on manual discipline.
  3. Sub-application runtime "contamination": Most solutions require sub-applications to introduce framework-specific SDKs, making sub-applications no longer pure.
  4. Route conflicts: When multiple sub-applications coexist, the popstate event triggers all active sub-application routers simultaneously.

So we extracted the core modules from our production environment and built PavilionMfe—a micro-frontend framework with only 5 runtime packages and zero dependencies for sub-applications.

Architecture Overview

┌──────────────────────────────────────────────────────┐
│                    Main App                          │
│  ┌──────────┐  ┌──────────┐  ┌────────────────┐      │
│  │  Router   │  │ EventBus │  │  Log System    │      │
│  │ Lifecycle │  │ Cross-App │  │ Per-Module     │      │
│  │           │  │   Comm   │  │   Config       │      │
│  └────┬─────┘  └────┬─────┘  └────────────────┘      │
│       │             │                                  │
│  ┌────▼─────────────▼───────────────────────────────┐  │
│  │              #pavilion-mfe-container              │  │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐        │  │
│  │  │ Sub-App A │  │ Sub-App B │  │ Sub-App C │       │  │
│  │  │ (Vue 3)   │  │ (React)   │  │ (Vue 2)   │       │  │
│  │  │ + Sandbox │  │ + Sandbox │  │ + Sandbox │       │  │
│  │  │ +CSS Scope│  │ +CSS Scope│  │ +CSS Scope│       │  │
│  │  └──────────┘  └──────────┘  └──────────┘        │  │
│  └──────────────────────────────────────────────────┘  │
│                                                        │
│          Module Federation Runtime Preload Plugin       │
└──────────────────────────────────────────────────────┘

Package Dependency Relationships—Bottom-Up Layered Design

bridge (zero deps)    sandbox (zero deps)    tabs (zero deps)
    │                   │                   │
    │                   ▼                   │
    │               router ─────────────────┘
    │                   │
    ▼                   ▼
  runtime (aggregation layer, MF Remote shared)
    │
    ▼
  vite (Vite plugin, build-time only)

The design principle is clear: bottom-layer packages have zero dependencies, and upper layers combine them as needed. The sandbox, bridge, and tabs packages can be used independently. router depends on them to provide complete route scheduling capabilities. runtime is the aggregation layer that ensures a singleton via MF Remote. vite only functions at build time.

Core Design: Solving Four Problems with 500 Lines of Code

1. JS Sandbox: Stack-Based Side-Effect Tracking

Traditional Proxy sandboxes (like qiankun) need to create an independent window proxy for each sub-application, which incurs significant overhead. PavilionMfe takes a different approach: not isolating window, but tracking and cleaning up side effects.

// Core implementation: module-level activeStack + one-time global patch
const activeStack: Sandbox[] = []
let globalsPatched = false

function patchGlobals(): void {
  if (globalsPatched) return  // Execute only once
  globalsPatched = true

  globalThis.setTimeout = ((handler, timeout, ...args) => {
    const id = origSetTimeout(handler, timeout, ...args)
    const active = activeStack[activeStack.length - 1]
    if (active) active._timeouts.add(id)  // Attributed to the top sandbox on the stack
    return id
  }) as any

  // Similarly intercept setInterval / addEventListener / removeEventListener
}

Core ideas:

const sandbox = new Sandbox('my-app')
sandbox.activate()    // Push onto stack top, start tracking
// ... sub-application runs, setTimeout/addEventListener automatically tracked
sandbox.deactivate()  // Pop from stack, clear 3 timers, 2 intervals, 5 listeners

Log output visually demonstrates the cleanup process:

[PavilionMfe] sandbox   sandbox-deactivate appCode=demo-app  timers=3  intervals=1  listeners=2

2. CSS Scoping: The Zero-Specificity Magic of :where()

The biggest headache with CSS isolation is the specificity problem. Suppose you add an .app prefix to a sub-application:

/* Specificity changes after adding the prefix! */
.pavilion-demo .card { color: red; }  /* Specificity 0,2,0 */
.card { color: blue; }               /* Specificity 0,1,0 — overridden */

PavilionMfe's solution is to use the :where() pseudo-class:

/* Input */
.card { color: red; }
@keyframes fadeIn { from { opacity: 0; } }

/* PostCSS Output — :where() has zero specificity */
:where(.pavilion-mfe-demo-app) .card { color: red; }
@keyframes pavilion-mfe-demo-app-fadeIn { from { opacity: 0; } }

The key feature of :where(): the selector it wraps contributes zero specificity. Therefore:

The PostCSS plugin implementation is only 130 lines, runs at build time, and is completely transparent to sub-applications.

3. Route Isolation: popstate Proxy Interception

In scenarios where multiple sub-applications coexist, browser forward/back navigation triggers the popstate listeners of all sub-applications. PavilionMfe's approach is proxy interception:

// Inside sandbox's addEventListener patch
if (type === 'popstate' && routeMatcher) {
  const appCode = active.appCode
  const proxyHandler = (event: Event) => {
    // Only trigger the original handler when the current sub-application's route matches
    if (routeMatcher(appCode, location.pathname)) {
      handler(event)
    } else {
      // Inactive sub-application: only log, do not trigger
      console.log('popstate-blocked', appCode, location.pathname)
    }
  }
  active._listeners.push({ target: globalThis, type, handler: proxyHandler })
  origAddEventListener(type, proxyHandler)
}

Each sub-application's popstate listener is replaced with a proxy—only when the routeMatcher determines the current path belongs to that sub-application will the callback actually be triggered. Inactive sub-applications receive no navigation events at all, preventing route conflicts at the source.

You can clearly see the interception process in the logs:

[PavilionMfe] sandbox   popstate-blocked appCode=demo-app  path=/react/dashboard

4. Keep-Alive Caching: Don't Destroy Framework Instances

Initialization of large sub-applications (Element Plus component library + business code) can take hundreds of milliseconds. Repeatedly destroying and rebuilding in multi-tab scenarios creates a poor experience.

const pavilionMfeRouter = createPavilionMfeRouter({
  apps: [{
    name: 'demo-app',
    keepAlive: true,  // Enable caching
    // ...
  }],
  maxCache: 5,  // Global LRU eviction limit
})

Fine-grained caching strategy design:

The state machine adds a CACHED state:

MOUNTED → (unmount/leave) → CACHED → (restore/return) → MOUNTED

The Minimal Contract for Sub-Applications

Sub-applications only need to export an object—three lifecycle functions, zero framework dependencies:

// main.ts — Vue 3 sub-application
export default {
  mount: async (ctx, el) => {
    const app = createApp(App)
    app.use(router)
    app.mount(el)
    return () => app.unmount()  // Return a cleanup function
  },
  unmount: async (ctx, el) => {
    el.innerHTML = ''
  },
}

// Standalone runtime self-start
if (!window.__PAVILION_MFE_ENV__) {
  createApp(App).use(router).mount('#app')
}

The same pattern applies to Vue 2 and React, only changing the framework invocation method. The key is that mount returns a cleanup function, which the framework calls at the appropriate time.

Developer Experience

Per-Module Logging

import { configureLog } from '@pavilion-mfe/router'

configureLog({
  modules: {
    router:  true,   // Route events + sub-application lifecycle
    sandbox: true,   // Sandbox activate/deactivate + popstate interception
    preload: true,   // MF remote registration + preload status
    bridge:  true,   // EventBus emit/subscribe
  },
})

Output is uniformly styled and highly readable:

[PavilionMfe] router  router-start       subApps=3
[PavilionMfe] router  sub-app-load       appCode=demo-app  ms=320
[PavilionMfe] sandbox sandbox-activate   appCode=demo-app
[PavilionMfe] router  sub-app-mount      appCode=demo-app  ms=45

Route Event System

pavilion-mfe:before-routing   → Before route switch
pavilion-mfe:after-routing    → Route switch complete
pavilion-mfe:sub-app-switch   → Active sub-application changed
pavilion-mfe:sub-app-error    → Sub-application load/mount failed

Can be used for analytics tracking, global loading states, permission guards, and similar scenarios.

Registry

// mfe.json — Single declaration for routing + build
{
  "apps": [
    { "appCode": "demo-app", "routes": ["/demo"], "devPort": 6020 },
    { "appCode": "react-app", "routes": ["/react"], "devPort": 6030 },
    { "appCode": "vue2-app",  "routes": ["/vue2"], "devPort": 6040 }
  ]
}

A single configuration drives route registration, MF remote module declaration, and development port allocation simultaneously.

Comparison with Other Solutions

Dimension qiankun micro-app wujie PavilionMfe
Sandbox Method Proxy window Style+JS isolation iframe isolation Stack-based side-effect tracking
CSS Isolation Experimental sandbox Shadow DOM Natural isolation :where() zero specificity
Sub-App Dependencies Requires qiankun lifecycle Zero deps Zero deps Zero deps
Multi-Instance Concurrency Limited support Supported Supported Stack-based support
Keep-Alive Community solutions Built-in Built-in Built-in LRU
Build Tool Webpack primarily Any Any Vite + MF
Bundle Size Large Medium Medium ~15KB

PavilionMfe's advantages lie in:

Limitations:

Applicable Scenarios

PavilionMfe is particularly suitable for:

Project Running Instance

Online Experience: Github Pages

ScreenShot_2026-07-04_220958_488.png

ScreenShot_2026-07-04_221104_336.png

Conclusion

PavilionMfe's design philosophy is "sub-applications are unaware of the framework". We believe a good micro-frontend solution should be like a browser—you don't need to know you're running inside an iframe, and the framework has no right to invade your code.

The core handles sandboxing in 500 lines, CSS scoping in 130 lines, and route scheduling in 300 lines. On the path to pursuing "small and beautiful", we replaced heavy Shadow DOM with :where(), replaced Proxy agents with stack-based tracking, and replaced route hijacking with popstate proxies.

If you are also maintaining a multi-team admin dashboard, give PavilionMfe a try.


GitHub: pavilion-mfe License: MIT