虚拟滚动实现

虚拟滚动或者移动是指禁止原生滚动,之后通过监听浏览器的相关事件实现模拟滚动。所以虚拟滚动包含两部分内容

  1. 禁止原生滚动:将cssoverfow属性设置为hidden。这样即便是内容高度或者宽度超过了盒子的宽度或者高度也无法进行滚动了
html 复制代码
<div id="vs-container">
  <div id="vs-content">
    <p>内容</p>
    <p>内容</p>
    <p>内容</p>
    <p>内容</p>
    <p>内容</p>
    <p>内容</p>
    <p>内容</p>
    <p>内容</p>
  </div>
</div>
<style>
#vs-container {
  overflow:hidden;
  height:100px;
}
#vs-content {
  height:200px;
}
</style>
  1. 模拟滚动:通过监听鼠标的wheel事件,调整内容位置,从而形成滚动效果;通过监听onmousedownonmousemoveonmouseup实现虚拟滚动条的移动

解决什么问题?

  1. 服务虚拟列表,尤其不定高度内容的虚拟列表实现;不定高内容虚拟列表在滑动过程中由于滚动速度大于渲染速度导致过快滑动时出现白屏现象。如果有虚拟滚动,则可以先进行数据渲染待渲染完毕再进行滚动,这样就彻底解决了白屏问题。
  2. 在我工作中遇到使用虚拟列表实现不定高数据渲染问题,正好也出现了白屏问题

Dom结构

本文使用vue2实现虚拟滚动,DOM结构以及一些初始化数据如下

内容和盒子

js 复制代码
<template>
  <div id="vs-container" ref="container">
    <div id="vs-content" :style="{ transform: contentTransform }">
      <p :key="num" v-for="num in list">{{ num }}</p>
    </div>
  </div>
</template>
<script>
export default {
  data () {
    return {
      list: 1000,
      contentOffset: 0
    }
  },
  computed: {
    contentTransform () {
      return `translate3d(${this.contentOffset}px)`
    }
  }
}
</script>
<style lang="scss" scoped>
#vs-container {
  margin-top: 200px;
  margin-left: 20px;
  height: 200px;
  border: 1px solid #333;
  overflow: hidden;
  width: 500px;
  position: relative;
  box-sizing: border-box;
}

</style>

上述代码内容id为vs-content,盒子id为vs-container,盒子高度200px,并且禁止盒子的原生滚动,设置盒子overflowhiddencontentTransform用来动态变化滚动位置。给盒子增加ref,标记container为后面开发使用。

虚拟滚动条

在上述代码中添加虚拟滚动条,虚拟滚动条包括滑道,其ref设置为slider;还包括手柄,手柄ref为handle

js 复制代码
<template>
  <div id="vs-container" ref="container">
    <div id="vs-content" :style="{ transform: contentTransform }">
      <p :key="num" v-for="num in list">{{ num }}</p>
    </div>
    <div id="vs-slider" ref="slider">
      <div
        id="vs-handle"
        :style="{ transform: handleTransformt }"
        ref="handle"
      ></div>
    </div>
  </div>
</template>
<script>
export default {
  data () {
    return {
      ...
      handleOffset: 0
    }
  },
  computed: {
    ...
    handleTransform () {
      return `translateY(${this.handleOffset}px)`
    }
  }
}
</script>
<style lang="scss" scoped>
#vs-container {
  ...
  #vs-slider {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 10px;
    height:20px;
    box-sizing: border-box;
    background-color: #6b6b6b;

    #vs-handle {
      background-color: #f1f2f3;
      cursor: pointer;
      border-radius: 10px;
    }
  }
}

</style>

contentTransform用来动态变化虚拟滚动条的滚动位置,设置滚动条高度20px。到此处整个虚拟滚动示例长这样

虚拟滚动实现

实现虚拟滚动,开头说了模拟滚动原理:通过监听鼠标的wheel事件,调整内容位置,从而形成滚动效果;通过监听onmousedownonmousemoveonmouseup实现虚拟滚动条的移动。

本文使用translateY值的变化实现内容区或虚拟滚动条的滚动。本文只实现垂直方向上的滚动,水平方向上的滚动原理基本一致。

监听鼠标滚轮或触屏版实现内容区滚动

使用上文中ref获取相应的dom元素,然后给内容区盒子container绑定wheel事件。关于wheel详情查看:developer.mozilla.org/zh-CN/docs/...

监听wheel事件获取事件对象的wheelDeltaY,其含义为

返回一个整型数,表示垂直滚动量。

在谷歌浏览器下,如果是触屏版滑动返回0、1、2、3......或者0、-1、-2、-3......,如果是鼠标滚轮滚动返回150或-150。具体实现内容区滚动

