前端项目难点,虚拟滚动/虚拟树白屏,读element-plus源码并手写滚动条解决,

899 阅读12分钟

1. 背景

工作中的项目用到了大数据(高达1W+)的结构树,我在自己实现了一个虚拟树之后,发现拉动滚动条从一个区域一下子拖了很大一段距离,出现了闪烁和短暂的白屏现象,即便我给虚拟树增加了缓冲区也不能解决。现象如下图:

滚动条01-白屏现象.gif

模拟实现一个滚动条,能够解决这个问题,效果如下:

滚动条02-滚动条解决白屏问题对比.gif

我还想补充:

  • 增加缓冲区数据是有效的,如果拖动的距离很小,或者是通过鼠标滚轮滚动,能够解决一定程度的白屏、闪烁。
  • 但是当鼠标拖动滚动条很快、很大段距离时,白屏依然会出现,这时模拟的滚动条就派上用场了。

2. 原因

2.1 为什么原生的滚动条会导致出现白屏?

从现象来看,是滚动条滚动了,但是dom还没渲染好导致的白屏,dom渲染是异步的有时候就跟不上。如果浏览器设计为等dom渲染好了再去修改滚动条的位置,滚动行为将是不流畅的。

而不管是虚拟树还是虚拟滚动,最终的本质都是一样的,拖动滚动条时,需要计算滚动的scrollTop值,这个值 / 每一项数据的高度得到开始索引,再加上可视区域可以展示的数据量,得到结束索引。利用开始索引和结束索引去截取数组的值。这个计算的过程其实是非常快的,就是dom渲染慢了。我写了一个代码的debug过程,如下图:

滚动条03-dom渲染才是耗时的.gif

可以看到右侧js执行完毕,但是左侧页面还没渲染。

2.2 为什么手写一个滚动条能够解决白屏?

在手写的滚动条中,因为拖动的是我们自己模拟的div,拖动的时候不会立刻触发scroll事件,而是会计算拖动的距离,进而设置容器的scrollTop值(此时dom会开始渲染更新),scrollTop值变化就会触发scroll事件,进而修改滚动条滑块的位置。

过程:
1.计算鼠标拖动的距离
2.修改容器scrollTop值(dom异步开始渲染)
3.触发容器scroll事件
4.更新滚动条滑块的位置

对比使用原生滚动条:

1. 拖动滑块,滑块位置更新,触发原生scroll事件,scrollTop值也更新了
2. JS计算好,dom开始异步渲染 

这样你能看出,因为执行顺序不同,所以手写的滚动条能够避免白屏的问题出现了吧。在手写的滚动条中,dom很早就开始渲染了,滚动条位置的更新反而是属于最后一步的事情。

3. 如何实现一个手写的滚动条呢?

3.1 准备条件

项目地址

virtualTree-my.vue组件

image.png

3.2 隐藏默认的滚动条

3.2.1 方法1:使用CSS属性

目标是隐藏该容器的默认滚动条: image.png

.viewport {
  width: 300px;
  height: 600px;
  overflow: auto;
  // 记得这里设置相对定位
  position: relative;
  margin-right: -17px;
  margin-bottom: -17px;
+  scrollbar-width: none; /* 适用于 Firefox */
+  &::-webkit-scrollbar {
+      display: none;
+  }
}

scrollbar-width: none;属性同时支持多种浏览器,但是兼容器稍微差一点,如下图比如对Chrome对120版本以后的浏览器才支持;对火狐浏览器的兼容性较好 image.png

webkit-scrollbar属性,不支持火狐浏览器,所以必须搭配scrollbar-width: none;属性一起使用 image.png

3.2.2 方法2:借助margin-right属性

该方法我未能实现,但还是讲一下逻辑思路

在mounted方法中调用:

  mounted () {
    this.getScrollWidth()
  },

