手撸 React 轮播组件

1,521 阅读5分钟

尝试自己写一个 React 的轮播组件,用来练练手~

讲解写的不多,主要还是代码,不过里面也有不少注释

难点

  • 随时停止。在切换或者复位的移动过程中,若手指再次进入,将使移动暂停,转而跟随手指移动
  • 无限滚动。可以一直向同一个方向切换
  • 高度自适应。不用固定高度,整个轮播组件的高度将根据图片的高度来,整个轮播组件的高度将由最高的那张图片来决定

知识点

带过渡的前进

  • 使用二分法来前进,每次前进剩余距离的二分之一
  • window.requestAnimationFrame 中不断移动

边界的划定

/**
 * 标记实际所存在的dom个数及分布方式
 *
 * |-2|  |-1|  |0|  |1|  |2|  |3|  |3|
 *              ↑
 *      在中间可视区域内显示的那个
 *
 * 左边有2个“预留”元素,右边有4个“预留”元素
 * 不对称性的原因是,轮播组件中“向右移动”的情况居多
 *
 * p.s. 可以适当增加左右侧预留数量,但是数量越多,性能越差(因为dom变多了)
 */
const Reserved = [-2, -1, 0, 1, 2, 3, 4];

源码

// Carousel.module.less
.carousel {
  width: 100%;
  overflow: hidden;
  position: relative;
  font-size: 0;
  > .surface {
    position: absolute;
    top: 0;
    height: 100%;
    > ul {
      width: 100%;
      height: 100%;
      display: flex;
      flex-direction: row;
      justify-content: center;
      align-items: center;
      transform: translate3d(0, 0, 0);
      &:after,
      &:before {
        content: "";
        display: table;
        clear: both;
      }
      > li {
        display: flex;
        justify-content: center;
        align-items: center;
        flex: 1;
        > * {
          display: block;
          width: 100%;
        }
      }
    }
  }
  > .skeleton {
    position: relative;
    visibility: hidden;
    opacity: 0;
    pointer-events: none;
    display: flex;
    flex-direction: row;
    justify-content: flex-start;
    align-items: center;
    > li {
      flex: 1;
      > * {
        display: block;
        width: 100%;
      }
    }
  }
}
// Carousel.tsx
import React from "react";
import classes from "./Carousel.module.less";

/**
 * 触发swiper的阀值
 * 目前情况已经能捕捉绝大部分情况,如果还有体验较差(捕捉不够灵敏、或太过灵敏)等情况,再调整数值
 */
const TriggerSwiperThreshold = {
  size: 0.5, // 0.5 个元素的宽度
  speed: 0.6, // 每毫秒移动 0.6 个像素
};

/**
 * 移动速率常量
 *
 * 这里使用二分法来“前进”,所以是2
 * 即第一个周期(16ms)移动路程的二分之一 (50%),第二个周期移动剩余路程的二分之一(25%),第三个周期移动到剩余路程的二分之一(12.5%)
 *
 * 修改能使路程分割的更细(移动得更顺滑),但是基本不会影响周期数(数学原理),所以完成一段路程所需时间差不多
 */
const MovingRateConstant = 2;

/**
 * 移动抵达阀值
 *
 * 因为二分某个数字,只能无限接近于0
 * 当距离目标值已经小于 [MovingApproachThreshold] 了,则判断为已经抵达目的地,直接赋予目标值
 */
const MovingApproachThreshold = 12;

/**
 * 标记实际所存在的dom个数及分布方式
 *
 * |-2|  |-1|  |0|  |1|  |2|  |3|  |3|
 *              ↑
 *      在中间可视区域内显示的那个
 *
 * 左边有2个“预留”元素,右边有4个“预留”元素
 * 不对称性的原因是,轮播组件中“向右移动”的情况居多
 *
 * p.s. 可以适当增加左右侧预留数量,但是数量越多,性能越差(因为dom变多了)
 */
const Reserved = [-2, -1, 0, 1, 2, 3, 4];

const LeftOffsetCount = Reserved.indexOf(0); // 左侧有几个
const RightOffsetCount = Reserved.length - LeftOffsetCount - 1; // 右侧有几个

interface ICarouselProps {
  /**
   * 是否开启自动轮播
   **/
  autoplay: boolean;

  /**
   * 每个元素占据屏幕宽度
   * 取值范围为 (0,1]
   * 当为 1 时,每个元素的宽度与屏幕宽度一致
   **/
  itemSize: number;

  /**
   * 非当前元素的 scale 值(缩放比例)
   * 取值范围为 (0,1]
   * 当值小于 1 时,非当前元素的尺寸就会比当前元素小,在切换时就会有缩放效果(带过渡时间的)
   **/
  inactiveScale: number;

  /**
   * 每次切换之前会触发
   **/
  beforeChange?: (from: number, to: number) => void;
}
interface ICarouselState {
  current: number;
  list: Array<number>;
  transformX: number;
  disabledTouchAction: boolean;
}
export default class Carousel extends React.Component<
  ICarouselProps,
  ICarouselState
