Code前端首页关于Code前端联系我们

Vue3开发必备,如何打造一款轻量高效、开箱即用的滚动组件?

terry 5小时前 阅读数 67 #Vue
文章标签 Vue3滚动组件

最近在做一个数据展示类的后台项目,用户提了个需求:列表加载500条以上数据时不能卡顿,还要支持触底自动加载、下拉刷新、自定义滚动条样式,甚至偶尔需要禁止滚动方向锁死——本来想直接用市面上现成的组件库,比如Element Plus的InfiniteScroll或者Vant的PullRefresh,但后台项目适配深色模式还要改很多默认样式,移动端和PC端偶尔还要混用,现成的组件耦合度太高,拆改起来麻烦,索性自己从零撸了一个符合需求的滚动组件,踩了不少坑也总结了很多经验,今天就用问答的形式分享给大家,看完你也能写出适合自己项目的专属组件。

为什么要自己开发Vue3滚动组件,不用现成的?

很多刚接触Vue3的开发者会觉得“造轮子不如用轮子”,但实际开发中,现成组件库的“不贴合”真的会耽误很多时间,举几个我踩过的坑:

  1. 样式耦合严重:Element Plus的InfiniteScroll绑定在el-table或者div上,但滚动条样式是全局生效的,要是后台不同页面需要不同的粗细、颜色,还要用scope样式覆盖,稍微不注意权重就出问题;Vant的PullRefresh默认是适配移动端手势的,PC端用滚轮下拉触发还要单独配置,而且深色模式下的loading图标和下拉箭头还要自己重写。
  2. 功能冗余或缺失:有的组件库把“自动回顶”“滚动锚点”“虚拟列表”都塞在一起,虚拟列表又要求item高度固定,我后台的列表项是动态内容,高度不一样,用不了反而增加了包体积;有的组件触底触发逻辑是“距离底部还有100px就触发”,但不能设置“距离底部还有多少px就预加载”,也不能手动停止预加载状态。
  3. 定制化难度大:我这次的项目需要禁止用户在垂直滚动时横向滑动(虽然CSS有touch-action: pan-y,但有时候不管用),现成组件库很少会开放这个内部逻辑,我翻了Element Plus的源码,发现它的InfiniteScroll没有处理touch事件的默认行为,只能自己写原生事件去覆盖,反而增加了复杂度。

造轮子也要看场景,如果你的项目是快速上线的小项目,对样式和功能要求不高,现成组件库肯定是首选;但如果是长期维护的中大型项目,或者有特殊的业务需求,自己开发滚动组件反而更灵活、更轻量。

自己开发Vue3滚动组件,核心要实现哪些功能?

我自己的组件命名为MyScroll,结合业务需求和市面上热门组件的优势,核心实现了以下5个功能,基本能覆盖90%的开发场景:

  1. 基础滚动区域封装:提供固定高度或者百分比高度的滚动容器,超出容器的内容自动滚动,默认隐藏原生滚动条,同时开放一个自定义滚动条的插槽或者样式类。
  2. 触底自动加载/预加载:可以自定义“距离底部还有多少px触发预加载”“距离底部还有多少px正式触发加载”,支持手动切换加载状态(防止重复请求)、加载失败后重试。
  3. 下拉刷新:支持PC端滚轮下拉和移动端触摸下拉,自定义刷新阈值、刷新动画、刷新成功/失败的提示文字和图标。
  4. 滚动方向控制:可以单独禁止水平滚动、垂直滚动,或者禁止双向滚动;还可以设置“滚动方向切换阈值”,比如垂直滚动超过5px后禁止水平滚动,防止用户误触。
  5. 滚动事件监听与工具方法:暴露滚动到顶部、滚动到底部、滚动到指定元素/位置的工具方法,同时监听滚动距离、滚动速度、滚动方向,方便业务方做额外的处理(比如滚动超过一定距离显示回顶按钮)。

用Vue3的Composition API开发,有哪些优势?

Vue3的Composition API(组合式API)是开发组件的核心利器,相比Vue2的Options API(选项式API),它有3个非常明显的优势:

  1. 逻辑复用更方便:我们可以把触底加载、下拉刷新、滚动方向控制这些逻辑抽离成独立的Composable函数,比如useInfiniteScrollusePullRefreshuseScrollLock,以后开发其他组件的时候可以直接复用,不用再重复写代码。
  2. 代码组织更清晰:在Options API中,触底加载的逻辑可能分散在data、methods、mounted、beforeDestroy等多个选项中,找起来很麻烦;而在Composition API中,我们可以把触底加载的所有逻辑(包括状态、方法、生命周期钩子)都放在useInfiniteScroll函数里,一眼就能看到整个逻辑流程。
  3. 性能优化更精准:Vue3的响应式系统是基于Proxy的,比Vue2的Object.defineProperty更高效;Composition API可以用computedwatchEffectrefreactive等API更精准地控制响应式数据的更新,减少不必要的重渲染。