计算浏览器默认滚动条的宽度,给到自己的滚动条,下面有图分析

    getScrollWidth () {
      const outer = document.createElement("div");
      outer.className = "el-scrollbar__wrap";
      outer.style.width = '100px';
      outer.style.visibility = "hidden";
      outer.style.position = "absolute";
      outer.style.top = "-9999px";
      document.body.appendChild(outer);

      // 没有滚动条外部容器的宽度
      const widthNoScroll = outer.offsetWidth;
      // 当设置scroll时,此时outer有滚动条了,内容会往里面挤一点 
      outer.style.overflow = "scroll";

      // inner是内容,就是挤占后的宽度,
      const inner = document.createElement("div");
      inner.style.width = "100%";
      outer.appendChild(inner);

      const widthWithScroll = inner.offsetWidth;
      outer.parentNode.removeChild(outer);
      // 没有滚动条的宽度 - 有滚动条的宽度就是滚动条的宽度
      this.padRight = widthNoScroll - widthWithScroll;
    },

image.png

不同浏览器的宽度不尽相同,比如chrome的是17px,edge的是15px;拿到这个宽度,给wrap容器设置margin-right:-17px;

比如element-ui效果: image.png

3.3 准备结构

增加滚动条结构:

  • bar是滑轨
  • thumb是滑块
+<div class="virtualTree">
    <div class="viewport" ref="viewport" @scroll="handleScroll">
        <ul>
            <li>
            ……
            </li>
        </ul>
    </div>
+    <div 
      ref="bar"
      class='scrollbar-bar'
    >
      <div 
        ref="thumb" 
        class="scrollbar-thumb" 
      ></div>
+    </div>
</div>

image.png

增加样式:

  • thumb要增加cursor: pointer,而bar不用
  • thumb的高度默认设置为0,后续会变高
  • 通过给bar设置绝对定位,将滚动条设置在右边
.virtualTree {
  position: relative;
  width: 300px;
}
.scrollbar-bar {
  position: absolute;
  right: 2px;
  bottom: 2px;
  z-index: 1;
  width: 6px;
  height: 100%;
  border-radius: 4px;
  transition: opacity 120ms ease-out;
}
.scrollbar-thumb {
  position: relative;
  display: block;
  width: 100%;
  height: 0;
  border-radius: inherit;
  background-color: rgba(135,141,153,.3);
  transition: .3s background-color;
  cursor: pointer;
}

通过检查元素能看到滚动条的位置:

image.png

3.4 计算滑块的高度

目标效果:注意观察右边滚动条的高度变化

tutieshi_600x1104_7s.gif

data里面增加数据

data () {
    return {
       thumbHeight: 0, // 滑块高度 
    }
},

更新滚动条高度的函数

// 更新滚动条高度和位置
updateThumb () {
  try {
+    let wrap = this.$refs.viewport
+    // 滚动条高度 = 元素内容高度 clientHeight * 100 / 元素滚动的高度scrollHeight
+    let heightPercent = wrap.clientHeight * 100 /  wrap.scrollHeight
+    this.thumbHeight = heightPercent
  } catch (error) {
    console.log(error, 'error');
  }
},

为什么滚动条的高度 = wrap.clientHeight * 100 / wrap.scrollHeight + '%'?

首先要理解clientHeight是可视区域的高度,这里我就固定设置为600px;scrollHeight是所有已经展示和未展示的数据项的数量 * 每一项的高度。

如下图,如果视口的可视区域的高度是600px,所有的scrollHeight的高度假设是1200px,那么600 * 100 / 1200就是50%,滚动条就是50%,这个是不是很好理解,因为下面还有一半的数据未展示 image.png

如下图,如果可视区域的高度是600px,暂时下面没有待展示的数据,scrollHeight也是600px,这时overflow值为scroll; 滚动条的高度就是100%,600 / 600 * 100 = 100 image.png

计算好后赋值

<div 
  ref="bar"
  class='scrollbar-bar'
>
  <div 
    ref="thumb" 
    class="scrollbar-thumb" 
    :style="{
+      height: `${thumbHeight}%`
    }"
  ></div>
</div>

调用时机:

初始化时需要调用,注意使用nextTick,等下一次视图更新好再调用,不然拿不到wrap包围容器

    // 1. 构建数据
    buildData () {
      ……省略很多代码
      this.treeData = data;
      // 2. 拍平数据
      this.flattenTree = this.flatten(this.treeData)
      // 3. 更新可视区域的数据
      this.updateVisibleData()
      // 4. 更新滚动条
      this.$nextTick(() => {
+        this.updateThumbHeight()
      })
    },

