tsx实现适配vue3的滚动列表插件

在vue3中想使用滚动列表插件的时候发现,vue-seamless-scroll并不支持vue3版本。

所以这里介绍一下适配vue3版本的滚动列表插件,采用的这位前辈的源代码,技术使用的是tsx。

完整代码

import { computed, CSSProperties, defineComponent, onBeforeMount, onMounted, ref, watch } from 'vue'

const props = {
  // 是否开启自动滚动
  modelValue: {
    type: Boolean,
    default: true,
  },
  // 原始数据列表
  list: {
    type: Array,
    required: true,
  },
  // 步进速度,step 需是单步大小的约数
  step: {
    type: Number,
    default: 1,
  },
  // 开启滚动的数据量
  limitScrollNum: {
    type: Number,
    default: 1,
  },
  // 是否开启鼠标悬停
  hover: {
    type: Boolean,
    default: false,
  },
  // 控制滚动方向
  direction: {
    type: String,
    default: 'up',
  },
  // 单步运动停止的高度
  singleHeight: {
    type: Number,
    default: 0,
  },
  // 单步运动停止的宽度
  singleWidth: {
    type: Number,
    default: 0,
  },
  // 单步停止等待时间(默认值 1000ms)
  singleWaitTime: {
    type: Number,
    default: 1000,
  },
  // 是否开启 rem 度量
  isRemUnit: {
    type: Boolean,
    default: false,
  },
  // 开启数据更新监听
  isWatch: {
    type: Boolean,
    default: true,
  },
  // 动画时间
  delay: {
    type: Number,
    default: 0,
  },
  // 动画方式
  ease: {
    type: [String, Object],
    default: 'ease-in',
  },
  // 动画循环次数,-1表示一直动画
  count: {
    type: Number,
    default: -1,
  },
  // 拷贝几份滚动列表
  copyNum: {
    type: Number,
    default: 1
  }
}