开发基础滚动区域,要注意哪些CSS细节?

基础滚动区域看起来简单,但其实有很多CSS细节要注意,不然会出现各种奇怪的问题:

  1. 固定高度与百分比高度的处理:如果滚动容器的父元素没有明确的高度,百分比高度是无效的,这时候可以用vh单位,或者让父元素继承上一级的高度,或者用Flex布局的flex: 1来撑开,比如在后台管理系统的布局中,通常是Header + Main + Footer的结构,Main元素用flex: 1overflow: hidden,然后我们的滚动容器放在Main里,设置height: 100%overflow: auto就可以了。
  2. 隐藏原生滚动条但保留滚动功能:不同浏览器隐藏原生滚动条的CSS属性不一样:
    • Chrome、Safari、Edge:用:-webkit-scrollbar { display: none; }
    • Firefox:用scrollbar-width: none;
    • IE10+:用-ms-overflow-style: none; 不过要注意,直接隐藏原生滚动条可能会影响可访问性(比如键盘用户看不到滚动位置),如果项目对可访问性要求高,可以保留原生滚动条但把它做得细一点,或者用自定义滚动条。
  3. 自定义滚动条的实现:自定义滚动条可以用绝对定位的两个div来做(一个是滚动轨道,一个是滚动滑块),然后监听滚动容器的scroll事件来同步滑块的位置,监听滑块的mousedownmousemovemouseup事件来实现拖拽滚动,不过拖拽滚动的时候要注意防止触发原生滚动,还有移动端触摸事件的兼容。
  4. 抖动:有时候滚动容器的内容高度刚好等于容器高度的时候,滚动条会自动隐藏,然后内容加载后高度超过容器高度,滚动条又显示,这时候内容会往左边缩一点,造成抖动,解决方法是给滚动容器加padding-right: calc(100vw - 100%);,这样不管滚动条是否显示,内容的宽度都是固定的。

抽离触底自动加载的Composable函数,要实现哪些逻辑?

触底自动加载的逻辑相对独立,非常适合抽离成Composable函数,我把它命名为useInfiniteScroll,接收的参数有:

  • containerRef:滚动容器的ref引用
  • loadMore:触底时触发的加载函数,必须返回一个Promise
  • options:可选配置项,包括预加载距离、正式加载距离、是否立即执行第一次加载等

实现的逻辑主要有3步:

  1. 初始化状态:用ref定义加载中状态isLoading、加载完成状态isFinished、加载失败状态isError
  2. 监听滚动事件:用watchEffect或者onMounted+onUnmounted监听滚动容器的scroll事件,在事件回调函数中计算当前滚动距离scrollTop、容器可视高度clientHeight总高度scrollHeight,然后判断是否满足预加载或者正式加载的条件。
  3. 执行加载函数:如果满足加载条件,并且isLoadingisFinishedisError都是false,就把isLoading设为true,然后调用loadMore函数,根据Promise的结果设置isFinished(比如后端返回的数据为空)、isError(比如请求失败),最后把isLoading设为false。

这里要注意几个性能优化的细节:

  • 节流滚动事件:滚动事件触发的频率非常高(每秒几十次甚至上百次),如果不节流的话,会造成不必要的性能浪费,可以用lodash.throttle,或者自己手写一个简单的节流函数。
  • 用IntersectionObserver代替scroll事件:IntersectionObserver是浏览器提供的API,可以监听元素是否进入视口,性能比scroll事件好很多,我们可以在滚动容器的底部放一个“哨兵元素”,然后用IntersectionObserver监听这个哨兵元素,当它进入视口的时候就触发加载,不过IntersectionObserver在IE11及以下版本不兼容,如果项目需要兼容旧浏览器,还是要用scroll事件。
  • 立即执行第一次加载:很多时候我们需要页面加载完成后立即加载第一页数据,这时候可以在useInfiniteScrolloptions里加一个immediate配置项,默认设为true,然后在onMounted钩子中判断immediate是否为true,如果是就立即调用loadMore函数。

下拉刷新的Composable函数,要区分PC端和移动端吗?

不用完全区分,但要处理PC端和移动端不同的触发方式:

  • 移动端:主要是触摸事件,包括touchstarttouchmovetouchendtouchcancel
  • PC端:主要是鼠标滚轮事件wheel,不过鼠标滚轮下拉和触底自动加载的滚轮上滑/下滑容易冲突,所以可以加一个“只有滚动到顶部时才能触发下拉刷新”的判断;也可以支持鼠标拖拽顶部区域触发下拉刷新,逻辑和移动端触摸事件差不多。