展开节点时需要调用

// 点击某个节点
handleNodeClick (item) {
  // 展开或者关闭取反
  this.$set(item, 'showChildren', !item.showChildren)
  // 递归修改子元素的visible
  this.recursionVisible(item, item.showChildren)
  // 更新视图
  this.updateView()
  // 更新滑块高度
  this.$nextTick(() => {
+    this.updateThumbHeight()
  })
},

3.5 鼠标滚轮滚动,更新滚动条的位置

目标效果:注意关注右侧滚动条的位置 tutieshi_552x1014_6s.gif

求滚动条的滚动比例scrollThuPer:

updateThumbPosition () {
  let wrap = this.$refs.viewport
  // wrap.scrollTop是滚动的距离
  // this.allVisibleDataLength * this.option.itemHeight就是整个scrollHeight
  // clientSize就是视口高度,就是600px
  // this.allVisibleDataLength * this.option.itemHeight - this.clientSize就是剩余可以滚动的距离
  let scrollThuPer = wrap.scrollTop / (this.allVisibleDataLength * this.option.itemHeight - this.clientSize)
  this.scrollThuPer = scrollThuPer > 1 ? 1 : scrollThuPer // 防止越界
},

如下图,假设滚动了200px,而待滚动的区域是1200-600=600px,200/600就是1/3,那么滚动条应该移动剩余可移动距离的1/3

image.png

求出滚动条剩余的可移动距离

  • 在计算属性中设置totalSteps的值,表示可以移动的步数,见下图
  • this.clientSize是视口的高度
  • this.thumbHeight / 100是滑块高度的百分比
  • this.thumbHeight / 100 * this.clientSize就是最终滑块的高度
  • 为什么要写在计算属性中呢?因为该值依赖滑块的高度值
  computed: {
    allVisibleDataLength () {
      return this.allVisibleData.length
    },
    totalSteps () {
      // 视口高度减去滚动条滑块高度,就是可以滑动的距离
      return this.clientSize - this.thumbHeight / 100 * this.clientSize
    },
  },

image.png

最终滚动条移动距离:

  • 侦听器中监听滚动比例
  • 滚动比例 * 剩余可以移动总距离 = 此次应该移动的距离
  • 为什么要侦听呢?因为scrollThuPer的值依赖容器的scrollTop的值的变化
  watch: {
    allVisibleDataLength: {
      handler (newVal, oldVal) {
        if (newVal) {
          // 监听可视数据的变化,去更新占位符的高度,展开或者折叠的时候滚动条发生变化
          this.updatePlaceHolderHeight()
        }
      },
      deep: true
    },
+    scrollThuPer (newV, oldV) {
+      // 滚动比例 * 可以滚动的距离,就是位移的距离
+      this.traveled = Math.ceil(newV * this.totalSteps)
+    }
  },

最终给dom设置:

<div 
  ref="bar"
  class='scrollbar-bar'
>
  <div 
    ref="thumb" 
    class="scrollbar-thumb" 
    :style="{
        height: `${thumbHeight}%`,
+        transform: `translateY(${traveled}px)`
    }"
  ></div>
</div>

3.6 点击滚动条滑轨的空白区域,滚动条直接位移,并且视图能够更新

目标效果:

滚动条06-鼠标点击滑轨滚动条移动.gif

实现方式:

给滑轨组件绑定mouesedown事件

<div 
  ref="bar"
  class='scrollbar-bar'
+  @mousedown="clickTrackHandle"
>
  <div 
    ref="thumb" 
    class="scrollbar-thumb" 
    :style="{
    height: `${thumbHeight}%`,
    transform: `translateY(${traveled}px)`
    }"
  ></div>
</div>

clickTrackHandle函数内部执行:

