跳到主要内容

微前端架构

问题

什么是微前端?有哪些实现方案?qiankun 和 Module Federation 分别是如何工作的?JS 沙箱和 CSS 隔离如何实现?

答案

微前端(Micro Frontends) 是一种将前端应用拆分为多个独立的、可自治的小型应用的架构模式,每个子应用可以由不同团队使用不同技术栈独立开发、独立部署,最终组合成一个完整的产品。这一理念借鉴自后端的微服务架构。

核心理念

微前端的核心目标是 技术栈无关、独立开发、独立部署、增量升级。它特别适合大型企业级应用的渐进式重构和多团队协作场景。

微前端架构概览

适用场景

微前端并非适合所有项目,以下是典型的适用场景:

场景说明
巨型单体应用拆分单体应用过于庞大,构建慢、维护难
多团队协作不同团队负责不同业务模块,需要独立开发部署
渐进式重构旧系统(如 jQuery)逐步迁移到新框架
技术栈异构多个子系统使用不同技术栈,需要统一整合
独立部署需求子应用需要独立发布,互不影响
不适用场景
  • 小型项目或初创项目,引入微前端会增加不必要的复杂度
  • 所有模块技术栈统一且耦合紧密的项目
  • 团队人数较少,不需要独立开发部署

实现方案对比

主流方案总览

方案原理JS 隔离CSS 隔离接入成本通信方式适用场景
iframe原生浏览器隔离天然隔离天然隔离极低postMessage简单集成、隔离要求高
single-spa路由劫持 + 生命周期无(需自行处理)无(需自行处理)自定义 Event底层框架、高度定制
qiankun基于 single-spa 增强Proxy 沙箱Shadow DOM / Scopedprops / GlobalState企业级主流方案
Module FederationWebpack 5 模块共享无(同域)无(需自行处理)中低共享模块同技术栈、模块共享
无界 wujieWebComponent + iframeiframe 天然隔离Shadow DOMprops / EventBus新一代方案、强隔离
micro-appWebComponent 增强Proxy 沙箱Shadow DOM / Scopeddata / EventCenter类 WebComponent 接入

iframe 方案

iframe 是最简单、最古老的微前端方案,利用浏览器原生隔离能力:

iframe-container.ts
// 主应用中嵌入 iframe 子应用
function createIframeMicroApp(
containerId: string,
url: string
): HTMLIFrameElement {
const container = document.getElementById(containerId);
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
container?.appendChild(iframe);
return iframe;
}

// 主应用与 iframe 子应用通信
function sendMessageToChild(
iframe: HTMLIFrameElement,
message: Record<string, unknown>
): void {
iframe.contentWindow?.postMessage(message, '*');
}

// 监听子应用消息
window.addEventListener('message', (event: MessageEvent) => {
// 安全校验:只接受指定来源的消息
if (event.origin !== 'https://child-app.example.com') return;
console.log('收到子应用消息:', event.data);
});
iframe 的优缺点

优点:天然隔离(JS、CSS、DOM 完全隔离),接入成本极低,无需改造子应用

缺点

  • URL 不同步:刷新后 iframe 状态丢失
  • DOM 割裂:弹窗只能在 iframe 内显示,无法覆盖全局
  • 通信困难:只能通过 postMessage,数据序列化有性能开销
  • 白屏闪烁:每次加载都需要重新初始化整个页面
  • SEO 不友好:搜索引擎无法抓取 iframe 内容

single-spa 方案

single-spa 是微前端的鼻祖框架,核心思路是路由劫持 + 应用生命周期管理

single-spa/main.ts
import { registerApplication, start } from 'single-spa';

// 定义子应用加载函数类型
type LifeCycles = {
bootstrap: () => Promise<void>;
mount: () => Promise<void>;
unmount: () => Promise<void>;
};

// 注册子应用
registerApplication({
name: 'react-app',
// 加载子应用的入口模块
app: (): Promise<LifeCycles> =>
import('./apps/react-app/main') as Promise<LifeCycles>,
// 当路由匹配时激活子应用
activeWhen: (location: Location): boolean =>
location.pathname.startsWith('/react'),
// 传递给子应用的自定义 props
customProps: {
authToken: 'xxx',
},
});

// 启动 single-spa
start();
single-spa/child-app.ts
// 子应用需要导出三个生命周期钩子
export async function bootstrap(): Promise<void> {
console.log('子应用初始化');
}

export async function mount(
props: Record<string, unknown>
): Promise<void> {
console.log('子应用挂载, props:', props);
// 渲染子应用到指定容器
const container = document.getElementById('micro-app-container');
if (container) {
container.innerHTML = '<div id="react-root"></div>';
// ReactDOM.render(...) 或 createRoot(...)
}
}

export async function unmount(): Promise<void> {
console.log('子应用卸载');
// 清理 DOM、事件监听等
const container = document.getElementById('micro-app-container');
if (container) {
container.innerHTML = '';
}
}

qiankun 详解

qiankun 是蚂蚁金服开源的微前端框架,基于 single-spa 封装,提供了开箱即用的 JS 沙箱CSS 隔离预加载能力。

主应用配置

main-app/src/micro-apps.ts
import {
registerMicroApps,
start,
addGlobalUncaughtErrorHandler,
} from 'qiankun';

// 子应用配置列表
interface MicroApp {
name: string;
entry: string;
container: string;
activeRule: string;
props?: Record<string, unknown>;
}

const microApps: MicroApp[] = [
{
name: 'react-sub-app',
entry: '//localhost:3001', // 子应用入口(HTML Entry)
container: '#subapp-container', // 子应用挂载的 DOM 节点
activeRule: '/react', // 路由匹配规则
props: {
mainAppRouter: null, // 主应用路由实例
globalStore: null, // 全局状态
},
},
{
name: 'vue-sub-app',
entry: '//localhost:3002',
container: '#subapp-container',
activeRule: '/vue',
},
];

// 注册子应用
registerMicroApps(microApps, {
beforeLoad: async (app) => {
console.log(`[qiankun] ${app.name} beforeLoad`);
},
beforeMount: async (app) => {
console.log(`[qiankun] ${app.name} beforeMount`);
},
afterMount: async (app) => {
console.log(`[qiankun] ${app.name} afterMount`);
},
afterUnmount: async (app) => {
console.log(`[qiankun] ${app.name} afterUnmount`);
},
});

