从一次业务需求想到的四种轮播组件实现思路

Afterward -
从一次业务需求想到的四种轮播组件实现思路
需求原型

假设有一列不知数量长度的数据,想在一个容器内做轮播展示,基本结构大概如下

<Carousel>
    <ul>
      <li
        v-for="item in 10"
        :key="item"
      >item</li>
    </ul>
</Carousel>

我们需要实现的就是<Carousel>组件

实现思路一

我们利用css3的translateX进行平移滑动,在列表移出容器的瞬间重置到容器右侧不可见处

第一步,完成布局

定好组件的基本结构

<div >
  <div  ref="wrapRef">
    <div
      ref="contentRef"
      
      :
    >
      <slot></slot>
    </div>
  </div>
</div>

设置基本样式结构

.carousel {
  position: relative;
  display: flex;
  align-items: center;
  &-wrap {
    overflow: hidden;
    position: relative;
    display: flex;
    flex: 1;
    align-items: center;
    height: 100%;
  }
  &-content {
    transition-timing-function: linear;
  }
}

这就完成了整体布局,上面具体可看上面视图一,接下来的问题就是怎么让元素动起来

第二步,设置动画

设置基本参数

const state = reactive({
  offset: 0,
  duration: 0
})

主要动画实现方式

// 样式控制
const getStyle = (data) => {
  return {
    transform: data.offset ? `translateX(${data.offset}px)` : '',
    transitionDuration: `${data.duration}s`,
  }
}
// 轮播样式控制
const style = computed(() => getStyle(state))
第三步,计算动画逻辑

首先肯定需要具体的元素获取参数计算

// 容器宽度
let wrapWidth = 0
// 内容宽度
let contentWidth = 0
let startTime = null
const wrapRef = ref(null)
const contentRef = ref(null)

// 挂载元素后开始计算逻辑
onMounted(reset)

常规的暴露给外部选项

const props = defineProps({
  show: {
    type: Boolean,
    default: true,
  },
  speed: {
    type: [Number, String],
    default: 30,
  },
  delay: {
    type: [Number, String],
    default: 0,
  },
})

初次挂载计算逻辑

const reset = () => {
  // 重置参数
  wrapWidth = 0
  contentWidth = 0
  state.offset = 0
  state.duration = 0
  clearTimeout(startTime)
  
  startTime = setTimeout(() => {
    // 拦截DOM未渲染阶段
    if (!wrapRef.value || !contentRef.value) return

    const wrapRefWidth = useRect(wrapRef).width
    const contentRefWidth = useRect(contentRef).width

    // 内容宽度超过容器宽度才运行
    if (contentRefWidth > wrapRefWidth) {
      wrapWidth = wrapRefWidth;
      contentWidth = contentRefWidth;
      // 重复调用
      doubleRaf(() => {
        state.offset = -contentWidth / 2;
        state.duration = -state.offset / +props.speed;
      })
    }
  }, props.delay);
}

这段代码其实有几个思考的点

doubleRaf函数有什么作用

这是完整函数代码,可以看到他只是单纯的在后面第二次重绘的时候才执行回调

export function raf(fn) {
  return requestAnimationFrame(fn)
}

export function doubleRaf(fn) {
  raf(() => raf(fn));
}

我们回顾requestAnimationFrame的作用是什么

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

回调函数执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的iframe里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。

总的来说就是会根据浏览器提供最佳最快的执行时机,并且在不可见的运行方式会自动暂时调用

这时候会引申出第二个问题

为什么需要等到第二次重绘才执行回调

这个用法实际是我学习vantUI库源码的时候看到的,他们代码注释是这么说的

// use double raf to ensure animation can start

PC模拟器试了运行一下直接在下一次重绘执行回调也没问题,但是实际运行会有写影响因素,假设在最简模式下,我们希望一个元素位移是

translateX(0) -> translateX(1000px) -> translateX(500px)

如果代码如下

box.style.transform = 'translateX(1000px)'
requestAnimationFrame(() => {
  box.style.transform = 'translateX(500px)'
})

它的执行顺序会是,具体原因可以回想一下requestAnimationFrame的作用

translateX(0) -> translateX(500px)

既然知道问题了,这个答案就出来了

第四步,重复执行动画

就如上图二,动画结束的一瞬间重置到容器右侧不可见位置,

首先我们整个动画用的是CSS3实现,直接设置偏移值,再通过过渡效果形成动画

const getStyle = (data) => {
  return {
    transform: data.offset ? `translateX(${data.offset}px)` : '',
    transitionDuration: `${data.duration}s`,
  }
}

既然知道是CSS3过渡动画实现,我们就可以使用transitionend监听

