本系列大纲
- 微前端模块联邦实践系列(1) - 微前端入门
- 微前端模块联邦实践系列(2) - 微前端与模块联邦
- 微前端模块联邦实践系列(3) - 第一个案例
- 微前端模块联邦实践系列(4) - subApp之间共享libraries
- 微前端模块联邦实践系列(5) - Path to Production
- 微前端模块联邦实践系列(6) - 集成React & Vue
- 微前端模块联邦实践系列(7) - 路由管理
- 微前端模块联邦实践系列(8) - 消息通信
目前已经完成的部分中,都还没有涉及到路由的管理,一般在大型的应用中,路由也是一个必不可少的关键角色,因此下面主要讲讲如何在模块联邦中使用路由。
微前端中,因为不限定subapp使用的技术栈,因此路由的选择也是五花八门,常见的有react-router, vue-router, angluar自身集成的router,所以我们在设计路由的时候需要满足几个条件。
- subapp 的路由不应该跟具体的某个技术栈绑定
- 每个 subapp 的路由实现机制并不能保证是一样的,因此使用起来可能会有冲突,所以在选择时应该更加依赖于底层相同的api
- subapp 不应该对地址栏进行管理,所有的地址栏更新操作都应该交由 container app 实现
- 这样做的好处是防止 subapp 和 container app 在某些情况下同时操作地址栏时带来的地址混乱
- container app 和 subapp 中应该建立某种通信机制,以便能够传递相应的路由变化
- subapp 并不一定要求有路由
- subapp 既能需要在 container 中运行,也需要能够单独运行
所以综合下来,大致是这么个图:
- 在container app中我使用了browser history,原因是container app需要管理浏览器的地址栏。
- posts app中,由于posts和对应的comments可以在单独的页面中展示完成,因此设计成了无路由。
- albums app中,由于每一个album中的photos展示了太多的内容,因此不太适合放在同一个页面,所以我拆了一个详情页来单独显示photos,history采用memoryHistory,不对地址栏进行管理,将路由缓存在内存中。
MemoryHistory
是一种 history 管理机制,通常用于 不需要真实 URL 路由的应用场景。它和浏览器的BrowserHistory
或HashHistory
不同,不依赖于浏览器地址栏的变化。MemoryHistory
存储在内存中,因此适合在 非浏览器环境 或 需要模拟导航行为 的应用中使用,比如:
- 测试环境:用于单元测试或集成测试,模拟路由切换而无需真正操作浏览器地址栏。
- React Native 应用:这类应用不运行在浏览器中,没有真实的 URL。
- 嵌入式或客户端渲染的应用:这些应用可能不需要修改浏览器地址栏的路由。
下面来看看具体的实现细节:
albums 路由配置
页面作了些许的UI部分,感兴趣可以去代码库里看。
设计路由paths
// apps/albums/src/router.js
const routes = [
{ name: 'home', path: '/albums', component: Home },
{
name: 'photos',
path: '/albums/:id/photos',
component: Photos,
props: (route) => ({ query: route.query.ids }),
},
{ name: 'not-found', path: '/:catchAll(.*)', component: NotFound },
]
const createRouterWithHistory = (history) => {
return createRouter({
history,
routes,
})
}
使用memoryHistory
// apps/albums/src/bootstrap.js
const app = createApp(App)
const history = createMemoryHistory()
const router = createRouterWithHistory(history)
app.use(router)
app.mount(el)
在bootstrap.js
中,我们将memoryHistory
传递给router,放在这里的原因原来是打算使用history监听路由变化,以便能够通知到container app,但是综合测试下来,history无法监听。这里我调试了很久,没有找到原因,可能不太熟悉vue的缘故,有缘人可以告知。
本地单独运行
使用了memoryHistory
之后,由于不操作浏览器地址,同时我们在路由中也没有配置根路由/
, 因此运行之后,发现无法跳转到albums中,所以页面会一片空白。所以我们还要兼容在albums
app单独运行时,能够使用browser history进行地址栏管理。稍微改造下
const mount = (el, { defaultHistory }) => {
// ...
const app = createApp(App)
const history = defaultHistory || createMemoryHistory()
const router = createRouterWithHistory(history)
app.use(router)
app.mount(el)
}
if (process.env.NODE_ENV === 'development') {
const root = document.getElementById('albumsRoot')
if (root) {
mount(root, { defaultHistory: createWebHistory() })
}
}
这样再请求localhost:8082
,就可以在本地看到albums页面了
跳转到photos页面出现了问题?
从albums页面中随便点击一个album进入到photos页面中,发现出现了以下错误
看到这里,不明觉厉,不过你切到network面板中,你就恍然大悟了
发现请求的main.js
竟然来自于http://localhost:8082/albums/2
下,事实上我们应该请求的地址为:http://localhost:8082/
。
原因是当前由于没有配置publicPath
,导致浏览器在请求http://localhost:8082/albums/2/photos?ids=1,2,3,4,5,6,7,8,9,10
时默认会取http://localhost:8082/albums/2/
作为当前的path,然后拼接上main.js
所以我们在webpack的配置中做一点点小小的改动即可。
// apps/albums/config/webpack.dev.js
module.exports = merge(commonConfig, {
mode: 'development',
output: {
publicPath: '/',
},
// ...
})
将publicPath
设置成/
,就可以成功看到photos页面了。
container 路由配置
在 container app中,使用了react-router,配置如下:
const AppRouter = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/" element={<Navigate to="/posts" />} />
<Route path="/posts" element={<PostsApp />} />
<Route path="/albums/*" element={<AlbumsApp />} />
</Route>
</Routes>
</BrowserRouter>
)
默认跳转到posts首页。
万事大吉了么?
通过上述配置,是否可以正常访问到 posts app以及 albums app了呢?不出意外就是出了意外,输入localhost:8080
,回车,出现以下错误
乍一看,还是不是特别明显,切到network面板瞅瞅
有没有一种很熟悉的感觉,似曾相识。localhost:8080
怎么会有vue-router
呢?这玩意不得从localhost:8082
来么?还记得我们之前在albums
app中将publicPath
改成了/
么?你或许大致就知道答案了。
所以我们还需要在albums
中做一点小小的改动,才能解决上述问题
// apps/albums/config/webpack.dev.js
module.exports = merge(commonConfig, {
mode: 'development',
output: {
publicPath: 'http://localhost:8082/',
},
// ...
})
// apps/albums/config/webpack.prod.js
module.exports = merge(commonConfig, {
mode: 'production',
output: {
publicPath: process.env.MFE_ALBUMS_DOMAIN,
},
})
将publicPath
改成对应的domain
就可以了,记得生产环境也得换成我们配置的secret
。同理,posts
以及container
app也需要相应配置,这里就不列举了。
再来试试localhost:8080
,就可以正常访问了
路由同步
通过以上步骤,我们在输入localhost:8080
就可以正常访问到posts页面了,但是是否可以正常访问albums页面呢?
404 - Not Found
切换到albums菜单栏时,出现了404,为什么?不着急看解析,这里可以停顿几秒想一想。
解析:在第一次切换到albums时,albums app中的router系统刚刚建立,所有此时的albums中的路由指向了/
路径,但是我们在albums中又没有配置次路径对应的component,所以直接跳转到了404。
通过router的beforeEach我们可以看到它最终指向的路径是什么
router.beforeEach((to, from, next) => {
console.log('albums app router beforeEach -->', 'to:', to.fullPath, ',from:', from.fullPath)
next()
})
控制台打印如下
可以看到它指向了/
,这就是为啥会跳404了。
同步container路由到albums
了解了上述问题,我们解决问题的办法就是如何同步container app的router改动到albums,这里我们采用callback的方法。
// apps/albums/src/bootstrap.js
export const mount = (el, { onSubAppRouteChange, defaultHistory }) => {
const app = createApp(App)
const history = defaultHistory || createMemoryHistory()
const router = createRouterWithHistory(history)
app.use(router)
app.mount(el)
// history.listen 不太工作
return {
onContainerRouteChange: (pathname) => {
const { location } = history
if (location !== pathname) {
router.push(pathname)
}
},
}
}
在container app挂载完albums之后,返回一个回调函数onContainerRouteChange
,目的是为了在container路由变化时能够执行此回调函数,从而在albums app中使用router
push到新的路由中。
// apps/container/src/entries/AlbumsApp.js
const AlbumsApp = () => {
const albumsRef = useRef(null)
const location = useLocation()
useEffect(() => {
if (albumsRef.current) {
const { onContainerRouteChange } = mount(albumsRef.current)
if (onContainerRouteChange) {
onContainerRouteChange(createPath(location))
}
}
}, [])
return <div className="w-full h-full" ref={albumsRef} />
}
当挂载完后,直接调用回调函数传递当前的path
给albums app。
为什么不需要history.listen
?
原因是我们在container的app中,只要路由切换了,就会重新mount新的app,所以每次这个useEffect
都会被重新执行一次,因此也就可以取到最新的location了。
记住:这里需要使用createPath
,因为子系统的路由中可能会带有query。
再次刷新,点击Albums
菜单,works!!!
同步albums路由到container
其实做到这里,所有的页面都可以work了,点击任意一个album到photos页面都是可以正常显示的,但是在进入photos页面后,导航栏的地址并没有改变。原因是从albums到photos,这里面的路由变化其实只发生在albums app内部而已,container app并不知晓。
子app的路由改变需要container感知么?如果没有多app的说法,按照正常的路由系统,一个路由被push
了,那么地址栏是需要发生改变的,但是如果从微服务的角度出发,是否需要改变,其实我觉得不一定,container其实不太需要关心子app的内部路由变化,因为对自身来说并没有多大影响。
要实现应该怎么做?我们同样也可以在container app中传递一个回调函数到albums中,但albums中的路由发生变化,则调用这个回调函数。
// apps/albums/src/bootstrap.js
export const mount = (el, { onSubAppRouteChange, defaultHistory }) => {
const app = createApp(App)
const history = defaultHistory || createMemoryHistory()
const router = createRouterWithHistory(history)
app.use(router)
app.mount(el)
if (onSubAppRouteChange) {
router.beforeEach((to, from, next) => {
onSubAppRouteChange(to.fullPath)
next()
})
}
return {
onContainerRouteChange: (pathname) => {
const { location } = history
if (location !== pathname) {
router.push(pathname)
}
},
}
}
通过beforeEach
来监听albums内部路由变化,然后传递出去交由container app中的history去改变地址栏。
// apps/container/src/entries/AlbumsApp.js
const { onContainerRouteChange } = mount(albumsRef.current, {
onSubAppRouteChange: (path) => {
history.push(path)
},
})
这里有一点需要指出的是:react-router v6版本没有导出useHistory
这个hooks,因此这里在github找了一圈,发现了一个workaround,就是手动包装一个BrowserRouter
。
// apps/container/src/components/BrowserRouter.js
const HistoryContext = createContext()
export const useHistory = () => useContext(HistoryContext)
const BrowserRouter = ({ children }) => {
const { current: history } = useRef(createBrowserHistory({ window }))
const [{ action, location }, setHistoryState] = useState({
action: history.action,
location: history.location,
})
useLayoutEffect(() => history.listen(setHistoryState), [history])
return (
<Router action={action} location={location} navigator={history}>
<HistoryContext.Provider value={history}>{children}</HistoryContext.Provider>
</Router>
)
}
好了,至此,所有关于路由的问题都已经解决了。🎉🎉🎉