在很多移动端项目里,都会有一个“回到首页”的悬浮球。它通常有几个要求:

  • 全局都能看到
  • 可以随手拖动,不挡内容
  • 不会因为页面切换而消失
  • 能稳定悬在普通内容和多数遮罩层之上

这类需求看起来简单,真做起来其实有几个关键点:挂载位置、拖拽逻辑、层级控制、点击与拖动的区分
在这里插入图片描述


一、整体思路

一个通用的首页悬浮球,推荐放在应用的根布局层,而不是单页组件里。

核心原因很简单:

  • 放在根组件里,路由切换时不会被销毁
  • 可以统一控制显示隐藏
  • 方便做全局层级管理

实现上一般分四步:

  1. 在全局布局中渲染悬浮球
  2. 使用 position: fixed 固定到视口
  3. 用鼠标/触摸事件实现拖动
  4. z-index 保证它盖在大部分页面内容之上

二、为什么要放在根布局里

如果你把按钮写在某个页面里,那么跳转路由之后它很可能就没了。

所以更稳的做法是把它放到应用根部,比如:

<template>
  <div id="app">
    <FloatingHomeButton v-if="showFloatingBtn" />
    <router-view />
  </div>
</template>

这样做的好处是:

  • 只渲染一次
  • 所有页面共享同一个悬浮球
  • 页面切换不会影响位置和状态

如果还想按页面控制显示,可以监听路由:

watch: {
  $route(to) {
    this.showFloatingBtn = !['login', 'register', 'error'].includes(to.name)
  }
}

三、拖动的核心实现

拖动本质上就是三件事:

  • 记录按下时的位置
  • 计算移动偏移
  • 更新按钮坐标

1. 记录起点

onDragStart(e) {
  this.dragging = true
  this.moved = false
  this.startX = e.clientX
  this.startY = e.clientY
  this.originX = this.left
  this.originY = this.top

  document.addEventListener('mousemove', this.onDragging)
  document.addEventListener('mouseup', this.onDragEnd)
}

移动端则是:

onTouchStart(e) {
  const t = e.touches[0]
  this.dragging = true
  this.moved = false
  this.startX = t.clientX
  this.startY = t.clientY
  this.originX = this.left
  this.originY = this.top

  document.addEventListener('touchmove', this.onTouchMove, { passive: false })
  document.addEventListener('touchend', this.onTouchEnd)
}

这里的关键是:
按下时记录“起始坐标”和“按钮原始位置”,后面计算位移才有依据。


2. 计算拖动后的新位置

updatePosition(dx, dy) {
  let newLeft = this.originX + dx
  let newTop = this.originY + dy

  const maxLeft = window.innerWidth - this.buttonWidth
  const maxTop = window.innerHeight - this.buttonHeight

  if (newLeft < 0) newLeft = 0
  if (newTop < 0) newTop = 0
  if (newLeft > maxLeft) newLeft = maxLeft
  if (newTop > maxTop) newTop = maxTop

  this.left = newLeft
  this.top = newTop
}

这段代码的重点是边界限制

如果不限制,用户一拖就可能把悬浮球拖出屏幕,最后找都找不到。
所以一定要把坐标限制在:

  • left >= 0
  • top >= 0
  • left <= 屏幕宽度 - 按钮宽度
  • top <= 屏幕高度 - 按钮高度

这一步是“能拖动”变成“好用”的关键。


3. 区分拖动和点击

这是很多人容易漏掉的点。

如果按钮既能拖动又能点击,那就必须区分:

  • 轻点一下:执行“回到首页”
  • 明显拖动:只移动位置,不触发跳转

一般通过阈值判断:

if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
  this.moved = true
}

结束时再判断:

onDragEnd() {
  this.dragging = false
  document.removeEventListener('mousemove', this.onDragging)
  document.removeEventListener('mouseup', this.onDragEnd)

  if (!this.moved) {
    this.goHome()
  }
}

这个设计很重要,因为它避免了一个常见问题:

  • 用户想拖一下
  • 松手却误触跳转

加上这个判断后,交互会稳定很多。


四、移动端为什么要 preventDefault

移动端拖动时,建议在 touchmove 里阻止默认行为:

onTouchMove(e) {
  if (!this.dragging) return

  const t = e.touches[0]
  const dx = t.clientX - this.startX
  const dy = t.clientY - this.startY

  if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
    this.moved = true
  }

  this.updatePosition(dx, dy)
  e.preventDefault()
}

原因很简单:

  • 防止页面跟着一起滚动
  • 防止手势冲突
  • 提升拖拽手感

如果你做的是 H5 或移动 Web,这一步基本是必做的。


五、为什么它能悬在所有页面上方

这个问题的答案其实就两个字:定位

1. position: fixed

