React实现一个简易版Swiper
阅读原文时间:2023年07月08日阅读:3

最近在公司内部进行一个引导配置系统的开发中,需要实现一个多图轮播的功能。到这时很多同学会说了,“那你直接用swiper不就好了吗?”。但其实是,因为所有引导的展示都是作为npm依赖的形式来进行插入的,所以我们想要做的就是:尽量减少外部依赖以及包的体积。所以,我们开始了手撸简易版swiper之路。

首先,由于我们所有的内容都是支持配置的,所以首先需要支持停留时间(delay)的可配置;由于不想让用户觉得可配置的内容太多,所以我们决定当停留时间(delay)大于0时,默认开启autoplay

其次,在常规的自动轮播外,还需要满足设计同学对于分页器(Pagination)的要求,也就是当前的展示内容对应的气泡(bullet)需要是一个进度条的样式,有一个渐进式的动画效果

最后,由于滑动效果实现起来太麻烦,所以就不做了,其他的基本都是swiper的常规功能了。

由此,整体我们要开发的功能就基本确定,后面就是开始逐步进行实现。

1、入参与变量定义

由于需要用户自定义配置整体需要展示的图片,并且支持自定义整体的宽高轮播时间(delay);同样,我们也应该支持用户自定义轮播的方向(direction)

综上我们可以定义如下的入参:

{
  direction?: 'horizontal' | 'vertical';
  speed?: number;
  width: string;
  height: string;
  urls: string[];
}

而在整个swiper运行的过程中我们同样是需要一些参数来帮助我们实现不同的基础功能,比如

2、dom结构

从dom结构上来说,swiper的核心逻辑就是,拥有单一的可视区,然后让所有的内容都在可视区移动、替换,以此来达到轮播的效果实现。

那么如何来实现上的效果呢?这里简单梳理一下html的实现:

// 可见区域容器
<div id="swiper">
  // 轮播的真实内容区,也就是实际可以移动的区域
  <div className="swiper-container" id="swiper-container">
    // 内部节点的渲染
    {urls.map((f: string, index: number) => (
      <div className="slide-node">
        <img src={f} alt="" />
      </div>
    ))}
  </div>
</div>

到这里一个简陋的dom结构就出现了。接下来就需要我们为他们补充一些样式

3、样式(style)

为了减少打包时处理的文件类型,并且以尽可能简单的进行样式开发为目标。所以我们在开发过程中选择了使用styled-components来进行样式的编写,具体使用方式可参考styled-components: Documentation

首先,我们先来梳理一下对于最外层样式的要求。最基本的肯定是要支持参数配置宽高以及仅在当前区域内可查看

而真正的代码实现其实很简单:

import styled from "styled-components";
import React, { FC } from "react";

const Swiper = styled.div`
  overflow: hidden;
  position: relative;
`;

const Swiper: FC<
  {
    direction?: 'horizontal' | 'vertical';
    speed?: number;
    width: string;
    height: string;
    urls: string[];
  }
> = ({
  direction = "horizontal",
  speed = 3,
  width = "",
  height = "",
  urls = []
}) => {
    return (<Swiper style={{ width, height }}></Swiper>);
}

export default Swiper;

其次,我们来进行滚动区的样式的开发。

但是这里我们要明确不同的是,我们除了单独的展示样式的开发外,我们还要主要对于过场动画效果的实现。

import styled from "styled-components";
import React, { FC } from "react";

const Swiper = styled.div`
  overflow: hidden;
  position: relative;
`;

const SwiperContainer = styled.div`
  position: relative;
  width: auto;
  display: flex;
  align-item: center;
  justify-content: flex-start;
  transition: all 0.3s ease;
  -webkit-transition: all 0.3s ease;
  -moz-transition: all 0.3s ease;
  -o-transition: all 0.3s ease;
`;

const Swiper: FC<
  {
    direction?: 'horizontal' | 'vertical';
    speed?: number;
    width: string;
    height: string;
    urls: string[];
  }