> {
  itemRef = React.createRef<HTMLLIElement>();

  timeout?: number; // 滚动过程定时器
  interval?: number; // 自动轮播定时器

  targetX?: number; // 本次移动将要前往的目标位置(px)

  // 记录手指开始触碰屏幕时的位置信息
  mark = {
    current: 0,
    position: 0,
    timestamp: 0,
  };

  /**
   * 连续滚动次数
   *
   * 因为当每次 switch 结束都会 [reset],但是如果每次都还没等切换自动结束时就迅速的再次尝试 switch,
   * 如此连续触发而不给它机会去 [reset],那么 [Reserved] 中定义的“预留”元素将会不足,
   * 从而导致如果迅速向同一方向切换的话,将会发现后续并没有元素可以延续了(出现空白)
   *
   * 这里引入一个属性来记录当前已经连续切换多少次了,在连续往同一方向切换一定次数后,将会禁止继续往该方向切换
   * 范围是: -RightOffsetCount <= uninterruptedSwitch <= LeftOffsetCount
   * 负数为切换到下一个(to-next),即dom向左移动,右侧元素将进入可视区域,正数反之
   *
   * 在每次to-next时减1,每次to-prev时加1,在reset时重置为0,
   * 数值将会被约束在 [-RightOffsetCount,LeftOffsetCount] 这一区间内,超出了则会禁用to-next和to-prev
   */
  uninterruptedSwitch: number = 0;

  constructor(props: ICarouselProps) {
    super(props);
    this.state = {
      current: 0,
      list: [],
      transformX: 0,
      disabledTouchAction: false,
    };
  }

  static defaultProps = {
    autoplay: false,
    itemSize: 1,
    inactiveScale: 1,
  };

  componentDidMount() {
    this.reset();
  }

  get itemWidth() {
    return this.itemRef.current?.clientWidth || 0;
  }

  get count() {
    return React.Children.toArray(this.props.children).length;
  }

  /**
   * 每个元素的scale值其实就是n个向左右偏移的抛物线
   * y=a(x-h)²+k
   *
   * h:偏移量,每一个元素偏移 [itemWidth]
   * a:开口,开口向下,值由y变化范围 [1-inactiveScale] 以及h决定,即 -((1-inactiveScale)/(itemWidth)²)
   * k:顶点,scale的最大值,即为1
   */
  get scale() {
    const itemWidth = this.itemWidth,
      { inactiveScale } = this.props,
      { transformX } = this.state,
      calcScale = (index: number) => {
        const scale =
          -((1 - inactiveScale) / Math.pow(itemWidth, 2)) * // a
            Math.pow(-transformX - index * itemWidth, 2) + // (x-h)²
          1; // k
        return scale < inactiveScale ? inactiveScale : scale; // 两段式,当计算的scale小于inactiveScale时,取inactiveScale
      };
    return Reserved.map((item) => calcScale(item));
  }

  /**
   * 重置dom
   * 计算几个dom分别展示的是哪些children
   */
  private reset() {
    const { current } = this.state;
    let list: Array<number> = [];
    const count = this.count;
    switch (count) {
      case 0:
        list = [];
        break;
      case 1:
        list = Reserved.map(() => 0);
        break;
      default:
        list = Reserved.map((item) => {
          if (item < 0) {
            return (count + (current + item)) % count;
          } else if (item === 0) {
            return current;
          } else {
            return (current + item) % count;
          }
        });
        break;
    }
    this.setState(
      {
        list,
        transformX: 0,
      },
      () => {
        this.uninterruptedSwitch = 0;
      }
    );
  }

  componentWillUnmount() {
    window.clearInterval(this.interval);
  }

  handleTouchStart(e: React.TouchEvent<HTMLUListElement>) {
    // console.log(e.nativeEvent);
    this.timeout && window.cancelAnimationFrame(this.timeout);
    this.mark = {
      current: this.state.transformX,
      position: e.nativeEvent.targetTouches[0].pageX,
      timestamp: e.timeStamp,
    };
  }

  handleTouchMove(e: React.TouchEvent<HTMLUListElement>) {
    e.stopPropagation();
    const diff = e.nativeEvent.targetTouches[0].pageX - this.mark.position;
    if (Math.abs(diff) < 10) {
      /**
       * 当手指头只是移动很小的距离时,轮播组件不响应
       * 用户可能只是想滚动整个页面,而非操作轮播组件时,从而防止页面在纵向滚动的同时,轮播组件也在横向移动
       */
      this.setState({
        disabledTouchAction: true,
      });
    } else {
      this.setState({
        disabledTouchAction: false,
        transformX:
          this.mark.current +
          e.nativeEvent.targetTouches[0].pageX -
          this.mark.position,
      });
    }
  }

  handleTouchEnd(e: React.TouchEvent<HTMLUListElement>) {
    /**
     * needContinue 是一个关键值,其标识着上一次 touchend 是否有未完成的事情
     *
     * 如迅速连续切换,使每次 touchend 都没有完成整个周期(即 scroll 完成后 reset )的话,
     * 就意味着,在本次 touchend 所要移动的距离,是需要考虑上一次 touchend 未完成距离的~
     */
    const needContinue =
      this.mark.current !== 0 && typeof this.targetX !== "undefined";

    // 计算移动距离
    const diff = Math.abs(
      e.nativeEvent.changedTouches[0].pageX - this.mark.position
    );

    // 计算移动速度
    const speed = diff / (e.nativeEvent.timeStamp - this.mark.timestamp);

    // 当距离和速度达到触发阀值,则认为其是切换,都则认为并未切换,滚动到该去的位置
    if (
      diff < this.itemWidth * TriggerSwiperThreshold.size && // 距离不满足
      speed < TriggerSwiperThreshold.speed // 速度不满足
    ) {
      if (needContinue) {
        // 上次还有未完成的距离,所以本次要完成
        this.moveTo(this.targetX, () => this.reset());
      } else {
        // 上次没有未完成的距离,所以需要归位
        this.moveTo(0);
      }
      return;
    }

    // 计算方向,-1或者是1
    let direction =
      e.nativeEvent.changedTouches[0].pageX - this.mark.position < 0 ? -1 : 1;

    // 更新连续切换次数
    this.uninterruptedSwitch = this.uninterruptedSwitch + direction;

    // 如果连续切换次数超出范围,则本次当作“未切换”
    if (
      this.uninterruptedSwitch < RightOffsetCount * -1 ||
      this.uninterruptedSwitch > LeftOffsetCount
    ) {
      direction = 0;
    }

    // 计算上次未完成的切换方向矢量值,可能不止-1或者1,这里是“未完成距离➗每个元素宽度”
    const offsetCount = needContinue
      ? Math.round((this.targetX || 0) / this.itemWidth)
      : 0;

    // 得到新的“当前元素”的index
    const newCurrent =
      (this.count + (this.state.current - direction)) % this.count;

    this.setState({
      current: newCurrent,
    });
    this.props.beforeChange &&
      this.props.beforeChange(this.state.current, newCurrent);

    // 开始移动到对应位置
    this.moveTo(this.itemWidth * (direction + offsetCount), () => {
      this.reset();
    });
  }

  moveTo(targetX: number, callback?: () => void) {
    this.timeout && window.cancelAnimationFrame(this.timeout);

    this.targetX = targetX; // 每次尝试移动前,都要先记录本次的目标位置

    let denominator = MovingRateConstant; // 分母,初始值为速率常量

    const fn = () => {
      const { transformX: nowX } = this.state;
      const direction = targetX > nowX ? 1 : -1;
      const diff = Math.abs(
        (nowX < 0 ? nowX * -1 : nowX) - (targetX < 0 ? targetX * -1 : targetX)
      );
      if (diff > 0) {
        if (diff < MovingApproachThreshold) {
          this.setState({
            transformX: targetX,
          });
        } else {
          if (denominator > diff) {
            denominator = MovingRateConstant;
          }
          this.setState({
            transformX: nowX + (diff / denominator) * direction,
          });
          denominator = denominator * MovingRateConstant;
        }
        this.timeout = window.requestAnimationFrame(fn);
      } else {
        this.timeout && window.cancelAnimationFrame(this.timeout);
        callback && callback();
      }
    };
    this.timeout = window.requestAnimationFrame(fn);
  }

  render() {
    const { list, transformX, disabledTouchAction } = this.state;
    const { itemSize } = this.props;

    const childrens = React.Children.toArray(this.props.children);

    return (
      <div className={classes.carousel}>
        <div
          className={classes.surface}
          style={{
            width: Reserved.length * 100 * itemSize + "%",
            left: ((1 - itemSize) / 2 - LeftOffsetCount * itemSize) * 100 + "%",
          }}
        >
          <ul
            style={{
              transform: `translate3d(${transformX}px, 0, 0)`,
              touchAction: disabledTouchAction ? "none" : "",
            }}
            onTouchStart={this.handleTouchStart.bind(this)}
            onTouchMove={this.handleTouchMove.bind(this)}
            onTouchEnd={this.handleTouchEnd.bind(this)}
          >
            {list.map((item, n) => (
              <li
                ref={this.itemRef}
                style={{
                  transform: `scale(${this.scale[n]})`,
                }}
                key={`${item}-${n}`}
              >
                {childrens[item]}
              </li>
            ))}
          </ul>
        </div>

        {/* skeleton,意为“骨架”,撑起整个轮播组件的高度,但是不可见并不可触碰 */}
        <ul
          className={classes.skeleton}
          style={{
            width: childrens.length * 100 * itemSize + "%",
          }}
        >
          {childrens.map((item, n) => (
            <li key={n}>{item}</li>
          ))}
        </ul>
      </div>
    );
  }
}