React无限级菜单:一个项目带你突破技术瓶颈

有很多前端同学工作了很多年,但是感觉技术进步不是很大 ,什么原因呢?因为在工作中基本上都是在用ui框架,每天都在实现上级或老板安排的业务需求。没有对技术进行深度的思考,所以很难有所进步,那么怎么办才能让自己的技术有所进步呢?发挥自己的大脑,去封装一些公用的东西,或者去思考ui 框架中的组件是如何实现的,自己动手亲自实现下,长期这样做相信你的技术水平必然会有所提升。今天我们就通过使用React封装一个无限级的菜单来提升下自己吧!

一个常规的菜单有哪些功能呢?

  • 一般都会有一个图标,一个是标题
  • 每级菜单下面都很有可能还有子菜单,有子菜单的菜单项需要有一个箭头让用户知道下面还有子菜单
  • 点击有子菜单的菜单项,如果当前项的子菜单是收起来的,点击应该展开,如果当前项的子菜单是展开的,点击应该应该收起来
  • 点击没有子菜单的菜单项,当前项应该选中,而其他的最后一层级的菜单项选中状态应该消失,即最后一层级的选中的在同一时刻只能有一个
  • 不管点击有没有子菜单的都应该响应一个回调给用户,让用户好处理跳转后的逻辑,比如跳转,权限验证等逻辑

代码实现

1. 创建一个react 项目

npx create-react-app react-menu

2. 布局分析

要想实现无限级菜单,那么肯定要用到递归,在react 中如何实现组件的递归呢?假设现在我们有两个react组件,一个是Menu组件,一个是MenuItem组件。这两个组件要形成递归关系怎么做呢,其实很简单,在Menu组件中使用MenuItem组件,又在MenuItem组件中使用Menu组件就会形成递归调用关系了。但是递归必须要有结束条件,否则就会出现无限循环,内存溢出的情况,而在我们的无限级菜单实现的过程中有没有子菜单就是递归结束的条件。

3.根据布局分析创建代码结构

4. 布局代码实现

App.js

js 复制代码
import Menu from "./components/Menu/Menu";
import menuList from "./data/menuData";

function App() {
  return (
    <div className="App">
      <Menu menuList={menuList} />
    </div>
  );
}

export default App;

menuData.js

js 复制代码
function getImageUrl(imageName) {
  return require(`../assets/images/menu/${imageName}`);
}
const menuList = [
  {
    title: "菜单一级",
    icon: getImageUrl("menu-1.svg"),
    activeIcon: getImageUrl("menu-1-on.svg"),
    children: [
      {
        title: "菜单二级1",
        icon: getImageUrl("menu-1-1.svg"),
        activeIcon: getImageUrl("menu-1-1-on.svg"),
      },
      {
        title: "菜单二级2",
        icon: getImageUrl("menu-1-2.svg"),
        activeIcon: getImageUrl("menu-1-2-on.svg"),
      },
      {
        title: "菜单二级3",
        icon: getImageUrl("menu-1-3.svg"),
        activeIcon: getImageUrl("menu-1-3-on.svg"),
        children: [
          {
            title: "菜单三级1",
            icon: getImageUrl("menu-1-3-1.svg"),
            activeIcon: getImageUrl("menu-1-3-1-on.svg"),
          },
          {
            title: "菜单三级2",
            icon: getImageUrl("menu-1-3-2.svg"),
            activeIcon: getImageUrl("menu-1-3-2-on.svg"),
          },
        ],
      },
    ],
  },
  {
    title: "菜单一级2",
    icon: getImageUrl("menu-2.svg"),
    activeIcon: getImageUrl("menu-2-on.svg"),
    children: [
      {
        title: "菜单二级2-1",
        icon: getImageUrl("menu-2-1.svg"),
        activeIcon: getImageUrl("menu-2-1-on.svg"),
      },
    ],
  },
  {
    title: "菜单一级3",
    icon: getImageUrl("menu-3.svg"),
    activeIcon: getImageUrl("menu-3-on.svg"),
  },
  {
    title: "菜单一级4",
    icon: getImageUrl("menu-4.svg"),
    activeIcon: getImageUrl("menu-4-on.svg"),
  },
];

