目前绝大部分单页应用都由前端控制路由,如react
可以使用react-router
、vue
可以使用vue-router
。路由库的使用已经有非常多的文章来介绍,所以本文的重点在于「基于路由配置自动生成导航菜单」,所用技术栈为react
+ react-router-dom
+ antd
。
为什么是菜单呢?
它绝大部分内容和路由是相同的,但又有部分不同,如果维护两份带来的是巨大的维护成本,只需要修改一处即可同步路由与菜单的变化。
本文目标读者为了解基本的react-router
使用,想要在项目中通过路由配置自动生成导航菜单,并正确处理菜单的高亮。
贯穿全文的需求
本文会以如下页面作为示例,逐步优化,实现我们预期的效果。
应用有四个页面,分别为「商品列表」、「商品详情」、「购物车」和「个人中心」;当访问/goods
时,菜单「商品列表」高亮,同时右侧展示商品列表;
点击商品列表中任意一条「查看详情」,则跳转至/goods/0
,同时「商品列表」仍保持高亮状态,右侧展示商品详情。
配置方式的转变
最简单的配置
在了解需求后,我们可以很快得到如下配置(线上实例):
1 | export default function RouterConfig() { |
相比之前,react-router
更加灵活,也更易于理解了,但如果出现嵌套路由,则路由配置将分散在多处,不利于管理,以及无法自动生成菜单。
集中维护路由配置
所以还是需要以集中管理的形式来对路由进行配置,对应上面的例子,我们可以得出如下的配置:
1 | const routes = [ |
那么与之对应的路由组件为:
1 | <Router> |
新增的布局概念
这里出现了BasicLayout
,就是前面提到的嵌套路由,引入的目的是为了解决部分页面布局不同的问题,如登录页不会显示菜单,而开始的配置方式,无论如何都会显示出<Link to="/">首页</Link>|
这部分组件。
将routes
传入,并在BasicLayout
组件内再做路由配置。BasicLayout
大概长这样(线上示例):
1 | class BasicLayout extends React.Component { |
虽然有一些理解成本,但之后只需要关心BasicLayout
组件内的<Switch>
即可。布局与面包屑导航会在另一篇博客中介绍。
生成菜单
Menu
组件位于BasicLayout
组件内,所以也可以拿到routes
,并生成对应组件即可(示例同上)。
1 | export default class NavMenu extends React.Component { |
可以得到这样的菜单:
可以发现,虽然按照我们预期,生成了菜单,但存在一些问题
- 商品详情不应该展示
- 没有高亮
商品详情不应该展示
自动生成的「商品详情」菜单,点击后跳转到/goods/:id
这个地址,对我们来说没有任何作用,所以需要隐藏掉。
解决方法也非常简单,不渲染这条配置即可,可以判断route.path
是否包含:
符号,如果包含,就返回null
。
1 | if (route.path.indexOf(':') > -1) { |
也可以在routes
配置中,添加hide
属性,然后在渲染时判断hide === true
,并决定是否渲染。如果我们不希望在菜单中展示「首页」,就可以通过这种方式:
1 | const routes = [ |
菜单高亮
高亮的原理很简单,获取当前url
,判断和哪个菜单项匹配。由于使用HashRouter
,location.pathname
不能正确返回我们预期的值。当然这个问题react-router
帮我们解决了,可以通过两种方式,第一种是props
;第二种是withRouter
。
1 | <Route |
那么先在BasicLayout
组件内打印看看这两个属性到底是什么吧
match
表示的是当前匹配到的Route
的属性,location
是当前url
的信息。
1 | computeSelectedMenuItem = () => { |
但无论怎么切换页面,match.path
都是/
,而不是我们预期的/goods
或者/goods/:id
。
答案也很简单,因为BasicLayout
对应的Route
的path
就是/
。
实际上,react-router
的原理就是,Route
组件获取当前url
,与自身path
对比,如果匹配,就显示自身,否则就不显示。
对应到我们的应用中,就是<Route path="/"
这个组件,计算当前/goods
匹配自身成功,所以显示出BasicLayout
组件。而组件内的<Route path="/goods"
组件,也认为当前url
匹配自身,所以展示Goods
组件。
所以我们暂时先通过props.location.pathname
实现高亮。将location
传给NavMenu
组件,并在组件内维护selectedKeys
变量,保存当前选中的菜单项的key
,传给Menu
就 OK 啦(示例)!
1 | constructor(props) { |
至此,高亮完成了第一步,我们能发现访问「商品详情」时无法正确高亮「商品列表」,所以接下来解决这个问题。
高亮父菜单
当访问/goods/0
时,selectedKeys
中保存的是'/goods/0
,没有任何菜单的path
能正确匹配到,所以没有任何菜单高亮。
所以,我们将该路径分割,得到/goods
和/0
两部分,返回第一部分就能正确高亮了。
1 | computeSelectedMenuItem = location => { |
虽然满足了我们当前的用例,访问「商品详情」时高亮「商品列表」,但如果出现「子菜单」的情况呢?
将「个人中心」与「购物车」页面,放到一起作为「我的」子菜单,访问「购物车」的路径变成了/my/cart
,这种情况必然无法高亮。
修改routes
:
1 | const routes = [ |
但修改完成后,菜单只展示「我的」,而没有子菜单,因为还没有在NavMenu
组件中没有对子菜单进行渲染。
1 | renderMenus = (routes = [], parentPath = '') => { |
虽然菜单正常显示了,但当访问/my/cart
时,并不能正确展示Cart
组件。同样是因为在路由渲染时,没有处理「子页面」的情况。
这里特意使用了「子页面」,而不是「子路由」。因为
/my
页面与/my/cart
页面不是嵌套路由的关系,并且/my
并没有实际的页面。
所以需要修改BasicLayout
组件内渲染路由的方式,本质上类似如下配置(嵌套路由的配置):
1 | const ChildRouter = ( |
所以我们最终的BasicLayout
是这样的(最终的代码):
1 | renderRouterConfig = (routes, parentPath = '') => { |
确实发现路径/my/cart
时,「购物车」菜单项并没有高亮,所以需要修改返回的selectedKeys
,能够满足两种情况。
维护一个全局变量,保存所有的路由,即
1 | // 在生成菜单项时新增 |
如果当前props.location.pathname
能够在this.paths
中找到,表示不包含动态参数,返回pathname
。如果是/goods/0
,无法匹配到,所以返回经过处理的。
1 | computeSelectedMenuItem = location => { |
即使是更复杂的例子,如三层菜单也能正确处理。