微前端实战:从“能跑就行”到Lovable的重构之路
去年冬天,我正窝在杭州家里,一边给MacBook Pro吹着小风扇(别问为什么冬天还烫,问就是Node_modules太重),一边和产品经理撕逼——哦不,是“深入沟通”。事情起因很简单:我们这个外包团队接了个国企的大活,客户原来的主站是用JSP写的,现在想搞个“数字化转型”,但又舍不得老系统,非要在同一个域名下塞进一堆新功能。
更离谱的是,他们希望新模块能随时插拔、独立部署,最好还能让不同供应商同时开发不同模块。我当时心里就咯噔一下:这不就是微前端的典型场景吗?
说实话,在此之前我对微前端的理解还停留在“面试八股文”层面。真正上手才发现,这玩意儿比想象中复杂得多。今天这篇文章,就聊聊我们怎么把一个“能跑就行”的拼凑项目,一步步打磨成团队内部公认的Lovable系统——这个词不是随便说的,是我们技术总监在周会上原话:“现在这个架构,看着都舒服。”
为什么是微前端?因为老板不想重写啊!
先交代下背景。客户的老系统是个单体应用,前后端不分离,页面跳转全靠<a href>,状态管理靠URL参数+session。而新需求要求:
- 新增一个智能客服模块(要集成第三方聊天组件)
- 上线一个数据看板(实时图表,性能敏感)
- 接入一个AI助手(对,就是ChatGPT那种)
三个模块由三家不同供应商并行开发,交付时间差两个月。如果硬塞进同一个代码库,光是合并冲突就能让运维兄弟原地辞职。
于是我们拍板:上微前端!选型时对比了qiankun、single-spa、Module Federation。最后选了qiankun,原因很现实——文档中文、社区活跃、阿里背书。虽然我知道有些大厂已经转向Module Federation,但对我们这种人力紧张的外包团队来说,稳定压倒一切。
第一次尝试:把React子应用塞进去,结果样式炸了
我们的主应用是Vue2(别问,问就是历史包袱),子应用清一色React 18。第一个坑来得飞快:CSS隔离失效。
子应用用了Ant Design,主应用用了Element UI,两个UI库的全局样式直接干架。最惨的是那个数据看板页面,字体忽大忽小,按钮颜色随机切换。测试同事截图发群里:“你们确定这是同一个页面?”
查了半天,发现qiankun默认只隔离了JS,CSS还得手动处理。我们试过Scoped CSS,但动态加载的组件样式还是漏出去。最后解决方案是:
// 主应用注册子应用时,开启沙箱 + 样式隔离
registerMicroApps([
{
name: 'ai-assistant',
entry: '//localhost:3001',
container: '#subapp-container',
activeRule: '/ai',
props: { ... },
}
], {
beforeLoad: [async app => {
console.log('Loading:', app.name);
}],
afterMount: [async app => {
// 动态注入唯一前缀,避免样式冲突
const styleSheets = document.styleSheets;
for (let i = 0; i < styleSheets.length; i++) {
try {
const rules = styleSheets[i].cssRules;
for (let j = 0; j < rules.length; j++) {
if (rules[j].selectorText && !rules[j].selectorText.startsWith(`.${app.name}-`)) {
// 实际生产中我们用PostCSS插件自动加前缀,这里简化示意
}
}
} catch (e) {
// 跨域样式表会报错,跳过
}
}
}]
}, {
sandbox: {
strictStyleIsolation: true, // 开启严格样式隔离(Shadow DOM)
}
});
但注意:strictStyleIsolation 依赖 Shadow DOM,IE11 直接躺平。好在客户明确说只支持现代浏览器,不然又得加班改方案。
ChatGPT接入后,内存泄漏差点让我秃了
第二个大坑出在AI助手上。我们封装了一个React组件,通过WebSocket连接后端,再对接OpenAI API(实际用的是Azure OpenAI,合规要求)。本地测试一切正常,一上线,用户多开几个标签页,浏览器内存直接飙到4GB。
Chrome DevTools Memory面板一看,全是未释放的WebSocket实例和React Fiber节点。原来qiankun在卸载子应用时,并不会自动清理子应用内部的定时器、事件监听、WebSocket连接。
教训:微前端子应用必须实现完整的生命周期清理!
我们在每个React子应用的根组件里加了这段:
// AiAssistant.tsx
useEffect(() => {
const ws = new WebSocket('wss://ai-backend.example.com');
ws.onmessage = handleIncomingMessage;
// 关键:监听 qiankun 的 unmount 事件
window.addEventListener('single-spa:before-mount-routing-event', () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
});
return () => {
// 组件卸载时也要清理(比如路由切换)
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, []);
但这样还不够健壮。后来我们抽象出一个useMicroAppCleanup Hook:
export const useMicroAppCleanup = (cleanupFn: () => void) => {
useEffect(() => {
// 子应用卸载时触发
window.addEventListener('single-spa:before-unmount-routing-event', cleanupFn);
return () => {
window.removeEventListener('single-spa:before-unmount-routing-event', cleanupFn);
cleanupFn(); // 组件级卸载也调用
};
}, []);
};
从此,内存泄漏问题基本消失。上线后监控显示,平均内存占用下降60%。
性能优化:让用户感觉不到“这是拼起来的”
微前端最大的用户体验风险就是加载慢和白屏抖动。客户领导第一次演示时吐槽:“怎么点一下等三秒?隔壁竞品秒开!”
我们做了三件事:
1. 预加载策略
利用qiankun的prefetch能力,在用户空闲时偷偷加载可能访问的子应用:
start({
prefetch: 'all', // 或者自定义策略
// 自定义预加载逻辑
fetch: async (url) => {
// 加上缓存头,避免重复请求
const cached = sessionStorage.getItem(url);
if (cached) return cached;
const res = await window.fetch(url);
const text = await res.text();
sessionStorage.setItem(url, text);
return text;
}
});
2. 公共依赖抽离
所有子应用都用了React、Lodash、Axios。如果不处理,每个子应用都会打包一份,用户得下载N份相同代码。
解决方案:Webpack externals + CDN
主应用HTML里提前引入:
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
子应用webpack.config.js:
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
axios: 'axios',
lodash: '_'
}
};
结果:子应用体积平均减少45%,首屏加载从3.2s降到1.4s。
3. Loading状态精细化控制
不再用一个全局loading遮罩糊弄人。我们为每个子应用容器单独控制状态:
// 主应用中的子应用容器
const MicroAppContainer = ({ name, loading }) => {
if (loading) {
return (
<div className="subapp-skeleton">
{/* 显示骨架屏,而不是空白 */}
<div className="header-bar shimmer"></div>
<div className="content-block shimmer"></div>
</div>
);
}
return <div id={`subapp-${name}`}></div>;
};
配合Lottie动画,用户感知延迟大幅降低。有次客户体验后说:“现在感觉像是原生功能,不是外挂。”
状态共享:别再用localStorage传数据了!
早期为了图快,我们让子应用之间通过localStorage通信。结果某次双11压测,因为频繁读写localStorage导致主线程卡顿,差点酿成P0事故。
痛定思痛,我们基于qiankun的props机制 + 自定义事件总线,搞了一套轻量状态管理:
// shared-state.ts
class SharedState {
private state: Record<string, any> = {};
private callbacks: Record<string, Function[]> = {};
setState(key: string, value: any) {
this.state[key] = value;
this.notify(key);
}
getState(key: string) {
return this.state[key];
}
subscribe(key: string, callback: Function) {
if (!this.callbacks[key]) this.callbacks[key] = [];
this.callbacks[key].push(callback);
}
private notify(key: string) {
if (this.callbacks[key]) {
this.callbacks[key].forEach(cb => cb(this.state[key]));
}
}
}
export const globalState = new SharedState();
主应用初始化时注入:
registerMicroApps([...], {}, {
// 所有子应用都能通过 props.globalState 访问
globalContext: {
globalState
}
});
子应用使用:
// React子应用中
const UserInfo = () => {
const [user, setUser] = useState(null);
useEffect(() => {
const globalState = (window as any).__POWERED_BY_QIANKUN__
? props.globalState
: mockGlobalState;
const unsubscribe = globalState.subscribe('currentUser', setUser);
setUser(globalState.getState('currentUser'));
return () => unsubscribe();
}, []);
return <div>Hello, {user?.name}</div>;
};
安全、高效、可追溯。再也不用担心localStorage被其他脚本篡改了。
Lovable的关键:不是技术,是体验细节
说了这么多技术,其实让系统变得“Lovable”的,反而是些小细节:
- 错误边界隔离:子应用崩溃不影响主框架,我们给每个子应用包裹ErrorBoundary,并上报Sentry。
- 统一登录态:通过主应用拦截401,弹出统一登录框,子应用无需关心认证。
- 开发调试友好:本地启动时,通过环境变量决定是否启用微前端模式。平时开发子应用就像普通React App一样丝滑。
- TypeScript类型共享:我们把公共接口定义抽成npm包,主/子应用共同依赖,避免类型不一致。
最让我自豪的是上周五晚上,我远程连回家里的Mac,看到测试群里发消息:“AI助手模块热更新成功,用户无感知!” ——那一刻,感觉四年的外包生涯没白干。
血泪总结:微前端不是银弹
最后说点掏心窝子的话:
- 别为了微前端而微前端。如果你的团队就5个人,项目也不复杂,老老实实用Monorepo吧。
- 通信成本远高于技术成本。微前端最大的挑战其实是协调多个团队的发布节奏、接口规范、错误监控。
- 性能债迟早要还。初期图快留下的坑,后期排查起来要命。
但我们这个项目证明了:只要设计得当,微前端完全可以做到高性能、高可用、高维护性。客户最近又追加了两个新模块的需求,这次他们主动说:“按你们现在的架构来就行。”
对了,顺便提一句:那个集成ChatGPT的AI助手,现在成了客户的明星功能。每次演示,领导都要亲自点开聊两句。虽然背后是我熬了三个通宵调的WebSocket心跳重连逻辑……
但看到系统稳定运行,用户点赞,心里还是暖的。毕竟,我们写的不是代码,是别人眼中的“数字化未来”。
(完)

评论 0