我抽离的usePullRefresh函数接收的参数有:

  • containerRef:滚动容器的ref引用
  • onRefresh:下拉刷新时触发的函数,必须返回一个Promise
  • options:可选配置项,包括刷新阈值、下拉最大距离、刷新动画的时长等

实现的核心逻辑是:

  1. 监听触摸/鼠标事件:记录初始的触摸/鼠标位置startY,然后在移动过程中计算当前位置和初始位置的差值deltaY,如果deltaY大于0并且滚动容器的scrollTop为0,就说明用户在下拉,这时候可以更新下拉的距离pullDistance
  2. 控制下拉距离和状态:当下拉距离小于刷新阈值时,显示“下拉刷新”的提示;当下拉距离大于等于刷新阈值时,显示“释放刷新”的提示;当下拉距离超过下拉最大距离时,不再继续增加。
  3. 执行刷新函数:当用户释放触摸/鼠标时,如果下拉距离大于等于刷新阈值,就把isRefreshing设为true,然后调用onRefresh函数,刷新完成后把pullDistance设为0,把isRefreshing设为false,同时可以显示一个“刷新成功”的提示。

这里要注意几个细节:

  • 防止触发原生下拉刷新:在移动端,浏览器默认有下拉刷新的功能(比如Chrome的下拉刷新会显示一个小齿轮),我们可以在touchmove事件回调函数中调用e.preventDefault()来防止触发,但要注意不要在所有情况下都调用,不然会影响滚动容器的正常滚动。
  • 用transform代替top/left属性:更新下拉距离的时候,用transform: translateY(pullDistance)代替top: pullDistance,性能更好,因为transform不会触发重排(reflow),只会触发重绘(repaint)。
  • 加入阻尼效果:当下拉距离超过刷新阈值时,加入一点阻尼效果,也就是下拉距离的增长速度变慢,这样用户会觉得更舒服,阻尼效果的公式可以简单一点:pullDistance = refreshThreshold + (deltaY - refreshThreshold) * 0.4

滚动方向控制和工具方法,怎么实现?

滚动方向控制

滚动方向控制可以抽离成useScrollLock函数,接收的参数有:

  • containerRef:滚动容器的ref引用
  • options:可选配置项,包括是否禁止水平滚动、是否禁止垂直滚动、滚动方向切换阈值等

实现的逻辑是:

  1. 监听滚动事件或者触摸事件:记录初始的滚动位置prevScrollTopprevScrollLeft,然后在滚动过程中计算当前位置和初始位置的差值deltaYdeltaX
  2. 判断滚动方向:根据deltaYdeltaX的正负判断滚动方向(上/下/左/右),如果设置了滚动方向切换阈值,就等差值超过阈值后再判断方向。
  3. 禁止滚动:如果禁止垂直滚动,就把scrollTop固定为prevScrollTop;如果禁止水平滚动,就把scrollLeft固定为prevScrollLeft;如果设置了方向锁,就等垂直滚动超过阈值后禁止水平滚动,或者等水平滚动超过阈值后禁止垂直滚动。

这里要注意,禁止滚动的时候,不要用overflow: hidden,因为这样会隐藏滚动条,内容会抖动,最好的方法是监听滚动事件并手动固定滚动位置。

滚动事件监听与工具方法

滚动事件监听可以直接在组件内部或者useInfiniteScrollusePullRefresh函数中做,然后通过组件的emit方法暴露给业务方,比如暴露@scroll事件,事件参数包括滚动距离、滚动速度、滚动方向。

工具方法包括:

  • scrollToTop(duration):滚动到顶部,duration是滚动时长,默认300ms。
  • scrollToBottom(duration):滚动到底部。
  • scrollToPosition(position, duration):滚动到指定的位置。
  • scrollToElement(element, duration, offset):滚动到指定的元素,offset是偏移量,默认0。

滚动动画的实现可以用requestAnimationFrame,这是浏览器提供的API,专门用来做动画的,性能比setTimeoutsetInterval好很多,简单的滚动动画公式是:

const startTime = performance.now();
const startPosition = containerRef.value.scrollTop;
const endPosition = targetPosition;
const distance = endPosition - startPosition;
function step(currentTime) {
  const elapsed = currentTime - startTime;
  const progress = Math.min(elapsed / duration, 1);
  const easeProgress = 1 - Math.pow(1 - progress, 3); // 用ease-out的缓动函数
  containerRef.value.scrollTop = startPosition + distance * easeProgress;
  if (progress < 1) {
    requestAnimationFrame(step);
  }
}
requestAnimationFrame(step);

怎么把这些Composable函数整合到MyScroll组件中?