// 点击轨道某个位置,更新视图和滚动条
clickTrackHandle (e) {
  // 点击位置距离滑轨顶部的距离
  const offset = Math.abs(e.target.getBoundingClientRect().top - e.clientY)
  // 滑块高度的一半
  const thumbHalf = this.$refs.thumb.offsetHeight / 2

  // 计算滚动条在滚动框的百分比位置
  const thumbPositionPercent = (offset - thumbHalf) / this.$refs.bar.offsetHeight
  // 改变包围容器的scrollTop  
  let wrap = this.$refs.viewport
  wrap.scrollTop = thumbPositionPercent *  wrap.scrollHeight
},

offset的值:

  • e.target是滑轨,e.target.getBoundingClientRect().top是滑轨距离浏览器顶部
  • e.clientY是点击位置距离浏览器顶部,我们希望滑块的中间位置移动到这里
  • 上面两个相减就是点击位置距离滑轨顶部,如下图:

image.png

offset - thumbHalf的值:

  • 通过上面求的offset减去滑块高度的一半,就是滑块要移动的距离

image.png

(offset - thumbHalf) / bar.offsetHeight * 100:

  • 滑块移动的距离占整个滑轨的高度,如下图

image.png

wrap.scrollTop = thumbPositionPercent * wrap.scrollHeight

  • wrap容器的srcollTop表示他要设置的滚动距离
  • thumbPositionPercent是滚动条滚动距离占滑轨比例
  • wrap.scrollHeight是总的高度,包括没有显示的数据,注意一定是scrollHeight,而不是offsetHeight image.png

注意设置阻止冒泡:

如下图,当点击滑块时,也触发了滑轨的点击事件,导致滚动条位置发生变化 滚动条06-鼠标点击滑块冒泡.gif

给thumb滑块绑定点击事件

    <div 
      ref="bar"
      class='scrollbar-bar'
      @mousedown="clickTrackHandle"
    >
      <div 
        ref="thumb" 
        class="scrollbar-thumb" 
+        @mousedown="clickThumbHandle"
        :style="{
          height: `${thumbHeight}%`,
          transform: `translateY(${traveled}px)`
        }"
      ></div>

阻止冒泡后解决

    clickThumbHandle (e) {
      e.stopPropagation()
    },

如下图效果 tutieshi_552x1014_5s.gif

3.7 鼠标拖动滚动条,滚动条发生位移,同时视图要能够更新数据

效果如下:

tutieshi_552x1014_10s.gif

在点击滑块的clickThumbHandle方法中补充:

// 点击滑块时触发
clickThumbHandle (e) {
  e.stopPropagation()

  // 判断是否按ctrl键或者鼠标中键、右键
  if (e.ctrlKey || [1, 2].includes(e.button)) {
    return
  }

  this.cursorDown = true

  // 计算鼠标距离滑块底部
  this.state.y = (
    e.currentTarget.offsetHeight // 滑块高度
    -
    ( 
      e.clientY // 鼠标点击距离页面顶部
      - 
      e.currentTarget.getBoundingClientRect().top // 滑块距离页面顶部
    ) // 鼠标点击距离滑块顶部
  )
  this.attachEvents()
},
  • 如上代码中,e.ctrlKey为true表示此时是否按下键盘ctrl键,e.button如果为1表示鼠标此时点的是中键,为2代表右键,0是左键 ==> 拖动时不能按ctrl键,且鼠标不能是按下中键或者右键
  • cursorDown用于判断是否点击了鼠标的滑块
  • this.state.y用于计算鼠标点击位置距离滑块底部的距离,如下图

image.png

attachEvents方法:

  • 监听鼠标移动和鼠标抬起的方法,监听mousemove mouseup事件
  • document.onselectstart = () => false:拖拽时,禁止长文本移动,
attachEvents () {
  // 监听鼠标的移动和抬起事件
  window.addEventListener('mousemove', this.onMouseMoveThumb)
  window.addEventListener('mouseup', this.onMouseUpThumb)
  // 拖拽时禁止长文本移动
  onSelectStart = document.onselectstart
  document.onselectstart = () => false
},

mousemove事件中:

// 鼠标的移动
onMouseMoveThumb (e) {
  // 非按下状态
  if (this.cursorDown === false) return

  // 鼠标拖动时鼠标距离滑轨顶部的距离
  const offset = (
    this.$refs.bar.getBoundingClientRect().top - e.clientY
  ) * -1
  
  if (offset > this.$refs.bar.offsetHeight) return

  // 鼠标距离滑块顶部 这个值可能一直不变
  // 用e.target或者e.currentTarget可能都有问题
  const thumbPosition = this.$refs.thumb.offsetHeight - this.state.y

  // 鼠标移动到底部边界继续移动,不触发
  // 滚动距离
  const distance = offset - thumbPosition
  // 滚动距离占滚动条的比例
  const thumbPositionPercentage = distance * 100 / this.$refs.bar.offsetHeight

  // 滚动距离的比例 * 滚动的高度 / 100
  this.$refs.viewport.scrollTop = thumbPositionPercentage * this.$refs.viewport.scrollHeight / 100
},
  • if (this.cursorDown === false) return如果是非按下状态,则下面的代码都不执行
  • offset值如下图,滚动过程中,该值会一直变大,但是不能超出边界值
  • 求offset值时注意判断是否超过容器高度,超过则不能继续向下执行代码

image.png

  • thumbPosition = 滑块的高度 - 鼠标距离顶部的距离

image.png

  • distance滑块累计移动的距离 = 鼠标距离滑轨顶部 - 鼠标距离滑块顶部

image.png

  • distance / bar.offsetHeight * viewport.scrollHeight
1. 滑块滚动的距离 / 滑轨的高度 ===> 滑块滚动的距离占比
2. 滑块滚动的距离占比 * 整个容器的scrollHeight ===> 容器应该设置的scrollTop值

至此,就能实现如下效果了

效果如下:

tutieshi_552x1014_10s.gif

3.8 鼠标抬起事件

    // 鼠标的抬起
    onMouseUpThumb () {
      this.cursorDown = false
      this.detachEvents()
    }

如下事件中,清空事件监听,恢复onSelectStart的原生事件。 (该变量我是用一个全局变量存储)

    detachEvents() {
      // 移除事件
      window.removeEventListener('mousemove', this.onMouseMoveThumb)
      window.removeEventListener('mouseup', this.onMouseUpThumb)
      document.onselectstart = onSelectStart
    },

4. 源码分析

4.1 源码地址

我在实际项目中用的是vue2,对应UI库是element-ui,element-ui里面是没有虚拟树的,所以我是看element-plus的源码,因为他实现了虚拟树,且他的虚拟树的滚动条也是自己封装的滚动条,没有白屏的效果

element-plus里面有一个单独的scrollBar文件夹,如下:

image.png

但是虚拟树没有直接用这个组件,而是单独有一个文件,地址packages/components/virtual-list,里面专门有个scrollbar.ts,这是虚拟树专门用的组件,源码地址 image.png

4.2 滚动条高度的计算

滑块的高度设置:

  • 如下通过h函数渲染组件,高亮部分是滑块的样式设置
    return () => {
      return h(
        'div',
        {
          role: 'presentation',
          ref: trackRef,
          class: [
            nsVirtualScrollbar.b(),
            props.class,
            (props.alwaysOn || state.isDragging) && 'always-on',
          ],
          style: trackStyle.value,
          onMousedown: withModifiers(clickTrackHandler, ['stop', 'prevent']),
          onTouchstartPrevent: onThumbMouseDown,
        },
        h(
          'div',
          {
            ref: thumbRef,
            class: nsScrollbar.e('thumb'),
+            style: thumbStyle.value,
            onMousedown: onThumbMouseDown,
          },
          []
        )
      )
    }

thumbStyle值来自于thumbSize的值

const thumbStyle = computed<CSSProperties>(() => {
  if (!Number.isFinite(thumbSize.value)) {
    return {
      display: 'none',
    }
  }

+  const thumb = `${thumbSize.value}px`

  const style: CSSProperties = renderThumbStyle(
    {
      bar: bar.value,
      size: thumb,
      move: state.traveled,
    },
    props.layout
  )

  return style
})

