给React路由加上Tabs页签效果
发布于 4 年前 作者 yunqiangwu 8111 次浏览 来自 分享

给React路由加上Tabs页签效果

很多后台管理系统中都有多Tab窗口切换多效果比如 jQadmin 中的的效果:

jQadmin github 链接

jQadmin中实现的tabs切换的效果以及很多中台后端系统中这种类似的功能大部分是通过 iframe 标签来实现的。

如果要走在 React 框架中实现这种效果,我们不能使用 iframe 来实现,react构建到大型常规应用中,一般都会有一个全局到 state 状态树,而网页到多个 frame 中 js 到执行环境是隔离的,所以没办法统一使用 Redux 这样就工具做到统一的状态管理了,这个方案pass、掉。

那么如何在React中实现Tabs效果,你可能想到到第一个方案是通过 Antd 提供到 Tabs 组件实现这个需求,但是还是会有一个问题,在 Tabs 下面切换页面是并不是通过 React-Router 切换页面到,所以这个方案 pass 掉。

解决思路

在 React-Router中 有一个 history 对象,history这个为React Router提供核心功能的包。它能轻松地在客户端为项目添加基于location的导航,这种对于单页应用至关重要的功能。

通过 history 我们可以:

  • 获取当前页面路由
  • 监听页面路由变化
  • 设置当前页面切换到另外一个页面

所以我们只要拿到history对象,我们完全可以实现 Tabs 页面切换这个功能

在 React-Router 4 中还提供一张根简单到方式获取 history 对象: withRouter高阶组件,提供了history让你使用~

import React from "react";
import {withRouter} from "react-router-dom";

class MyComponent extends React.Component {
  ...
  myFunction() {
    this.props.history.push("/some/Path");
  }
  ...
}
export default withRouter(MyComponent);

为了实现这个功能,我们可以定义一个组件 RouterTabs

RouterTabs 中可以使用 @withRouter 注入 history 。

在组件中 通过 history 监听 路由到变化,当切换到新到页面时,在RouterTabs的state中增加一个页签到数据,并把当前到路由参数也保存下来 ,并设置当前路由对于的页签为激活状态,切换到其他路由时,根据 history 的事件变更组件到状态,让组件和页面保持同步。

点击 RouterTabs 上面到标签,通过 history控制页面路由的切换。

具体实现代码如下:


import React, { Component } from 'react';
import ReactDom from 'react-dom';
import classNames from 'classnames';
import { Tag, Dropdown, Icon, Tooltip, Menu } from 'antd';
import styles from './index.less';
import {withRouter} from "react-router-dom";

const { SubMenu } = Menu;


// 模拟全局路由配置对象,
const routerCcnfig = [
    '/a': {name: 'A页面'},
    '/b': {name: 'B页面'}
];

// 通过 pathname 获取 pathname 对应到路由描述信息对象
const getTitleByPathname = (pathname) => {
    return routerCcnfig[pathname]:pathname;
}

@withRouter
export class RouterTabs extends Component {
  static unListen = null;
  static defaultProps = {
    initialValue: [],
  };

  constructor(props) {
    super(props);
    const { pathname } = this.props.location;
 
    this.state = {
      currentPageName: [], // 当前路由对应到 pathname
      refsTag: [], // tabs 所有到所有页签
      searchMap: {}, // 每个 页签对应的路由参数
    };

    this.handleMenuClick = this.handleMenuClick.bind(this);
  }

  componentDidMount() {
    if (this.unListen) {
      this.unListen();
      this.unListen = null;
    }
    // 监听路由切换事件
    this.unListen = this.props.history.listen((_location) => {
      if (this.didUnMounted) {
        return;
      }
      if (this.notListenOnce) {
        this.notListenOnce = false;
        return;
      }
      const { pathname } = _location;
      if (pathname === '/' || !getTitleByPathname(pathname)) {
        this.setState({
          currentPageName: '',
        });
        return;
      }
      const newRefsTag = [...this.state.refsTag];
      const currentPathname = pathname;
      if (newRefsTag.indexOf(currentPathname) === -1) {
        newRefsTag.push(currentPathname);
      }
      this.state.searchMap[pathname] = _location.search;
      this.setState({
        currentPageName: pathname,
        refsTag: newRefsTag,
      });
      // 假如是新的 导航item 添加进来,需要在 添加完成后调用 scrollIntoTags
      clearTimeout(this.tagChangeTimerId);
      this.tagChangeTimerId = setTimeout(() => {
        this.scrollIntoTags(pathname);
      }, 100);
    });
    const { pathname } = this.props.location;
    this.scrollIntoTags(pathname);
  }

  componentWillUnmount() {
    this.didUnMounted = true;
    if (this.unListen) {
      this.unListen();
      this.unListen = null;
    }
  }

  scrollIntoTags(pathname) {
    let dom;
    try {
      // eslint-disable-next-line react/no-find-dom-node
      dom = ReactDom.findDOMNode(this)
        .querySelector(`[data-key="${pathname}"]`);
      if (dom === null) {
        // 菜单 还没有假如 导航条(横)
      } else {
        // 菜单 已经加入 导航条(横)
        dom.scrollIntoView(false);
      }
    } catch (e) {
      // console.error(e);
    }
  }