const Vue3SeamlessScroll = defineComponent({
  name: 'vue3SeamlessScroll',
  inheritAttrs: false,
  props,
  emits: ['stop', 'count'],
  setup(props, { slots, emit, attrs }) {
    const scrollRef = ref(null)
    const slotListRef = ref<HTMLBodyElement>()
    const realBoxRef = ref<HTMLBodyElement>()
    const reqFrame = ref<number>(0)
    const singleWaitTimeout = ref(0)
    const realBoxWidth = ref(0)
    const realBoxHeight = ref(0)
    const xPos = ref(0)
    const yPos = ref(0)
    const isHover = ref(false)
    const _count = ref(0);

    // 如果列表长度大于最小滚动长度,就可以发生滚动
    const isScroll = computed(() => props.list!.length >= props.limitScrollNum)

    const realBoxStyle = computed<CSSProperties>(() => {
      return {
        width: realBoxWidth.value ? `${realBoxWidth.value}px` : 'auto',
        transform: `translate(${xPos.value}px,${yPos.value}px)`,
        // @ts-ignore
        transition: `all ${typeof props.ease === 'string' ? props.ease : 'cubic-bezier(' + props.ease.x1 + ',' + props.ease.y1 + ',' + props.ease.x2 + ',' + props.ease.y2 + ')'} ${props.delay}ms`,
        overflow: 'hidden'
      }
    })

    const isHorizontal = computed(
      () => props.direction == 'left' || props.direction == 'right'
    )

    const floatStyle = computed<CSSProperties>(() => {
      return isHorizontal.value
        ? { float: 'left', overflow: 'hidden' }
        : { overflow: 'hidden' }
    })

    const baseFontSize = computed(() => {
      return props.isRemUnit
        ? parseInt(
          globalThis.window.getComputedStyle(globalThis.document.documentElement, null).fontSize
        )
        : 1
    })

    const realSingleStopWidth = computed(
      () => props.singleWidth * baseFontSize.value
    )

    const realSingleStopHeight = computed(
      () => props.singleHeight * baseFontSize.value
    )

    const step = computed(() => {
      let singleStep: number
      let _step = props.step
      if (isHorizontal.value) {
        singleStep = realSingleStopWidth.value
      } else {
        singleStep = realSingleStopHeight.value
      }
      if (singleStep > 0 && singleStep % _step > 0) {
        console.error(
          "如果设置了单步滚动,step需是单步大小的约数,否则无法保证单步滚动结束的位置是否准确。~~~~~"
        )
      }
      return _step
    })

    function cancle() {
      cancelAnimationFrame(reqFrame.value);
      reqFrame.value = 0;
    }

    function move() {
      cancle();
      if (isHover.value || !isScroll.value || _count.value === props.count) {
        emit('stop', _count.value);
        _count.value = 0;
        return;
      }
      reqFrame.value = requestAnimationFrame(function () {
        const h = realBoxHeight.value / 2
        const w = realBoxWidth.value / 2
        let { direction, singleWaitTime } = props
        if (direction === 'up') {
          if (Math.abs(yPos.value) >= h) {
            yPos.value = 0
            _count.value += 1
            emit('count', _count.value)
          }
          yPos.value -= step.value
        } else if (direction === 'down') {
          if (yPos.value >= 0) {
            yPos.value = h * -1
            _count.value += 1
            emit('count', _count.value)
          }
          yPos.value += step.value
        } else if (direction === 'left') {
          if (Math.abs(xPos.value) >= w) {
            xPos.value = 0
            _count.value += 1
            emit('count', _count.value)
          }
          xPos.value -= step.value
        } else if (direction === 'right') {
          if (xPos.value >= 0) {
            xPos.value = w * -1
            _count.value += 1
            emit('count', _count.value)
          }
          xPos.value += step.value
        }
        if (singleWaitTimeout.value) {
          clearTimeout(singleWaitTimeout.value)
        }
        if (!!realSingleStopHeight.value) {
          if (Math.abs(yPos.value) % realSingleStopHeight.value < step.value) {
            singleWaitTimeout.value = setTimeout(() => {
              move()
            }, singleWaitTime)
          } else {
            move()
          }
        } else if (!!realSingleStopWidth.value) {
          if (Math.abs(xPos.value) % realSingleStopWidth.value < step.value) {
            singleWaitTimeout.value = setTimeout(() => {
              move()
            }, singleWaitTime)
          } else {
            move()
          }
        } else {
          move()
        }
      });
    }

    function initMove() {
      if (isHorizontal.value) {
        let slotListWidth = slotListRef?.value?.offsetWidth;
        slotListWidth = slotListWidth! * 2 + 1;
        realBoxWidth.value = slotListWidth;
      }

      if (isScroll.value) {
        realBoxHeight.value = realBoxRef?.value?.offsetHeight!;
        if (props.modelValue) {
          move();
        }
      } else {
        cancle();
        yPos.value = xPos.value = 0;
      }
    }

    function startMove() {
      isHover.value = false;
      move();
    }

    function stopMove() {
      isHover.value = true;
      if (singleWaitTimeout.value) {
        clearTimeout(singleWaitTimeout.value);
      }
      cancle();
    }

    // ok
    const hoverStop = computed(
      () => props.hover && props.modelValue && isScroll.value
    )

    function reset() {
      cancle();
      isHover.value = false;
      initMove();
    }

    watch(
      () => props.list,
      () => {
        if (props.isWatch) {
          reset();
        }
      },
      {
        deep: true,
      }
    );

    watch(
      () => props.modelValue,
      (newValue) => {
        if (newValue) {
          startMove();
        } else {
          stopMove();
        }
      }
    );

    watch(() => props.count, (newValue) => {
      if (newValue !== 0) {
        startMove();
      }
    })

    onBeforeMount(() => {
      cancle();
      clearTimeout(singleWaitTimeout.value);
    });

    onMounted(() => {
      initMove()
    })
    
    const { default: $default, html } = slots
    const copyNum = new Array(props.copyNum).fill(null)
    return () => (
      <div ref={scrollRef} class={attrs.class}>
        <div ref={realBoxRef} style={realBoxStyle.value} onMouseenter={() => {
          if (hoverStop.value) {
            stopMove();
          }
        }} onMouseleave={() => {
          if (hoverStop.value) {
            startMove();
          }
        }}>
          <div ref={slotListRef} style={floatStyle.value}>
            {$default!()}
          </div>
          {
            isScroll ? copyNum.map(() => {
              if (html && typeof html === 'function') {
                return (<div style={floatStyle.value}>{html()}</div>)
              } else {
                return (<div style={floatStyle.value}>{$default!()}</div>)
              }
            }) : null
          }
        </div >
      </div >
    )
  }
})