js 复制代码
<template>
  <div id="vs-container" ref="container">
    <div id="vs-content" :style="{ transform: contentTransform }">
      <p :key="num" v-for="num in list">{{ num }}</p>
    </div>
    <div id="vs-slider" ref="slider">
      <div
        id="vs-handle"
        :style="{ transform: handleTransform, height: handleStyleHeight }"
        ref="handle"
      ></div>
    </div>
  </div>
</template>
<script>

export default {
  methods: {
    bindContainerEvent () {
      const { $container } = this.$element
      const contentSpace = $container.scrollHeight - $container.offsetHeight

      const bindContainerOffset = (event) => {
        event.preventDefault()
        this.contentOffset += event.wheelDeltaY
        if (this.contentOffset < 0) {
          this.contentOffset = Math.max(this.contentOffset, -contentSpace)
        } else {
          this.contentOffset = 0
        }
      }
      $container.addEventListener('wheel', bindContainerOffset)
      this.unbindContainerEvent = () => {
        $container.removeEventListener('wheel', bindContainerOffset)
      }
    },
     // 获取dom元素
    saveHtmlElementById () {
      const { container, slider, handle } = this.$refs
      this.$element = {
        $container: container,
        $slider: slider,
        $handle: handle
      }
      this.bindContainerEvent()
    }
  },
  created () {
    this.$nextTick(() => {
      this.saveHtmlElementById()
    })
  },
  beforeDestroy () {
    this.unbindContainerEvent()
  }
}
</script>

event.wheelDeltaY值为负值,表示内容区向上滚动,反之内容区向下滚动。之后需要限制滚动区间

js 复制代码
if (this.contentOffset < 0) {
   this.contentOffset = Math.max(this.contentOffset, -contentSpace)
} else {
   this.contentOffset = 0
}

内容区向上移动的最大距离为contentSpace,向下滚动的最大距离为0。

监听虚拟滚动条事件实现内容区滚动

监听虚拟滚动条的onmousedown事件,之后使用手柄偏移量handleOffset以及计算属性handleTransform实现手柄的上下滑动

js 复制代码
<script>
export default {
  data () {
    return {
      ...
      handleOffset: 0,
    }
  },
  computed: {
    handleTransform () {
      return `translateY(${this.handleOffset}px)`
    }
  },
  methods: {
    bindHandleEvent () {
      const { $slider, $handle } = this.$element
      const handleSpace = $slider.offsetHeight - this.handleHeight
      $handle.onmousedown = (e) => {
        const startY = e.clientY
        const startTop = this.handleOffset
        window.onmousemove = (e) => {
          const deltaX = e.clientY - startY
          this.handleOffset =
            startTop + deltaX < 0
              ? 0
              : Math.min(startTop + deltaX, handleSpace)
        }

        window.onmouseup = function () {
          window.onmousemove = null
          window.onmouseup = null
        }
      }
    },
    saveHtmlElementById () {
      ...
      this.bindHandleEvent()
    }
  },
  created () {
    this.$nextTick(() => {
      this.saveHtmlElementById()
    })
  }
}
</script>

基本实现逻辑:在鼠标按下时记录当前位置,鼠标移动则将移动值通过一定的转换逻辑赋给手柄偏移量,同时限制手柄移动上下边界

js 复制代码
this.handleOffset =
            startTop + deltaX < 0
              ? 0
              : Math.min(startTop + deltaX, handleSpace)

最小为0,最大为handleSpace

关联手柄移动与内容区移动

到此处已经实现了滚动条的移动和内容区的移动。但二者还是各自为战的,需要关联起来。具体关联逻辑是关联内容区最大滚动距离和虚拟滚动条最大移动距离。二者比例就是移动距离的数值关系。

增加关联方法transferOffset

js 复制代码
  methods: {
    transferOffset (to = 'handle') {
      const { $container, $slider } = this.$element
      const contentSpace = $container.scrollHeight - $container.offsetHeight
      const handleSpace = $slider.offsetHeight - this.handleHeight
      const assistRatio = handleSpace / contentSpace // 小于1
      const _this = this
      const computedOffset = {
        handle () {
          return -_this.contentOffset * assistRatio
        },
        content () {
          return -_this.handleOffset / assistRatio
        }
      }
      return computedOffset[to]()
    }
  }

contentSpace为内容最大滚动距离,handleSpace为手柄最大移动距离。assistRatio为二者比例。转换对象computedOffset包含两个方法,分别是通过内容移动距离转为手柄移动距离和通过手柄移动距离转为内容移动距离。使用转换方法

