一、为什么要搞定微前端里的样式和状态问题?
1.1 实际遇到的麻烦
很多公司做大一点的前端项目,会拆成几个子应用,比如电商后台的商品管理、订单处理、用户中心,之前是三个独立的代码库,后来要合并成微前端,这样可以共用一些基础库,不用每个项目都装一遍,节省空间。但上线后问题来了:商品子应用里写的按钮样式,订单子应用一引用,按钮颜色就变了,完全不符合设计稿;还有用户登录后,用户中心显示的用户名是张三,订单页面显示的用户名是李四,刷新页面后还会乱,这就是样式冲突和状态混乱的问题,得赶紧解决。
1.2 选Module Federation的原因
之前也试过别的微前端方案,比如要加一个单独的基座框架,还要学新的语法,对前端同学来说太麻烦了,就像要租房子还要先考个租房证一样。后来发现Webpack5自带的Module Federation就很好,不用额外框架,就像家里的路由器,不同的房间(子应用)都能连同一个WiFi,还能各自有自己的房间号,不会搞混,刚好适合我们的需求。
二、具体怎么用Module Federation解决?
2.1 样式隔离:给每个子应用的样式加“门牌”
样式冲突的核心是“类名撞了”,就像两个房间都叫“卧室”,隔壁进来会搞错。解决方法很简单:给每个子应用的所有样式类名都加自己的前缀,比如商品子应用的类名都叫goods-开头,订单子应用叫order-开头,再用CSS Modules把类名加一串随机的哈希后缀,这样就算两个子应用都叫button,最终生成的类名也会是goods-button-abc123和order-button-def456,绝对不会撞。
2.1.1 样式隔离的代码示例
我们拿商品子应用的Button组件举例,写法很简单:
// goods-app/src/components/GoodsButton.jsx
import styles from './GoodsButton.module.css';
// 所有类名都加goods前缀,CSS Modules自动加哈希后缀,避免冲突
export default function GoodsButton({ children, onClick }) {
return (
<button className={styles.goodsButton} onClick={onClick}>
{children}
</button>
);
}
// goods-app/src/components/GoodsButton.module.css
/* 前缀是goods,哪怕内部样式也不会和其他应用冲突 */
.goodsButton {
background: #2563eb;
color: white;
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
}
.goodsButton:hover {
background: #1d4ed8;
}
然后要配置子应用的Webpack,告诉Webpack用Module Federation暴露这个组件,同时用CSS Modules处理样式:
// goods-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: { port: 3001 }, // 子应用的端口,用来区分不同子应用
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: 'babel-loader',
},
// 处理CSS Modules,确保类名生成带哈希的唯一标识
{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: { modules: true },
},
],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'goodsApp', // 子应用的唯一名字,不能和其他应用重名
filename: 'remoteEntry.js', // 暴露的入口文件,主应用会引入这个
exposes: {
// 暴露GoodsButton组件,主应用可以直接用
'./GoodsButton': './src/components/GoodsButton.jsx',
},
shared: {
react: { singleton: true }, // 只装一次React,避免重复加载
'react-dom': { singleton: true },
},
}),
],
};
主应用的配置也很简单,引入商品子应用,这样就能用商品子应用的组件了:
// main-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: { port: 3000 }, // 主应用的端口
module: {
rules: [
{ test: /\.jsx?$/, exclude: /node_modules/, use: 'babel-loader' },
// 主应用的样式不用模块化,或者也加前缀,看需求
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
],
},
plugins: [
new ModuleFederationPlugin({
name: 'mainApp',
remotes: {
// 引入商品子应用,地址是子应用的remoteEntry.js文件
goodsApp: 'goodsApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
};
2.2 全局状态共享:用公共的“前台信息”
状态混乱的核心是“每个人都存一份用户信息”,就像公司前台的名字,每个人都自己记,有人改了别人不知道。解决方法是搞一个共享的Store,就像公司的公共前台,所有子应用都用这个前台的信息,谁改了大家都能看到,不用自己存。
2.2.1 全局状态共享的代码示例
我们做一个共享的用户Store,主应用提供,所有子应用都可以用:
// main-app/src/shared-store/userStore.js
import { createContext, useContext, useState } from 'react';
// 创建共享的Context,这是公共的用户状态,就像公司的前台
const UserContext = createContext();
// 提供状态的组件,主应用用这个包裹整个应用,所有子应用都能拿到
export function UserProvider({ children }) {
const [user, setUser] = useState({
id: 1,
name: '张三',
avatar: '/avatar.jpg',
});
// 暴露修改用户的方法,所有应用都可以调用,修改后大家同步
const updateUser = (newUser) => setUser(newUser);
return (
<UserContext.Provider value={{ user, updateUser }}>
{children}
</UserContext.Provider>
);
}
// 自定义Hook,方便子应用直接用,不用每次都引入Context
export function useUser() {
return useContext(UserContext);
}
然后要在主应用的Webpack里暴露这个共享Store,这样子应用才能引入:
// main-app/webpack.config.js,修改plugins部分
plugins: [
new ModuleFederationPlugin({
name: 'mainApp',
remotes: {
goodsApp: 'goodsApp@http://localhost:3001/remoteEntry.js',
},
exposes: {
// 暴露共享的用户Store,子应用可以引入使用
'./userStore': './src/shared-store/userStore.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
]
子应用(比如订单子应用)里用这个共享状态,不用自己维护用户信息:
// order-app/src/pages/OrderPage.jsx
// 引入主应用暴露的共享Store,注意路径
import { useUser } from 'mainApp/userStore';
// 引入商品子应用的GoodsButton组件
import GoodsButton from 'goodsApp/GoodsButton';
export default function OrderPage() {
// 直接用共享的用户状态,不用自己写逻辑
const { user, updateUser } = useUser();
// 点击按钮修改用户名字,所有应用都会同步
const handleChangeName = () => {
updateUser({ ...user, name: '李四' });
};
return (
<div>
<h1>订单页面</h1>
<p>当前用户:{user.name}</p>
<GoodsButton onClick={handleChangeName}>改名</GoodsButton>
</div>
);
}
三、这个方案的好坏分析
3.1 优点
首先,不用复杂的基座框架,前端同学只要懂Webpack和React就能上手,学习成本极低,就像买了个现成的路由器,插上线就能用;其次,样式隔离绝对靠谱,前缀+CSS Modules的组合,基本不会出现样式冲突的问题;第三,状态共享简单,一个公共Store搞定,不用再做复杂的跨应用通信,适合中等规模的项目,比如有3-5个子应用的情况;还有,Module Federation是Webpack自带的,不用额外维护依赖,减少了项目的复杂度。
3.2 缺点
首先,必须用Webpack5以上的版本,低版本的Webpack不支持Module Federation,这点要注意;其次,如果共享Store写得不好,比如随便改数据,会导致子应用之间互相影响,比如订单子应用改了用户的密码,用户中心也会跟着变,容易出问题;第三,子应用的依赖(比如React)必须是单例,不然会重复加载,占用更多内存,影响页面性能;还有,上线的时候要配置好远程子应用的地址,不然生产环境会报错。
四、要注意的坑
4.1 样式的坑
首先,所有样式类名都要加前缀,哪怕是子应用内部的样式,不要偷懒,比如子应用里的表格类,也要叫goods-table,不然内部会和自己的其他组件冲突;其次,CSS Modules的配置要对,一定要开modules选项,不然不会生成哈希类名;第三,全局样式比如reset.css,只在主应用引入,子应用不要重复引入,不然reset会覆盖主应用的样式,导致样式混乱;还有,不要在子应用里写全局样式,比如直接写table的样式,必须用CSS Modules。
4.2 状态的坑
首先,共享状态要做单向数据流,尽量只有主应用能修改共享状态,子应用只能调用主应用提供的方法修改,不要子应用自己改;其次,共享的变量要命名规范,比如叫sharedUser,不要叫user,避免子应用自己也有user变量,导致冲突;第三,要给共享状态做校验,比如修改用户信息的时候,要检查格式是否正确,不然会导致整个项目的状态混乱。
4.3 Module Federation的坑
首先,主应用和子应用的Webpack版本必须一致,不然Module Federation会抛出奇怪的错误,比如找不到remoteEntry.js;其次,暴露和引入的路径要写对,比如主应用暴露的是./src/shared-store/userStore.js,子应用引入的时候要写对名字,不要写错路径;第三,开发的时候,远程子应用的端口要正确,比如商品子应用的端口是3001,不要写成3002,不然主应用连接不上。
五、总结
微前端架构下,React子应用的样式隔离和全局状态共享是两个核心痛点,用Webpack5的Module Federation方案,能简单高效地解决这两个问题,不用额外的复杂框架,学习成本低,上手快,适合中等规模的前端项目。只要注意样式前缀、状态规范、Webpack版本等细节,就能搭建出稳定的微前端架构,避免样式冲突和状态混乱,提升开发效率,减少重复代码,让多个子应用可以协同工作,共用资源。
评论
围绕“微前端架构下React子应用间样式隔离与全局状态共享,使用Module Federation解决冲突的架构设计”参与讨论