transitionend 事件在 CSS 完成过渡后触发。

注意: 如果过渡在完成前移除,例如 CSS transition-property 属性被移除,过渡事件将不被触发。

const onTransitionEnd = () => {
  state.offset = wrapWidth
  state.duration = 0

  raf(() => {
    // use double raf to ensure animation can start
    doubleRaf(() => {
      state.offset = -contentWidth;
      state.duration = (contentWidth + wrapWidth) / +props.speed;
    });
  });
}

可以看到过滤结束之后,会立马重置属性,然后再更新状态

为什么再嵌入一层raf回调

回顾Vue3的响应式原理,它主要关键步骤

当一个值被读取时进行追踪:proxy 的 get 处理函数中 track 函数记录了该 property 和当前副作用。当某个值改变时进行检测:在 proxy 上调用 set 处理函数。重新运行代码来读取原始值trigger 函数查找哪些副作用依赖于该 property 并执行它们。

一个组件的模板被编译成一个 render函数。渲染函数创建 VNodes,描述该组件应该如何被渲染。它被包裹在一个副作用中,允许 Vue 在运行时跟踪被“触达”的 property。

一个 render 函数在概念上与一个 computed property 非常相似。Vue 并不确切地追踪依赖关系是如何被使用的,它只知道在函数运行的某个时间点上使用了这些依赖关系。如果这些 property 中的任何一个随后发生了变化,它将触发副作用再次运行,重新运行 render 函数以生成新的 VNodes。然后这些举动被用来对 DOM 进行必要的修改。

Vue 在更新 DOM 时是异步执行的。当数据发生变化,Vue将开启一个异步更新队列,视图需要等队列中所有数据变化完成之后,再统一进行更新

所以理论上考虑到性能损耗我们应该在下一个队列更新动画,Vue提供了一个全局APInextTick

将回调推迟到下一个 DOM 更新周期之后执行。在更改了一些数据以等待 DOM 更新后立即使用它。

为什么不用nextTick反而再次嵌套一层raf,从vant源码有相关备注

// wait for Vue to render offset
// using nextTick won't work in iOS14
不足

整个代码实现思路已经完成了,但是这种写法会有种明显的不足,整个动画只能整进整出,界面中间会有一段元素空白期等待动画进场

所以需要往下扩展,考虑怎么实现无缝轮播的效果

实现思路二

我们直接复制两份一样的元素,然后利用延迟执行达到一种无缝连接的效果

第一步,完成布局
<div
  ref="contentRef"
  
  :
  @transitionend="onTransitionEnd"
>
  <slot></slot>
</div>

<div
  
  :
  @transitionend="onTransitionEnd"
>
  <slot></slot>
</div>

改成绝对定位

.carousel {
  position: relative;
  display: flex;
  align-items: center;
  &-wrap {
    overflow: hidden;
    position: relative;
    display: flex;
    flex: 1;
    align-items: center;
    height: 100%;
  }
  &-content {
    position: absolute;
    display: flex;
    white-space: nowrap;
    transition-timing-function: linear;
  }
}
第二步,设置动画

把思路一的基本变量复制两份,所以省略代码

第三步,计算动画逻辑

这是关键的代码核心

const reset = () => {
    ...省略...
    // 内容宽度超过容器宽度才运行
    if (contentRefWidth > wrapRefWidth) {
      wrapWidth = wrapRefWidth
      contentWidth = contentRefWidth;
      
      // 实际动画属性是一样的
      const offset = -(contentWidth + wrapWidth)
      const duration = -offset / +props.speed
      
      // 元素1动画
      doubleRaf(() => {
        state1.offset = offset;
        state1.duration = duration;
      })
      
      // 元素2动画
      setTimeout(() => {
        doubleRaf(() => {
          state2.offset = offset;
          state2.duration = duration;
        })
      }, contentWidth / +props.speed * 1000); // 衔接在元素1后面开始滑动
    }
    ...省略...
}
偏移值计算方式

因为从容器右侧到容器左侧实际等于容器宽度+元素宽度,已知速度恒定可知道偏移时长

const offset = -(contentWidth + wrapWidth)
const duration = -offset / +props.speed
延时计算方式

为了达到无缝衔接效果,元素2需要在元素1刚好不重合的时候开始滑动,即元素偏移距离等于自身宽度的时候

contentWidth / +props.speed * 1000
第四步,重复执行动画

这时候其实就能发现元素1和元素2是在重复执行相同的动画,所以他们共用同一个事件

const onTransitionEnd = () => {
  state.offset = 0
  state.duration = 0

  raf(() => {
    doubleRaf(() => {
      state.offset = -(contentWidth + wrapWidth);
      state.duration = -state.offset / +props.speed;
    });
  });
}
不足

