在业务中,我是如何实现虚拟滚动的(源码和解决方案) 下

·  阅读 447

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第6篇文章,点击查看活动详情

我们承接前两篇文章

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 上

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中

继续来说说rc-virtual-list的滚动是如何实现的。不要忘记原来提出的问题!

如何计算滚动条的高度?
如何滚动?谁在滚动?
复制代码

滚动条模块

在这里,我们需要注意一点,我们所说的滚动条模块,仅仅是样式上的滚动条!!

样式

我们将滚动条样式的代码注释掉后,其还是可以实现滚动加载的,只是滚动条样式没有了而已 image.png

image.png 所以我们来看看滚动条的样式是如何搞定的吧!!

//...

const MIN_SIZE = 20;
export interface ScrollBarProps {
  prefixCls: string;
  // 列表内容滚动高度
  scrollTop: number;
  // scrollHeight这里指所有的item的高度之和
  scrollHeight: number;
  // 可视区域的高度
  height: number;
  // 所有数据的长度
  count: number;
  // 滚动时,通过syncScrollTop方法设置容器rc-virtual-list-holder的滚动高度
  onScroll: (scrollTop: number) => void;
}
//...
  // 组件更新后立即调用
  componentDidUpdate(prevProps: ScrollBarProps) {
    if (prevProps.scrollTop !== this.props.scrollTop) {
      this.delayHidden();
    }
  }
//...
  // 两秒没有滚动,滚动条将会消失
  delayHidden = () => {
    clearTimeout(this.visibleTimeout);

    this.setState({ visible: true });
    this.visibleTimeout = setTimeout(() => {
      this.setState({ visible: false });
    }, 2000);
  };
//...

  // ======================= Clean =======================
  patchEvents = () => {
    window.addEventListener('mousemove', this.onMouseMove);
    window.addEventListener('mouseup', this.onMouseUp);

    this.thumbRef.current.addEventListener('touchmove', this.onMouseMove);
    this.thumbRef.current.addEventListener('touchend', this.onMouseUp);
  };

  removeEvents = () => {
    window.removeEventListener('mousemove', this.onMouseMove);
    window.removeEventListener('mouseup', this.onMouseUp);

    this.scrollbarRef.current?.removeEventListener('touchstart', this.onScrollbarTouchStart);

    if (this.thumbRef.current) {
      this.thumbRef.current.removeEventListener('touchstart', this.onMouseDown);
      this.thumbRef.current.removeEventListener('touchmove', this.onMouseMove);
      this.thumbRef.current.removeEventListener('touchend', this.onMouseUp);
    }

    raf.cancel(this.moveRaf);
  };

  // ======================= Thumb =======================
  // 保持鼠标状态在滚动条上
  onMouseDown = (e: React.MouseEvent | TouchEvent) => {
    const { onStartMove } = this.props;

    this.setState({
      dragging: true,
      pageY: getPageY(e),
      startTop: this.getTop(),
    });

    onStartMove();
    this.patchEvents();
    e.stopPropagation();
    e.preventDefault();
  };

  onMouseMove = (e: MouseEvent | TouchEvent) => {
    const { dragging, pageY, startTop } = this.state;
    const { onScroll } = this.props;

    raf.cancel(this.moveRaf);

    if (dragging) {
      const offsetY = getPageY(e) - pageY;
      const newTop = startTop + offsetY;

      const enableScrollRange = this.getEnableScrollRange();
      const enableHeightRange = this.getEnableHeightRange();

      const ptg = enableHeightRange ? newTop / enableHeightRange : 0;
      const newScrollTop = Math.ceil(ptg * enableScrollRange);
      this.moveRaf = raf(() => {
        onScroll(newScrollTop);
      });
    }
  };

  onMouseUp = () => {
    const { onStopMove } = this.props;
    this.setState({ dragging: false });

    onStopMove();
    this.removeEvents();
  };

  // ===================== Calculate =====================
  // 计算滚动条的高度
  getSpinHeight = () => {
    const { height, count } = this.props;
    // 基本高度 = 可视高度/数量总长度*10;
    let baseHeight = (height / count) * 10;
    // 最小20
    baseHeight = Math.max(baseHeight, MIN_SIZE);
    // 最大可视区域高度的一半
    baseHeight = Math.min(baseHeight, height / 2);
    // 向下取整
    return Math.floor(baseHeight);
  };

  getEnableScrollRange = () => {
    const { scrollHeight, height } = this.props;
    // 所有item的高度和 - 可视区域高度
    return scrollHeight - height || 0;
  };

  getEnableHeightRange = () => {
    const { height } = this.props;
    const spinHeight = this.getSpinHeight();
    // 可视区域高度 - 滚动条高度
    return height - spinHeight || 0;
  };

  getTop = () => {
    // 列表内容滚动高度
    const { scrollTop } = this.props;
    // 启用滚动范围
    const enableScrollRange = this.getEnableScrollRange();
    // 使高度范围
    const enableHeightRange = this.getEnableHeightRange();
    // 列表滚动高度或者可以滚动的范围是0
    if (scrollTop === 0 || enableScrollRange === 0) {
      return 0;
    }
    // 组件滚动的高度 / 可以滚动的范围 
    const ptg = scrollTop / enableScrollRange;
    // 乘以 可以滚动的区域
    return ptg * enableHeightRange;
  };

  // Not show scrollbar when height is large than scrollHeight
  //什么时候展示滚动条
  showScroll = (): boolean => {
    const { height, scrollHeight } = this.props;
    return scrollHeight > height;
  };

  // ====================== Render =======================
  render() {
    const { dragging, visible } = this.state;
    const { prefixCls } = this.props;
    const spinHeight = this.getSpinHeight();
    const top = this.getTop();

    const canScroll = this.showScroll();
    const mergedVisible = canScroll && visible;

    return (
      // 滚动条轨道
      <div
        ref={this.scrollbarRef}
        className={classNames(`${prefixCls}-scrollbar`, {
          [`${prefixCls}-scrollbar-show`]: canScroll,
        })}
        style={{
          width: 8,
          top: 0,
          bottom: 0,
          right: 0,
          position: 'absolute',
          //如果超过两秒滚动条没有移动,则滚动条隐藏
          display: mergedVisible ? null : 'none',
        }}
        //鼠标按住 滚动条保持不消失
        onMouseDown={this.onContainerMouseDown}
        //鼠标离开 开启滚动条消失倒计时
        onMouseMove={this.delayHidden}
      >
        {/* 真正的滚动条 */}
        <div
          ref={this.thumbRef}
          className={classNames(`${prefixCls}-scrollbar-thumb`, {
            [`${prefixCls}-scrollbar-thumb-moving`]: dragging,
          })}
          style={{
            height: spinHeight,
            top,
           //...
          }}
          onMouseDown={this.onMouseDown}
        />
      </div>
    );
  }
}