  handleClose = (tag) => {
    const { pathname } = this.props.location;
    const { history } = this.props;
    let { currentPageName } = this.state;
    const { searchMap } = this.state;
    const newRefsTag = [...this.state.refsTag.filter(t => t !== tag)];
    if (currentPageName === tag) {
      currentPageName = this.state.refsTag[this.state.refsTag.indexOf(tag) - 1];
    }
    this.setState({
      currentPageName,
      refsTag: newRefsTag,
    });
    if (pathname !== currentPageName) {
      this.notListenOnce = true;
      history.push({
        pathname: currentPageName,
        search: searchMap[currentPageName],
      });
    }
  };

  handleClickTag = (tag, e) => {
    if (e && e.target.tagName.toLowerCase() === 'i') {
      return;
    }
    if (tag !== this.state.currentPageName) {
      this.props.history.push({
        pathname: tag,
        search: this.state.searchMap[tag] ? this.state.searchMap[tag].replace(/from=[^&]+&?/, '') : undefined,
      });
    }
  }

  handleMenuClick = (e) => {
    const eKey = e.key;
    let currentPathname = this.props.location.pathname;
    const { refsTag } = this.state;

    let newRefsTag;
    if (eKey === '1') {
      newRefsTag = '/';
      currentPathname = '首页';
    } else if (eKey === '2') {
      newRefsTag = [webConfig.indexPath];
      if (currentPathname !== webConfig.indexPath) {
        newRefsTag.push(currentPathname);
      }
    } else {
      this.handleClickTag(eKey);
      return;
    }
    if (currentPathname !== this.state.currentPageName) {
      this.props.history.push({
        pathname: currentPathname,
        search: this.state.searchMap[currentPathname],
      });
    }
    this.setState({
      refsTag: newRefsTag,
    });
  }

  render() {
    const { currentPageName, refsTag } = this.state;
    const { className, style } = this.props;
    const cls = classNames(styles['router-tabs'], className);

    const tags = refsTag.map((pathname, index) => {
      const routeInfo = getTitleByPathname(pathname); // 这里假设每个pathname都能获取到指定到页面名称
      let title = routeInfo ? routeInfo.name : '404';
      const isLongTag = title.length > 30;
      const tagElem = (
        <Tag
          key={pathname}
          data-key={pathname}
          className={classNames(styles.tag,
            { [styles.active]: pathname === currentPageName })}
          onClick={e => this.handleClickTag(pathname, e)}
          closable={index !== 0}
          afterClose={() => this.handleClose(pathname)}
        >
          <span className={styles.icon} />{isLongTag ? `${title.slice(
            0, 30)}...` : title}
        </Tag>
      );
      return isLongTag
        ? <Tooltip title={title} key={`tooltip_${pathname}`}>{tagElem}</Tooltip>
        : tagElem;
    });
    this.tags = tags;
    /* eslint-disable */
    return (
      <div className={cls} style={{
        ...style,
        height: '40px',
        maxHeight: '40px',
        lineHeight: '40px',
        marginRight: '-12px',
      }}>
        <div style={{
          flex: '1',
          height: '40px',
          position: 'relative',
          overflow: 'hidden',
          background: '#f0f0f0',
          padding: '0px 0px',
        }}>
          <div style={{
            position: 'absolute',
            whiteSpace: 'nowrap',
            width: '100%',
            top: '0px',
            padding: '0px 10px 0px 10px',
            overflowX: 'auto',
          }}>
            {tags}
          </div>
        </div>
        <div style={{
          width: '96px',
          height: '100%',
          background: '#fff',
          boxShadow: '-3px 0 15px 3px rgba(0,0,0,.1)',
        }}>
          <Dropdown overlay={<Menu onClick={this.handleMenuClick}>
            <Menu.Item key="1">关闭所有</Menu.Item>
            <Menu.Item key="2">关闭其他</Menu.Item>
            <SubMenu title="切换标签">
              {
                tags.map(item => (<Menu.Item key={item.key}>{item.props.children}</Menu.Item>))
              }
            </SubMenu>
          </Menu>}
          >
            <Tag size={'small'} color="#2d8cf0"
              style={{ marginLeft: 12 }}>
              标签选项 <Icon type="down" />
            </Tag>
          </Dropdown>
        </div>
      </div>
    );
  }
}


样式定义:


.router-tabs {
  user-select: none;
  display: flex;
  position: relative;
  overflow: hidden;
  transition: all .3s;
  box-shadow: rgba(0, 0, 0, 0.4) 0 1px 1px 0, inset rgba(0, 0, 0, 0.52) 0 5px 2px;
  clear: both;


  .tag {
    margin-top: 4px;
    height: 32px;
    line-height: 32px;
    border: 1px solid #e9eaec!important;
    color: #495060!important;
    background: #fff!important;
    padding: 0 12px;

    .icon {
      display: inline-block;
      width: 12px;
      height: 12px;
      margin-right: 8px;
      border-radius: 50%;
      background: #e9eaec;
      position: relative;
      top: 1px;
    }
    &.active {
      .icon {
        background: #2d8cf0;
      }
    }
  }
  
}

使用方法

只要把组件放到页面可以实现 Tabs 页面切换到效果了

render() {
    return (
        <Layout>
            <Header className={styles.header}>
                <RouterTabs />
            </Header>
            <Content>
                  <Switch>
                    { routers }
                    <Redirect exact from="/" to={webConfig.indexPath} />
                    <Route component={NotFound} />
                  </Switch>
            </Content>
        </Layout>
    );
  }

预览效果

3 回复

不做前端好多年,跟不上节凑了

但是tab之间的切换 怎么保存状态呢

使用新版antd 有个bug onClose={(e) => this.handleClose(pathname,e)} 需要在handleClose 里阻止冒泡

回到顶部