export default Vue3SeamlessScroll

组件结构

首先看看在defineComponent中返回的部分:

// 通过结构赋值拿到slots的 default 和 html 属性
const { default: $default, html } = slots

以前我并没有使用过这个html属性,于是去查了一下slots的类型定义:

export declare type Slots = Readonly<InternalSlots>;

declare type InternalSlots = {
    [name: string]: Slot | undefined;
};
export declare type Slot = (...args: any[]) => VNode[];

可以看到slots是一个只读的对象,key为字符串,value为一个返回VNode数组的函数。这也是为什么我们可以很方便的使用匿名插槽、具名插槽等功能,默认情况下这里的name的值为default,通过slots.default!()便可以拿到匿名插槽中的VNode。

而html部分,是在父组件也为tsx文件时,向子组件传递进来了一个html结构的函数,可以通过判断html是否为function来进行渲染。

const copyNum = new Array(props.copyNum).fill(null)

props.copyNum的作用是声明待滚动列表需要拷贝的次数,fill方法是将数组中的所有元素都用null值代替,假设copyNum = 3,那么通过这个操作可以生成[null, null, null]数组。

平时使用的时候将文件设置为无限循环滚动的话就不必使用这个功能

return () => (
    <!-- attrs值为是父组件中没有通过bind绑定的数据 -->
    <div ref={scrollRef} class={attrs.class}>
        <!-- 作为滚动列表item的父容器,设置transition等动画样式 -->
        <div ref={realBoxRef} style={realBoxStyle.value} 
            <!-- 增加两个原生事件,负责判断鼠标悬停 -->
            onMouseenter={() => {
                if (hoverStop.value) {
                    stopMove();
                }
            }} 
            onMouseleave={() => {
                if (hoverStop.value) {
                    startMove();
                }
            }}
        >
        	<!-- 设置具体的子滚动item -->
            <div ref={slotListRef} style={floatStyle.value}>
                {$default!()}
            </div>
            <!-- 这里也是设置具体的子滚动item,设置的是拷贝列表的数据 -->
            {	
                isScroll ? copyNum.map(() => {
                    if (html && typeof html === 'function') {
                        return (<div style={floatStyle.value}>{html()}</div>)
                    } else {
                        return (<div style={floatStyle.value}>{$default!()}</div>)
                    }
                }) : null
            }
        </div>
    </div>
)

所以这个组件大致的html结构为:

|外部根容器
|
|————滚动item的父容器,设置动画样式
|
|————————滚动item

结构功能

结合组件结构往下介绍,首先从上向下介绍组件结构中出现过的方法

realBoxStyle

通过computed动态返回css对象形式的样式

const realBoxStyle = computed<CSSProperties>(() => {
	return {
        width: realBoxWidth.value ? `${realBoxWidth.value}px` : 'auto',
        transform: `translate(${xPos.value}px,${yPos.value}px)`,
        // @ts-ignore
        transition: `all ${typeof props.ease === 'string' ? props.ease : 'cubic-bezier(' + props.ease.x1 + ',' + props.ease.y1 + ',' + props.ease.x2 + ',' + props.ease.y2 + ')'} ${props.delay}ms`,
        overflow: 'hidden'
      }
    })

hoverStop

如果想要执行鼠标悬停的功能,首先需要确保props传递进来的hover为true。

所以如果开启了悬停功能,hoverStop的值将固定为true,这是为了确保后面的onMouseEnter事件顺利的恒定执行

const hoverStop = computed(
	() => props.hover && props.modelValue && isScroll.value
)

stopMove

暂停滚动效果的时候,就是鼠标悬停的时候,所以负责记录鼠标isHover的值将会变为true

同时,毕竟这是一个滚动的列表,所以需要清除定时器。之后会在具体的滚动方法中介绍设置定时器