复制代码

这里,我着重关注的是我前面提出的问题

1. 滚动条的高度是如何计算的
2. 如何知道滚动条滚到了什么地方
复制代码

我们可以查阅上述代码,并搜索spinHeighttop,关注一下Calculate模块下的代码,

// 计算滚动条的高度
  getSpinHeight = () => {
    const { height, count } = this.props;
    // 基本高度 = 可视高度/数量总长度*10;
    let baseHeight = (height / count) * 10;
    // 最小20
    baseHeight = Math.max(baseHeight, MIN_SIZE);
    // 最大可视区域高度的一半
    baseHeight = Math.min(baseHeight, height / 2);
    // 向下取整
    return Math.floor(baseHeight);
  };

  getEnableScrollRange = () => {
    const { scrollHeight, height } = this.props;
    // 所有item的高度和 - 可视区域高度
    return scrollHeight - height || 0;
  };

  getEnableHeightRange = () => {
    const { height } = this.props;
    const spinHeight = this.getSpinHeight();
    // 可视区域高度 - 滚动条高度
    return height - spinHeight || 0;
  };

  getTop = () => {
    // 列表内容滚动高度
    const { scrollTop } = this.props;
    // 启用滚动范围
    const enableScrollRange = this.getEnableScrollRange();
    // 使高度范围
    const enableHeightRange = this.getEnableHeightRange();
    // 列表滚动高度或者可以滚动的范围是0
    if (scrollTop === 0 || enableScrollRange === 0) {
      return 0;
    }
    // 组件滚动的高度 / 可以滚动的范围 
    const ptg = scrollTop / enableScrollRange;
    // 乘以 使高度范围
    return ptg * enableHeightRange;
  };
复制代码

我们用一个数学计算,结束这里,来通过数字,走一遍儿top是如何得出的

假设:

我们拥有一个可视高度heignt600,共有item30个,并且所有item高度总和3000;