thumbSize的值:

  • ratio的值是(clientSize * 100) / this.estimatedTotalSize, 就是上面3.4节中的wrap.clientHeight * 100 / wrap.scrollHeight + '%'
  • 如果ratio >= 100:滚动条不显示,所有内容在可视区域都可见
  • 如果ratio >= 50:假设可视区域是200,总高度scrollHeight是400,那么200 * 100 / 400 = 50,可视区域占总的50%,50 * 200 / 100 = 100,滚动条高度应该是100
  • 如果ratio < 50:element-ui给他设置了最小的高度,这里通常情况就是取值clientSize / 3,官方示例的是高度69
const thumbSize = computed(() => {
  const ratio = props.ratio!
  const clientSize = props.clientSize!
  if (ratio >= 100) {
    return Number.POSITIVE_INFINITY
  }

  if (ratio >= 50) {
    return (ratio * clientSize) / 100
  }

+  const SCROLLBAR_MAX_SIZE = clientSize / 3  // ratio < 50,通常就是用这个值作为滚动条高度

  return Math.floor(
    Math.min(
      Math.max(ratio * clientSize, SCROLLBAR_MIN_SIZE),
      SCROLLBAR_MAX_SIZE
    )
  )
})

ratio的值来自于父组件:packages/components/virtual-list/src/build-list

  const scrollbar = h(Scrollbar, {
    ref: 'scrollbarRef',
    clientSize,
    layout,
    onScroll: onScrollbarScroll,
+    ratio: (clientSize * 100) / this.estimatedTotalSize,
    scrollFrom:
      states.scrollOffset / (this.estimatedTotalSize - clientSize),
    total,
  })

4.3 鼠标滚轮滚动,更新滑块位置

对照上面3.5节的说明看:

packages/components/virtual-list/src/components/scrollBar.ts里面的state.traveled决定了滑块的位移,如下代码是关键

// 监听scrollFrom属性的变化,更新滑块位置
watch(
  () => props.scrollFrom,
  (v) => {
    if (state.isDragging) return
    /**
     *  this is simply mapping the current scrollbar offset
     *
     *  formula 1:
     *    v = scrollOffset / (estimatedTotalSize - clientSize)
     *    traveled = v * (clientSize - thumbSize - GAP) --> v * totalSteps
     *
     *  formula 2:
     *    traveled = (v * clientSize) / (clientSize / totalSteps) --> (v * clientSize) * (totalSteps / clientSize) --> v * totalSteps
     */
+    state.traveled = Math.ceil(v! * totalSteps.value)
  }
)

totalSteps.value是剩余可以滚动的距离,来自下面的props.clientSize视口高度-滚动条的高度-滑块的间距

// 计算总的滚动步数 ==> 还能够滚动的距离
const totalSteps = computed(() => {
  return Math.floor(props.clientSize! - thumbSize.value - unref(GAP))
})

scrollFrom是滚动的比例,值来自build-list.ts, 这里就是我们上面求的scrollThuPer

      const scrollbar = h(Scrollbar, {
        ref: 'scrollbarRef',
        clientSize,
        layout,
        onScroll: onScrollbarScroll,
        ratio: (clientSize * 100) / this.estimatedTotalSize,
+        scrollFrom:
+          states.scrollOffset / (this.estimatedTotalSize - clientSize),
        total,
      })

4.4 点击滑轨空白区域,更新滑块和视图

scrollBar.ts文件:

const clickTrackHandler = (e: MouseEvent) => {
  const offset = Math.abs(
    (e.target as HTMLElement).getBoundingClientRect()[bar.value.direction] -
    e[bar.value.client]
  )
  const thumbHalf = thumbRef.value![bar.value.offset] / 2
  const distance = offset - thumbHalf

  state.traveled = Math.max(0, Math.min(distance, totalSteps.value))
  emit('scroll', distance, totalSteps.value)
}
  • offset是鼠标点击距离滑轨顶部 = getBoundingClientRect().top滑轨距离浏览器顶部 - e.clientY鼠标距离浏览器顶部
  • thumbHalf是滑块的一半,我们希望滑块的一半滑动到鼠标这个位置,
  • distance是滑块要修改的top值,直接赋值给traveled;Math.min(distance, totalSteps.value)表示distance值不能超过totalSteps.value的值
  • emit触发scroll事件,