> = ({
  direction = "horizontal",
  speed = 3,
  width = "",
  height = "",
  urls = []
}) => {
  return (<Swiper style={{ width, height }}>
    <SwiperContainer
      id="swiper-container"
      style={{
        height,
        // 根据轮播方向参数,调整flex布局方向
        flexDirection: direction === "horizontal" ? "row" : "column",
      }}
    >
    </SwiperContainer>
  </Swiper>);
}

export default Swiper;

在这里,我们给了他默认的宽度为auto,来实现整体宽度自适应。而使用transition让后续的图片轮换可以有动画效果

最后,我们只需要将图片循环渲染在列表中即可。

import styled from "styled-components";
import React, { FC } from "react";

const Swiper = styled.div`
  overflow: hidden;
  position: relative;
`;

const SwiperContainer = styled.div`
  position: relative;
  width: auto;
  display: flex;
  align-item: center;
  justify-content: flex-start;
  transition: all 0.3s ease;
  -webkit-transition: all 0.3s ease;
  -moz-transition: all 0.3s ease;
  -o-transition: all 0.3s ease;
`;

const SwiperSlide = styled.div`
  display: flex;
  align-item: center;
  justify-content: center;
  flex-shrink: 0;
`;

const Swiper: FC<
  {
    direction?: 'horizontal' | 'vertical';
    speed?: number;
    width: string;
    height: string;
    urls: string[];
  }
> = ({
  direction = "horizontal",
  speed = 3,
  width = "",
  height = "",
  urls = []
}) => {
  return (<Swiper style={{ width, height }}>
    <SwiperContainer
      id="swiper-container"
      style={{
        height,
        // 根据轮播方向参数,调整flex布局方向
        flexDirection: direction === "horizontal" ? "row" : "column",
      }}
    >
     {urls.map((f: string, index: number) => (
        <SwiperSlide style={{ ...styles }}>
          <img src={f} style={{ ...styles }} alt="" />
        </SwiperSlide>
      ))}
    </SwiperContainer>
  </Swiper>);
}

export default Swiper;

至此为止,我们整体的dom结构样式就编写完成了,后面要做的就是如何让他们按照我们想要的那样,动起来

4、动画实现

既然说到了轮播动画的实现,那么我们最先想到的也是最方便的方式,肯定是我们最熟悉的setInterval,那么整体的实现思路是什么样的呢?

先思考一下我们想要实现的功能:

1、按照预设的参数实现定时的图片切换功能;

2、如果没有预设delay的话,则不自动轮播;

3、每次轮播的距离,是由用户配置的图片宽高决定;

4、轮播至最后一张后,停止轮播。

首先,为了保证元素可以正常的移动,我们在元素身上添加refid便于获取正确的dom元素。

import React, { FC, useRef } from "react";

const swiperContainerRef = useRef<HTMLDivElement>(null);
...
<SwiperContainer
  id="swiper-container"
  ref={swiperContainerRef}
  style={{
    height,
    // 根据轮播方向参数,调整flex布局方向
    flexDirection: direction === "horizontal" ? "row" : "column",
  }}
>
...
</SwiperContainer>
...

其次,我们需要定义activeIndex这个state,用来标记当前展示的节点;以及用isDone标记是否所有图片都已轮播完成(所以反馈参数)。

import React, { FC, useState } from "react";

const [activeIndex, setActiveIndex] = useState<number>(0);
const [isDone, setDone] = useState<boolean>(false);

然后,我们还需要进行timer接收参数的定义,这里我们可以选择使用useRef来进行定义。

import React, { FC, useRef } from "react";

const timer = useRef<any>(null);

在上面的一切都准备就绪后,我们可以进行封装启动方法的封装

  // 使用定时器,定时进行activeIndex的替换
  const startPlaySwiper = () => {
    if (speed <= 0) return;
    timer.current = setInterval(() => {
      setActiveIndex((preValue) => preValue + 1);
    }, speed * 1000);
  };

但是到此为止,我们只是进行了activeIndex的自增,并没有真正的让页面上的元素动起来,为了实现真正的动画效果,我们使用useEffect对于activeIndex进行监听。

import React, { FC, useEffect, useRef, useState } from "react";

useEffect(() => {
  const swiper = document.querySelector("#swiper-container") as any;
  // 根据用户传入的轮播方向,决定是在bottom上变化还是right变化
  if (direction === "vertical") {
    // 兼容用户输入百分比的模式
    swiper.style.bottom = (height as string)?.includes("%")
      ? `${activeIndex * +(height as string)?.replace("%", "")}vh`
      : `${activeIndex * +height}px`;
  } else {
    swiper.style.right = (width as string)?.includes("%")
      ? `${activeIndex * +(width as string)?.replace("%", "")}vw`
      : `${activeIndex * +width}px`;
  // 判断如果到达最后一张,停止自动轮播
  if (activeIndex >= urls.length - 1) {
    clearInterval(timer?.current);
    timer.current = null;
    setDone(true);
  }
}, [activeIndex, urls]);

截止到这里,其实简易的自动轮播就完成了,但是其实很多同学也会有疑问,是不是还缺少分页器(Pagination)

5、分页器(Pagination)

分页器的原理其实很简单,我们可以分成两个步骤来看。

1、渲染与图片相同个数的节点;

2、根据activeIndex动态改变分页样式。

import React, { FC } from "react";
import styled from "styled-components";

const SwiperSlideBar = styled.div`
  margin-top: 16px;
  width: 100%;
  height: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const SwiperSlideBarItem: any = styled.div`
  cursor: pointer;
  width: ${(props: any) => (props.isActive ? "26px" : "16px")};
  height: 4px;
  background: #e6e6e6;
  margin-right: 6px;
`;

const SlideBarInner: any = styled.div`
  width: 100%;
  height: 100%;
  background: #0075ff;
  animation: ${innerFrame} ${(props: any) => `${props.speed}s`} ease;
`;

{urls?.length > 1 ? (
  <SwiperSlideBar>
    {urls?.map((f: string, index: number) => (
      <SwiperSlideBarItem
        onClick={() => slideToOne(index)}
        isActive={index === activeIndex}
      >
        {index === activeIndex ? <SlideBarInner speed={speed} /> : null}
      </SwiperSlideBarItem>
    ))}
  </SwiperSlideBar>
) : null}

细心的同学可能看到我在这里为什么还有一个SlideBarInner元素,其实是在这里实现了一个当前所在分页停留时间进度条展示的功能,感兴趣的同学可以自己看一下,我这里就不在赘述了。

6、整体实现代码

最后,我们可以看到完整的Swiper代码如下:

import React, { FC, useEffect, useRef, useState } from "react";
import styled, { keyframes } from "styled-components";

const innerFrame = keyframes`
  from {
    width: 0%;
  }
  to {
    width: 100%;
  }
`;

const Swiper = styled.div`
  overflow: hidden;
  position: relative;
`;

const SwiperNextTip = styled.div`
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  right: 24px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: #ffffff70;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  opacity: 0.7;
  user-select: none;
  :hover {
    opacity: 1;
    background: #ffffff80;
  }
`;

const SwiperPrevTip = (styled as any)(SwiperNextTip)`
  left: 24px;
`;

const SwiperContainer = styled.div`
  position: relative;
  display: flex;
  align-item: center;
  justify-content: flex-start;
  transition: all 0.3s ease;
  -webkit-transition: all 0.3s ease;
  -moz-transition: all 0.3s ease;
  -o-transition: all 0.3s ease;
`;

const SwiperSlide = styled.div`
  display: flex;
  align-item: center;
  justify-content: center;
  flex-shrink: 0;
`;

const SwiperSlideBar = styled.div`
  margin-top: 16px;
  width: 100%;
  height: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const SwiperSlideBarItem: any = styled.div`
  cursor: pointer;
  width: ${(props: any) => (props.isActive ? "26px" : "16px")};
  height: 4px;
  background: #e6e6e6;
  margin-right: 6px;
`;

const SlideBarInner: any = styled.div`
  width: 100%;
  height: 100%;
  background: #0075ff;
  animation: ${innerFrame} ${(props: any) => `${props.speed}s`} ease;
`;

const Swiper: FC<
  {
    direction?: 'horizontal' | 'vertical';
    speed?: number;
    width: string;
    height: string;
    urls: string[];
  }
> = ({
  direction = "horizontal",
  speed = 3,
  width = "",
  height = "",
  urls = []
}) => {
  const [activeIndex, setActiveIndex] = useState<number>(0);
  const [isDone, setDone] = useState<boolean>(false);
  const [swiperStyle, setSwiperStyle] = useState<{
    width: string;
    height: string;
  }>({
    width: (width as string)?.replace("%", "vw"),
    height: (height as string)?.replace("%", "vh"),
  } as any);

  const timer = useRef<any>(null);
  const swiperContainerRef = useRef<HTMLDivElement>(null);

  const styles = {
    width: isNaN(+swiperStyle.width)
      ? swiperStyle!.width
      : `${swiperStyle!.width}px`,
    height: isNaN(+swiperStyle.height)
      ? swiperStyle.height
      : `${swiperStyle.height}px`,
  };

  const startPlaySwiper = () => {
    if (speed <= 0) return;
    timer.current = setInterval(() => {
      setActiveIndex((preValue) => preValue + 1);
    }, speed * 1000);
  };

  const slideToOne = (index: number) => {
    if (index === activeIndex) return;
    setActiveIndex(index);
    clearInterval(timer?.current);
    startPlaySwiper();
  };

  useEffect(() => {
    if (swiperContainerRef?.current) {
      startPlaySwiper();
    }
    return () => {
      clearInterval(timer?.current);
      timer.current = null;
    };
  }, [swiperContainerRef?.current]);

  useEffect(() => {
    const swiper = document.querySelector("#swiper-container") as any;
    if (direction === "vertical") {
      swiper.style.bottom = (height as string)?.includes("%")
        ? `${activeIndex * +(height as string)?.replace("%", "")}vh`
        : `${activeIndex * +height}px`;
    } else {
      swiper.style.right = (width as string)?.includes("%")
        ? `${activeIndex * +(width as string)?.replace("%", "")}vw`
        : `${activeIndex * +width}px`;
    }

    if (activeIndex >= urls.length - 1) {
      clearInterval(timer?.current);
      timer.current = null;
      setDone(true);
    }
  }, [activeIndex, urls]);

  return (<>
      <Swiper style={{ width, height }}>
        <SwiperContainer
          id="swiper-container"
          ref={swiperContainerRef}
          style={{
            height,
            // 根据轮播方向参数,调整flex布局方向
            flexDirection: direction === "horizontal" ? "row" : "column",
          }}
        >
         {urls.map((f: string, index: number) => (
            <SwiperSlide style={{ ...styles }}>
              <img src={f} style={{ ...styles }} alt="" />
            </SwiperSlide>
          ))}
        </SwiperContainer>
      </Swiper>

      // Pagination分页器
      {urls?.length > 1 ? (
        <SwiperSlideBar>
          {urls?.map((f: string, index: number) => (
            <SwiperSlideBarItem
              onClick={() => slideToOne(index)}
              isActive={index === activeIndex}
            >
              {index === activeIndex ? <SlideBarInner speed={speed} /> : null}
            </SwiperSlideBarItem>
          ))}
        </SwiperSlideBar>
      ) : null}
  </>);
}

export default Swiper;

其实很多时候,我们都会觉得对于一个需求(功能)的开发无从下手。可是如果我们耐下心来,将我们要实现的目标进行抽丝剥茧样的拆解,让我们从最最简单的部分开始进行实现和设计,然后逐步自我迭代,将功能细化、优化、深化。那么最后的效果可能会给你自己一个惊喜哦。

妙言至径,大道至简。