export default menuList;

Menu.js

js 复制代码
import React, { Component } from "react";
import MenuItem from "./MenuItem";
import "../../assets/css/menu.css";
export default class Menu extends Component {
  render() {
    let menuList = this.props.menuList;

    return (
      <ul className="menu">
        {menuList.map((item) => {
          return <MenuItem key={item.title} item={item}></MenuItem>;
        })}
      </ul>
    );
  }
}

MenuItem.js

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    return (
      <li>
        <p className={showToggleIcon}>
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
          />
        )}
      </li>
    );
  }
}

关键点分析:

  • 在MenuItem中调用了Menu,而Menu中又调用了MenuItem,这就形成了递归
  • 通过item.children && item.children.length > 0 来判断递归的结束条件和是否显示收缩的箭头

menu.css

css 复制代码
.menu,
p {
  padding:0;
  margin: 0;
}

.menu li {
  list-style: none;
  background: rgb(84, 92, 100);
  color: #fff;
  line-height: 50px;
  cursor: pointer;
  padding-left: 20px;
}
.menu li p{
  position: relative;
}

.menu li p::after {
  position: absolute;
  right: 20px;
  top: 50%;
  margin-top: -12px;
  content: '';
  width: 24px;
  height: 24px;
  background-image: url("../images/menu/menu-toggle.svg");
  background-size: contain;
}
.menu li p.hide::after {
  display: none;
}
.menu li p.show::after {
  display: block;
}

.menu-icon {
  display: inline-block;
  width: 24px;
  height: 24px;
  vertical-align: middle;
  background-size: contain;
  margin-right: 5px;
}
.menu.hide {
  display: none;
}
.menu.show{
  display: block;
}

效果展示:

5.将子菜单全收起来

一般的菜单默认都是只展示一级菜单的,二级及以下的都收起来

在MenuItem.js中 添加state, isShowSub 用来控制是否显示子菜单 在调用的Menu上添加className={this.state.isShowSub ? "show" : "hide"}

修改后的MenuItem.js如下

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    return (
      <li>
        <p className={showToggleIcon}>
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

react class不会自动传到子组件,需要手动去添加,所以还需要在Menu.js中添加 className={"menu " + (this.props.className || "")}

修改后代码如下:

js 复制代码
import React, { Component } from "react";
import MenuItem from "./MenuItem";
import "../../assets/css/menu.css";
export default class Menu extends Component {
  render() {
    let menuList = this.props.menuList;

    return (
      <ul className={"menu " + (this.props.className || "")}>
        {menuList.map((item) => {
          return <MenuItem key={item.title} item={item}></MenuItem>;
        })}
      </ul>
    );
  }
}

关键点:

当然这样改了只是判断了类名,还得添加样式,样式修改如下:

css 复制代码
.menu,
p {
  padding:0;
  margin: 0;
}

.menu li {
  list-style: none;
  background: rgb(84, 92, 100);
  color: #fff;
  line-height: 50px;
  cursor: pointer;
  padding-left: 20px;
}
.menu li p{
  position: relative;
}

.menu li p::after {
  position: absolute;
  right: 20px;
  top: 50%;
  margin-top: -12px;
  content: '';
  width: 24px;
  height: 24px;
  background-image: url("../images/menu/menu-toggle.svg");
  background-size: contain;
}
.menu li p.hide::after {
  display: none;
}
.menu li p.show::after {
  display: block;
}

.menu-icon {
  display: inline-block;
  width: 24px;
  height: 24px;
  vertical-align: middle;
  background-size: contain;
  margin-right: 5px;
}

.menu.hide {
  display: none;
}
.menu.show{
  display: block;
}

关键点:

效果展示:

可以看到现在就只展示一级的菜单了,二级及以下都收了起来。

6.点击展开收起功能

给MenuItem组件中的li下面的p添加点击事件,点击之后修改isShowSub 的状态,从而达到展开收起的功能。修改后的代码如下:

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState({
      isShowSub: !this.state.isShowSub,
    });
  }
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    return (
      <li>
        <p className={showToggleIcon} onClick={this.itemClick.bind(this)}>
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

效果展示:

可以看到,菜单的展开收缩功能就实现了。

7.给有子菜单的,且展开的添加选中样式

有子菜单的项且子菜单是展开状态的菜单标题和菜单图标,右侧的箭头都应该高亮,且右侧的箭头应该向上。 MenuItem.js修改后如下:

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState({
      isShowSub: !this.state.isShowSub,
    });
  }
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    return (
      <li>
        <p
          className={isExpand ? showToggleIcon + " active" : showToggleIcon}
          onClick={this.itemClick.bind(this)}
        >
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${isExpand ? item.activeIcon : item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

效果演示:

可以看到有子菜单的且子菜单是站看的就有选中的样式了。

8.解决选中样式没有占满一行问题

上面可以看到选中样式有了,但是有个问题,选中样式没有沾满一行,这样看上去非常不美观,且你点击左边没占满的那部分,展开收缩功能是失效的。这是什么原因呢?因为我们层级的缩进是给到li 上了,而点击事件和选中样式是给到p标签上的。所以就出现了这个问题,那么怎么解决呢?

  1. 你可以把点击事件和选中样式拿到li上面去,但是你拿过去之后会有很多问题,你点击子菜单的时候,也会触发展开收缩功能,需要给li下面的所有子菜单添加阻止冒泡事件,另外样式也需要做些调整,非常麻烦。
  2. 将层级缩进放到p标签上,将层级缩进放到p标签后,层级的缩进就不能写死了,需要根据层级来计算出缩进值。因为你直接写死的话你会发现所有的缩进都一样,分不清是多少级菜单了,就像下面这样了。

所以层级缩进放到p标签上后必须通过层级算出不同的值。这个时候我们就需要对用户传过来的数据进行格式化一下了,给每项都加上层级,level字段。在components/Menu下面新建一个utils.js

代码如下:

js 复制代码
export function formatMenu(menuList, level = 1) {
  let currMemnList = menuList.map((item) => {
    const newItem = {
      ...item,
      level,
    };
    if (item.children && item.children.length > 0) {
      newItem.children = formatMenu(item.children, level + 1);
    }
    return newItem;
  });
  return currMemnList;
}

components/Menu 继续新增一个index.js, 为什么要新增index.js呢?因为Menu组件和MenuItem组件是递归调用的,这两个组件里面对数据进行数据格式化的执行都非常不合理,因为会被执行多次。而数据格式化只需要执行一次即可。而把数据格式化交给用户来格式化也不合理,每用一下这个组价还得自己格式化一下。所以我们新建一个index.js来包一层,这样就可以实现数据格式化只执行一次。且在我们后面实现最里面层的菜单选中的时候还会遇到这个初始化的特性。

index.js 代码如下

js 复制代码
import { formatMenu } from "./utils";
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuIndex extends Component {
  render() {
    const menuList = formatMenu(this.props.menuList);
    console.log("格式化后的菜单数据:", menuList);
    return <Menu menuList={menuList} />;
  }
}

修改App.js 导入的文件路径

js 复制代码
import Menu from "./components/Menu/index";
import menuList from "./data/menuData";

function App() {
  return (
    <div className="App">
      <Menu menuList={menuList} />
    </div>
  );
}

export default App;

关键点:

查看浏览器控制台

可以看到格式化后的数据就具备了level层级字段。现在就可以根据层级字段来给p标签设置缩进了 在MenuItem组件的p标签添加 style={{ paddingLeft: item.level * 20 + "px" }}

修改后的代码如下:

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState({
      isShowSub: !this.state.isShowSub,
    });
  }
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    return (
      <li>
        <p
          className={isExpand ? showToggleIcon + " active" : showToggleIcon}
          onClick={this.itemClick.bind(this)}
          style={{ paddingLeft: item.level * 20 + "px" }}
        >
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${isExpand ? item.activeIcon : item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

查看效果:

可以看到现在就可以实现整行选中了。且缩进正常。

9.处理没有子菜单的选中逻辑

前面针对有子菜单的选中逻辑已经实现了,现在我们来实现下没有子菜单的选中逻辑。没有子菜单的选中逻辑相对要复杂一些,因为它需要将之前的没子菜单的选中状态变成默认状态,怎么处理这个问题呢?在同一层级的还好处理,在不同层级的,甚至它所在的最顶层的菜单都不是同一个菜单的,处理起来就非常麻烦,因为Menu组件和MenuItem组件它们是递归调用的,即可能是父子关系,也有可能是子父关系。要是想通过父子传递的方式,很难行得通。那么到底该怎么做呢?

其实可以通过Context来处理,创建一个context, 在index.js中提供初始值来保存选中的状态的项,并且在index.js 提供一个更改context值的方法updateValue,在点击的时候不管是在哪个层级都可以获取到context, 调用调用context上的updateValue 方法,更改当前选中状态值的context值,即可实现同一时刻只选中一个没有子菜单的菜单选中项。

components/Menu 下面创建 Context.js,代码如下:

js 复制代码
import { createContext } from "react";
export const ActiveMenuContext = createContext({});

修改components/Menu/index.js

js 复制代码
import { formatMenu } from "./utils";
import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuIndex extends Component {
  state = {
    value: "",
  };

  updateValue(value) {
    console.log("dianji upldate", value);
    this.setState({ value });
  }

  render() {
    const menuList = formatMenu(this.props.menuList);
    console.log("格式化后的菜单数据:", menuList);
    return (
      <ActiveMenuContext.Provider
        value={{
          value: this.state.value,
          updateValue: this.updateValue.bind(this),
        }}
      >
        <Menu menuList={this.props.menuList} />
      </ActiveMenuContext.Provider>
    );
  }
}

关键点:

修改components/Menu/MenuItem.js

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState(
      {
        isShowSub: !this.state.isShowSub,
      },
      () => {
        const { updateValue } = this.context;
        updateValue(this.props.item.title);
      }
    );
  }
  static contextType = ActiveMenuContext;
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    const isActiveItem = this.context.value === item.title && !isChildMenu;
    console.log(isActiveItem, item.level);
    return (
      <ActiveMenuContext.Consumer>
        {() => {
          return (
            <li>
              <p
                className={
                  isExpand || isActiveItem
                    ? showToggleIcon + " active"
                    : showToggleIcon
                }
                onClick={this.itemClick.bind(this)}
                style={{ paddingLeft: item.level * 20 + "px" }}
              >
                <i
                  className={"menu-icon"}
                  style={{
                    backgroundImage: `url(${
                      isExpand || isActiveItem ? item.activeIcon : item.icon
                    })`,
                  }}
                ></i>
                {item.title}
              </p>
              {isChildMenu && (
                <Menu
                  menuList={item.children}
                  className={this.state.isShowSub ? "show" : "hide"}
                />
              )}
            </li>
          );
        }}
      </ActiveMenuContext.Consumer>
    );
  }
}

