悬浮菜单项目.jpg

这是我前段时间写公司项目里面的实践,在此之前我还确实没写过类似的东西,写出这篇文章的目的就是做个详细的编写思路和过程吧!

为了准备这篇文章,我还单独重新写了一次,编写过程还在 B 站全程直播了,然而因为 OBS 的设置问题,导致直播画面显示异常,录像也没有成功留下,最后就只有一个差不多完成了的源码,实属可惜。

确认需求

用 JS 做交互,首先要确认大概要实现什么样的效果。在这里,我们需要实现一个鼠标移入 Item 之后弹出一个菜单的功能,鼠标离开 Item 和菜单均会让菜单消失。

确认实现方式

这个需求里面,每个 Item 展开的菜单项都是一样的,唯独不同的就是点击之后跳转的页面或是传参。这个功能最简单的实现就是已 Item 作为父元素(position: relative)然后给每一个 Item 下都写一个菜单的 DOM 结构,再使用 position: absolute 定位,根据父元素的 Hover 状态(.site-item:hover > .action-menu)展开菜单。

这样不太好的地方就是 DOM 太多太复杂,而且需要大量的遍历和 onClick 事件。因此我打算尝试一套船新的方案,即菜单的 DOM 只有一个,根据 Item 的鼠标移入,改变菜单的坐标,实现菜单的操作。这样就只需要给一个元素实现点击事件,点击之后干什么,也就只需要改改临时变量就完事了。

这个方案实现的主要难点,其实就是坐标的算法了,除此之外没有什么麻烦的地方了。

前期准备

先写好 HTML 和 CSS,一个容器里面放着多个站点(Item),鼠标移入每一个站点的时候,展示菜单。

<body>
  <div class="site">
    <div class="site-item">
      <span>保</span>
      <p>保罗的小窝</p>
    </div>
    <div class="site-item">
      <span>保</span>
      <p>保罗 API</p>
    </div>
    <div class="site-item">
      <span>奇</span>
      <p>奇趣起始页</p>
    </div>
    <div class="site-item">
      <span>奇</span>
      <p>奇趣框架</p>
    </div>
    <div class="site-item">
      <span>奇</span>
      <p>奇趣播放器</p>
    </div>
    <div class="site-item">
      <span>方</span>
      <p>方块播放器</p>
    </div>
  </div>

  <div class="float-menu">
    <a>管理此网站</a>
    <a>删除此网站</a>
  </div>
</body>
body {
  padding: 3em;
  font-family: sans-serif;
}

a {
  color: #3498db;
  text-decoration: none;
}

.site {
  display: grid;
  grid-gap: 1em;
  text-align: center;
  grid-template-columns: repeat(auto-fill, minmax(10em, 1fr));
}

.site-item {
  display: block;
}
.site-item span {
  width: 3em;
  height: 3em;
  line-height: 3;
  color: #fff;
  display: inline-block;
  font-size: 3em;
  cursor: pointer;
  background: #ccc;
  border-radius: 6em;
  user-select: none;
}

.float-menu {
  box-shadow: 0 0 1em rgba(0, 0, 0, 0.2);
  position: absolute;
  border-radius: 0.5em;
  padding: 0.5em 0;
  visibility: hidden;
  background: #fff;
  z-index: 1;
}
.float-menu.active {
  visibility: visible;
}

.float-menu a {
  color: inherit;
  display: block;
  cursor: pointer;
  padding: 0.5em 1.5em;
  transition: color 0.3s, background 0.3s;
}
.float-menu a:hover {
  color: #fff;
  background: #3498db;
}

开始起码

显示菜单

然后就可以开始写 JS 了,先让鼠标移入 Item 的时候能把菜单展示出来:

let menu = document.querySelector(".float-menu");
let items = document.querySelectorAll(".site-item");

function item_enter(){
  menu.classList.add("active"); // 让 menu 显示出来,主要是修改了 visibility 属性
}

items.forEach(item => {
  item.onmouseenter = item_enter;
})

坐标算法

接下来就是把坐标安排上了,我们要拿到 Item 当前在屏幕上的 X 和 Y 坐标,分别是使用 offsetTopoffsetWidth 属性,对应 Item 左上角的位置。我们如果要让横向坐标相对于 Item 居中,那么就得改改算法了,具体计算公式可以参考代码和图片说明。得到坐标之后不要忘记 px 单位喔!

function item_enter(ev){
  menu.classList.add("active"); // 让 menu 显示出来,主要是修改了 visibility 属性

  menu.style.top = ev.target.offsetTop + ev.target.offsetHeight - 25 + "px"; // 纵向坐标
  menu.style.left = ev.target.offsetLeft + ev.target.offsetWidth / 2 - (menu.offsetWidth / 2) + "px"; // 横向坐标
}

悬浮菜单项目-纵坐标.jpg

悬浮菜单项目-横坐标.jpg

