目前绝大部分单页应用都由前端控制路由,如react
可以使用react-router
、vue
可以使用vue-router
。路由库的使用已经有非常多的文章来介绍,所以本文的重点在于「基于路由配置自动生成导航菜单」,所用技术栈为react
+ react-router-dom
+ antd
。
为什么是菜单呢?
它绝大部分内容和路由是相同的,但又有部分不同,如果维护两份带来的是巨大的维护成本,只需要修改一处即可同步路由与菜单的变化。
本文目标读者为了解基本的react-router
使用,想要在项目中通过路由配置自动生成导航菜单,并正确处理菜单的高亮。
贯穿全文的需求
本文会以如下页面作为示例,逐步优化,实现我们预期的效果。

应用有四个页面,分别为「商品列表」、「商品详情」、「购物车」和「个人中心」;当访问/goods
时,菜单「商品列表」高亮,同时右侧展示商品列表;

点击商品列表中任意一条「查看详情」,则跳转至/goods/0
,同时「商品列表」仍保持高亮状态,右侧展示商品详情。
配置方式的转变
最简单的配置
在了解需求后,我们可以很快得到如下配置(线上实例):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| export default function RouterConfig() { return ( <Router> <div> <Link to="/">首页</Link>| <Link to="/goods">商品列表</Link>| <Link to="/cart">购物车</Link>| <Link to="/info">个人中心</Link> <Switch> <Route path="/" exact component={Home} /> <Route path="/goods" exact component={Goods} /> <Route path="/goods/:id" component={GoodsDetail} /> <Route path="/cart" component={Cart} /> <Route path="/info" component={Info} /> </Switch> </div> </Router> ); }
const rootElement = document.getElementById('root'); ReactDOM.render(<RouterConfig />, rootElement);
|
相比之前,react-router
更加灵活,也更易于理解了,但如果出现嵌套路由,则路由配置将分散在多处,不利于管理,以及无法自动生成菜单。
集中维护路由配置
所以还是需要以集中管理的形式来对路由进行配置,对应上面的例子,我们可以得出如下的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const routes = [ { path: '/', name: '首页', component: Home, }, { path: '/goods', name: '商品列表', component: Goods, }, { path: '/goods/:id', name: '商品详情', component: GoodsDetail, }, { path: '/cart', name: '购物车', component: Cart, }, { path: '/info', name: '个人中心', component: Info, }, ];
|
那么与之对应的路由组件为:
1 2 3 4 5 6 7 8
| <Router> <Route path="/" render={props => { return <BasicLayout {...props} routes={routes} />; }} /> </Router>
|
新增的布局概念
这里出现了BasicLayout
,就是前面提到的嵌套路由,引入的目的是为了解决部分页面布局不同的问题,如登录页不会显示菜单,而开始的配置方式,无论如何都会显示出<Link to="/">首页</Link>|
这部分组件。
将routes
传入,并在BasicLayout
组件内再做路由配置。BasicLayout
大概长这样(线上示例):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class BasicLayout extends React.Component { render() { return ( <Layout> <Sider> <Menu /> </Sider> <Header /> <Content> <Switch> {routes.map(route => { return ( <Route key={route.path} path={route.path} exact component={route.component} /> ); })} </Switch> </Content> </Layout> ); } }
|

虽然有一些理解成本,但之后只需要关心BasicLayout
组件内的<Switch>
即可。布局与面包屑导航会在另一篇博客中介绍。
生成菜单
Menu
组件位于BasicLayout
组件内,所以也可以拿到routes
,并生成对应组件即可(示例同上)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| export default class NavMenu extends React.Component { renderMenus = (routes = [], parentPath = '') => { return routes.map(route => { const path = `${parentPath}/${route.path}`.replace(/\/+/g, '/'); return ( <Menu.Item key={route.path}> <Link to={path}> <span>{route.name}</span> </Link> </Menu.Item> ); }); }; render() { const { routes } = this.props; return ( <Menu theme="dark" mode="inline"> {this.renderMenus(routes)} </Menu> ); } }
|
可以得到这样的菜单:

可以发现,虽然按照我们预期,生成了菜单,但存在一些问题
商品详情不应该展示
自动生成的「商品详情」菜单,点击后跳转到/goods/:id
这个地址,对我们来说没有任何作用,所以需要隐藏掉。
解决方法也非常简单,不渲染这条配置即可,可以判断route.path
是否包含:
符号,如果包含,就返回null
。
1 2 3
| if (route.path.indexOf(':') > -1) { return null; }
|
也可以在routes
配置中,添加hide
属性,然后在渲染时判断hide === true
,并决定是否渲染。如果我们不希望在菜单中展示「首页」,就可以通过这种方式:
1 2 3 4 5 6 7 8 9 10
| const routes = [ { path: '/', name: '首页', component: Home, hide: true, }, ];
|
菜单高亮
高亮的原理很简单,获取当前url
,判断和哪个菜单项匹配。由于使用HashRouter
,location.pathname
不能正确返回我们预期的值。当然这个问题react-router
帮我们解决了,可以通过两种方式,第一种是props
;第二种是withRouter
。
1 2 3 4 5 6 7
| <Route path="/" render={props => { return <BasicLayout {...props} routes={routes} />; }} />
|
那么先在BasicLayout
组件内打印看看这两个属性到底是什么吧