关键点:

查看效果:

10. 给用户添加事件处理函数

现在我们点击有子菜单的项能实现展开和收缩了,也实现了没有子菜单的在同一时刻只有一个的选择逻辑,但是菜单一般都是需要跳转的,或者还要处理其他逻辑,这怎么处理比较好呢!这其实提供一个回调函数比较好,让用户自己处理这部分逻辑,因为这部分逻辑是千差万别的,在菜单这种公共逻辑里面不好处理。来看下具体实现。

在App.js中添加一个clickMenu函数,传给Menu,具体代码修改如下:

js 复制代码
import Menu from "./components/Menu/index";
import menuList from "./data/menuData";

function App() {
  function clickMenu(item) {
    // 这里处理用户点击后的逻辑
    console.log(item, "---item");
  }
  return (
    <div className="App">
      <Menu menuList={menuList} clickMenu={clickMenu} />
    </div>
  );
}

export default App;

关键点:

在components/Menu/index.js 添加clickMenu的传递

js 复制代码
import { formatMenu } from "./utils";
import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuIndex extends Component {
  state = {
    value: "",
  };

  updateValue(value) {
    console.log("dianji upldate", value);
    this.setState({ value });
  }

  render() {
    const menuList = formatMenu(this.props.menuList);
    console.log("格式化后的菜单数据:", menuList);
    return (
      <ActiveMenuContext.Provider
        value={{
          value: this.state.value,
          updateValue: this.updateValue.bind(this),
          clickMenu: this.props.clickMenu,
        }}
      >
        <Menu menuList={menuList} />
      </ActiveMenuContext.Provider>
    );
  }
}