这种写法属于简单复制元素拼接达到轮流滑动的效果,但是也有一些明显的弊端

代码跟视觉效果不一定统一, 假如元素内的布局间隔不对等或者不对称,就会明显的看到是两个不同的元素合并,实际上这是没法避免的问题,只能规范元素样式

计算值的偏差

因为里面涉及元素的像素,偏移位置,过渡时间和定时器,有些数值计算带有小数会造成明显的不同步,具体表现在滑动速度不同步,元素之间间隔过大或者部分重合等问题,

第二点暂时没想法,所以后面放弃这种写法

实现思路三

其实跟思路二相似,只是不用绝对定位使用自然偏移,初始时候元素就已经出现在视图内

第一步,完成布局

不用绝对定位

第二步,设置动画

把思路一的基本变量复制两份,所以省略代码

第三步,计算动画逻辑

这是关键的代码核心

const reset = () => {
  // 重置参数
  wrapWidth = 0
  contentWidth = 0
  state1.offset = 0
  state1.duration = 0
  state2.offset = 0
  state2.duration = 0
  clearTimeout(startTime)
  startTime = setTimeout(() => {
    // 拦截DOM未渲染阶段
    if (!wrapRef.value || !contentRef.value) return

    const wrapRefWidth = useRect(wrapRef).width
    const contentRefWidth = useRect(contentRef).width

    // 内容宽度超过容器宽度才运行
    if (contentRefWidth > wrapRefWidth) {
      wrapWidth = wrapRefWidth
      contentWidth = contentRefWidth;
      doubleRaf(() => {
        state1.offset = -contentWidth;
        state1.duration = -state1.offset / +props.speed;
        state2.offset = -contentWidth * 2;
        state2.duration = -state2.offset / +props.speed;
      })
    }
  }, props.delay);
}
元素1偏移计算

因为本身已经在视图内,只需要偏移出自身宽度即可

state1.offset = -contentWidth;
state1.duration = -state1.offset / +props.speed;
元素2偏移计算

因为是衔接在元素1的后面,所以初始偏移距离等于两者合

state2.offset = -contentWidth * 2;
state2.duration = -state2.offset / +props.speed;
第四步,重复执行动画

和思路三相比因为用的不是绝对定位,所以重置的时候偏移值需要计算位置,,两个元素也需要单独计算

因为元素1的初始偏移值是0,但是重置之后需要定位到元素二的初始位置,所以

const onTransitionEnd1 = () => {
  state1.offset = contentWidth * 2 - wrapWidth
  state1.duration = 0

  raf(() => {
    doubleRaf(() => {
      state1.offset = -contentWidth * 2;
      state1.duration = -state1.offset / +props.speed;
    });
  });
}

至于元素二初始位置不需要变,偏移值也不需要改变

const onTransitionEnd2 = () => {
  state2.offset = 0
  state2.duration = 0

  raf(() => {
    doubleRaf(() => {
      state2.offset = -contentWidth * 2;
      state2.duration = -state2.offset / +props.speed;
    });
  });
}
不足

依然有思路二的问题,所以也放弃了

实现思路四

这是一种不太严谨的写法,但是vue3-seamless-scroll内部也是用同样的方式做

直接把两个元素当作一个整体,在偏移值达到自身宽度的一半瞬间回滚位置

第一步,完成布局
<div
  ref="contentRef"
  
  :
  @transitionend="onTransitionEnd"
>
  <slot></slot>
  <slot></slot>
</div>
第二步,设置动画

把思路一的基本变量复制两份,所以省略代码

第三步,计算动画逻辑

这是关键的代码核心,因为元素宽度合并计算了,所以偏移值需要除二

const reset = () => {
    ...省略...
      doubleRaf(() => {
        state.offset = -contentWidth / 2;
        state.duration = -state.offset / +props.speed;
      }
    ...省略...
}
第四步,重复执行动画

就是一直重复,不需要改动

const onTransitionEnd = () => {
  state.offset = 0
  state.duration = 0

  raf(() => {
    doubleRaf(() => {
      state.offset = -contentWidth / 2;
      state.duration = -state.offset / +props.speed;
    });
  });
}
不足

因为一直都是自身偏移,没有上面的问题二偏差,不过重置瞬间仔细看会有一丝停顿,除此之外都很丝滑,所以最后用这个方案实现了一个Carousel组件

特别申明:本文内容来源网络,版权归原作者所有,如有侵权请立即与我们联系(cy198701067573@163.com),我们将及时处理。
下一篇: 现代前端开发

Tags 标签

javascript前端vue.jscsshtml5

扩展阅读

加个好友,技术交流

1628738909466805.jpg