.floating-home-btn {
  position: fixed;
  right: 16px;
  bottom: 20%;
}

fixed 的好处是:

  • 相对浏览器视口定位
  • 不受页面滚动影响
  • 不参与普通文档流

所以它天然就是“浮起来”的。


2. z-index 足够高

.floating-home-btn {
  z-index: 3000;
}

z-index 决定了它的层级顺序。
只要它比普通页面内容更高,就能显示在上面。

不过要注意:

  • 有些弹窗、遮罩、loading 也会用很高的 z-index
  • 如果业务里层级很多,最好统一管理

比如:

.page-loading-mask {
  z-index: 4000 !important;
}

这类做法表示:
悬浮球不是绝对最高,而是通过层级体系去协调。


六、一个通用版示例

下面给一个可以直接参考的通用结构。

模板

<template>
  <div
    class="floating-home-btn"
    ref="btn"
    :style="{ left: left + 'px', top: top + 'px' }"
    @mousedown="onDragStart"
    @touchstart.prevent="onTouchStart"
    @click="goHome"
  >
    <span>首页</span>
  </div>
</template>

脚本

export default {
  data() {
    return {
      left: 300,
      top: 400,
      dragging: false,
      moved: false,
      startX: 0,
      startY: 0,
      originX: 0,
      originY: 0,
      buttonWidth: 50,
      buttonHeight: 50
    }
  },
  methods: {
    onDragStart(e) {
      this.dragging = true
      this.moved = false
      this.startX = e.clientX
      this.startY = e.clientY
      this.originX = this.left
      this.originY = this.top

      document.addEventListener('mousemove', this.onDragging)
      document.addEventListener('mouseup', this.onDragEnd)
    },
    onDragging(e) {
      if (!this.dragging) return
      const dx = e.clientX - this.startX
      const dy = e.clientY - this.startY
      if (Math.abs(dx) > 5 || Math.abs(dy) > 5) this.moved = true
      this.updatePosition(dx, dy)
    },
    onDragEnd() {
      this.dragging = false
      document.removeEventListener('mousemove', this.onDragging)
      document.removeEventListener('mouseup', this.onDragEnd)
      if (!this.moved) this.goHome()
    },
    onTouchStart(e) {
      const t = e.touches[0]
      this.dragging = true
      this.moved = false
      this.startX = t.clientX
      this.startY = t.clientY
      this.originX = this.left
      this.originY = this.top

      document.addEventListener('touchmove', this.onTouchMove, { passive: false })
      document.addEventListener('touchend', this.onTouchEnd)
    },
    onTouchMove(e) {
      if (!this.dragging) return
      const t = e.touches[0]
      const dx = t.clientX - this.startX
      const dy = t.clientY - this.startY
      if (Math.abs(dx) > 5 || Math.abs(dy) > 5) this.moved = true
      this.updatePosition(dx, dy)
      e.preventDefault()
    },
    onTouchEnd() {
      this.dragging = false
      document.removeEventListener('touchmove', this.onTouchMove)
      document.removeEventListener('touchend', this.onTouchEnd)
      if (!this.moved) this.goHome()
    },
    updatePosition(dx, dy) {
      let newLeft = this.originX + dx
      let newTop = this.originY + dy

      const maxLeft = window.innerWidth - this.buttonWidth
      const maxTop = window.innerHeight - this.buttonHeight

      newLeft = Math.max(0, Math.min(newLeft, maxLeft))
      newTop = Math.max(0, Math.min(newTop, maxTop))

      this.left = newLeft
      this.top = newTop
    },
    goHome() {
      this.$router.push({ name: 'home' })
    }
  }
}

样式

.floating-home-btn {
  position: fixed;
  width: 50px;
  height: 50px;
  right: 16px;
  bottom: 20%;
  z-index: 3000;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 8px rgba(0, 0, 0, 0.12);
  display: flex;
  align-items: center;
  justify-content: center;
  user-select: none;
  cursor: pointer;
}

七、这类方案的优点

总结一下,这种悬浮球实现方式有几个明显优势:

  • 全局常驻,路由切换不丢失
  • 交互自然,支持拖动和点击
  • 可控性强,方便做显示隐藏
  • 层级清晰,容易压住普通内容
  • 对移动端友好,体验比较稳定

如果是 H5、Vue App、政务类门户、工具类应用,这套思路都很适合。


结语

首页悬浮球看似只是一个小按钮,其实涉及了布局、事件、手势、层级和交互细节。
真正好用的实现,不是“能显示就行”,而是要做到:

  • 全局稳定出现
  • 拖动顺手
  • 点击不误触
  • 层级不打架

如果你也在做类似功能,推荐直接按“根组件挂载 + fixed 定位 + 拖拽边界限制 + 点击/拖动区分”这条路线来实现,基本就能一次做对。

更多推荐