制作这个图片还挺费时间的,且看且珍惜啊~

隐藏菜单

坐标搞定之后,那么就该处理让菜单消失的逻辑了。给 Item 增加 onmouseleave 事件,让菜单离开 Item 就消失。在此之前我用了错误的 onmouseout 事件,这个事件的机制是离开 Item 本身就算是“移出”,鼠标移入 Item 的子元素(span 和 p)都会触发,很明显是不符合我们需求的。

function item_leave(){
  menu.classList.remove("active"); // 让 menu 隐藏,你会发现菜单还没进去就消失了
}

items.forEach(item => {
  item.onmouseenter = item_enter;
  item.onmouseleave = item_leave;
})

写完之后发现,菜单确实可以展示和消失了,但是我鼠标移入菜单,它也会消失,为什么呢?

这其实是因为我们设定的是 Item 的移出事件,我们并不清楚「有没有进入过菜单」的使用情形。所以说得在鼠标移入菜单之前,需要给个很短的时间缓冲(setTimeout),如果确实没移入我们再去隐藏菜单。

设置延迟

设置延迟的位置,主要是在 item_leave 函数里面,因为我们离开了 Item 的时候,有那么一瞬间可能会进入菜单,所以在这里写延迟函数,当然,先把延迟函数本身的存储变量在函数外定义一下。

let timer, menu_entered = false; // 定时器临时变量,还有是否进入菜单的状态记录

function item_leave(){
  timer = setTimeout(() => {
    // 如果没有进入过菜单,才让菜单隐藏
    if(!menu_entered) menu.classList.remove("active");

    // 活干完了,要把定时器取消掉,还原变量
    timer = clearTimeout(timer);
  }, 100);
}

然后进入菜单的时候,把 menu_entered 标记为 true,离开菜单的时候,把定时器干掉(因为下次访问也需要干净的状态)把 menu_entered 还原为 false,并且隐藏菜单(离开菜单是绝对需要隐藏菜单的,如果没进入过菜单,是不可能离开菜单的)

function menu_enter() {
  menu_entered = true;
}

function menu_leave() {
  menu_entered = false; // 还原状态

  timer = clearTimeout(timer); // 清除定时器

  menu.classList.remove("active"); // 隐藏菜单
}

menu.onmouseenter = menu_enter;
menu.onmouseleave = menu_leave;

基本上,这个逻辑就写完了,我们来测试一下程序运行实际情况:

咦,为什么第二次把鼠标移入 Item 的时候,菜单会隐藏呢?

这其实是因为 setTimeout 没有被清除导致的,因为我们只在移出菜单的 menu_leave 里面强制清除了定时器,而定时器本身只到延时结束了才去清除状态,就在这短短的间隙时间里面,我们很有可能会不进入菜单,而切换到其他的 Item,从而出现了上述视频的情况。

解决办法,就是让进入 Item 的时候,强制清除一次定时器,如果它没有被清除的话:

function item_enter(ev){
  if(timer) timer = clearTimeout(timer); // 只要上次的定时器没清除,就清除掉,防止菜单莫名其妙隐藏

  menu.classList.add("active"); // 让 menu 显示出来,主要是修改了 visibility 属性

  menu.style.top = ev.target.offsetTop + ev.target.offsetHeight - 25 + "px"; // 纵向坐标
  menu.style.left = ev.target.offsetLeft + ev.target.offsetWidth / 2 - (menu.offsetWidth / 2) + "px"; // 横向坐标
}

判断条件放上去之后,一切效果都正常了!

菜单项的执行

菜单项目的执行,可以直接先定义一个函数,再去取对应 Item 的 dataset 就可以搞定。

function menu_item_manage(ev){
   // 点击按钮跳转对应页面
  if(url) window.open(url);
}

menu.children[0].onclick = menu_item_manage;

如何确定我当前处于哪个元素呢,你可以在鼠标移入到 Item 的时候(item_enter)存一次对应的状态,离开的时候清除一次。

let timer, menu_entered = false, url;

function item_enter(ev) {
  if (timer) timer = clearTimeout(timer); // 只要上次的定时器没清除,就清除掉,防止菜单莫名其妙隐藏

  url = ev.target.dataset.url;

  ...
}

function item_leave() {
  timer = setTimeout(() => {
    if(!menu_entered){
      menu.classList.remove("active");

      url = undefined; // 离开的时候清除,要放在 menu_entered 里面
    }

    // 活干完了,要把定时器取消掉,还原变量
    timer = clearTimeout(timer);
  }, 100);
}

function menu_leave() {
  ...

  url = undefined;

  ...
}

全篇源码可以从下面的 iframe 处访问:

总结

以上就是本次分享的主要内容,可以看出 setTimeout 在利用 DOM 事件实现页面交互的重要性之高,实现其他类似的方式,这也是一个很好的参考模型,这次的教程你 Get 到了吗?