// 全局错误处理
addGlobalUncaughtErrorHandler((event: Event | string) => {
console.error('[qiankun] 全局未捕获错误:', event);
});

// 启动 qiankun
start({
prefetch: 'all', // 预加载所有子应用
sandbox: {
strictStyleIsolation: true, // 严格样式隔离(Shadow DOM)
// experimentalStyleIsolation: true, // 实验性样式隔离(Scoped CSS)
},
});

子应用接入(React)

子应用需要做两件事:导出生命周期钩子配置允许跨域访问

react-sub-app/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

// 判断是否在 qiankun 环境中运行
declare global {
interface Window {
__POWERED_BY_QIANKUN__?: boolean;
__INJECTED_PUBLIC_PATH_BY_QIANKUN__?: string;
}
}

// 动态设置 publicPath(确保子应用资源正确加载)
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ ?? '';
}

let root: ReactDOM.Root | null = null;

// 获取子应用挂载的 DOM 容器
function getContainer(container?: HTMLElement): HTMLElement {
const dom = container
? container.querySelector('#root')
: document.getElementById('root');
return dom as HTMLElement;
}

// 生命周期 —— 初始化(只调用一次)
export async function bootstrap(): Promise<void> {
console.log('[react-sub-app] bootstrap');
}

// 生命周期 —— 挂载
export async function mount(props: {
container?: HTMLElement;
onGlobalStateChange?: (
callback: (state: Record<string, unknown>) => void
) => void;
setGlobalState?: (state: Record<string, unknown>) => void;
}): Promise<void> {
console.log('[react-sub-app] mount, props:', props);

const container = getContainer(props.container);
root = ReactDOM.createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

// 监听全局状态变化
props.onGlobalStateChange?.((state) => {
console.log('全局状态变化:', state);
});
}

// 生命周期 —— 卸载
export async function unmount(props: {
container?: HTMLElement;
}): Promise<void> {
console.log('[react-sub-app] unmount');
const container = getContainer(props.container);
root?.unmount();
root = null;
container.innerHTML = '';
}

子应用接入(Vue 3)

vue-sub-app/src/main.ts
import { createApp, type App as VueApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import AppComponent from './App.vue';
import routes from './routes';

let app: VueApp | null = null;
let router: ReturnType<typeof createRouter> | null = null;

function render(container?: HTMLElement): void {
router = createRouter({
// 在 qiankun 中需要设置 base 路径
history: createWebHistory(
window.__POWERED_BY_QIANKUN__ ? '/vue/' : '/'
),
routes,
});

app = createApp(AppComponent);
app.use(router);

const mountEl = container
? (container.querySelector('#app') as HTMLElement)
: (document.getElementById('app') as HTMLElement);
app.mount(mountEl);
}

// 独立运行时直接渲染
if (!window.__POWERED_BY_QIANKUN__) {
render();
}

export async function bootstrap(): Promise<void> {
console.log('[vue-sub-app] bootstrap');
}

export async function mount(props: {
container?: HTMLElement;
}): Promise<void> {
console.log('[vue-sub-app] mount');
render(props.container);
}

export async function unmount(): Promise<void> {
console.log('[vue-sub-app] unmount');
app?.unmount();
app = null;
router = null;
}

qiankun 沙箱机制

qiankun 提供了三种 JS 沙箱:

沙箱类型实现方式多实例支持性能兼容性
SnapshotSandbox快照 diff不支持一般IE 11+
LegacySandbox单实例 Proxy不支持不支持 IE
ProxySandbox多实例 Proxy支持不支持 IE

qiankun CSS 隔离

qiankun 提供两种 CSS 隔离策略:

  1. strictStyleIsolation:使用 Shadow DOM,将子应用包裹在 Shadow Root 中
  2. experimentalStyleIsolation:为子应用所有样式规则添加特定前缀选择器 div[data-qiankun="app-name"]
qiankun/css-isolation-config.ts
import { start } from 'qiankun';

start({
sandbox: {
// 方案一:Shadow DOM 严格隔离
strictStyleIsolation: true,

// 方案二:Scoped CSS(实验性,二选一)
// experimentalStyleIsolation: true,
},
});
Shadow DOM 的局限
  • 子应用中的弹窗(如 Ant Design 的 Modal)会挂载到 document.body,脱离 Shadow DOM 后样式丢失
  • 需要手动将弹窗的 getPopupContainer 指向 Shadow Root 内的容器
  • 部分第三方库不兼容 Shadow DOM

Module Federation 详解

Module Federation(模块联邦)是 Webpack 5 内置的模块共享能力,允许多个独立构建的应用在运行时动态共享代码。它不像 qiankun 那样是一个完整的微前端框架,而是一种模块级别的共享和加载机制。

核心概念

  • Host(宿主):消费远端模块的应用
  • Remote(远端):暴露模块给其他应用消费的应用
  • Shared(共享):多个应用共享的依赖(如 React、Vue),避免重复加载

Remote 端配置(暴露模块)

remote-app/webpack.config.ts
import { container } from 'webpack';

const { ModuleFederationPlugin } = container;

// remote 端 webpack 配置
const remoteConfig = {
plugins: [
new ModuleFederationPlugin({
// 远端应用的名称,Host 通过此名称引用
name: 'remoteApp',
// 远端入口文件名
filename: 'remoteEntry.js',

// 暴露的模块列表
exposes: {
'./Button': './src/components/Button',
'./utils': './src/utils/index',
'./store': './src/store/userStore',
},

// 共享的依赖
shared: {
react: {
singleton: true, // 确保只加载一个版本
requiredVersion: '^18.0.0',
eager: false, // 异步加载,减少初始包体积
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
},
}),
],
};

export default remoteConfig;

Host 端配置(消费模块)

host-app/webpack.config.ts
import { container } from 'webpack';

const { ModuleFederationPlugin } = container;

const hostConfig = {
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',

// 声明远端应用的地址
remotes: {
// 格式:远端名称@远端入口文件地址
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},

// 共享依赖
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
},
}),
],
};

export default hostConfig;

在 Host 中使用 Remote 模块

host-app/src/App.tsx
import React, { Suspense, lazy } from 'react';

