前言
上篇文章 5个例子带你入门 Webpack5 模块联邦 Module Federation(上篇),通过两个简单例子介绍Module Federation
入门用法,本文继续介绍剩下3个使用场景及例子。
例子3:动态添加导航路由
需求是:客户想主应用上线后,不动主应用,只部署子应用就可以在主应用上添加新导航以及对应页面,实现新功能。
思路是:
- 首先现在子应用里定义俩数组,导航和路由,并且export出来;
- 然后在主应用路由文件中,动态import进来,并且渲染出来。
- 另外也可以子应用中定义一级路由,然后再在路由组件中定义更多二级路由和导航。
// sub1/src/nav.jsx
function Page1() { ... }
function Page2() { ... }
// 子应用导航数组
export const navs = [{
title: 'Sub1 nav1', // 对应导航名称
url: '/sub1/page1', // url
}, {
title: 'Sub1 nav2', // 对应导航名称
url: '/sub1/page2', // url
}]
// 子应用路由数组
export const routers = [{
exact: true,
path: '/sub1/page1',// 路由path
element: <Page1 />, // 路由组件
}, {
exact: true,
path: '/sub1/page2',// 路由path
element: <Page2 />, // 路由组件
}]
// sub1/webpack.config.js
new ModuleFederationPlugin({
...
exposes: {
...
'./nav': './src/nav.jsx',
// main/src/router.jsx
export default function RootRouter() {
const [navs, setNavs] = useState([])
const [routers, setRouters] = useState([])
useEffect(() => {
// 动态异步加载子应用navs和routers列表
import('sub1/nav').then(({ navs, routers }) => {
setNavs(navs)
setRouters(routers)
})
}, [])
return (
<BrowserRouter>
<div className='layout'>
<div className='nav'>
<NavLink to='/' className='nav-link'>Home</NavLink>
<NavLink to='/page1' className='nav-link'>Page1</NavLink>
<NavLink to='/page2' className='nav-link'>Page2</NavLink>
{/* 遍历渲染导航 */}
{navs.map((item, i) => <NavLink key={i} to={item.url} className='nav-link'>{item.title}</NavLink>)}
</div>
<div className='layout-body'>
<Routes>
<Route exact path='/' element={<Home />} />
<Route exact path='/page1' element={<Page1 />} />
<Route exact path='/page2' element={<Page2 />} />
{/* 遍历渲染路由 */}
{routers.map((item, i) => <Route key={i} {...item} />)}
</Routes>
</div>
</div>
</BrowserRouter>
)
}
效果图:
例子4:动态替换导航
需求是:客户想主应用上线后,不动主应用,只部署子应用就可以重写主应用里的部分页面逻辑,也就是替换页面。
思路是:在子应用里配置需要替换的路由path及组件,然后在主应用里取到这个替换列表,再在渲染每个路由之前判断下当前path是否在替换列表里,如果在就直接渲染子应用对应的路由组件,如果不在就还是渲染主应用的路由组件。
// sub1/src/replace.jsx
const Sub1Page1 = () => <div>Sub1 Replace Page1</div>
// 替换主应用里page1路由
export default [{
exact: true,
path: '/page1',
element: <Sub1Page1 />,
}]
// main/src/router.jsx
import replaces from 'sub1/replace';
<Routes>
{/* 外层套个自定义Wrapper,重写路由加载逻辑 */}
<Route exact path='/' element={<Wrapper replaces={replaces} />} >
<Route exact path='/' element={<Home />} />
<Route exact path='/page1' element={<Page1 />} />
<Route exact path='/page2' element={<Page2 />} />
</Route>
</Routes>
function Wrapper({ replaces }) {
const { pathname } = useLocation()
const replace = replaces.find(item => matchPath(item, pathname)) // 判断当前path是否在子应用的replace路由列表里
if (replace) {
return replace.element // 如果有,则渲染子应用里配置的路由组件
}
return <Outlet />
}
动态import
这个例子有个限制,由于主应用里不知道子应用里到底配置了哪些替换路由,所以只能在渲染路由之前把子应用配置加载出来,从上一篇文章知道,子应用得用动态import,才能避免子应用如果挂掉了影响主应用。但是动态import是异步的,所以得处理下渲染顺序。
// main/src/router.jsx
// import replaces from 'sub1/replace';
useEffect(() => {
// 动态异步加载子应用replace路由列表
import('sub1/replace').then(result => {
setReplaces(result.default)
}).catch(e => { setReplaces([]) })
}, [])
{/* 子应用replace路由列表加载完成才能渲染路由 */}
{replaces && <Routes>...
例子5:共享Common控件
这里的Common控件是指项目业务用到的公共控件或utils、常量等等,并不是指第三方库。
这种控件定义在主应用里,并以路径方式引用,然后也需要用在子应用里,有几种方案:
- Module Federation expose 暴露共享代码。
- 用 Webpack output library umd 方式把common代码单独打包,并且引用在html script里。
Module Federation expose 暴露共享代码
还是利用 Module Federation
特性,反过来,把主应用里的组件export出来,在子应用里引用:
// main/src/common/SearchButton.jsx 主应用里定义一个SearchButton控件
export default function SearchButton({ children, ...others }) {
return <Button icon={<SearchOutlined />} {...others}>{children}</Button>
}
// main/src/common/index.js 公共控件入口export
export { default as SearchButton } from './SearchButton';
// main/webpack.config.js
new ModuleFederationPlugin({
name: 'main_app', // 主应用名字
filename: 'main.js', // 编译生成的文件名,给子应用引用
exposes: {
'./common': './src/common/index.js', // 主应用common模块,给子应用引用
},
})
// sub1/replace.jsx 在上例子应用replace的组件里引用公共控件
import { SearchButton } from 'main/common';
<SearchButton>Sub1 Search Button</SearchButton>
// sub1/webpack.config.js
new ModuleFederationPlugin({
remotes: {
'main': 'main_app@http://localhost:3000/main.js', // <import时的别名>: <主应用名字@主应用common入口路径>
},
})
效果如下图,发现common控件入口文件被构建成一个独立文件。
与微前端比较
关于微前端概念:关于微前端的几大特性技术选型分析
- 微前端:比较适合一个主应用架子,没有实质性的功能内容,然后通过多个子应用以微服务形式渲染到主应用里。
- 大多基于路由,一个路由对应一个子应用;
- 大部分功能是在子应用里实现的,并且多个,得考虑子应用之间的影响,比如样式隔离、JS隔离等机制。
- 各个子应用之间独立性比较强。
- 支持(适合)多框架。
- Module Federation:比较适合想在一个功能性比较全面的主应用中,实现一些类似自定制的页面功能改动,或者功能扩展。
- 可以基于组件,也可以基于路由,但是得主应用配合改动支持子应用;
- 大部分功能是在主应用里实现的,子应用只负责部分功能的替换、重写、或者在原有功能上添加功能;
- 主应用可以独立运行,不依赖子应用;子应用依赖主应用;
- 由于主子应用依赖性比较强,如果是相同框架会有比较好的表现。
通过上面分析可以看出来:如果是在一个已有项目里支持自定制的话,Module Federation比较适合。如果是比较小、或者新开发的微前端,Module Federation也可能是一个好的选择。
待优化
子应用入口js文件hash\version缓存问题
从上篇文章里的network分析里,可以看到 http://localhost:3001/sub1.js
这个js是没有hash的,因为这个文件是在子应用里编译生成的,然后在主应用里动态加载,因为主应用不知道子应用编译出来的文件是啥名,所以不能给文件加hash。
那加version呢?也就是版本号,但是主子应用的部署是相互独立的,相互不知道对方是啥时候部署的,所以version不能写死,能起到部分作用,但依然存在缓存问题。
所以当前方案是:
- 方案1:对此文件不做缓存,或者主应用里每次服务器渲染html的时候都加上随机version,这个文件只是一个子应用的webpack模块实例化文件,用来给主应用请求加载远程依赖用的,并不会把子应用业务实现代码构建进去的,所以可以size是比较固定的,也很小,所以不用担心load效率或流量等问题。
- 方案2:无论是主应用还是子应用,如果子应用里有代码改动,每次部署时都得手动改动下主应用里加载这个js的version。
渲染效率优化
都知道Module Federation的加载远程模块是异步的,体现在两方面:
- shared的第三方包是异步的,所以主应用入口也得做成异步的,势必会影响页面渲染速度。
- 有些需求得在异步加载子应用远程模块之后,再实例化页面甚至路由,会导致页面局部渲染慢,甚至白页。
- 如果卸载子应用,只运行主应用也会多少有些影响,肯定是不如在加子应用之前的运行状态的。
以上都会导致页面渲染效率,导致白页,影响用户体验、SEO等等。这些都是必须要考虑的问题。
总结
综合上篇 5个例子带你入门 Webpack5 模块联邦 Module Federation(上篇),5个例子就介绍完了。也列举了一些 故障排除 和 简单分析,以及 待优化的问题。有些方案可能不是最优方案,希望提供的思路能帮助或启发到大家。最后列下这5个例子:
- 例子1:动态添加Tab
- 例子2:动态import
- 例子3:动态添加导航路由
- 例子4:动态替换导航
- 例子5:共享Common控件