动态tab水平菜单,这个需求很常见,特别是对于后台管理系统来说
实现的思路有点绕,有更好的姿势请留言,谢谢阅读。
只有一个时候是不允许关闭,所以也不会显示关闭的按钮,关闭其他也不会影响唯一的
tag
换行
mobx
&
mobx-react
react-router-dom v4
styled-components
react 16.4.x
antd 3.8.x
为了保持后台的风格一致化,直接基于
antd
的基础上封装一下
实现的思路基本是一样的(哪怕是自己把组件都写了)
mobx
来维护打开的菜单数据,数据用数组来维护
tab
展示页面内容,同时关联侧边栏的菜单
tab
自身可以关闭,注意规避只有一个的时候不显示关闭按钮,高亮的
tab
的时候(
tab
和路由匹配的情况),再次渲染组件
url
以外的的所有
tab
有兴趣的自行拓展,具体
idea
如下
icon
,这样把
icon
同步到水平菜单就比较好看了,目前水平都是直接写死
Model
我们要考虑这么几点
item
的的组
key
,和子
key
,子
name
以及访问的
url
action
,删除的
action
思路有了.剩下就是东西的出炉了,先构建
model
,其实就是
mobx
数据结构
import { observable, action, computed, toJS } from 'mobx';
function findObj(array, obj) {
for (let i = 0, j = array.length; i < j; i++) {
if (array[i].childKey === obj.childKey) {
return true;
return false;
class RouterStateModel {
@observable
currentUrl; // 当前访问的信息
@observable
urlHistory; // 访问过的路由信息
constructor() {
this.currentUrl = {};
this.urlHistory = [];
// 当前访问的信息
@action
addRoute = values => {
// 赋值
this.currentUrl = values;
// 若是数组为0
if (this.urlHistory.length === 0) {
// 则追加到数组中
this.urlHistory.push(this.currentUrl);
} else {
findObj(toJS(this.urlHistory), values)
? null
: this.urlHistory.push(this.currentUrl);
// 设置index为高亮路由
@action
setIndex = index => {
this.currentUrl = toJS(this.urlHistory[index]);
// 关闭单一路由
@action
closeCurrentTag = index => {
// 当历史集合长度大于一才重置,否则只剩下一个肯定保留额
this.urlHistory.splice(index, 1);
this.currentUrl = toJS(this.urlHistory[this.urlHistory.length - 1]);
// 关闭除了当前url的其他所有路由
@action
closeOtherTag = route => {
if (this.urlHistory.length > 1) {
this.urlHistory = [this.currentUrl];
} else {
return false;
// 获取当前激活的item,也就是访问的路由信息
@computed
get activeRoute() {
return toJS(this.currentUrl);
// 获取当前的访问历史集合
@computed
get historyCollection() {
return toJS(this.urlHistory);
const RouterState = new RouterStateModel();
export default RouterState;
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
// antd
import { Layout, Menu, Icon } from 'antd';
const { Sider } = Layout;
const { SubMenu, Item } = Menu;
import RouterTree, { groupKey } from 'router';
// Logo组件
import Logo from 'pages/Layout/Logo';
@inject('rstat')
@withRouter
@observer
class Sidebar extends Component {
constructor(props) {
super(props);
// 初始化置空可以在遍历不到的时候应用默认值
this.state = {
openKeys: [''],
selectedKeys: ['0'],
rootSubmenuKeys: groupKey,
itemName: ''
setDefaultActiveItem = ({ location, rstat } = this.props) => {
RouterTree.map(item => {
if (item.pathname) {
// 做一些事情,这里只有二级菜单
// 因为菜单只有二级,简单的做个遍历就可以了
if (item.children && item.children.length > 0) {
item.children.map(childitem => {
// 为什么要用match是因为 url有可能带参数等,全等就不可以了
// 若是match不到会返回null
if (location.pathname.match(childitem.path)) {
this.setState({
openKeys: [item.key],
selectedKeys: [childitem.key]
// 设置title
document.title = childitem.text;
// 调用mobx方法,缓存初始化的路由访问
rstat.addRoute({
groupKey: item.key,
childKey: childitem.key,
childText: childitem.text,
pathname: childitem.path
getSnapshotBeforeUpdate(prevProps, prevState) {
const { location, match } = prevProps;
// 重定向的时候用到
if (!prevState.openKeys[0] && match.path === '/') {
let snapshop = '';
RouterTree.map(item => {
if (item.pathname) {
// 做一些事情,这里只有二级菜单
// 因为菜单只有二级,简单的做个遍历就可以了
if (item.children && item.children.length > 0) {
return item.children.map(childitem => {
// 为什么要用match是因为 url有可能带参数等,全等就不可以了
// 若是match不到会返回null
if (location.pathname.match(childitem.path)) {
snapshop = {
openKeys: [item.key],
selectedKeys: [childitem.key]
if (snapshop) {
return snapshop;
return null;
componentDidMount = () => {
// 设置菜单的默认值
this.setDefaultActiveItem();
componentDidUpdate = (prevProps, prevState, snapshot) => {
if (snapshot) {
this.setState(snapshot);
if (prevProps.location.pathname !== this.props.location.pathname) {
this.setState({
openKeys: [this.props.rstat.activeRoute.groupKey],
selectedKeys: [this.props.rstat.activeRoute.childKey]
OpenChange = openKeys => {
const latestOpenKey = openKeys.find(
key => this.state.openKeys.indexOf(key) === -1
if (this.state.rootSubmenuKeys.indexOf(latestOpenKey) === -1) {
this.setState({ openKeys });
} else {
this.setState({
openKeys: latestOpenKey ? [latestOpenKey] : [...openKeys]
// 路由跳转
gotoUrl = (itemurl, activeRoute) => {
// 拿到路由相关的信息
const { history, location } = this.props;
// 判断我们传入的静态路由表的路径是否和路由信息匹配
// 不匹配则允许跳转,反之打断函数
if (location.pathname === itemurl) {
return;
} else {
// 调用mobx方法,缓存路由访问
this.props.rstat.addRoute({
pathname: itemurl,
...activeRoute
history.push(itemurl);
render() {
const { openKeys, selectedKeys } = this.state;
const { collapsed, onCollapse } = this.props;
const SiderTree = RouterTree.map(item => (
<SubMenu
key={item.key}
title={
<Icon type={item.title.icon} />
<span>{item.title.text}</span>
</span>
{item.children &&
item.children.map(menuItem => (
key={menuItem.key}
onClick={() => {
// 设置高亮的item
this.setState({ selectedKeys: [menuItem.key] });
// 设置文档标题
document.title = menuItem.text;
this.gotoUrl(menuItem.path, {
groupKey: item.key,
childKey: menuItem.key,
childText: menuItem.text
{menuItem.text}
</Item>
</SubMenu>
return (
<Sider
collapsible
breakpoint="lg"
collapsed={collapsed}
onCollapse={onCollapse}
trigger={collapsed}>
<Logo collapsed={collapsed} />
subMenuOpenDelay={0.3}
theme="dark"
openKeys={openKeys}
selectedKeys={selectedKeys}
mode="inline"
onOpenChange={this.OpenChange}>
{SiderTree}
</Menu>
</Sider>
export default Sidebar;
import React, { Component } from 'react';
import styled from 'styled-components';
import { withRouter } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
import { Button, Popover } from 'antd';
import TagList from './TagList';
const DynamicTabMenuCSS = styled.div`
box-shadow: 0px 1px 1px -1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12);
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
background-color: #fff;
.tag-menu {
flex: 1;
.operator {
padding:0 15px;
flex-shrink: 1;
@inject('rstat')
@withRouter
@observer
class DynamicTabMenu extends Component {
constructor(props) {
super(props);
this.state = {
closeTagIcon: false // 控制关闭所有标签的状态
// 关闭其他标签
closeOtherTagFunc = () => {
this.props.rstat.closeOtherTag();
render() {
const { rstat } = this.props;
const { closeTagIcon } = this.state;
return (
<DynamicTabMenuCSS>
<div className="tag-menu">
<TagList />
className="operator"
onClick={this.closeOtherTagFunc}
onMouseEnter={() => {
this.setState({
closeTagIcon: true
onMouseLeave={() => {
this.setState({
closeTagIcon: false
<Popover
placement="bottom"
title="关闭标签"
content={'只会保留当前访问的标签'}
trigger="hover">
<Button type="dashed" shape="circle" icon="close" />
</Popover>
</DynamicTabMenuCSS>
export default DynamicTabMenu;
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
import { Icon, Menu } from 'antd';
@inject('rstat')
@withRouter
@observer
class TagList extends Component {
constructor(props) {
super(props);
this.state = {
showCloseIcon: false, // 控制自身关闭icon
currentIndex: '' // 当前的索引
render() {
const { rstat, history, location } = this.props;
const { showCloseIcon, currentIndex } = this.state;
return (
<Menu selectedKeys={[rstat.activeRoute.childKey]} mode="horizontal">
{rstat.historyCollection &&
rstat.historyCollection.map((tag, index) => (
<Menu.Item
key={tag.childKey}
onMouseEnter={() => {
this.setState({
showCloseIcon: true,
currentIndex: tag.childKey
onMouseLeave={() => {
this.setState({
showCloseIcon: false
onClick={() => {
rstat.setIndex(index);
if (tag.pathname === location.pathname) {
return;
} else {
history.push(tag.pathname);
type="tag-o"
style={{ padding: '0 0 0 10px' }}
{tag.childText}
</span>
{showCloseIcon &&
rstat.historyCollection.length > 1 &&
currentIndex === tag.childKey ? (
type="close-circle"
style={{
position: 'absolute',
top: 0,
right: -20,
fontSize: 24
onClick={event => {
event.stopPropagation();
rstat.closeCurrentTag(index);
history.push(
rstat.activeRoute.pathname
) : null}
</Menu.Item>
</Menu>
export default TagList;
import React from 'react';
import asyncComponent from 'components/asyncComponent/asyncComponent';
// 数据分析
const Monitor = asyncComponent(() => import('pages/DashBoard/Monitor'));
const Analyze = asyncComponent(() => import('pages/DashBoard/Analyze'));
// 音频管理
const VoiceList = asyncComponent(() => import('pages/AudioManage/VoiceList'));
const CallVoice = asyncComponent(() => import('pages/AudioManage/CallVoice'));
const PrivateChat = asyncComponent(() =>
import('pages/AudioManage/PrivateChat')
const Topic = asyncComponent(() => import('pages/AudioManage/Topic'));
// APP 管理
const USERLIST = asyncComponent(() => import('pages/AppManage/UserList'));
// 安全中心
const REPORT = asyncComponent(() => import('pages/Safety/Report'));
const RouterTree = [
key: 'g0',
title: {
icon: 'dashboard',
text: '数据分析'
exact: true,
path: '/dashboard',
children: [
key: '1',
text: '数据监控',
path: '/dashboard/monitor',
component: Monitor
key: '2',
text: '数据分析',
path: '/dashboard/analyze',
component: Analyze
key: 'g1',
title: {
icon: 'play-circle',
text: '音频管理'
exact: true,
path: '/voice',
children: [
key: '8',
text: '声兮列表',
path: '/voice/sxlist',
component: VoiceList
key: '9',
text: '回声列表',
path: '/voice/calllist',
component: CallVoice
key: '10',
text: '私聊列表',
path: '/voice/privatechat',
component: PrivateChat
key: '11',
text: '热门话题',
path: '/voice/topcis',
component: Topic
key: 'g2',
title: {
icon: 'schedule',
text: '活动中心'
exact: true,
path: '/active',
children: [
key: '17',
text: '活动列表',
path: '/active/list',
component: Analyze
key: '18',
text: '新建活动',
path: '/active/add',
component: Analyze
key: 'g3',
title: {
icon: 'scan',
text: '电影专栏'
exact: true,
path: '/active',
children: [
key: '22',
text: '电影大全',
path: '/active/list',
component: Analyze
key: 'g4',
title: {
icon: 'apple-o',
text: 'APP管理'
exact: true,
path: '/appmanage',
children: [
key: '29',
text: '移动交互',
path: '/appmanage/interaction',
component: Analyze
key: '30',
text: '用户列表',
path: '/appmanage/userlist',
component: USERLIST
key: '31',
text: '用户协议',
path: '/platform/license',
component: Analyze
key: '32',
text: '帮助中心',
path: '/platform/help',
component: Analyze
key: 'g5',
title: {
icon: 'safety',
text: '安全中心'
exact: true,
path: '/safety',
children: [
key: '36',
text: '举报处理',
path: '/safety/report',
component: REPORT
key: '37',
text: '广播中心',
path: '/safety/broadcast',
component: Analyze
key: 'g6',
title: {
icon: 'user',
text: '系统设置'
exact: true,
path: '/user',
children: [
key: '43',
text: '个人设置',
path: '/user/setting',
component: Analyze
key: '44',
text: '用户列表',
path: '/user/list',
component: Analyze
export const groupKey = RouterTree.map(item => item.key);
export default RouterTree;