match
表示的是当前匹配到的Route
的属性,location
是当前url
的信息。
1 2 3 4
| computeSelectedMenuItem = () => { const { match } = this.props; }
|
但无论怎么切换页面,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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| constructor(props) { super(props); const { location } = props; this.state = { selectedKeys: this.computeSelectedMenuItem(location) }; } componentWillReceiveProps(nextProps) { const { location: nextLocation } = nextProps; const { location } = this.props; if (nextLocation.pathname !== location.pathname) { this.setState({ selectedKeys: this.computeSelectedMenuItem(nextLocation) }); } } computeSelectedMenuItem = location => { return [location.pathname]; };
|
至此,高亮完成了第一步,我们能发现访问「商品详情」时无法正确高亮「商品列表」,所以接下来解决这个问题。
高亮父菜单
当访问/goods/0
时,selectedKeys
中保存的是'/goods/0
,没有任何菜单的path
能正确匹配到,所以没有任何菜单高亮。
所以,我们将该路径分割,得到/goods
和/0
两部分,返回第一部分就能正确高亮了。
1 2 3 4 5 6 7 8 9
| computeSelectedMenuItem = location => { const { pathname } = location; const paths = pathname .split('/') .filter(item => item !== '') .map(path => `/${path}`); console.log(paths); return [paths[0]]; };
|
虽然满足了我们当前的用例,访问「商品详情」时高亮「商品列表」,但如果出现「子菜单」的情况呢?
将「个人中心」与「购物车」页面,放到一起作为「我的」子菜单,访问「购物车」的路径变成了/my/cart
,这种情况必然无法高亮。
修改routes
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const routes = [ { path: '/', name: '首页', component: Home }, { path: '/goods', name: '商品列表', component: Goods }, { path: '/goods/:id', name: '商品详情', component: GoodsDetail }, { path: '/my', name: '我的', children: [ { path: '/cart', name: '购物车', component: Cart }, { path: '/info', name: '个人中心', component: Info } ] } ];
|
但修改完成后,菜单只展示「我的」,而没有子菜单,因为还没有在NavMenu
组件中没有对子菜单进行渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| renderMenus = (routes = [], parentPath = '') => { return routes.map(route => { if (route.path.indexOf(':') > -1) { return null; } const hasChildMenu = route.children; if (hasChildMenu) { return ( <SubMenu title={route.name} key={route.path}> {this.renderMenus(route.children, route.path)} </SubMenu> ); } let path = `${parentPath}/${route.path}`.replace(/\/+/g, '/'); return ( <Menu.Item key={route.path}> <Link to={path}> <span>{route.name}</span> </Link> </Menu.Item> ); }); };
|
虽然菜单正常显示了,但当访问/my/cart
时,并不能正确展示Cart
组件。同样是因为在路由渲染时,没有处理「子页面」的情况。
这里特意使用了「子页面」,而不是「子路由」。因为/my
页面与/my/cart
页面不是嵌套路由的关系,并且/my
并没有实际的页面。
所以需要修改BasicLayout
组件内渲染路由的方式,本质上类似如下配置(嵌套路由的配置):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const ChildRouter = ( <Switch> <Route path="/" exact component={Home} /> <Route path="/goods" exact component={Goods} /> <Route path="/goods/:id" component={GoodsDetail} /> <Route path="/my" render={() => { return ( <Switch> {} <Route path="/my/cart" component={Cart} /> <Route path="/my/login" component={Login} /> </Switch> ); }} /> </Switch> );
export default function RouterConfig() { return ( <Router> <Route path="/" children={ChildRouter} /> </Router> ); }
|
所以我们最终的BasicLayout
是这样的(最终的代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| renderRouterConfig = (routes, parentPath = '') => { return ( <Switch> {routes.map(route => { let path = `${parentPath}/${route.path}`.replace( /\/+/g, '/' ); if (route.children) { return ( <Route key={path} path={path} render={() => { return this.renderRouterConfig( route.children, path ); }} /> ); } return ( <Route key={path} path={path} exact component={route.component} /> ); })} </Switch> ); };
|
确实发现路径/my/cart
时,「购物车」菜单项并没有高亮,所以需要修改返回的selectedKeys
,能够满足两种情况。
维护一个全局变量,保存所有的路由,即
1 2 3 4 5 6
| this.paths = { '/goods': true, '/goods/:id': true, '/my/cart': true, };
|
如果当前props.location.pathname
能够在this.paths
中找到,表示不包含动态参数,返回pathname
。如果是/goods/0
,无法匹配到,所以返回经过处理的。
1 2 3 4 5 6 7 8
| computeSelectedMenuItem = location => { const { pathname } = location; const paths = pathname .split('/') .filter(item => item !== '') .map(path => `/${path}`); return this.paths[pathname] ? [pathname] : [paths[0]]; };
|
即使是更复杂的例子,如三层菜单也能正确处理。