前言
动态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
同步到水平菜单就比较好看了,目前水平都是直接写死 - 加上水波纹动效,目前没有..就是MD风格点一下扩散那种
- 拖拽,这样可以摆出更符合自己使用习惯的水平菜单
- 固定额外不被消除的标签,类似chrome的固定,不会给关闭所有干掉
代码实现
RouterStateModel.js(mobx状态维护)
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;
Sidebar.js(侧边栏组件)
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={ <span> <Icon type={item.title.icon} /> <span>{item.title.text}</span> </span> }> {item.children && item.children.map(menuItem => ( <Item 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} /> <Menu subMenuOpenDelay={0.3} theme="dark" openKeys={openKeys} selectedKeys={selectedKeys} mode="inline" onOpenChange={this.OpenChange}> {SiderTree} </Menu> </Sider> ); } } export default Sidebar;
DynamicTabMenu.js(动态菜单组件)
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 /> </div> <div 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> </div> </DynamicTabMenuCSS> ); } } export default DynamicTabMenu;
TagList(标签页)
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); } }}> <span> <Icon type="tag-o" style={{ padding: '0 0 0 10px' }} /> {tag.childText} </span> {showCloseIcon && rstat.historyCollection.length > 1 && currentIndex === tag.childKey ? ( <Icon 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;
RouterTree
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;