关键点:

在MenuItem中接收事件,并在点击的时候触发,将当前点击项的数据传回给用户

js 复制代码
import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState(
      {
        isShowSub: !this.state.isShowSub,
      },
      () => {
        const { updateValue, clickMenu } = this.context;
        updateValue(this.props.item.title);
        clickMenu(this.props.item);
      }
    );
  }
  static contextType = ActiveMenuContext;
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    const isActiveItem = this.context.value === item.title && !isChildMenu;
    console.log(isActiveItem, item.level);
    return (
      <ActiveMenuContext.Consumer>
        {() => {
          return (
            <li>
              <p
                className={
                  isExpand || isActiveItem
                    ? showToggleIcon + " active"
                    : showToggleIcon
                }
                onClick={this.itemClick.bind(this)}
                style={{ paddingLeft: item.level * 20 + "px" }}
              >
                <i
                  className={"menu-icon"}
                  style={{
                    backgroundImage: `url(${
                      isExpand || isActiveItem ? item.activeIcon : item.icon
                    })`,
                  }}
                ></i>
                {item.title}
              </p>
              {isChildMenu && (
                <Menu
                  menuList={item.children}
                  className={this.state.isShowSub ? "show" : "hide"}
                />
              )}
            </li>
          );
        }}
      </ActiveMenuContext.Consumer>
    );
  }
}

关键点:

点击菜单查看效果:

可以看到在App.js中就获取到了用户点击的项,这样用户想做什么逻辑处理就可以自己处理了。到此,使用React开发一个无限级菜单就开发完成了。

总结

本篇分享了使用React 开发一个无限级菜单的过程

  • 从布局分析要实现无限级,我们就必须使用递归,并且把当前项有无子菜单了作为递归的结束条件
  • 在实现选中过程时,我们发现菜单没有选中整行,然后提供了两种解决方案,最终经过分析选择第二种方案
  • 选择第二种方案后层级缩进消失,然后我们添加了index.js进行包裹,在这里面我们进行了数据格式化,给数据添加level层级属性,用于计算当前项的层级缩进
  • 在处理点击没有子菜单项其他项需要去除选中状态的逻辑时,我们经过分析选择了Context作为解决方案,很好的解决了这个问题。
  • 为了组件的灵活性和扩展性,我们提供了点击事件处理逻辑给用户自己处理

今天的分享就到这里了,感谢收看,本篇已收录到React 知识储备专栏, 欢迎关注后续更新

相关推荐
用户461169657285 分钟前
网站 HTTPS 不稳定?证书排查指南
前端
n876517 分钟前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
keyipatience18 分钟前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
xifangge20251 小时前
基于 JavaScript 实现 CNC 数控切削参数与螺纹公差纯前端核算引擎
开发语言·前端·javascript
wangruofeng1 小时前
2026 Web 技术选型指南,AI 时代如何选择适合项目的技术栈
前端·aigc·ai编程
honkun61 小时前
vue 表格组件 vxe-table 修改滚动条样式,修改滚动条样式,统一宽度、颜色与自动隐藏
前端·javascript·vue.js
爱勇宝2 小时前
研究数字孪生的这半年,做了个 Demo,也有了一些新想法
前端·程序员
不可能片场2 小时前
工程实践 一个缓存把选片从十分钟压到一分钟
前端·electron
前端小万2 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试
FRing032 小时前
JavaScript入门到精通二十四(正则表达式、元字符、修饰符)
开发语言·前端·javascript·正则表达式