跪拜 Guibai
← All articles
Frontend · Architecture · Vite

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

By 木木的木云 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Teams maintaining admin dashboards with frequent tab switches hit real performance cliffs from full sandbox teardown and CSS specificity wars. PavilionMfe replaces both with lighter primitives—side-effect tracking and zero-specificity scoping—that keep sub-apps framework-agnostic and cut the runtime to ~15 KB, at the cost of requiring Vite and Chrome 88+.

Summary

PavilionMfe isolates JavaScript sub-applications by patching globals once and tracking timers, intervals, and event listeners on a module-level stack, then cleaning them up on deactivation. CSS scoping runs through a 130-line PostCSS plugin that wraps selectors in `:where()` to add a namespace without increasing specificity, so library defaults are never overridden. Route conflicts are prevented by intercepting `popstate` listeners and only forwarding events to the sub-app whose route matcher claims the current path.

Sub-apps export a three-function lifecycle contract with zero framework imports, and a built-in LRU keep-alive cache hides DOM nodes instead of destroying instances, preserving form state and scroll position across tab switches. The whole runtime ships as five packages totaling roughly 15 KB, with bottom-layer packages carrying no dependencies.

A single `mfe.json` registry drives route registration, Module Federation remote declarations, and dev port allocation. Per-module logging and a route event bus expose hooks for analytics, loading spinners, and permission guards.

Takeaways
Global side-effect patching runs once and assigns every timer, interval, and listener to whichever sandbox sits at the top of the active stack.
CSS scoping uses `:where()` to prefix selectors without adding specificity, so scoped rules never override library defaults.
popstate listeners are wrapped in a proxy that only fires the handler when the current path matches the sub-app's route matcher.
Keep-alive caching hides the DOM with `display: none` instead of destroying the framework instance, then restores it without re-mounting.
Sub-apps export `mount`, `unmount`, and an optional cleanup function; they contain zero PavilionMfe imports.
A single `mfe.json` file declares routes, Module Federation remotes, and dev ports for all sub-apps.
The runtime consists of five packages (~15 KB total) with zero dependencies in the bottom-layer packages.
Per-module logging and a route event bus (`before-routing`, `after-routing`, `sub-app-switch`, `sub-app-error`) are built in.
Conclusions

Stack-based side-effect tracking is a deliberate trade-off: it avoids the overhead of per-app window proxies but trusts that sub-app code won't deliberately mutate shared globals beyond the patched APIs.

Choosing `:where()` over Shadow DOM keeps sub-app CSS fully authorable with standard tooling, but it ties the framework to Chrome 88+ and sacrifices the hard boundary that Shadow DOM provides.

The popstate proxy approach treats route matching as a first-class concern of the sandbox rather than the router, which means the sandbox must understand URL patterns—a coupling that simplifies the API but complicates the sandbox's responsibilities.

Requiring only a three-function export from sub-apps makes the framework trivially adoptable across Vue 2, Vue 3, and React, but it also means the framework provides no standard way to share state or coordinate lifecycles beyond the EventBus.

Concepts & terms
Stack-based side-effect tracking
Instead of creating a separate window proxy for each sub-application, a single global patch intercepts setTimeout, setInterval, and addEventListener. Each side effect is assigned to whichever sandbox is at the top of a module-level stack, and deactivating a sandbox cleans up all effects it owns.
:where() zero-specificity scoping
The CSS :where() pseudo-class wraps a selector prefix without contributing to its specificity score. A rule like :where(.scope) .card keeps a specificity of (0,1,0), identical to an unscoped .card, so scoped styles never accidentally override library defaults.
popstate proxy interception
Each sub-app's popstate listener is replaced by a proxy that checks a route matcher before calling the original handler. Sub-apps whose routes don't match the current URL receive no navigation events, preventing multiple routers from reacting to the same browser back/forward action.
LRU keep-alive cache
When a sub-app is switched away, its DOM is hidden with display: none but the framework instance stays mounted. Switching back restores visibility without re-mounting. An LRU eviction policy destroys the oldest cached instance when a configurable maxCache limit is exceeded.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