// 使用类型声明确保类型安全
declare module 'remoteApp/Button' {
const Button: React.FC<{ label: string; onClick: () => void }>;
export default Button;
}

// 动态导入远端模块(React.lazy 异步加载)
const RemoteButton = lazy(() => import('remoteApp/Button'));

function App(): React.ReactElement {
return (
<div>
<h1>Host 应用</h1>
<Suspense fallback={<div>Loading Remote Button...</div>}>
<RemoteButton
label="来自远端的按钮"
onClick={() => console.log('clicked')}
/>
</Suspense>
</div>
);
}

export default App;
Vite 中的 Module Federation

Vite 也可以通过插件 @originjs/vite-plugin-federation 支持 Module Federation,配置方式类似。Rspack 原生支持 Module Federation。

JS 沙箱方案

JS 沙箱的核心目标是隔离子应用对全局变量(window)的修改,防止多个子应用之间产生冲突。

快照沙箱(SnapshotSandbox)

快照沙箱通过在子应用激活/卸载时拍摄 window 快照并 diff来实现隔离。适用于不支持 Proxy 的环境,但一次只能运行一个子应用。

sandbox/snapshot-sandbox.ts
class SnapshotSandbox {
private windowSnapshot: Map<string, unknown> = new Map();
private modifyPropsMap: Map<string, unknown> = new Map();
private isActive: boolean = false;

/** 激活沙箱:保存当前 window 快照 */
activate(): void {
// 1. 拍摄 window 快照
this.windowSnapshot.clear();
for (const key in window) {
this.windowSnapshot.set(key, (window as Record<string, unknown>)[key]);
}

// 2. 恢复上次沙箱运行时的修改
this.modifyPropsMap.forEach((value, key) => {
(window as Record<string, unknown>)[key] = value;
});

this.isActive = true;
console.log('SnapshotSandbox 激活');
}

/** 失活沙箱:diff 找出修改并还原 */
deactivate(): void {
this.modifyPropsMap.clear();

// 遍历 window,找出被修改的属性
for (const key in window) {
const currentValue = (window as Record<string, unknown>)[key];
const originalValue = this.windowSnapshot.get(key);

if (currentValue !== originalValue) {
// 记录修改,以便下次激活时恢复
this.modifyPropsMap.set(key, currentValue);
// 还原 window 为原始值
(window as Record<string, unknown>)[key] = originalValue;
}
}

this.isActive = false;
console.log('SnapshotSandbox 失活');
}
}

Proxy 沙箱(ProxySandbox)

Proxy 沙箱为每个子应用创建一个 fakeWindow(window 的代理对象),子应用对 window 的读写操作都被拦截到 fakeWindow 上,实现真正的多实例隔离。

sandbox/proxy-sandbox.ts
class ProxySandbox {
private fakeWindow: Record<string, unknown>;
public proxyWindow: Window;
private isActive: boolean = false;

constructor() {
// 创建一个空对象作为 fakeWindow
this.fakeWindow = {};

const that = this;
const rawWindow = window;

this.proxyWindow = new Proxy(rawWindow, {
// 拦截属性读取
get(target: Window, key: string | symbol): unknown {
const prop = key as string;

// 特殊属性直接返回原始 window
if (prop === 'window' || prop === 'self' || prop === 'globalThis') {
return that.proxyWindow;
}

// 优先从 fakeWindow 读取(子应用修改过的值)
if (prop in that.fakeWindow) {
return that.fakeWindow[prop];
}

// 否则从真实 window 读取
const value = (target as Record<string, unknown>)[prop];
// 如果是函数,需要绑定 this 为原始 window
if (typeof value === 'function' && !value.toString().includes('class')) {
return value.bind(rawWindow);
}
return value;
},

// 拦截属性设置
set(_target: Window, key: string | symbol, value: unknown): boolean {
const prop = key as string;
if (that.isActive) {
// 所有写操作都只修改 fakeWindow,不污染真实 window
that.fakeWindow[prop] = value;
}
return true;
},

// 拦截属性检查
has(_target: Window, key: string | symbol): boolean {
return key in that.fakeWindow || key in window;
},

// 拦截属性删除
deleteProperty(_target: Window, key: string | symbol): boolean {
const prop = key as string;
if (prop in that.fakeWindow) {
delete that.fakeWindow[prop];
}
return true;
},
}) as unknown as Window;
}

activate(): void {
this.isActive = true;
}

deactivate(): void {
this.isActive = false;
}
}

// 使用示例:每个子应用拥有独立的 proxyWindow
const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();

sandbox1.activate();
sandbox2.activate();

// 子应用1 修改 window 不影响子应用2
(sandbox1.proxyWindow as Record<string, unknown>).appName = 'app1';
(sandbox2.proxyWindow as Record<string, unknown>).appName = 'app2';

console.log((sandbox1.proxyWindow as Record<string, unknown>).appName); // 'app1'
console.log((sandbox2.proxyWindow as Record<string, unknown>).appName); // 'app2'
console.log((window as Record<string, unknown>).appName); // undefined(真实 window 未被污染)
快照 vs Proxy 沙箱
  • 快照沙箱:兼容性好(支持 IE),但性能一般(需要遍历 window),且不支持多实例
  • Proxy 沙箱:性能好(懒拦截),支持多实例,但不兼容 IE
  • qiankun 默认使用 ProxySandbox,当不支持 Proxy 时降级为 SnapshotSandbox

CSS 隔离方案

微前端中 CSS 隔离的目标是防止主应用与子应用、以及子应用之间的样式互相污染

方案对比

方案隔离强度实现难度兼容性缺点
Shadow DOM现代浏览器弹窗样式丢失、第三方库兼容性差
CSS Modules全部需要构建工具支持,全局样式仍可能泄漏
BEM 命名规范全部靠人工约束,无法强制隔离
动态样式表全部需要管理样式的插入和移除
CSS-in-JS全部运行时性能开销
Scoped CSS 前缀全部选择器权重可能被覆盖

Shadow DOM 隔离