function stopMove() {
	isHover.value = true;
	if (singleWaitTimeout.value) {
		clearTimeout(singleWaitTimeout.value);
	}
	cancle();
}

滚动

在进行滚动部分的代码编写之前,再次看看组件的结构。

具体滚动的是滚动item,所以在具体的执行滚动操作之前,我们需要先对他的父元素进行一些初始化。

initMove

function initMove() {
    // 设置滚动列表的宽度
	if (isHorizontal.value) {
        // 获取到每一个item的宽度
		let slotListWidth = slotListRef?.value?.offsetWidth;
		slotListWidth = slotListWidth! * 2 + 1;
		realBoxWidth.value = slotListWidth;
	}
	// 判断是否可以滚动 如果可以滚动,就执行内部的move方法
	if (isScroll.value) {
		realBoxHeight.value = realBoxRef?.value?.offsetHeight!;
		if (props.modelValue) {
			move();
		}
	} else {
		cancle();
		yPos.value = xPos.value = 0;
	}
}

由于可以通过props设置滚动的方向,所以在判断是否滚动之前,我们可以先判断一下它的具体滚动方向:

const isHorizontal = computed(() => 
	props.direction == 'left' || props.direction == 'right'
)

如果列表长度大于最小滚动长度,就可以发生滚动:

const isScroll = computed(() => props.list!.length >= props.limitScrollNum)

move

function move() {
    // 和使用定时器一样,在设置运动操作之前需要清除上一次的运动
	cancle();
    // 手动暂停,_count负责记录已经滚动过去了多少个item
    if (isHover.value || !isScroll.value || _count.value === props.count) {
        emit('stop', _count.value);
        _count.value = 0;
        return;
    }
    reqFrame.value = requestAnimationFrame(function () {
        const h = realBoxHeight.value / 2
        const w = realBoxWidth.value / 2
        let { direction, singleWaitTime } = props
        // 结合realBoxStop来看,transform(xPos,yPos),所以向上移动的话,yPos是 < 0的
        if (direction === 'up') {
            // 如果上移过多,就会回到原点,同时滚动次数+1
            if (Math.abs(yPos.value) >= h) {
                yPos.value = 0
                _count.value += 1
                emit('count', _count.value)
            }
            // 每次滚动单步距离
            yPos.value -= step.value
            // 下同
        } else if (direction === 'down') {
            if (yPos.value >= 0) {
                yPos.value = h * -1
                _count.value += 1
                emit('count', _count.value)
            }
            yPos.value += step.value
        } else if (direction === 'left') {
            if (Math.abs(xPos.value) >= w) {
                xPos.value = 0
                _count.value += 1
                emit('count', _count.value)
            }
            xPos.value -= step.value
        } else if (direction === 'right') {
            if (xPos.value >= 0) {
                xPos.value = w * -1
                _count.value += 1
                emit('count', _count.value)
            }
            xPos.value += step.value
        }
        if (singleWaitTimeout.value) {
            clearTimeout(singleWaitTimeout.value)
        }
        // !!的作用就是将所有的值转换为boolean类型
        // !!1 = true	!!0 = false	  !!null = false	!!'' = false
        if (!!realSingleStopHeight.value) {
            if (Math.abs(yPos.value) % realSingleStopHeight.value < step.value) {
                singleWaitTimeout.value = setTimeout(() => {
                    move()
                }, singleWaitTime)
            } else {
                move()
            }
        } else if (!!realSingleStopWidth.value) {
            if (Math.abs(xPos.value) % realSingleStopWidth.value < step.value) {
                singleWaitTimeout.value = setTimeout(() => {
                    move()
                }, singleWaitTime)
            } else {
                move()
            }
        } else {
            move()
        }
    });
}

数据刷新

在做完基本滚动功能后,可以考虑一下数据处理的问题

比如这里,我们可以监测list数值的变化,同时开启deep: true开启深度监测

function reset() {
	cancle();
	isHover.value = false;
	initMove();
}

watch(
	() => props.list,
	() => {
		if (props.isWatch) {
			reset();
		}
	},
	{
		deep: true,
	}
);
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值