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:
- 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. - 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.
- Sub-application runtime "contamination": Most solutions require sub-applications to introduce framework-specific SDKs, making sub-applications no longer pure.
- Route conflicts: When multiple sub-applications coexist, the
popstateevent 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:
activeStackis a module-level array that records the currently active sandbox stack.patchGlobals()executes only once, interceptingsetTimeout/setInterval/addEventListener/removeEventListener.- Each side effect is assigned to the top sandbox on the stack—natively supporting concurrent multi-instance operation.
- On
deactivate(), all timers / listeners / globals belonging to that sandbox are automatically cleaned up.
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 scoped
.cardremains at0,1,0and won't override library default styles. - Sub-application developers don't need to change any CSS writing habits.
@keyframesnames are also automatically prefixed to prevent animation name conflicts.
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:
- On switch away: Only hide the DOM (
display: none), do not destroy the framework instance, and the sandbox is not deactivated. - On switch back:
display: block, skip mount(), state is fully preserved (form data, scroll position, etc.). - LRU eviction: When
maxCacheis exceeded, the earliest cached sub-application will undergo a fulldeactivate() + unmount().
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:
- Sub-applications are completely pure: The runtime contains no
@pavilion-mfe/*code. - Build tool supports only Vite: Enjoy the extreme performance of native ESM + MF.
- Small bundle size: 5 core packages, zero dependencies at the bottom layer.
Limitations:
- Only supports Vite builds (does not support Webpack).
- Module Federation's shared dependency configuration requires some learning curve.
- Browser compatibility depends on
:where()(Chrome 88+).
Applicable Scenarios
PavilionMfe is particularly suitable for:
- Admin dashboard applications: Multi-tab, frequent sub-application switching.
- Vite tech stack teams: Deep integration with the Vite ecosystem.
- Multi-team collaboration: Independent sub-application repositories, independent releases, zero coupling.
- Multi-framework mixing: Running Vue 2, Vue 3, and React sub-applications within the same main application.
Project Running Instance
Online Experience: Github Pages
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