滚动条高度 = (600 / 300) * 10 = 20;
滚动范围 = (3000 - 600) || 0 = 2400;
使用高度范围 = (600 - 20) || 0 = 580;
复制代码

🌰1:

此时,滚动距离0;
top = 0;
复制代码

🌰2:

此时,滚动距离100;
top = (100 / 2400) * 580 = 24.1667
复制代码

🌰3:

此时,滚动距离2400;
top = (2400 / 2400) * 580 = 580
复制代码

注意这个滚动高度(scrollTop),也是有范围的,就是0 <--> (scrollHeight - heignt)

看到这里,我们滚动条的样式也知道了,但是我们前面说过,样式消失,还是可以实现逻辑,滚动的逻辑是什么实现的呢?

逻辑

是否还记得,我们前面一直提到的Component也就是我们的rc-virtual-list-holder第二层,其实真正的滚动逻辑就在它身上挂着呢。所以我们再想下前面的结构,第二层,同时拥有内容区和滚动条区。滚动条区仅仅是样式展示、所以所有的东东就给到了内容区了。 经过查找,发现滚动的核心是通过监听wheel(翻译过来'轮')事件进行自定义滚动,通过监听这个事件,来实现这一整套逻辑。

我们查看下面的代码,我们可以得知onRawWheel这个是关键,其对应着onWheel方法,经过对改方法的查看,我们知道了,改方法用来听过监听wheel方法,然后通过onWheelDelta也就是外边传入的对syncScrollTop方法的调用,然后动态改变rc-virtual-list-holder的滚动高度。 所以虚拟滚动的一切交互,都是基于rc-virtual-list-holder这一层滚动高度的变化,进而执行的其他逻辑。

  const [onRawWheel, onFireFoxScroll] = useFrameWheel(
    // 是否处于虚拟滚动中 使用虚拟滚动,且有值,并且每一项的高度*数据长度大于可视窗口的高度
    useVirtual,
    // 滚动条是否在顶端
    isScrollAtTop,
    // 滚动条是否在底部
    isScrollAtBottom,
    (offsetY) => {
      // offsetY 是滑动的距离
      // top之前的高度
      //通过syncScrollTop方法设置容器rc-virtual-list-holder的滚动高度
      syncScrollTop((top) => {
        const newTop = top + offsetY;
        return newTop;
      });
    },
  );
复制代码
function onWheel(event: WheelEvent) {
    if (!inVirtual) return;

    raf.cancel(nextFrameRef.current);

    const { deltaY } = event;
    // 获取到滚轮滑动距离,并认为该距离是元素滚动高度 每次滚动的距离之和
    offsetRef.current += deltaY;
    wheelValueRef.current = deltaY;

    // Do nothing when scroll at the edge, Skip check when is in scroll
    // ???
    if (originScroll(deltaY)) return;

    // Proxy of scroll events
    if (!isFF) {
      event.preventDefault();
    }

    nextFrameRef.current = raf(() => {
      // Patch a multiple for Firefox to fix wheel number too small
      // isMouseScrollRef.current != false 是火狐 
      const patchMultiple = isMouseScrollRef.current ? 10 : 1;
      onWheelDelta(offsetRef.current * patchMultiple);
      offsetRef.current = 0;
    });
  }
复制代码

总结

还记得我前面提的几个问题吗

1. 第三层是哪里来的呢?仅仅查看list代码是无法体现出来的?
2. 如何渲染出当前可视窗口的dom元素,依据是什么?
3. 如何计算滚动条的高度?
4. 如何滚动?谁在滚动?
复制代码

我们此时经过源码查看后,好像都解决了! 整个rc-virtual-list组件依托于监听wheel事件、动态改变rc-virtual-list-holder这一层也就是第二层的scrollTop滚动高度,然后其他组件ScrollBar根据这过程中的其他状态和传入的值进行页面显示的滚动条的计算。Filter组件根据状态,计算出需要在可视区域dom渲染的listChildren方法。

其中,一共查阅了三个自定义hooksuseChildren计算要渲染的item,并通过一个巧妙的方法,在useHeights中依托于useChildren保留的item的信息,来计算维护几个高度相关的map对象供其他使用。 useFrameWheel是一切源头,整个组件的滚动要依托它来实现。

最后

以上,是我对rc-virtual-list部分源码阅读后的个人见解。如有不对的地方,欢迎指出。你的每一句,都是我成长的动力!

资源引用

github.com/react-compo…

github.com/Nuibia/virt…

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改