css-isolation/shadow-dom.ts
/** 将子应用包裹在 Shadow DOM 中实现样式隔离 */
function createShadowContainer(
hostElement: HTMLElement,
appHtml: string,
appStyles: string
): ShadowRoot {
// 创建 Shadow Root
const shadowRoot = hostElement.attachShadow({ mode: 'open' });

// 将子应用的样式注入到 Shadow DOM 内部
const styleEl = document.createElement('style');
styleEl.textContent = appStyles;
shadowRoot.appendChild(styleEl);

// 将子应用的 HTML 注入到 Shadow DOM
const appContainer = document.createElement('div');
appContainer.id = 'micro-app-root';
appContainer.innerHTML = appHtml;
shadowRoot.appendChild(appContainer);

return shadowRoot;
}

// 使用示例
const host = document.getElementById('sub-app-wrapper')!;
createShadowContainer(
host,
'<div class="app">子应用内容</div>',
'.app { color: red; }' // 该样式不会泄漏到主应用
);

动态样式表(样式装载/卸载)

在子应用挂载时注入样式、卸载时移除样式:

css-isolation/dynamic-stylesheet.ts
class DynamicStylesheet {
private styleElements: HTMLStyleElement[] = [];
private appName: string;

constructor(appName: string) {
this.appName = appName;
}

/** 挂载时注入子应用样式 */
mount(cssTexts: string[]): void {
cssTexts.forEach((cssText) => {
const style = document.createElement('style');
style.setAttribute('data-micro-app', this.appName);
// 为选择器添加前缀,限制作用范围
style.textContent = this.scopeCSS(cssText);
document.head.appendChild(style);
this.styleElements.push(style);
});
}

/** 卸载时移除子应用样式 */
unmount(): void {
this.styleElements.forEach((style) => {
document.head.removeChild(style);
});
this.styleElements = [];
}

/** 为 CSS 选择器添加作用域前缀 */
private scopeCSS(cssText: string): string {
const prefix = `[data-qiankun="${this.appName}"]`;
// 简化版:为每个规则添加前缀选择器
return cssText.replace(
/(^|\})\s*([^{@][^{]*)\{/g,
(match, before: string, selector: string) => {
const scoped = selector
.split(',')
.map((s: string) => `${prefix} ${s.trim()}`)
.join(', ');
return `${before} ${scoped} {`;
}
);
}
}

// 使用示例
const stylesheet = new DynamicStylesheet('react-app');

// 子应用 mount 时
stylesheet.mount([
'.title { color: red; font-size: 16px; }',
'.button { background: blue; }',
]);

// 子应用 unmount 时
stylesheet.unmount();

通信机制

微前端架构中的通信需要解决主应用与子应用、以及子应用之间的数据传递问题。

Props 通信(最推荐)

主应用在注册子应用时通过 props 直接传递数据和方法。这是最简单、最直接的通信方式:

communication/props-communication.ts
// 主应用:定义传递给子应用的 props
interface MicroAppProps {
/** 当前登录用户信息 */
user: { name: string; role: string };
/** 主应用路由跳转方法 */
navigate: (path: string) => void;
/** 通知主应用的回调函数 */
onMessage: (type: string, payload: unknown) => void;
}

// 主应用中注册子应用时传递 props
const apps = [
{
name: 'sub-app',
entry: '//localhost:3001',
container: '#container',
activeRule: '/sub',
props: {
user: { name: '张三', role: 'admin' },
navigate: (path: string) => {
window.history.pushState(null, '', path);
},
onMessage: (type: string, payload: unknown) => {
console.log(`子应用消息: ${type}`, payload);
},
} satisfies MicroAppProps,
},
];

// 子应用:在 mount 生命周期中接收 props
export async function mount(props: MicroAppProps): Promise<void> {
console.log('用户信息:', props.user);
// 调用主应用方法跳转路由
props.navigate('/dashboard');
// 向主应用发送消息
props.onMessage('loaded', { timestamp: Date.now() });
}

CustomEvent 通信

利用浏览器原生的 CustomEvent 实现发布订阅通信,不依赖任何框架:

communication/custom-event.ts
// 定义事件数据类型
interface MicroEventDetail<T = unknown> {
source: string; // 来源应用
type: string; // 事件类型
payload: T; // 事件数据
}

/** 发送微前端事件 */
function emitMicroEvent<T>(detail: MicroEventDetail<T>): void {
const event = new CustomEvent('micro-frontend-event', {
detail,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
}

/** 监听微前端事件 */
function onMicroEvent<T>(
callback: (detail: MicroEventDetail<T>) => void
): () => void {
const handler = (event: Event): void => {
const customEvent = event as CustomEvent<MicroEventDetail<T>>;
callback(customEvent.detail);
};
window.addEventListener('micro-frontend-event', handler);
// 返回取消监听函数
return () => {
window.removeEventListener('micro-frontend-event', handler);
};
}

// 子应用 A 发送事件
emitMicroEvent({
source: 'app-a',
type: 'USER_LOGOUT',
payload: { userId: '123' },
});

// 子应用 B 监听事件
const unsubscribe = onMicroEvent<{ userId: string }>((detail) => {
if (detail.type === 'USER_LOGOUT') {
console.log(`用户 ${detail.payload.userId} 已登出`);
}
});

// 组件卸载时取消监听
unsubscribe();

qiankun 全局状态通信

qiankun 内置了 initGlobalState API 实现应用间的状态共享:

communication/qiankun-global-state.ts
import { initGlobalState, type MicroAppStateActions } from 'qiankun';

// 主应用:初始化全局状态
interface GlobalState {
user: { name: string; token: string } | null;
theme: 'light' | 'dark';
language: 'zh-CN' | 'en-US';
}

const initialState: GlobalState = {
user: null,
theme: 'light',
language: 'zh-CN',
};

const actions: MicroAppStateActions = initGlobalState(initialState);

// 主应用监听全局状态变化
actions.onGlobalStateChange((newState, prevState) => {
console.log('全局状态变化:', newState, '上一次:', prevState);
});

// 主应用更新全局状态
actions.setGlobalState({
...initialState,
user: { name: '张三', token: 'abc123' },
});

// --- 子应用中使用 ---
// 在 mount 生命周期中获取 actions
export async function mount(props: {
onGlobalStateChange: MicroAppStateActions['onGlobalStateChange'];
setGlobalState: MicroAppStateActions['setGlobalState'];
}): Promise<void> {
// 监听全局状态
props.onGlobalStateChange((state) => {
console.log('子应用收到全局状态:', state);
});

// 子应用修改全局状态
props.setGlobalState({ theme: 'dark' });
}

路由管理

微前端的路由管理需要协调主应用路由和子应用路由,确保 URL 变化能够正确匹配和激活对应的子应用。

routing/micro-router.ts
/** 微前端路由管理器 */
class MicroRouter {
private apps: Map<string, {
activeRule: string | ((location: Location) => boolean);
mount: () => Promise<void>;
unmount: () => Promise<void>;
}> = new Map();

private currentApp: string | null = null;

constructor() {
// 监听路由变化
window.addEventListener('popstate', () => {
this.handleRouteChange();
});

// 劫持 pushState 和 replaceState
this.patchHistoryMethod('pushState');
this.patchHistoryMethod('replaceState');
}

/** 劫持 History API,在路由变化时触发子应用切换 */
private patchHistoryMethod(method: 'pushState' | 'replaceState'): void {
const original = history[method];

history[method] = (...args: Parameters<typeof original>): void => {
original.apply(history, args);
// 路由变化后检查是否需要切换子应用
this.handleRouteChange();
};
}

/** 注册子应用路由 */
register(
name: string,
config: {
activeRule: string | ((location: Location) => boolean);
mount: () => Promise<void>;
unmount: () => Promise<void>;
}
): void {
this.apps.set(name, config);
}

/** 处理路由变化 */
private async handleRouteChange(): Promise<void> {
const pathname = window.location.pathname;

// 找到匹配的子应用
let matchedApp: string | null = null;
for (const [name, config] of this.apps) {
const isActive =
typeof config.activeRule === 'function'
? config.activeRule(window.location)
: pathname.startsWith(config.activeRule);

if (isActive) {
matchedApp = name;
break;
}
}

// 如果匹配的应用和当前应用相同,无需切换
if (matchedApp === this.currentApp) return;

// 卸载当前子应用
if (this.currentApp) {
const currentConfig = this.apps.get(this.currentApp);
await currentConfig?.unmount();
}

// 挂载新的子应用
if (matchedApp) {
const newConfig = this.apps.get(matchedApp);
await newConfig?.mount();
}

this.currentApp = matchedApp;
}
}

无界 wujie 与 micro-app

无界 wujie

无界 wujie 是腾讯开源的新一代微前端方案,核心思路是 WebComponent + iframe

  • 使用 iframe 运行子应用的 JS,天然实现 JS 沙箱
  • 使用 WebComponent(Shadow DOM) 渲染子应用的 DOM,实现 CSS 隔离
  • 通过 代理 将 iframe 中的 DOM 操作映射到 Shadow DOM
wujie/main-app.ts
// 主应用中使用 wujie(以 Vue 3 为例)
import WujieVue from 'wujie-vue3';
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

// 注册 wujie 插件
app.use(WujieVue);

// 在模板中使用
// <WujieVue
// name="react-sub-app"
// url="http://localhost:3001"
// :alive="true"
// :props="{ user: currentUser }"
// @dataChange="handleDataChange"
// />

micro-app

micro-app 是京东开源的微前端方案,基于 WebComponent 实现,使用类似自定义标签的方式接入子应用:

micro-app/main-app.ts
import microApp from '@micro-zoe/micro-app';

// 初始化 micro-app
microApp.start({
plugins: {
modules: {
'sub-app': [
{
loader(code: string): string {
// 可以在加载时对子应用代码做处理
return code;
},
},
],
},
},
});

// 在 HTML 模板中使用
// <micro-app
// name="sub-app"
// url="http://localhost:3001"
// baseroute="/sub"
// :data="{ user: currentUser }"
// ></micro-app>
wujie vs micro-app vs qiankun
  • qiankun:社区最成熟、生态最丰富,但 CSS 隔离方案不够完美
  • wujie:JS 隔离和 CSS 隔离最彻底(iframe + Shadow DOM),但比较新
  • micro-app:接入成本最低(类 WebComponent 标签),但底层仍使用 Proxy 沙箱

常见面试问题

Q1: qiankun 的 JS 沙箱是如何实现的?为什么需要 JS 沙箱?

答案

JS 沙箱的核心目的是隔离子应用对 window 全局变量的修改,防止多个子应用之间互相污染。

qiankun 提供了三种沙箱实现:

沙箱原理多实例兼容 IE
SnapshotSandbox激活时保存 window 快照,失活时 diff 还原不支持支持
LegacySandbox用 Proxy 代理 window,记录增/改/删操作不支持不支持
ProxySandbox为每个子应用创建 fakeWindow 代理支持不支持

ProxySandbox 核心原理

sandbox/proxy-sandbox-core.ts
// 简化版 ProxySandbox
class SimplifiedProxySandbox {
private fakeWindow: Record<string, unknown> = {};

createProxy(): Window {
const fakeWindow = this.fakeWindow;
return new Proxy(window, {
get(_target, key: string) {
// 优先读 fakeWindow(子应用修改过的值)
return key in fakeWindow ? fakeWindow[key] : (window as any)[key];
},
set(_target, key: string, value: unknown) {
// 写操作只影响 fakeWindow,不污染真实 window
fakeWindow[key] = value;
return true;
},
}) as unknown as Window;
}
}

// 每个子应用拥有独立的代理,互不干扰
const sandbox1 = new SimplifiedProxySandbox();
const sandbox2 = new SimplifiedProxySandbox();
const proxy1 = sandbox1.createProxy();
const proxy2 = sandbox2.createProxy();

(proxy1 as any).foo = 'bar'; // 只影响 sandbox1 的 fakeWindow
console.log((proxy2 as any).foo); // undefined
console.log((window as any).foo); // undefined

为什么需要 JS 沙箱

  • 子应用可能修改 window.xxx 全局变量,影响其他子应用
  • 子应用可能注册全局事件监听器(如 window.addEventListener),导致内存泄漏
  • 子应用可能修改原生 API(如 Array.prototype.xxx),导致其他应用行为异常
  • 子应用可能设置全局定时器(setInterval),卸载后仍在执行

Q2: 微前端中如何实现子应用之间的通信?

答案

微前端通信主要有以下几种方式:

方式适用场景优点缺点
Props 传递主->子通信简单直接、类型安全只支持主->子单向
全局状态多应用共享状态支持双向、可监听需要框架支持(qiankun)
CustomEvent任意应用通信无框架依赖、灵活缺乏类型约束
URL 参数简单数据传递天然持久化数据量有限
localStorage跨标签页共享持久化、简单无实时监听(需轮询)
BroadcastChannel同源跨标签页原生支持、实时仅同源

推荐的通信架构

communication/event-bus.ts
/** 类型安全的微前端 EventBus */
interface EventMap {
'user:login': { userId: string; token: string };
'user:logout': undefined;
'theme:change': { theme: 'light' | 'dark' };
'language:change': { lang: 'zh-CN' | 'en-US' };
}

class MicroEventBus {
private handlers = new Map<string, Set<Function>>();

/** 监听事件 */
on<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void
): () => void {
if (!this.handlers.has(event as string)) {
this.handlers.set(event as string, new Set());
}
this.handlers.get(event as string)!.add(handler);

// 返回取消订阅函数
return () => {
this.handlers.get(event as string)?.delete(handler);
};
}

/** 触发事件 */
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
this.handlers.get(event as string)?.forEach((handler) => {
handler(payload);
});
}

/** 清除所有监听 */
clear(): void {
this.handlers.clear();
}
}

// 挂载到 window 上供所有子应用使用
const eventBus = new MicroEventBus();
(window as any).__MICRO_EVENT_BUS__ = eventBus;

// 子应用 A 发送
eventBus.emit('user:login', { userId: '001', token: 'xxx' });

// 子应用 B 监听
const unsubscribe = eventBus.on('user:login', (data) => {
console.log(`用户登录: ${data.userId}`);
});

Q3: qiankun 和 Module Federation 有什么区别?如何选择?

答案

qiankun 和 Module Federation 是两种不同层面的微前端方案,解决的核心问题不同:

对比维度qiankunModule Federation
定位完整的微前端框架Webpack 5 模块共享能力
隔离能力JS 沙箱 + CSS 隔离无隔离(共享运行环境)
技术栈技术栈无关通常要求相同构建工具
加载方式HTML Entry(加载整个页面)JS Entry(加载模块)
共享粒度应用级别模块/组件级别
部署独立性完全独立部署独立部署,但共享依赖需版本协调
通信方式props / GlobalState / Event共享模块、直接 import
构建工具无要求必须 Webpack 5+ / Rspack
适用场景大型异构系统集成同技术栈的模块/组件共享

选型建议

selection-guide.ts
// 伪代码:选型决策树
function chooseMicroFrontendSolution(project: {
techStackUnified: boolean;
needStrongIsolation: boolean;
teamSize: 'small' | 'medium' | 'large';
hasLegacyApps: boolean;
buildTool: 'webpack5' | 'vite' | 'other';
}): string {
// 有遗留系统需要渐进迁移 -> qiankun
if (project.hasLegacyApps) {
return 'qiankun(技术栈无关,支持渐进迁移)';
}

// 技术栈统一,仅需模块级共享 -> Module Federation
if (project.techStackUnified && !project.needStrongIsolation) {
if (project.buildTool === 'webpack5') {
return 'Module Federation(模块级共享,零隔离开销)';
}
}

// 需要强隔离 -> wujie 或 qiankun
if (project.needStrongIsolation) {
return 'wujie(iframe + Shadow DOM,隔离最彻底)';
}

// 追求接入简单 -> micro-app
if (project.teamSize === 'small') {
return 'micro-app(类 WebComponent,接入成本最低)';
}

// 默认推荐 qiankun(社区成熟度最高)
return 'qiankun(社区成熟、文档完善、生态丰富)';
}

实践中常见的组合方案

  • qiankun + Module Federation:用 qiankun 管理应用生命周期和隔离,用 Module Federation 共享公共组件库
  • qiankun + monorepo:用 monorepo 管理所有子应用代码,用 qiankun 做运行时集成
  • Module Federation + Rspack:Rspack 原生支持 Module Federation,构建速度比 Webpack 快

Q4: JS 沙箱隔离有哪些方案?Proxy 沙箱的实现原理?

答案

JS 沙箱的核心目标是隔离子应用对全局环境(window)的修改,防止多个子应用之间产生变量污染和冲突。目前主流的 JS 沙箱方案有以下几种:

方案原理多实例兼容 IE性能代表框架
快照沙箱激活时快照 window,失活时 diff 还原不支持支持一般qiankun (降级方案)
单实例 Proxy 沙箱Proxy 代理 window,记录增删改操作不支持不支持qiankun LegacySandbox
多实例 Proxy 沙箱每个子应用一个 fakeWindow 代理支持不支持qiankun ProxySandbox
iframe 沙箱利用 iframe 的天然隔离支持支持wujie
with + Proxywith(proxy) { code } 限制作用域支持不支持micro-app、自定义沙箱
ShadowRealm(提案)TC39 提案,原生隔离环境支持未来标准

Proxy 沙箱(ProxySandbox)的实现原理

核心思路是为每个子应用创建一个 fakeWindow 对象,通过 Proxy 拦截子应用对 window 的所有读写操作,将写操作重定向到 fakeWindow,读操作优先从 fakeWindow 中查找:

sandbox/proxy-sandbox-detail.ts
class ProxySandbox {
private fakeWindow: Record<string, unknown> = {};
private isActive = false;
public proxyWindow: Window;

// 需要从真实 window 上获取的不可变属性
private static UNSCOPABLES: Set<string> = new Set([
'undefined', 'NaN', 'Infinity', 'Array', 'Object',
'String', 'Boolean', 'Math', 'Number', 'Symbol',
'parseFloat', 'Float32Array', 'isNaN', 'isFinite',
]);

constructor(appName: string) {
const that = this;
const rawWindow = window;

this.proxyWindow = new Proxy(rawWindow, {
get(target: Window, key: string | symbol): unknown {
const prop = key as string;

// 1. window/self/globalThis 指向代理对象(防止逃逸)
if (['window', 'self', 'globalThis'].includes(prop)) {
return that.proxyWindow;
}

// 2. 优先从 fakeWindow 读取(子应用修改过的值)
if (prop in that.fakeWindow) {
return that.fakeWindow[prop];
}

// 3. 从真实 window 读取,函数需要绑定原始 this
const rawValue = (target as Record<string, unknown>)[prop];
if (typeof rawValue === 'function') {
return rawValue.bind(rawWindow);
}
return rawValue;
},

set(_target: Window, key: string | symbol, value: unknown): boolean {
const prop = key as string;
// 所有写操作只影响 fakeWindow,不污染真实 window
if (that.isActive) {
that.fakeWindow[prop] = value;

// 同步更新:如果修改了 document.title 等需要同步的属性
if (prop === 'document') {
console.warn(`[${appName}] 不允许修改 document`);
return true;
}
}
return true;
},

has(_target: Window, key: string | symbol): boolean {
return key in that.fakeWindow || key in rawWindow;
},

deleteProperty(_target: Window, key: string | symbol): boolean {
if (that.isActive && key in that.fakeWindow) {
delete that.fakeWindow[key as string];
}
return true;
},
}) as unknown as Window;
}

activate(): void {
this.isActive = true;
}

deactivate(): void {
this.isActive = false;
}

/** 清除沙箱中的所有修改 */
destroy(): void {
this.isActive = false;
this.fakeWindow = {};
}
}

Proxy 沙箱还需要处理的边界问题

sandbox/edge-cases.ts
// 1. 事件监听器的劫持:子应用卸载时自动移除所有事件监听
class EventListenerPatcher {
private listeners: Map<string, Set<EventListener>> = new Map();

patch(proxyWindow: Window): void {
const rawAddEventListener = window.addEventListener;
const rawRemoveEventListener = window.removeEventListener;
const listeners = this.listeners;

// 劫持 addEventListener,记录子应用注册的所有事件
(proxyWindow as unknown as Record<string, unknown>).addEventListener =
function (type: string, handler: EventListener, options?: boolean | AddEventListenerOptions) {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
listeners.get(type)!.add(handler);
rawAddEventListener.call(window, type, handler, options);
};

(proxyWindow as unknown as Record<string, unknown>).removeEventListener =
function (type: string, handler: EventListener, options?: boolean | EventListenerOptions) {
listeners.get(type)?.delete(handler);
rawRemoveEventListener.call(window, type, handler, options);
};
}

// 子应用卸载时批量清除所有事件监听
unpatch(): void {
this.listeners.forEach((handlers, type) => {
handlers.forEach((handler) => {
window.removeEventListener(type, handler);
});
});
this.listeners.clear();
}
}

// 2. 定时器的劫持:子应用卸载时自动清除所有定时器
class TimerPatcher {
private timers: Set<number> = new Set();

patch(proxyWindow: Window): void {
const rawSetTimeout = window.setTimeout;
const rawSetInterval = window.setInterval;
const timers = this.timers;

(proxyWindow as unknown as Record<string, unknown>).setTimeout =
function (handler: TimerHandler, delay?: number, ...args: unknown[]): number {
const id = rawSetTimeout(handler, delay, ...args);
timers.add(id);
return id;
};

(proxyWindow as unknown as Record<string, unknown>).setInterval =
function (handler: TimerHandler, delay?: number, ...args: unknown[]): number {
const id = rawSetInterval(handler, delay, ...args);
timers.add(id);
return id;
};
}

unpatch(): void {
this.timers.forEach((id) => {
clearTimeout(id);
clearInterval(id);
});
this.timers.clear();
}
}
面试延伸

面试官可能追问 "Proxy 沙箱能 100% 隔离吗?",答案是不能。以下场景无法被 Proxy 拦截:

  • 子应用直接修改 DOM(如 document.body.style.xxx
  • 子应用修改原型链(如 Array.prototype.myMethod = ...
  • 子应用通过 eval()new Function() 执行的代码

因此实际生产中,Proxy 沙箱通常配合副作用清理(事件监听、定时器、DOM 修改等)一起使用,在子应用卸载时统一回收。

Q5: 微前端之间如何通信?

答案

微前端通信是指主应用与子应用、以及子应用之间传递数据和消息的机制。根据通信方向和场景,有以下几种主流方案:

1. Props 传递(主 -> 子,最推荐)

最简单直接的方式,主应用在注册子应用时通过 props 传递数据和方法,类似 React 的 props:

communication/props-demo.ts
// 主应用注册子应用时传入 props
import { registerMicroApps } from 'qiankun';

registerMicroApps([
{
name: 'sub-app',
entry: '//localhost:3001',
container: '#container',
activeRule: '/sub',
props: {
user: { id: '001', name: '张三', role: 'admin' },
token: 'eyJhbGciOiJIUzI1NiJ9...',
navigate: (path: string) => window.history.pushState(null, '', path),
showMessage: (msg: string) => alert(msg),
},
},
]);

// 子应用在 mount 中接收
export async function mount(props: {
user: { id: string; name: string; role: string };
token: string;
navigate: (path: string) => void;
showMessage: (msg: string) => void;
}): Promise<void> {
console.log('当前用户:', props.user.name);
// 调用主应用方法
props.navigate('/dashboard');
}

2. 全局状态管理(双向,qiankun 内置)

qiankun 内置 initGlobalState,类似一个简化版的全局 Store:

communication/global-state.ts
import { initGlobalState } from 'qiankun';

// 主应用初始化全局状态
const actions = initGlobalState({
user: null,
theme: 'light',
locale: 'zh-CN',
});

// 主应用监听变化
actions.onGlobalStateChange((newState, prevState) => {
console.log('全局状态变化:', newState);
});

// 主应用更新状态
actions.setGlobalState({ theme: 'dark' });

// --- 子应用 ---
export async function mount(props: {
onGlobalStateChange: (cb: (state: Record<string, unknown>) => void) => void;
setGlobalState: (state: Record<string, unknown>) => void;
}): Promise<void> {
// 子应用监听全局状态
props.onGlobalStateChange((state) => {
console.log('子应用收到:', state);
});

// 子应用修改全局状态
props.setGlobalState({ locale: 'en-US' });
}

3. 自定义 EventBus(任意方向,跨框架通用)

不依赖任何微前端框架,基于浏览器原生 CustomEvent 实现:

communication/typed-event-bus.ts
// 定义事件类型映射,保证类型安全
interface MicroEventMap {
'user:login': { userId: string; token: string };
'user:logout': undefined;
'cart:update': { itemCount: number };
'theme:change': { theme: 'light' | 'dark' };
}

class TypedEventBus {
/** 发送事件 */
emit<K extends keyof MicroEventMap>(
event: K,
payload: MicroEventMap[K]
): void {
window.dispatchEvent(
new CustomEvent(`micro:${String(event)}`, {
detail: payload,
bubbles: false,
})
);
}

/** 监听事件,返回取消订阅函数 */
on<K extends keyof MicroEventMap>(
event: K,
handler: (payload: MicroEventMap[K]) => void
): () => void {
const wrapper = (e: Event) => {
handler((e as CustomEvent).detail);
};
window.addEventListener(`micro:${String(event)}`, wrapper);
return () => window.removeEventListener(`micro:${String(event)}`, wrapper);
}
}

// 挂载到 window 供所有子应用使用
const bus = new TypedEventBus();
(window as any).__MICRO_BUS__ = bus;

// 子应用 A:发送
bus.emit('cart:update', { itemCount: 5 });

// 子应用 B:监听
const unsub = bus.on('cart:update', ({ itemCount }) => {
console.log(`购物车数量: ${itemCount}`);
});
// 卸载时取消监听
unsub();

4. URL 参数 / 共享 Storage

方式适用场景优点缺点
URL query / hash简单参数传递天然持久化,刷新不丢失数据量有限,仅字符串
localStorage跨标签页数据共享持久化无实时监听(需 storage 事件)
BroadcastChannel同源跨标签页实时通信原生支持,实时仅同源页面

通信方案选型建议

注意

无论使用哪种通信方式,都需要在子应用卸载时取消所有订阅,否则会导致内存泄漏和重复执行。建议在 unmount 生命周期中统一清理。

Q6: 微前端方案的选型(qiankun vs Module Federation vs iframe vs Web Components)

答案

微前端方案的选型取决于项目的具体需求,没有"银弹"方案。以下从多个维度对比四种主流方案:

核心方案对比

对比维度qiankunModule FederationiframeWeb Components
隔离能力Proxy 沙箱 + CSS 隔离无隔离(共享上下文)天然强隔离Shadow DOM CSS 隔离
技术栈限制无限制通常要求相同构建工具无限制无限制
加载方式HTML EntryJS Entry(远程模块)整页加载自定义元素
通信方式props / GlobalState共享模块直接调用postMessage属性 / 事件 / Slot
共享粒度应用级模块/组件级应用级组件级
性能中等好(按需加载模块)一般(重复加载)
SEO支持(SSR 需额外处理)支持不友好支持
调试体验中等好(同源调试)差(跨 iframe)
社区成熟度高(蚂蚁系广泛使用)中高非常成熟中等
接入成本中等中低极低中等

选型决策树

selection/decision-tree.ts
interface ProjectContext {
/** 是否有多种技术栈的子系统 */
multiTechStack: boolean;
/** 是否需要强 JS/CSS 隔离 */
needStrongIsolation: boolean;
/** 是否需要组件级别的共享(而非整个应用) */
needComponentSharing: boolean;
/** 是否有老旧系统需要集成 */
hasLegacySystems: boolean;
/** 构建工具是否统一为 Webpack 5+ / Rspack */
unifiedBuildTool: boolean;
/** 团队规模 */
teamSize: 'small' | 'medium' | 'large';
/** 对弹窗、路由同步等 UX 要求 */
uxRequirement: 'low' | 'medium' | 'high';
}

function selectMicroFrontend(ctx: ProjectContext): string {
// 场景1: 简单嵌入,不在意 UX 细节 -> iframe
if (ctx.uxRequirement === 'low' && ctx.needStrongIsolation) {
return 'iframe —— 最简单、隔离最强、但 UX 受限';
}

// 场景2: 需要组件级共享,技术栈统一 -> Module Federation
if (ctx.needComponentSharing && ctx.unifiedBuildTool && !ctx.multiTechStack) {
return 'Module Federation —— 模块级共享,无隔离开销';
}

// 场景3: 多技术栈 / 遗留系统集成 -> qiankun
if (ctx.multiTechStack || ctx.hasLegacySystems) {
return 'qiankun —— 技术栈无关,沙箱隔离,社区成熟';
}

// 场景4: 对浏览器标准化要求高 -> Web Components
if (!ctx.needStrongIsolation && ctx.teamSize === 'small') {
return 'Web Components —— 标准化、面向未来、适合组件分发';
}

// 默认推荐
return 'qiankun —— 综合能力最均衡的企业级方案';
}

各方案的典型适用场景

场景推荐方案原因
遗留 jQuery 系统渐进迁移qiankun技术栈无关,子应用改造最少
多团队各自维护的业务模块整合qiankun独立部署 + 运行时集成
同技术栈的微服务前端Module Federation模块共享,构建优化
第三方系统嵌入(如 CRM 嵌 ERP)iframe零改造,天然隔离
跨团队 UI 组件分发Web Components / MF组件级粒度,按需加载
需要最强隔离(如安全要求高)wujie / iframeiframe 天然隔离最可靠
追求极简接入micro-app类 HTML 标签,几行代码接入

实践中常见的组合方案

selection/combined-solutions.ts
// 组合方案1: qiankun + Module Federation
// - qiankun 负责应用级的生命周期管理和沙箱隔离
// - Module Federation 负责跨应用的公共组件库共享
// - 适合:大型企业级应用,既需要隔离又需要共享

// 组合方案2: qiankun + Monorepo
// - Monorepo (pnpm workspace + Turborepo) 管理所有子应用源码
// - qiankun 做运行时集成,每个子应用独立构建部署
// - 适合:同一团队维护多个子应用

// 组合方案3: Module Federation + Rspack
// - Rspack 原生支持 Module Federation,构建速度极快
// - 适合:追求构建性能的同技术栈项目
面试总结

选型没有标准答案,关键是能根据项目实际情况分析利弊。面试时建议按以下思路回答:

  1. 先明确项目的核心诉求(隔离性、共享粒度、技术栈差异)
  2. 列出候选方案及其优缺点
  3. 给出推荐方案及理由
  4. 提及可能的组合方案

相关链接