Vue3开发必备,如何打造一款轻量高效、开箱即用的滚动组件?
最近在做一个数据展示类的后台项目,用户提了个需求:列表加载500条以上数据时不能卡顿,还要支持触底自动加载、下拉刷新、自定义滚动条样式,甚至偶尔需要禁止滚动方向锁死——本来想直接用市面上现成的组件库,比如Element Plus的InfiniteScroll或者Vant的PullRefresh,但后台项目适配深色模式还要改很多默认样式,移动端和PC端偶尔还要混用,现成的组件耦合度太高,拆改起来麻烦,索性自己从零撸了一个符合需求的滚动组件,踩了不少坑也总结了很多经验,今天就用问答的形式分享给大家,看完你也能写出适合自己项目的专属组件。
为什么要自己开发Vue3滚动组件,不用现成的?
很多刚接触Vue3的开发者会觉得“造轮子不如用轮子”,但实际开发中,现成组件库的“不贴合”真的会耽误很多时间,举几个我踩过的坑:
- 样式耦合严重:Element Plus的InfiniteScroll绑定在el-table或者div上,但滚动条样式是全局生效的,要是后台不同页面需要不同的粗细、颜色,还要用scope样式覆盖,稍微不注意权重就出问题;Vant的PullRefresh默认是适配移动端手势的,PC端用滚轮下拉触发还要单独配置,而且深色模式下的loading图标和下拉箭头还要自己重写。
- 功能冗余或缺失:有的组件库把“自动回顶”“滚动锚点”“虚拟列表”都塞在一起,虚拟列表又要求item高度固定,我后台的列表项是动态内容,高度不一样,用不了反而增加了包体积;有的组件触底触发逻辑是“距离底部还有100px就触发”,但不能设置“距离底部还有多少px就预加载”,也不能手动停止预加载状态。
- 定制化难度大:我这次的项目需要禁止用户在垂直滚动时横向滑动(虽然CSS有touch-action: pan-y,但有时候不管用),现成组件库很少会开放这个内部逻辑,我翻了Element Plus的源码,发现它的InfiniteScroll没有处理touch事件的默认行为,只能自己写原生事件去覆盖,反而增加了复杂度。
造轮子也要看场景,如果你的项目是快速上线的小项目,对样式和功能要求不高,现成组件库肯定是首选;但如果是长期维护的中大型项目,或者有特殊的业务需求,自己开发滚动组件反而更灵活、更轻量。
自己开发Vue3滚动组件,核心要实现哪些功能?
我自己的组件命名为MyScroll,结合业务需求和市面上热门组件的优势,核心实现了以下5个功能,基本能覆盖90%的开发场景:
- 基础滚动区域封装:提供固定高度或者百分比高度的滚动容器,超出容器的内容自动滚动,默认隐藏原生滚动条,同时开放一个自定义滚动条的插槽或者样式类。
- 触底自动加载/预加载:可以自定义“距离底部还有多少px触发预加载”“距离底部还有多少px正式触发加载”,支持手动切换加载状态(防止重复请求)、加载失败后重试。
- 下拉刷新:支持PC端滚轮下拉和移动端触摸下拉,自定义刷新阈值、刷新动画、刷新成功/失败的提示文字和图标。
- 滚动方向控制:可以单独禁止水平滚动、垂直滚动,或者禁止双向滚动;还可以设置“滚动方向切换阈值”,比如垂直滚动超过5px后禁止水平滚动,防止用户误触。
- 滚动事件监听与工具方法:暴露滚动到顶部、滚动到底部、滚动到指定元素/位置的工具方法,同时监听滚动距离、滚动速度、滚动方向,方便业务方做额外的处理(比如滚动超过一定距离显示回顶按钮)。
用Vue3的Composition API开发,有哪些优势?
Vue3的Composition API(组合式API)是开发组件的核心利器,相比Vue2的Options API(选项式API),它有3个非常明显的优势:
- 逻辑复用更方便:我们可以把触底加载、下拉刷新、滚动方向控制这些逻辑抽离成独立的Composable函数,比如
useInfiniteScroll、usePullRefresh、useScrollLock,以后开发其他组件的时候可以直接复用,不用再重复写代码。 - 代码组织更清晰:在Options API中,触底加载的逻辑可能分散在data、methods、mounted、beforeDestroy等多个选项中,找起来很麻烦;而在Composition API中,我们可以把触底加载的所有逻辑(包括状态、方法、生命周期钩子)都放在
useInfiniteScroll函数里,一眼就能看到整个逻辑流程。 - 性能优化更精准:Vue3的响应式系统是基于Proxy的,比Vue2的Object.defineProperty更高效;Composition API可以用
computed、watchEffect、ref、reactive等API更精准地控制响应式数据的更新,减少不必要的重渲染。
开发基础滚动区域,要注意哪些CSS细节?
基础滚动区域看起来简单,但其实有很多CSS细节要注意,不然会出现各种奇怪的问题:
- 固定高度与百分比高度的处理:如果滚动容器的父元素没有明确的高度,百分比高度是无效的,这时候可以用
vh单位,或者让父元素继承上一级的高度,或者用Flex布局的flex: 1来撑开,比如在后台管理系统的布局中,通常是Header + Main + Footer的结构,Main元素用flex: 1和overflow: hidden,然后我们的滚动容器放在Main里,设置height: 100%和overflow: auto就可以了。 - 隐藏原生滚动条但保留滚动功能:不同浏览器隐藏原生滚动条的CSS属性不一样:
- Chrome、Safari、Edge:用
:-webkit-scrollbar { display: none; } - Firefox:用
scrollbar-width: none; - IE10+:用
-ms-overflow-style: none;不过要注意,直接隐藏原生滚动条可能会影响可访问性(比如键盘用户看不到滚动位置),如果项目对可访问性要求高,可以保留原生滚动条但把它做得细一点,或者用自定义滚动条。
- Chrome、Safari、Edge:用
- 自定义滚动条的实现:自定义滚动条可以用绝对定位的两个div来做(一个是滚动轨道,一个是滚动滑块),然后监听滚动容器的
scroll事件来同步滑块的位置,监听滑块的mousedown、mousemove、mouseup事件来实现拖拽滚动,不过拖拽滚动的时候要注意防止触发原生滚动,还有移动端触摸事件的兼容。 - 抖动:有时候滚动容器的内容高度刚好等于容器高度的时候,滚动条会自动隐藏,然后内容加载后高度超过容器高度,滚动条又显示,这时候内容会往左边缩一点,造成抖动,解决方法是给滚动容器加
padding-right: calc(100vw - 100%);,这样不管滚动条是否显示,内容的宽度都是固定的。
抽离触底自动加载的Composable函数,要实现哪些逻辑?
触底自动加载的逻辑相对独立,非常适合抽离成Composable函数,我把它命名为useInfiniteScroll,接收的参数有:
containerRef:滚动容器的ref引用loadMore:触底时触发的加载函数,必须返回一个Promiseoptions:可选配置项,包括预加载距离、正式加载距离、是否立即执行第一次加载等
实现的逻辑主要有3步:
- 初始化状态:用
ref定义加载中状态isLoading、加载完成状态isFinished、加载失败状态isError。 - 监听滚动事件:用
watchEffect或者onMounted+onUnmounted监听滚动容器的scroll事件,在事件回调函数中计算当前滚动距离scrollTop、容器可视高度clientHeight总高度scrollHeight,然后判断是否满足预加载或者正式加载的条件。 - 执行加载函数:如果满足加载条件,并且
isLoading、isFinished、isError都是false,就把isLoading设为true,然后调用loadMore函数,根据Promise的结果设置isFinished(比如后端返回的数据为空)、isError(比如请求失败),最后把isLoading设为false。
这里要注意几个性能优化的细节:
- 节流滚动事件:滚动事件触发的频率非常高(每秒几十次甚至上百次),如果不节流的话,会造成不必要的性能浪费,可以用
lodash.throttle,或者自己手写一个简单的节流函数。 - 用IntersectionObserver代替scroll事件:IntersectionObserver是浏览器提供的API,可以监听元素是否进入视口,性能比scroll事件好很多,我们可以在滚动容器的底部放一个“哨兵元素”,然后用IntersectionObserver监听这个哨兵元素,当它进入视口的时候就触发加载,不过IntersectionObserver在IE11及以下版本不兼容,如果项目需要兼容旧浏览器,还是要用scroll事件。
- 立即执行第一次加载:很多时候我们需要页面加载完成后立即加载第一页数据,这时候可以在
useInfiniteScroll的options里加一个immediate配置项,默认设为true,然后在onMounted钩子中判断immediate是否为true,如果是就立即调用loadMore函数。
下拉刷新的Composable函数,要区分PC端和移动端吗?
不用完全区分,但要处理PC端和移动端不同的触发方式:
- 移动端:主要是触摸事件,包括
touchstart、touchmove、touchend、touchcancel。 - PC端:主要是鼠标滚轮事件
wheel,不过鼠标滚轮下拉和触底自动加载的滚轮上滑/下滑容易冲突,所以可以加一个“只有滚动到顶部时才能触发下拉刷新”的判断;也可以支持鼠标拖拽顶部区域触发下拉刷新,逻辑和移动端触摸事件差不多。
我抽离的usePullRefresh函数接收的参数有:
containerRef:滚动容器的ref引用onRefresh:下拉刷新时触发的函数,必须返回一个Promiseoptions:可选配置项,包括刷新阈值、下拉最大距离、刷新动画的时长等
实现的核心逻辑是:
- 监听触摸/鼠标事件:记录初始的触摸/鼠标位置
startY,然后在移动过程中计算当前位置和初始位置的差值deltaY,如果deltaY大于0并且滚动容器的scrollTop为0,就说明用户在下拉,这时候可以更新下拉的距离pullDistance。 - 控制下拉距离和状态:当下拉距离小于刷新阈值时,显示“下拉刷新”的提示;当下拉距离大于等于刷新阈值时,显示“释放刷新”的提示;当下拉距离超过下拉最大距离时,不再继续增加。
- 执行刷新函数:当用户释放触摸/鼠标时,如果下拉距离大于等于刷新阈值,就把
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:可选配置项,包括是否禁止水平滚动、是否禁止垂直滚动、滚动方向切换阈值等
实现的逻辑是:
- 监听滚动事件或者触摸事件:记录初始的滚动位置
prevScrollTop和prevScrollLeft,然后在滚动过程中计算当前位置和初始位置的差值deltaY和deltaX。 - 判断滚动方向:根据
deltaY和deltaX的正负判断滚动方向(上/下/左/右),如果设置了滚动方向切换阈值,就等差值超过阈值后再判断方向。 - 禁止滚动:如果禁止垂直滚动,就把
scrollTop固定为prevScrollTop;如果禁止水平滚动,就把scrollLeft固定为prevScrollLeft;如果设置了方向锁,就等垂直滚动超过阈值后禁止水平滚动,或者等水平滚动超过阈值后禁止垂直滚动。
这里要注意,禁止滚动的时候,不要用overflow: hidden,因为这样会隐藏滚动条,内容会抖动,最好的方法是监听滚动事件并手动固定滚动位置。
滚动事件监听与工具方法
滚动事件监听可以直接在组件内部或者useInfiniteScroll、usePullRefresh函数中做,然后通过组件的emit方法暴露给业务方,比如暴露@scroll事件,事件参数包括滚动距离、滚动速度、滚动方向。
工具方法包括:
scrollToTop(duration):滚动到顶部,duration是滚动时长,默认300ms。scrollToBottom(duration):滚动到底部。scrollToPosition(position, duration):滚动到指定的位置。scrollToElement(element, duration, offset):滚动到指定的元素,offset是偏移量,默认0。
滚动动画的实现可以用requestAnimationFrame,这是浏览器提供的API,专门用来做动画的,性能比setTimeout、setInterval好很多,简单的滚动动画公式是:
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组件中?
整合的过程其实很简单,主要步骤是:
- 创建组件文件:在components目录下创建
MyScroll.vue文件。 - 引入Composable函数和Vue3的API:引入
useInfiniteScroll、usePullRefresh、useScrollLock,还有ref、reactive、computed、defineProps、defineEmits、defineExpose等Vue3的API。 - 定义组件的Props和Emits:Props包括滚动容器的高度、是否隐藏原生滚动条、是否启用触底自动加载、是否启用下拉刷新、触底加载的配置项、下拉刷新的配置项、滚动方向控制的配置项等;Emits包括
@scroll、@load-more、@refresh、@load-error、@refresh-error等。 - 初始化Composable函数:根据Props的配置项初始化对应的Composable函数。
- 编写组件的模板:模板包括滚动容器、哨兵元素(用于IntersectionObserver)、下拉刷新的提示区域、自定义滚动条(可选),还有一个默认插槽,用于放置滚动的内容。
- 暴露工具方法:用
defineExpose暴露滚动到顶部、滚动到底部等工具方法,方便业务方通过ref引用组件后调用。
开发完成后,怎么测试和优化?
测试和优化是开发组件必不可少的步骤,主要包括:
- 功能测试:测试所有核心功能是否正常,比如触底自动加载会不会重复请求、下拉刷新会不会触发原生下拉刷新、滚动方向控制会不会生效、自定义滚动条会不会卡顿等。
- 性能测试:测试组件在加载大量数据(比如1000条、2000条)时的性能,看是否会卡顿;测试滚动事件的触发频率,看是否需要进一步优化;测试组件的包体积,看是否可以进一步压缩。
- 兼容性测试:测试组件在不同浏览器(Chrome、Safari、Edge、Firefox)、不同设备(PC、手机、平板)上的兼容性,看是否有显示或者功能上的问题。
- 可访问性测试:测试组件对键盘用户、屏幕阅读器用户的友好度,比如键盘用户能不能用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前端网发表,如需转载,请注明页面地址。
code前端网