js 复制代码
  methods: {
    bindContainerEvent () {
      ...
      const updateHandleOffset = () => {
        // 使用关联方法
        this.handleOffset = this.transferOffset()
      }
      $container.addEventListener('wheel', bindContainerOffset)
      // 给手柄事件在增加一个订阅方法
      $container.addEventListener('wheel', updateHandleOffset)
      this.unbindContainerEvent = () => {
        $container.removeEventListener('wheel', bindContainerOffset)
        $container.removeEventListener('wheel', updateHandleOffset)
      }
    },
    bindHandleEvent () {
      const { $slider, $handle } = this.$element
      const handleSpace = $slider.offsetHeight - this.handleHeight
      $handle.onmousedown = (e) => {
        const startY = e.clientY
        const startTop = this.handleOffset
        window.onmousemove = (e) => {
          ...
          // 使用关联方法
          this.contentOffset = this.transferOffset('content')
        }

        window.onmouseup = function () {
          window.onmousemove = null
          window.onmouseup = null
        }
      }
    }
  },
  beforeDestroy () {
    this.unbindContainerEvent()
  }

到此虚拟滚动基本实现,看下效果

优化

动态设置手柄高度

默认将手柄高度设置为20px,这实际是不符合实际滚动条高度变化规则的。实际内容区高度和内容区盒子高度相差越大则手柄高度越小反之越大。本文虚拟滚动为了方便操作可以人为限制手柄最小高度。

优化手柄的高度逻辑,增加手柄高度属性,以及计算属性handleStyleHeight,限制手柄最小尺寸为20px,同时再增加手柄高度的初始化方法initHandleHeight

js 复制代码
<template>
  <div id="vs-container" ref="container">
    <div id="vs-content" :style="{ transform: contentTransform }">
      <p :key="num" v-for="num in list">{{ num }}</p>
    </div>
    <div id="vs-slider" ref="slider">
      <div
        id="vs-handle"
        :style="{ transform: handleTransform, height: handleStyleHeight }"
        ref="handle"
      ></div>
    </div>
  </div>
</template>
<script>
const HandleMixHeight = 20
export default {
  data () {
    return {
      ...
      handleHeight: HandleMixHeight
    }
  },
  computed: {
    ...
    handleStyleHeight () {
      return `${this.handleHeight}px`
    }
  },
  methods: {
    ...
    initHandleHeight () {
      const { $container, $slider } = this.$element
      
      // 根据比例变化
      this.handleHeight =
        ($slider.offsetHeight * $container.offsetHeight) /
        $container.scrollHeight

      // 最小值为HandleMixHeight
      if (this.handleHeight < HandleMixHeight) {
        this.handleHeight = HandleMixHeight
      }
    }
  },
  created () {
    this.$nextTick(() => {
      this.saveHtmlElementById()
    })
  }
}
</script>

禁止选中文本

在上文中的效果图中也可以看出,当鼠标拖动滚动条时,内容区文本被选中了。这样体验很不好,对手柄和滑道添加禁止选中,使用css实现

css 复制代码
<style lang="scss" scoped>
#vs-container {
  ...

  #vs-slider {
    ...
    -webkit-user-select: none; /* Safari/Chrome */
    -moz-user-select: none; /* Firefox */
    -ms-user-select: none; /* Internet Explorer/Edge */
    user-select: none; /* Standard */
    #vs-handle {
      ...
      -webkit-user-select: none; /* Safari/Chrome */
      -moz-user-select: none; /* Firefox */
      -ms-user-select: none; /* Internet Explorer/Edge */
      user-select: none; /* Standard */
    }
  }
}

</style>

总结

本文是对虚拟滚动的一种实现。具体是通过对wheel事件的监听模拟内容的移动;通过对onmousedownonmousemoveonmouseup的监听实现虚拟滚动条的移动。当然不管是内容的移动还是虚拟滚动条的移动都需要在一个闭区间内。

本文有2个没有处理的点

  • 不需要滚动条的情况
  • 滚动条手柄的上下部分

感兴趣可以进一步完善。本文的重点是垂直方向虚拟滚动的基本实现,是为后面不定高虚拟列表服务。

本文完。

参考文章

新手也能看懂的虚拟滚动实现方法

wheel

相关推荐
桂月二二4 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
CodeClimb5 小时前
【华为OD-E卷 - 第k个排列 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
沈梦研5 小时前
【Vscode】Vscode不能执行vue脚本的原因及解决方法
ide·vue.js·vscode
hunter2062065 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb5 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角6 小时前
CSS 颜色
前端·css
轻口味6 小时前
Vue.js 组件之间的通信模式
vue.js
九酒6 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae
浪浪山小白兔7 小时前
HTML5 新表单属性详解
前端·html·html5
lee5767 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm