Skip to content

出现需求

需要在文档展示多张照片时,在移动端会竖直依此排列,影响阅读体验。

准备工作

首先,确认 vitepress 项目的组件导入方式:

.vitepress/theme/index.js 中注册组件 & 导入全局样式

js
// .vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
// 自定义组件导入
import ImgScroller from './components/ImgScroller.vue'
import './custom.css' // 可选:用于覆盖 theme 层级的全局样式

export default {
  extends: DefaultTheme,
  enhanceApp ({ app }) {
    // 全局注册组件(在 markdown 中直接使用 <ImgScroller>)
    app.component('ImgScroller', ImgScroller)
  }
}

说明:使用 extends: DefaultTheme + enhanceApp 可以在不重写整个主题的情况下注入全局组件和样式。VitePress+1

你也可以把 CSS 直接放到 ImgScroller.vue 中。

封装

创建组件:

vue
<template>
  <div class="img-scroll-wrapper" ref="wrapper">
    <!-- 提示框 -->
    <div :class="['swipe-hint', { show: showHint }]">
      <!-- slot 提示 -->
      <slot name="hint" :type="hintType">
        <!-- 默认提示文字(如果外部没定义 slot) -->
        <span v-if="hintType === 'start'">👉 向右滑动查看更多图片</span>
        <span v-else>这是最后一张图片了</span>
      </slot>
    </div>

    <!-- 滑动容器 -->
    <div class="img-scroll-inner" ref="scroller" @scroll="onScroll">
      <slot />
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onBeforeUnmount } from 'vue'

export default {
  name: 'ImgScroller',
  props: {
    autoHideMs: { type: Number, default: 2500 }, // 提示自动隐藏时间(ms)
    mobileMaxWidth: { type: Number, default: 768 } // 认为是移动端的宽度阈值
  },
  setup(props) {
    const scroller = ref(null)
    const wrapper = ref(null)
    const showHint = ref(false)
    const hintType = ref('start') // 'start' | 'end'

    let hideTimer = null
    let showDelayTimer = null
    let atEndHintShown = false

    const isMobile = () => window.innerWidth <= props.mobileMaxWidth

    // 显示提示框并自动隐藏
    const startHintTimer = (type) => {
      if (!isMobile()) return
      clearTimeout(showDelayTimer)
      clearTimeout(hideTimer)

      hintType.value = type

      // 先延迟1.5秒显示提示
      showDelayTimer = setTimeout(() => {
        showHint.value = true
        // 显示后再等 autoHideMs 毫秒隐藏
        hideTimer = setTimeout(() => {
          showHint.value = false
        }, props.autoHideMs)
      }, 1500)
    }
    
    const isScrollAtEnd = () => {
      if (!scroller.value) return false
      const el = scroller.value
      return el.scrollWidth - el.scrollLeft - el.clientWidth < 10
    }

    // onScroll 是绑定在滑动容器 .img-scroll-inner 的 @scroll 事件上的
    // 当用户横向滚动时,它会被触发
    const onScroll = () => {
      if (isScrollAtEnd()) {
        if (!atEndHintShown) {
          startHintTimer('end')
          atEndHintShown = true
        }
      } else {
        atEndHintShown = false
      }
    }

    onMounted(() => {
      if (isMobile()) {
        const observer = new IntersectionObserver(
          (entries) => {
            if (entries[0].isIntersecting) {
              startHintTimer('start')
              observer.disconnect()
            }
          },
          { threshold: 0.1 }
        )
        if (wrapper.value) {
          observer.observe(wrapper.value)
        }
      }
    })

    onBeforeUnmount(() => {
      clearTimeout(hideTimer)
      clearTimeout(showDelayTimer)
    })

    return { scroller, wrapper, showHint, onScroll, hintType }
  }
}
</script>

<style scoped>
/* 滑动容器样式 */
.img-scroll-inner {
  height: 350px;
  display: flex;
  gap: 10px;
  overflow-x: auto;   /* 横向滚动 */
  overflow-y: hidden; /* 隐藏垂直滚动条 */
  scroll-snap-type: x mandatory; /* 可选:让滑动对齐 */
  -webkit-overflow-scrolling: touch; /* iOS 惯性滑动 */
  padding-bottom: 10px; /* 防止滚动条遮住内容 */
}

/* 图片样式 */
/* 穿透 slot 内 img 元素 */
::v-deep(.img-scroll-inner img) {
  flex: 0 0 auto;
  scroll-snap-align: start;
  border-radius: 4px;
}

/* 隐藏滚动条(可选) */
/* .img-scroll::-webkit-scrollbar {
  display: none;
} */

.img-scroll-wrapper {
  position: relative; /* 新增,建立定位上下文 */
}


/* 优化的 Toast 样式提示 */
.swipe-hint {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  max-width: 80vw;
  padding: 10px 20px;
  background: rgba(58, 58, 58, 0.9);
  color: white;
  font-size: 16px;
  border-radius: 24px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  pointer-events: none;
  opacity: 0;
  transition: opacity 1.2s ease;
  z-index: 1000;
  user-select: none;
  white-space: nowrap;
}

.swipe-hint.show {
  opacity: 1;
}
</style>

其中:

html
<!-- 默认提示文字(如果外部没定义 slot) -->
<span v-if="hintType === 'start'">👉 向右滑动查看更多图片</span>
<span v-else>这是最后一张图片了</span>
</slot>

它的作用是:

  • 作为插槽的默认内容,当父组件没有传入名为 hint 的具名插槽时,这部分内容就会显示。
  • 也就是说,这相当于给插槽提供了一个“默认模板”,确保组件即使外部没有传自定义内容,也能正常显示提示文字。
详解判断是否滚动到末尾的逻辑
const isScrollAtEnd = () => {
  if (!scroller.value) return false
  const el = scroller.value
  return el.scrollWidth - el.scrollLeft - el.clientWidth < 10
}

拆解一下这段逻辑:

  1. el.scrollWidth

    • 内容的总宽度(包括不可见的部分)。
  2. el.scrollLeft

    • 当前已经水平滚动的距离。
  3. el.clientWidth

    • 容器本身的可视宽度。
  4. 末尾判断公式

    el.scrollWidth - el.scrollLeft - el.clientWidth
    • 这个值表示剩余可滚动的距离
    • 如果这个值 小于 10 像素< 10),就认为“已经到末尾”了。

为什么用 < 10 而不是 === 0

  • 在实际滚动中可能会出现小数浮点计算误差(比如 0.0003-0.5)。
  • 不同浏览器和触控设备的滚动惯性也可能导致略微超出或不足末尾。
  • 加一个容差(< 10)可以让体验更稳定,避免误判。

结论isScrollAtEnd() 能够判断是否已经滚动到末尾(包括接近末尾的容差范围)。 所以它不仅能应对精确滚动,还能容忍微小的滚动误差,这就是它比较实用的原因。

使用

页面中的使用:

html
<ImgScroller>
	<template #hint="{ type }">
    <span v-if="type === 'start'">📌 自定义提示:向右滑动查看更多</span>
    <span v-else>📌 自定义提示:这是最后一张了</span>
  </template>
  <!-- 如果图片因为路径错误、网络慢或被阻止而无法显示,浏览器会用 alt 内容替代显示。 -->
	<img src="/xiangfa-京东农资招募.png" alt="A" />
  <img src="/xiangfa-京东农资门店.png" alt="B" />
  <img src="/xiangfa-京东农资招募1.png" alt="C" />
</ImgScroller>

Released under the MIT License.