整合的过程其实很简单,主要步骤是:

  1. 创建组件文件:在components目录下创建MyScroll.vue文件。
  2. 引入Composable函数和Vue3的API:引入useInfiniteScrollusePullRefreshuseScrollLock,还有refreactivecomputeddefinePropsdefineEmitsdefineExpose等Vue3的API。
  3. 定义组件的Props和Emits:Props包括滚动容器的高度、是否隐藏原生滚动条、是否启用触底自动加载、是否启用下拉刷新、触底加载的配置项、下拉刷新的配置项、滚动方向控制的配置项等;Emits包括@scroll@load-more@refresh@load-error@refresh-error等。
  4. 初始化Composable函数:根据Props的配置项初始化对应的Composable函数。
  5. 编写组件的模板:模板包括滚动容器、哨兵元素(用于IntersectionObserver)、下拉刷新的提示区域、自定义滚动条(可选),还有一个默认插槽,用于放置滚动的内容。
  6. 暴露工具方法:用defineExpose暴露滚动到顶部、滚动到底部等工具方法,方便业务方通过ref引用组件后调用。

开发完成后,怎么测试和优化?

测试和优化是开发组件必不可少的步骤,主要包括:

  1. 功能测试:测试所有核心功能是否正常,比如触底自动加载会不会重复请求、下拉刷新会不会触发原生下拉刷新、滚动方向控制会不会生效、自定义滚动条会不会卡顿等。
  2. 性能测试:测试组件在加载大量数据(比如1000条、2000条)时的性能,看是否会卡顿;测试滚动事件的触发频率,看是否需要进一步优化;测试组件的包体积,看是否可以进一步压缩。
  3. 兼容性测试:测试组件在不同浏览器(Chrome、Safari、Edge、Firefox)、不同设备(PC、手机、平板)上的兼容性,看是否有显示或者功能上的问题。
  4. 可访问性测试:测试组件对键盘用户、屏幕阅读器用户的友好度,比如键盘用户能不能用Tab键和箭头键控制滚动、屏幕阅读器能不能读出滚动位置等。

优化的方向主要有:

  • 进一步减少包体积:如果不需要自定义滚动条,可以把自定义滚动条的逻辑抽离成可选的插件;如果不需要兼容旧浏览器,可以把IntersectionObserver作为默认的触发方式,把scroll事件作为降级方案。
  • 进一步优化性能:如果滚动的内容是动态高度的,可以用虚拟列表(Vue Virtual Scroller是一个不错的选择,不过可以自己写一个简单的),只渲染可视区域内的内容,减少DOM节点的数量。
  • 进一步提高可定制性:开放更多的配置项和插槽,比如开放下拉刷新提示区域的插槽、开放预加载/加载中/加载完成/加载失败的状态提示插槽,让业务方可以完全自定义组件的外观。

自己开发Vue3滚动组件虽然需要一点时间,但它带来的灵活性和轻量性是现成组件库无法比拟的,本文主要分享了开发Vue3滚动组件的核心思路、关键功能的实现方法、需要注意的细节,还有Composable函数的抽离和整合,希望能对大家有所帮助。

如果大家有什么问题或者建议,欢迎在评论区留言交流,附上一个简单的MyScroll组件的使用示例:

<template>
  <MyScroll
    height="80vh"
    :use-infinite-scroll="true"
    :use-pull-refresh="true"
    :load-more-options="{ immediate: true, threshold: 100 }"
    :refresh-options="{ threshold: 80 }"
    @load-more="handleLoadMore"
    @refresh="handleRefresh"
    ref="myScrollRef"
  >
    <div v-for="item in list" :key="item.id" class="list-item">
      {{ item.content }}
    </div>
  </MyScroll>
</template>
<script setup>
import { ref } from 'vue';
import MyScroll from '@/components/MyScroll.vue';
const myScrollRef = ref(null);
const list = ref([]);
let page = 1;
const handleLoadMore = async () => {
  // 模拟请求后端数据
  await new Promise(resolve => setTimeout(resolve, 1000));
  const newData = Array.from({ length: 20 }, (_, i) => ({
    id: (page - 1) * 20 + i,
    content: `第${(page - 1) * 20 + i + 1}条数据`
  }));
  list.value.push(...newData);
  page++;
  // 如果后端返回的数据为空,就设置加载完成状态
  // isFinished.value = newData.length === 0;
};
const handleRefresh = async () => {
  // 模拟请求后端数据
  await new Promise(resolve => setTimeout(resolve, 1000));
  page = 1;
  list.value = Array.from({ length: 20 }, (_, i) => ({
    id: i,
    content: `第${i + 1}条数据`
  }));
};
// 业务方可以通过ref引用组件后调用工具方法
// const scrollToTop = () => {
//   myScrollRef.value.scrollToTop(500);
// };
</script>
<style scoped>
.list-item {
  height: 50px;
  line-height: 50px;
  padding: 0 20px;
  border-bottom: 1px solid #eee;
}
</style>

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门