触发父组件build-list.ts

      const scrollbar = h(Scrollbar, {
        ref: 'scrollbarRef',
        clientSize,
        layout,
+        onScroll: onScrollbarScroll,
        ratio: (clientSize * 100) / this.estimatedTotalSize,
        scrollFrom:
          states.scrollOffset / (this.estimatedTotalSize - clientSize),
        total,
      })

下面offset的距离是wrap容器应该设置的scrollTop值

      const onScrollbarScroll = (distanceToGo: number, totalSteps: number) => {
        // estimatedTotalSize.value => allVisibleData.length * itemSize
        // clientSize.value => 视口高度(可视区域的高度)
        // estimatedTotalSize.value - clientSize.value ==> 需要滚动的总距离
        // totalSteps => bar.offsetHeight - thumb.offsetHeight
        // (estimatedTotalSize.value - clientSize.value) / totalSteps ==> 需要滚动的距离 / 总步骤数 = 每一步的滚动距离 
        // distanceToGo => 当前位置滚动到目标为止的距离
        // 每一步的距离 * 当前位置滚动到目标为止的距离 = wrap的scrollTop的值

        const offset =
          ((estimatedTotalSize.value - (clientSize.value as number)) /
            totalSteps) *
          distanceToGo
        scrollTo(
          Math.min(
            estimatedTotalSize.value - (clientSize.value as number),
            offset
          )
        )
      }

在onUpdated函数里面触发修改,windowElement就是虚拟列表组件

onUpdated(() => {
        const { direction, layout } = props
        const { scrollOffset, updateRequested } = unref(states)
        const windowElement = unref(windowRef)
        if (updateRequested && windowElement) {
          if (layout === HORIZONTAL) {
            if (direction === RTL) {
              // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
              // This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
              // So we need to determine which browser behavior we're dealing with, and mimic it.
              switch (getRTLOffsetType()) {
                case RTL_OFFSET_NAG: {
                  windowElement.scrollLeft = -scrollOffset
                  break
                }
                case RTL_OFFSET_POS_ASC: {
                  windowElement.scrollLeft = scrollOffset
                  break
                }
                default: {
                  const { clientWidth, scrollWidth } = windowElement
                  windowElement.scrollLeft =
                    scrollWidth - clientWidth - scrollOffset
                  break
                }
              }
            } else {
              windowElement.scrollLeft = scrollOffset
            }
          } else {
+            windowElement.scrollTop = scrollOffset
          }
        }
      })

4.5 拖动滚动条

scrollBar.ts文件中:

const onThumbMouseDown = (e: Event) => {
  // 阻止冒泡,上文我们也写了
  e.stopImmediatePropagation()
  // 避免拖动时按了ctrl键,鼠标必须是左键
  if (
    (e as KeyboardEvent).ctrlKey ||
    [1, 2].includes((e as MouseEvent).button)
  ) {
    return
  }

  state.isDragging = true
  // bar.value.offset =>  el-scrollbar__thumb.offsetHeight 滑块的高度
  // e[bar.value.client] =>  e.clientY 鼠标距离顶部的距离
  // el.getBoundingClientRect()[bar.value.direction]) => el-scrollbar__thumb.getBoundingClientRect().top 滑块距离页面顶部的距离
  // bar.value.axis => Y
  state[bar.value.axis] =
    e.currentTarget![bar.value.offset] -
    (e[bar.value.client] -
      (e.currentTarget as HTMLElement).getBoundingClientRect()[
      bar.value.direction
      ])

  emit('start-move')
  attachEvents()
}

按下那一刻开始处理事件

const attachEvents = () => {
  // 监听鼠标的移动和抬起事件
  window.addEventListener('mousemove', onMouseMove)
  window.addEventListener('mouseup', onMouseUp)

  const thumbEl = unref(thumbRef)

  if (!thumbEl) return

  //  拖拽时,禁止鼠标长按长文本选中
  onselectstartStore = document.onselectstart
  document.onselectstart = () => false

  thumbEl.addEventListener('touchmove', onMouseMove, { passive: true })
  thumbEl.addEventListener('touchend', onMouseUp)
}

鼠标移动方法

