乐趣区

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

需要原型

假如有一列不知数量长度的数据, 想在一个容器内做轮播展现, 根本构造大略如下

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

咱们须要实现的就是 <Carousel> 组件

实现思路一

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

第一步, 实现布局

定好组件的根本构造

<div class="carousel">
  <div class="carousel-wrap" ref="wrapRef">
    <div
      ref="contentRef"
      class="carousel-content"
      :style="style"
    >
      <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 的响应式原理, 它次要关键步骤

  1. 当一个值被读取时进行追踪:proxy 的 get 处理函数中 track 函数记录了该 property 和以后副作用。
  2. 当某个值扭转时进行检测:在 proxy 上调用 set 处理函数。
  3. 从新运行代码来读取原始值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"
  class="carousel-content"
  :style="style1"
  @transitionend="onTransitionEnd"
>
  <slot></slot>
</div>

<div
  class="carousel-content"
  :style="style2"
  @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;
    });
  });
}

有余

这种写法属于简略复制元素拼接达到轮流滑动的成果, 然而也有一些显著的弊病

  1. 代码跟视觉效果不肯定对立, 如果元素内的布局距离不对等或者不对称, 就会显著的看到是两个不同的元素合并, 实际上这是没法防止的问题, 只能标准元素款式

  2. 计算值的偏差

    因为外面波及元素的像素, 偏移地位, 过渡工夫和定时器, 有些数值计算带有小数会造成显著的不同步, 具体表现在滑动速度不同步, 元素之间距离过大或者局部重合等问题,

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

实现思路三

其实跟思路二类似, 只是不必相对定位应用天然偏移, 初始时候元素就曾经呈现在视图内

第一步, 实现布局

不必相对定位

第二步, 设置动画

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

第三步, 计算动画逻辑

这是要害的代码外围

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"
  class="carousel-content"
  :style="style"
  @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 组件

退出移动版