微前端模块联邦实践系列(7) - 路由管理

329 阅读9分钟

本系列大纲

目前已经完成的部分中,都还没有涉及到路由的管理,一般在大型的应用中,路由也是一个必不可少的关键角色,因此下面主要讲讲如何在模块联邦中使用路由。

微前端中,因为不限定subapp使用的技术栈,因此路由的选择也是五花八门,常见的有react-router, vue-router, angluar自身集成的router,所以我们在设计路由的时候需要满足几个条件。

  • subapp 的路由不应该跟具体的某个技术栈绑定
    • 每个 subapp 的路由实现机制并不能保证是一样的,因此使用起来可能会有冲突,所以在选择时应该更加依赖于底层相同的api
  • subapp 不应该对地址栏进行管理,所有的地址栏更新操作都应该交由 container app 实现
    • 这样做的好处是防止 subapp 和 container app 在某些情况下同时操作地址栏时带来的地址混乱
  • container app 和 subapp 中应该建立某种通信机制,以便能够传递相应的路由变化
  • subapp 并不一定要求有路由
  • subapp 既能需要在 container 中运行,也需要能够单独运行

所以综合下来,大致是这么个图:

router

  • 在container app中我使用了browser history,原因是container app需要管理浏览器的地址栏。
  • posts app中,由于posts和对应的comments可以在单独的页面中展示完成,因此设计成了无路由。
  • albums app中,由于每一个album中的photos展示了太多的内容,因此不太适合放在同一个页面,所以我拆了一个详情页来单独显示photos,history采用memoryHistory,不对地址栏进行管理,将路由缓存在内存中。

MemoryHistory 是一种 history 管理机制,通常用于 不需要真实 URL 路由的应用场景。它和浏览器的 BrowserHistoryHashHistory 不同,不依赖于浏览器地址栏的变化。MemoryHistory 存储在内存中,因此适合在 非浏览器环境需要模拟导航行为 的应用中使用,比如:

  • 测试环境:用于单元测试或集成测试,模拟路由切换而无需真正操作浏览器地址栏。
  • React Native 应用:这类应用不运行在浏览器中,没有真实的 URL。
  • 嵌入式或客户端渲染的应用:这些应用可能不需要修改浏览器地址栏的路由。

下面来看看具体的实现细节:

albums 路由配置

页面作了些许的UI部分,感兴趣可以去代码库里看。

albums app

设计路由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中,所以页面会一片空白。所以我们还要兼容在albumsapp单独运行时,能够使用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页面中,发现出现了以下错误

router_error

看到这里,不明觉厉,不过你切到network面板中,你就恍然大悟了

router_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页面了。

image.png

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,回车,出现以下错误

albums_public_path_error

乍一看,还是不是特别明显,切到network面板瞅瞅

albums_public_path_network_error

有没有一种很熟悉的感觉,似曾相识。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,就可以正常访问了

container_app

路由同步

通过以上步骤,我们在输入localhost:8080就可以正常访问到posts页面了,但是是否可以正常访问albums页面呢?

404 - Not Found

切换到albums菜单栏时,出现了404,为什么?不着急看解析,这里可以停顿几秒想一想。

albums_not_found

解析:在第一次切换到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()
})

控制台打印如下

albums_to_path

可以看到它指向了/,这就是为啥会跳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中使用routerpush到新的路由中。

// 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_app_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>
  )
}

好了,至此,所有关于路由的问题都已经解决了。🎉🎉🎉