// 处理鼠标移动事件,更新滑块的位置并发出滚动事件
const onMouseMove = (e: Event) => {
  const { isDragging } = state
  // 判断是否是拖动
  if (!isDragging) return
  // 判断滑块和滑轨dom是否存在
  if (!thumbRef.value || !trackRef.value) return

  // prevPage就是state.y,是鼠标距离滑块顶部的距离
  const prevPage = state[bar.value.axis]
  if (!prevPage) return

  cAF(frameHandle!)
  // using the current track's offset top/left - the current pointer's clientY/clientX
  // to get the relative position of the pointer to the track.
  
  // 鼠标距离滑轨顶部 = (滑轨距离浏览器顶部 - 鼠标距离浏览器顶部) * (-1)
  const offset =
    (trackRef.value.getBoundingClientRect()[bar.value.direction] -
      e[bar.value.client]) *
    -1

  // find where the thumb was clicked on.
  // 鼠标点击距离滑块顶部 = 滑块高度 - 鼠标点击距滑块底部
  const thumbClickPosition = thumbRef.value[bar.value.offset] - prevPage
  /**
   *  +--------------+                                   +--------------+
   *  |              -  <--------- thumb.offsetTop       |              |
   *  |             |+|             <--+                 |              |
   *  |              -                 |                 |              |
   *  |   Content    |                 |                 |              |
   *  |              |                 |                 |              |
   *  |              |                 |                 |              |
   *  |              |                 |                 |              -
   *  |              |                 +-->              |             |+|
   *  |              |                                   |              -
   *  +--------------+                                   +--------------+
   */

  // using the current position - prev position to

  // 滑块距离滑轨顶部 = 鼠标距离滑轨顶 - 鼠标距离滑块顶
  const distance = offset - thumbClickPosition
  // get how many steps in total.
  // gap of 2 on top, 2 on bottom, in total 4.
  // using totalSteps ÷ totalSize getting each step's size * distance to get the new
  // scroll offset to scrollTo
  frameHandle = rAF(() => {
    // 更新滑块的位置,通常是distance的值
    state.traveled = Math.max(
      props.startGap,
      Math.min(
        distance,
        totalSteps.value // 2 is the top value
      )
    )
    emit('scroll', distance, totalSteps.value)
  })
}

执行父组件的这个

const scrollbar = h(Scrollbar, {
    ref: 'scrollbarRef',
    clientSize,
    layout,
+    onScroll: onScrollbarScroll,
    ratio: (clientSize * 100) / this.estimatedTotalSize,
    scrollFrom:
      states.scrollOffset / (this.estimatedTotalSize - clientSize),
    total,
})

下面的逻辑和之前的就很类似了

const onScrollbarScroll = (distanceToGo: number, totalSteps: number) => {
    // estimatedTotalSize.value => allVisibleData.length * itemSize
    // clientSize.value => 视口高度(可视区域的高度)
    // estimatedTotalSize.value - clientSize.value ==> 需要滚动的总距离
    // totalSteps => bar.offsetHeight - thumb.offsetHeight
    // (estimatedTotalSize.value - clientSize.value) / totalSteps ==> 需要滚动的距离 / 总步骤数 = 每一步的滚动距离 
    // distanceToGo => 当前位置滚动到目标为止的距离
    // 每一步的距离 * 当前位置滚动到目标为止的距离 = wrap的scrollTop的值

    const offset =
      ((estimatedTotalSize.value - (clientSize.value as number)) /
        totalSteps) *
      distanceToGo
    scrollTo(
      Math.min(
        estimatedTotalSize.value - (clientSize.value as number),
        offset
      )
    )
}

这里也几乎类似,这之后再执行onUpdated就好了

const scrollTo = (offset: number) => {
    offset = Math.max(offset, 0)

    if (offset === unref(states).scrollOffset) {
      return
    }
    states.value = {
      ...unref(states),
      scrollOffset: offset,
      scrollDir: getScrollDir(unref(states).scrollOffset, offset),
      updateRequested: true,
    }

    nextTick(resetIsScrolling)
}

5. 参考文章

阅读 element-ui 源代码来分析 Scrollbar(滚动条) 组件的实现

elementUI 源码-打造自己的组件库,系列五:Scrollbar组件