记录一次酷炫的首页react + swiper王者荣耀英雄皮肤切换!

PM Requirements

  1. 展示出当前用户所拥有的皮肤,包含皮肤中英雄的发布日期、持有皮肤的时间、皮肤、及是否是限定;
  2. 如果获取出错就展示默认皮肤图片,其他内容显示--,对应下方皮肤概览展示查不到;
  3. 皮肤可以循环滑动,滑动到最后可以滑到第一张;
  4. 根据每个皮肤所对应的英雄展示出他所拥有的其他皮肤,并标明他的价格以及获取方式;
  5. 如果查皮肤列表成功,查皮肤概览失败,则展示查皮肤概览失败;
  6. 如果皮肤名字过长后半部分隐藏,需要自己滚动将后续展示完整;
  7. 优化用户体验,使整个过程变得更加丝滑。

抽象abstract

swiper滑动效果

  • 毋庸置疑,肯定是要用swiper做;
  • 上半部分滑动时下半部分跟着一起滑动,下半部分滑动时上半部分也跟着动。要用到swiper的双向控制组件controller;
  • 上半部分的滑动是通过淡入淡出来实现的,并非常规切换。所以要改变切换方式,要用到swiper的切换效果Effects;
  • 多个皮肤需要显示个数,需要用到swiper的分页器pagination;
  • 循环滑动需要使用swiper的loop。

皮肤切换效果

  • 首先,每个swiper的slide上都包含2张皮肤;
  • 切换时右边的皮肤先进行一个特殊效果的切换,左半部分文字在进行淡入淡出切换。也就是说在一个swiper中有两种切换效果,且不是同时触发有先后顺序;
  • 而一个slide只能执行一种切换效果,不能执行两种。当前swiper的切换效果Effects中有淡入淡出选项,没有皮肤的切换效果。所以将左半部分文字的切换放到slide中,将右半部分皮肤切换单独分离出来;
  • 分离出来后的皮肤切换想要做成需求的样子,需要使用两个div来做一个动画;
  • 既然已经分离出来,那就不是每个slide上都有皮肤切换效果。而是通过定位将分离这部分定到swiper右侧。

实现implement

主要用到的包:react16.14.0 + swiper6.8.4 + typescript4.3.4。

swiper双向控制 + 分页器 + 淡入淡出

所有用到的swiper组件必须在SwiperCore.use注册,不注册直接使用无任何效果。

js 复制代码
import React, { FC, useState, useRef, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import SwiperCore, { EffectFade, Pagination, Controller } from "swiper";
import "swiper/swiper.min.css";

const MoreSkin: FC<MoreSkinProps> = ({ skinList }) => {
    SwiperCore.use([EffectFade, Pagination, Controller]); // 所有用到的swiper组件必须在此注册,不注册直接使用均无任何效果
    
    const [skinSwiper, setSkinSwiper] = useState<SwiperCore>(); // 上部分皮肤列表swiper实例
    const [rightsSwiper, setRightsSwiper] = useState<SwiperCore>(); // // 下部分皮肤概览swiper实例
    
    return <React.Fragment>
    
        {/** 皮肤列表swiper */}
        <Swiper
            effect="fade" // 淡入淡出
            fadeEffect={{ crossFade: true }} // 开启淡出
            controller={{ control: rightsSwiper }} // 与下半部分swiper绑定
            onSwiper={(swiper) => { setSkinSwiper(swiper); }} // 初始化上半部分swiper
            pagination={{ el: ".high-swiper-pagination" }} // 分页器
        >......</Swiper>
        
        {/** 皮肤概览swiper */}
         <Swiper
            controller={{ control: skinSwiper }} // 与上半部分swiper绑定
            onSwiper={(swiper) => { setRightsSwiper(swiper); }} // 初始化上半部分swiper
         >......</Swiper>
    </React.Fragment>;
}

淡入淡出

原本swiper所带的淡入淡出切换效果effect="fade"是默认的效果。无法通过它来控制淡出的时间、淡入的时间,除此之外更难控制的是淡入淡出开始的时间。

需求中的动画效果是在slideChange触发到所有动画完成的整个动画周期480ms中:

  • 0 ~ 260ms 完成两张皮肤的切换;
  • 160ms ~ 320ms 完成文字的渐隐;
  • 320ms ~ 480ms 完成文字的渐现。

这样才能造成在切换中,有那么一瞬间左半部分文字是完全空白的。

但是swiper自带的fade效果在一切换时就立马进行了切换,而需求中160ms后才实现渐隐。这时默认的fade效果早已执行完毕展示出了下一张。

怎么样可以做到延时淡出?

覆写swiper淡入淡出transition配套属性

通过查看swiper上的属性可以发现,他实现淡出是通过动态改变slide上的opacity属性搭配transition系列属性来实现的。

那这就好办了,只需将slide原有样式覆写掉就可以了。主要覆写以下两部分:

  • 渐隐的持续时间transition-duration
  • 渐隐的延时时间transition-delay
scss 复制代码
// 这里有小数会有误差,但是可以忽略不计,不影响效果
$duration: 480ms;
$thirdDuration: calc($duration / 3); 
$thirdTwoDuration: calc($duration / 3 * 2);

.swiper-slide {
    &.fadeOut {
        transition-property: opacity, transform;
        transition-duration: $thirdDuration !important; // 1/3淡出持续时间
        transition-delay: $thirdDuration; // 1/3淡出延时时间
    }
    &.fadeIn {
        transition-property: opacity, transform;
        transition-duration: $thirdDuration !important; // 1/3淡入持续时间
        transition-delay: $thirdTwoDuration; // 2/3淡入延时时间
    }
}

设置转换变量curIndex 与 prevIndex

上面确定了淡入淡出的实现样式,那么在什么时候 & 给哪个slide设置 对应样式就是比较关键的问题。很好理解的是有以下两点:

  1. 需要淡入样式的那一张肯定是滑动后的下一张,称其为当前张,需要淡出样式的那一张肯定是滑动前的那一张,称其为前一张;
  2. 当前张 与 前一张 只有当swiper确定滑动之后才可以确定。也就是说slideChange事件触发之后才可以拿到。

所以这里很明显需要定义两个变量当前张和前一张来控制文字的淡出淡入。但是这里也很好理解的两点是:

  1. 只有slideChange事件触发后,当前张需要发生变化,整个页面开始执行动画并刷新;
  2. 前一张事实上就是滑动前的当前张。他俩事实上是一个变量,只是一个在滑动前,一个在滑动后。而当前张刷新页面后,前一张跟随其改变,并不需要刷新页面。前一张这个变量的出现只是为了控制淡出样式。

所以根据上述,很容易写下以下代码实现淡入淡出效果。

tsx 复制代码
const [curIndex, setCurIndex] = useState(-1); // 当前张文字淡入效果
const prevIndex = useRef(-1); // 前一张的index控制文字淡出效果

function onSlideChange(swiper: SwiperCore) {
    console.log("--realIndex--", swiper.realIndex);
    prevIndex.current = curIndex === -1 ? 0 : curIndex; // 更新前一张索引
    setCurIndex(swiper.realIndex); // 更新当前张索引
}

return (
    <div className="test-f">
        {/** 皮肤列表swiper */}
        <Swiper
            effect="fade"
            fadeEffect={{ crossFade: true }}
            onSlideChange={onSlideChange}
        >
            {skinList.map((skin, index) => {
                const fadeOut = index === prevIndex.current ? "fadeOut" : "";
                const fadeIn = index === curIndex ? "fadeIn" : "";

                return <SwiperSlide key={index} className={`${fadeOut} ${fadeIn}`}>
                    ......
                </SwiperSlide>
                );
            })}
        </Swiper>
    </div>
    );

天不遂人愿,想法很美好,现实很骨感。

设置好之后查看实际情况发现在slideChange触发后无论是淡入还是淡出,都没有真正的在(透明度从0 => 1淡入)(透明度从1 => 0淡出)。而是皆处于一个神奇的半透明状态,然后由半透明状态分别延时,最后走到0和1。

想了半天没有想到问题出在哪里,做过以下调试:

  1. 在触发前人为手动设置好fadeOut与fadeIn是正常的;
  2. 对于同两张slide,先左滑再右滑即反方向滑动也没问题;
  3. swiper设置fade模式后,在滑动slide但还未触及slideChange时,两张卡片的opacity属性就已经开始了切换效果。随着滑动距离越大,切换越强。

所以只能猜测是在动画开始时,为slide临时添加属性会有时间上的误差,导致自己添加的样式与swiper的样式冲突。使得opacity停在了一个尴尬的位置。

这种方法有问题,又想到换一种方法。使用transition的升级加强版,直接上动画animation。在动画开始时的那一帧人为给他锁死🔒opacity属性不就ok了?

transition替换为animation

scss 复制代码
// 中间相同帧可以不写,这里写出只是方便理解,表明在动画不同阶段文字所处状态
@keyframes fadeOut {
    0% {
        opacity: 1;
    }
    33% {
        opacity: 1;
    }
    66% {
        opacity: 0;
    }
    100% {
        opacity: 0;
    }
}

@keyframes fadeIn {
    0% {
        opacity: 0;
    }
    33% {
        opacity: 0;
    }
    66% {
        opacity: 0;
    }
    100% {
        opacity: 1;
    }
}
.swiper-slide {
    height: 200px;
    &.fadeOut {
        animation: fadeOut $animateTime linear forwards;
    }
    &.fadeIn {
        animation: fadeIn $animateTime linear forwards;
    }
}

兼容loop

经过上述改造看起来已经可以正常运行了,但是当为swiper添加loop使其循环起来时又出了新问题。

初始化执行slideChange

添加loop会导致swiper在开始时在前后自动复制一张slide,这时会触发一次slideChang,但页面仍不变。

而我们前面slideChange中的内容是处理swiper真正滑动的过程,因此要将这一次排除在外。

tsx 复制代码
const isLoopFirstSlideChange = useRef(true); // 是否是loop引起的slideChange
function onSlideChange(swiper: SwiperCore) {
    // 排除loop属性所引发的第一次slideChange事件
    if (isLoopFirstSlideChange.current) {
        isLoopFirstSlideChange.current = false;
        return;
    }
    prevIndex.current = curIndex === -1 ? 0 : curIndex; // 更新上一张的索引
    setCurIndex(swiper.realIndex); // 更新当前张的索引
}

前后临界点重复slideChange

临界点指的是从第一张切换到最后一张,或者是从最后一张切换到第一张时。slideChange会触发两次。

第一次是loop所产生的复制slide滑动触发的,而第二次才是我们需要的正常的slideChange。所以这里的第一次也需要排除。

js 复制代码
function onSlideChange(swiper: SwiperCore) {
    if (isLoopFirstSlideChange.current) {
        isLoopFirstSlideChange.current = false;
        console.log("如果是loop引起的slideChange直接return");

        return;
    }
    if (swiper.realIndex === swiperConfig.curIndex) {
        console.log("到了loop的临界值,会执行两次slideChange,第一次切换loop复制的,第二才改变");

        return;
    }
}

皮肤切换

dom穿透

按照最先想好的设计将负责皮肤切换的两个div定位到右侧后,又出现了一个新的问题。

swiper滑不动了!

其实也很好理解,最上面的是两个div而不是slide,所以无法触发绑定在swiper上的touchStart等touch事件。而这时缩小slide的范围只在左侧的话又会影响到页面的展示,所以这时就需要一个方法来穿透两个皮肤的div,使的下方swiper的事件生效。这个方法就是pointer-events。

pointer-events CSS 属性指定在什么情况下 (如果有) 某个特定的图形元素可以成为鼠标事件的target。

除了指示该元素不是鼠标事件的目标之外,值none表示鼠标事件"穿透"该元素并且指定该元素"下面"的任何东西。

所以为包裹两个div的父元素设置此样式即可。

scss 复制代码
.skin-toggle {
    pointer-events: none;
 } 

皮肤切换方向

解决了滑动问题后,就是来写皮肤两个div的切换动画。根据需求gif可以明显看出,同一张皮肤在swiper左滑和右滑时他所展示的动画效果是不同的,因此需要获取滑动方向。

搜了半天文档没找到,还是自己来吧,其实想想也很简单。在滑动结束的一瞬间也就是slideChange触发时我们就能拿到最新的realIndex,只需和前一个比较一下就可以获得方向。

tsx 复制代码
function getAnimationDirection(realIndex: number): AnimationDirection {
    // 处理首位边界值
    if (curIndex === LAST_INDEX && realIndex === FIRST_INDEX) {
        return AnimationDirection.NEXT;
    } else if (curIndex === FIRST_INDEX && realIndex === LAST_INDEX) {
        return AnimationDirection.PRE;
    }

    return realIndex > curIndex ? AnimationDirection.NEXT : AnimationDirection.PRE;
}

皮肤切换变量与类名

拿到方向之后,就需考虑如何给两个皮肤div设置对应的className。众所周知,动画都是由两个不同的css属性进行切换变化时所产生的。如果一个样式从头到尾都没有变化,那么动画也不会产生。

所以这里会遇到一个很明显的问题,那就是我针对方向设置了className。如果一直向这个方向滑那类名就不会改变,会出现只有第一次有动画后面都没有的情况。所以需要在动画结束后将className置空。

如何获取到动画结束的钩子?swiper上的方法都是与slideChang相关的钩子,而动画是在slideChange之后进行的。所以即使知道定时器不准确的情况下,我这里也只能开启定时器用动画时间来表示动画结束了。

为了理解方便、代码方便,将两个皮肤切换的className设置为方向。从前向后滑动为正方向类名为next,反之为pre。

而类名置空一定是需要刷新页面的,所以最后

  • 对于两张皮肤切换的 方向变量使用useState来定义,用来在开启动画和结束动画时刷新页面;
  • 在动画开启时同步开启一个定时器,时间为动画结束时间,在结束时将动画方向置空。
tsx 复制代码
const [direction, setDirection] = useState<AnimationDirection>(AnimationDirection.NONE);

function startAnimate(swiper: SwiperCore) {
    setCurIndex(swiper.realIndex); // 更新当前index
    prevIndex.current = curIndex === -1 ? 0 : curIndex; // 更新前一个index
    setDirection(getAnimationDirection(swiper.realIndex)); // 更新滑动方向

    // 结束时将切换皮肤div欢动方向(类名)置空
    setTimeout(() => {
        setDirection(AnimationDirection.NONE);
    }, ANIMATE_TIME);
}

{/** 皮肤切换区域 */}
<div className="card-toggle">
    <p className={`${direction}`} style={{ backgroundImage: `url(${currentCard})` }}></p>
    <p className={`${direction}`} style={{ backgroundImage: `url(${nextCard})` }}></p>
</div>

皮肤切换动画帧

根据上述可以得知,两个皮肤展示框和两种滑动方向,排列组合起来共有四种动画,分别是:

  • 上部分皮肤currentCardswiper左滑时的动画
  • 上部分皮肤currentCardswiper右滑时的动画
  • 下部分皮肤nextCardswiper左滑时的动画
  • 下部分皮肤nextCardswiper右滑时的动画

这部分动画都是UI小姐姐按照不同阶段切给我的图,包括每个阶段每张卡位移的距离、放大/缩小的比例、z-index层级变换等。具体样式代码就不放了,大致说下遇到的坑吧。

  1. 因为JS对小数的不精确性,将一个尺寸放大在缩小,他的位置和大小与原来并不相同。 所以会造成滑动次数过多位移、大小偏差的问题。解决办法只能是通过不断的放慢来调整位移距离和缩放倍数,做到最可能精准。

  2. 在动画结束后刷新页面会将动画方向的className置空,也就是说两张卡片会回到他们最初的位置和大小,刚好解决了第一个问题但是又产生了新的问题。 动画前后如果有细微的位移尺寸误差,在动画结束后重刷页面归位时都会产生明显抖动。 解决办法其实很简单,因为卡片的切换在动画大概前2/3内完成,只需将以前在整个动画结束时刷新改为在2/3时刷新即可。这时类名会去掉,而动画仍然在进行。

  3. 两张卡片层级的交换时机。改变层级很好理解使用z-index,但什么时候改变很有学问。为了使整体看上去更流畅,在上下两张卡片接触最少的那段帧去改变两张卡的层级是最优的。 这样可以做到看上去就像两张卡上下调整,其实在那一瞬间进行了上下的突变,只是骗过了人眼而已。

皮肤切换样式

皮肤左滑

皮肤右滑

皮肤切换背景

这里又是一门学问。 当从开始的01状态切换到最终的12状态时,中间进行了动画中两个皮肤的上下换位。

背景切换的两种方案

所以最开始从01到12切换时如果为两个皮肤设置了12的背景,那么经过换位后刚好是反的21。但是在动画2/3时要去掉类名刷新页面,所以这时两个皮肤又会突变为最开始你设置好的12。 会经过两次变化。根据这两次变化有以下两种切换方案:

  • 先按起始背景切换上下换位动画结束之后再单独改变那一张需要变更的背景
  • 先改变那张背景再进行上下切换

方案一因为是起始背景所以动画开始时很流畅,但在结束后突变背景会有明显的闪烁,如果突变的皮肤在上层则更为明显。方案2在动画开始时突变也会产生闪烁,但是后续动画过程中处理得当都可以很流畅。

而且经过测试,方案一人眼在紧盯动画结束后处于专注状态,此时突变会非常明显。而方案二由于突变后立即开始了动画,且动画开始前人眼不集中,开始后人眼吸引力都集中于动画而不是背景。加上动画时间本来很短,人眼事实上捕捉到背景图片突变的时间极短,总体效果比方案一好非常多体验很好。所以最终采用方案二。

方案二实现

  • 因为中间要上下切换,所以在开始动画时二者背景就应该是反的,然后通过上下换位换到正确的位置。
  • 换位结束后去类名刷页面时再将二者背景切换为正常顺序背景,这样在同一位置切换同一背景人眼是无感的。
tsx 复制代码
// 由于这两个刷新页面的变量分开写会分别刷两次页面,加上结束时清空类名总计三次,对是否翻转背景不利,所以写到了一起。
const [swiperConfig, setSwiperConfig] = useState({
    direction: AnimationDirection.NONE,
    curIndex: -1
});
const isReverse = useRef(true); // 是否翻转两张卡片背景,默认翻转

// 获取皮肤背景样式
function getCardBackgroundStyle(): [CSSProperties, CSSProperties] {
    // 如果只有一张卡片就直接返回第一张皮肤的背景
    if (skinList.length === 1) {
        return [{ backgroundImage: `url(${skinList[FIRST_INDEX].picUrl})` }, {}];
    }

    // 无论是什么情况,只要swiper未被滑动,那么皮肤就一直保持默认的第0张和第1张
    if (swiperConfig.curIndex === -1) {
        return [
                { backgroundImage: `url(${cardList[FIRST_INDEX].picUrl})` },
                { backgroundImage: `url(${cardList[FIRST_INDEX + 1].picUrl})` }
        ];
    }

    // 只有真的被滑动了,才走入下面逻辑
    let bgIndexArr: [number, number] = [0, 0];

    if (prevIndex.current === LAST_INDEX && swiperConfig.curIndex === FIRST_INDEX) {
        // 从最后一张滑向第一张时(prev= last && cur = first),特殊处理
        bgIndexArr = isReverse.current ? [LAST_INDEX, FIRST_INDEX] : [FIRST_INDEX, LAST_INDEX];
    } else if (prevIndex.current === FIRST_INDEX && swiperConfig.curIndex === LAST_INDEX) {
        // 初始化后直接从第一张滑到最后一张(prev= first && cur = last),做特殊处理
        bgIndexArr = isReverse.current ? [FIRST_INDEX, LAST_INDEX] : [LAST_INDEX, FIRST_INDEX];
    } else if (swiperConfig.curIndex === LAST_INDEX && prevIndex.current < LAST_INDEX) {
        // 由倒数第二个向最后一个滑动时,此时后面已没有新皮肤,所以这时应该是循环的第一个皮肤,而不是继续+1
        bgIndexArr = isReverse.current ? [FIRST_INDEX, LAST_INDEX] : [LAST_INDEX, FIRST_INDEX];
    } else {
        bgIndexArr = isReverse.current
                ? [swiperConfig.curIndex + 1, swiperConfig.curIndex]
                : [swiperConfig.curIndex, swiperConfig.curIndex + 1];
    }

    const [upIndex, downIndex] = bgIndexArr;

    isReverse.current = !isReverse.current; // 每次获取完背景后将其取反,用于上面翻转背景

    return [
            { backgroundImage: `url(${cardList[upIndex].picUrl})` },
            { backgroundImage: `url(${cardList[downIndex].picUrl})` }
    ];
}

react异步更新变量

上面为了翻转背景,将duration即curIndex两个变量合并为一个swiperConfig对象。于是在更新他们的地方都要发生改动。但是在动画2/3结束时将duration置空时出现了问题。

tsx 复制代码
function startAnimate(swiper: SwiperCore) {
    prevIndex.current = swiperConfig.curIndex === -1 ? 0 : swiperConfig.curIndex; // 更新前一个index
    setSwiperConfig({
        curIndex: swiper.realIndex,
        direction: getAnimationDirection(swiper.realIndex)
    });

    // 结束时将切换皮肤div欢动方向(类名)置空
    setTimeout(() => {
        setSwiperConfig({  // 问题出在了这里
          ...swiperConfig,
          direction: AnimationDirection.NONE      
        });
    }, ANIMATE_TIME_THIRD_TWO);
}

在动画结束时想要拿取的...swiperConfig中的curIndex应该是更新过后的最新值。但是上面那种写法虽然使用了定时器异步,但是react可不管你,实际上拿到的还是当前未更新的curIndex。

这一点react官网中有详细说明和示例,就不再细说,改成如下就好了。

tsx 复制代码
// 结束时将切换皮肤div欢动方向(类名)置空
setTimeout(() => {
    setSwiperConfig((config) => {
        return {
            ...config,
            direction: AnimationDirection.NONE
        };
    });
}, ANIMATE_TIME_THIRD_TWO);

皮肤名称超出长度自动滚动

可以看我的另一篇文章:前端跑马灯。

优化optimization

皮肤概览接口预加载

由于获取英雄列表接口和获取对应英雄的皮肤概览是两个接口。所以在初始化时查完英雄列表后,每滑动一次swiper就需要查询一次下一个英雄的皮肤概览,页面会被调用接口的loading卡住。这样会造成一滑一卡非常的不流畅,所以为了保证整体滑动的流畅度增加一个预加载功能。

当皮肤概览接口没有调用完成时,皮肤概览的swiper默认显示为一个自定义loading。将接口调用自带的loading去掉,然后并发请求所有英雄的皮肤。

这样初始化后可以随便滑动,都不会有卡顿的情况(这部分代码就不贴了,循环调用借口而已)。

类似react更新机制的动画锁

经过上面的处理卡顿问题得到了解决,但是出现了一个新问题。在初始化后,当无限快滑动时,皮肤会出现跳来跳去不断切换的问题。当我停止滑动后,对应英雄的皮肤也可能出现错误。

但是如果我每次滑动相邻的间隔长一点,则不会有这个问题。所以很容易想到是在获取皮肤背景时动画前后两次刷新页面与动画流程冲突了。

bug产生的原因

找到上面的方案二实现:

tsx 复制代码
    bgIndexArr = isReverse.current.current ? xxx : xxx

通过isReverse这个变量,在动画开始的时候两张皮肤的背景取反isReverse更新为true。在动画结束时两张皮肤背景取正isReverse更新为false。

所以皮肤最终显示什么完全依赖于isReverse这个变量。而这个变量根据现有逻辑只要页面获取一次皮肤背景就更改一次。页面在每刷新一次页面的时候就会重新执行一次获取背景。所以,皮肤最终显示什么与页面被刷新了几次有很大的关系。

如果页面只在动画前刷新一次,动画结束刷新一次,那么就不会有问题。如果中间因为其他因素干扰导致多刷新了就会出现问题。而调用皮肤概览接口成功后也确实会刷新页面来渲染拿到的皮肤。所以要保证在动画渲染过程中,不能有其他干扰导致页面重新渲染。

react调度机制reconciler

在react的更新机制中,有一套自定义的任务重要程度划分机制。其中比较重要的是页面的渲染更新任务,大部分任务的优先级都低于他。所以在页面渲染更新的时候非常流畅不会受到阻塞。

但是也有极个别的任务,比如用户与页面的互动需要得到快速的响应,这类任务优先级要高于页面渲染。这时react会在任务列表中打一个断点,在执行完高优先级任务后,再继续执行页面渲染任务。

在这个项目中,动画是优先级最高的任务,更新英雄的皮肤概览是次要任务。

缓存皮肤概览的更新

因此,我们可以在获取皮肤概览接口调用成功后更新皮肤概览之前做一次判断。如果此时正在执行动画就将皮肤概览先缓存起来,等到动画结束后再判断是否有缓存,有缓存则进行更新。

tsx 复制代码
const isAnimate = useRef<boolean>(false); // 是否正处于动画状态
const rightsCache = useRef<RightsCache[]>([]); // 缓存的皮肤
const [rights, setRights] = useState<RightsSlide[]>( // 皮肤概览列表
    skinList.map(() => {
        return {
            queryStatus: LoadingEnum.LOADING, // 皮肤概览接口查询状态 loading | success | fail
            rights: [] // 查询到的皮肤列表
        };
    })
);

function startAnimate(swiper: SwiperCore) {
    /* 之所以写到这里而不是写到getCardBackgroundStyle的isReverse为true的位置,是因为这里只有真正执行动画时才会被调用。
    * 而getCardBackgroundStyle获取背景在动画未执行时也会被调用,并不准确
    * /
    isAnimate.current = true; // 将动画状态变为true
}

// 请求皮肤概览回调处理函数
function queryRightsHandler(response: QuerySkinOverviewResp, index: number, status: "success" | "fail") {
    const { premiumAreaRightsList = [] } = response.data || {};

    // 正在动画中,缓存当前权益
    if (isAnimate.current) {
        rightsCache.current.push({
            index,
            status,
            rights: status === "success" ? premiumAreaRightsList : []
        });
    } else {
        // 重新更新对应right的属性
        setRights((preRights) => {
            preRights[index] = {
                queryStatus: status === "success" ? LoadingEnum.SUCCESS : LoadingEnum.FAIL,
                rights: status === "success" ? premiumAreaRightsList : []
            };

            return [...preRights];
        });
    }
}

/**
 * 更新缓存的皮肤概览
 */
function updateCacheRights() {
    if (rightsCache.current.length === EMPTY) return;
    setRights((preRights) => {
        preRights.forEach((rightsSlide, index) => {
            rightsCache.current.forEach((cache) => {
                const { index: cacheIndex, status, rights } = cache;

                if (index === cacheIndex) {
                    preRights[index] = {
                        queryStatus: status === "success" ? LoadingEnum.SUCCESS : LoadingEnum.FAIL,
                        rights
                    };
                }
            });
        });

        // 更新缓存后,将缓存列表清空
        rightsCache.current = [];

        return [...preRights];
    });
        
}

function getCardBackgroundStyle(): [CSSProperties, CSSProperties] {
    /**
     * 这里根据上述很好理解
     * 1. 当isReverse.current === true 时是动画开始状态,当为false时是动画结束状态;
     * 2. 在动画结束后检查是否有更新,调用即可
     */
 
     // 例如
     if (swiperConfig.curIndex === LAST_INDEX && prevIndex.current < LAST_INDEX) {
        // 由倒数第二个向最后一个滑动时,此时后面已没有新皮肤,所以这时应该是循环的第一个皮肤,而不是继续+1
         if (isReverse.current) {
             bgIndexArr = [FIRST_INDEX, LAST_INDEX];
         } else {
             bgIndexArr = [LAST_INDEX, FIRST_INDEX];
             updateCacheRights(); // 更新缓存皮肤概览
         }
      }
}

图片预加载

做完这一切现在切换起来丝滑多了,但是仍然存在以下两个问题。

  • 当清空缓存第一次进入页面时,首页两张展示的皮肤会有一个缓慢加载的效果。虽然下方查询权益的swiper不卡顿了。
  • 在切换时,新出现的皮肤有一个缓慢加载的过程。

这里就需要对用到的图片进行预加载,而且要分开预加载。

  • 首页为了一出来就展示正常,可以在查询所有皮肤列表之后增加一个首页两张皮肤预加载的过程,预加载成功后再渲染页面(当然这里要对图片加载失败进行处理,不然就会白屏)。
  • 而对于后面的皮肤图片可以等页面加载出来之后再进行后续的预加载。

关于图片预加载,可以看以前写的文章:前端静态资源预加载。

预加载首页两张皮肤

tsx 复制代码
// SkinList.tsx  ------  首页获取皮肤列表
const [loading, setLoading] = useState<LoadingEnum>(LoadingEnum.LOADING); // 页面状态 loading | success | fail
const MAX_SKIN_INDEX_WHEN_INIT = 1; // 初始化时最多可以显示的皮肤数量索引 共前后两张皮肤,所以为1

function pageInit(res: QuerySkinListResp): void {
    const skinList = res.data.skinList;

    // 如果不为空,则进行前两张图片的预加载
    if (skinList.length > EMPTY) {
            imagePreloadWhenInit(skinList, FIRST_INDEX);
    }
    setResp(res);
}

/**
 * 初始化时预加载图片
 * 考虑到初始化时最多只显示两张卡片,以及加载多张卡片用户等待时长过久问题,所以初始化时最多只预加载两张图片
 * @param skinList 皮肤列表
 * @param index 卡片索引
 */
function imagePreloadWhenInit(skinList: Skin[], index: number) {
    const LAST_INDEX = skinList.length - 1;
    const img = document.createElement("img");

    img.src = skinList[index].picUrl;
    img.onload = () => {
        if (index === LAST_INDEX) {
            // 如果当前卡片是最后一张卡片,说明此时卡片数量大于0,小等2,结束图片预加载直接显示页面
            setLoading(LoadingEnum.SUCCESS);
        } else if (index < MAX_SKIN_INDEX_WHEN_INIT) {
            // 如果当前卡片小于MAX_SKIN_INDEX_WHEN_INIT且不是最后一张卡片就继续预加载图片
            imagePreloadWhenInit(skinList, index + 1);
        } else if (index === MAX_SKIN_INDEX_WHEN_INIT) {
            // 如果等于两张且不是最后一张卡片,则停止初始化预加载图片优先显示页面,剩余卡片在后续加载
            setLoading(LoadingEnum.SUCCESS);
        }
    };
}

除去前两张的剩余皮肤预加载

tsx 复制代码
// MoreSkin.tsx
const MAX_SKIN_WHEN_INIT = 1; // 初始化时最多可以显示的皮肤数量,所以为2

useEffect(() => {
    ......
    // 当皮肤数量大于两张时,从第三张开始继续预加载后面图片背景
    if (cardList.length > MAX_SKIN_WHEN_INIT) {
            imagePreloadFromThird(MAX_SKIN_WHEN_INIT);
    }
}, []);

/**
* 从第三张皮肤开始预加载
* @param index 皮肤索引
*/
function imagePreloadFromThird(index: number) {
    const img = document.createElement("img");

    img.src = skinList[index].picUrl;
    img.onload = () => {
        // 如果当前卡片不是最后一张,继续预加载下一张
        if (index !== LAST_INDEX) {
            imagePreloadFromThird(index + 1);
        } else {
            console.log("背景图片预加载结束");
        }
    };
}

经过上述处理基本上在打开页面之后,用户滑动之前第三张皮肤也快加载好了(不是测试那种一进来就猛滑)。滑完第三张第四张第五张也基本加载完成(图片比较小,图片特别大当我没说),整体效果有极大的提升,非常的流畅丝滑。用户几乎感受不到太多图片的加载过程。

皮肤名称过长的滚动时序

添加这个效果的本意是让用户看清这个皮肤的全称。但是按照目前的效果进入页面查询皮肤列表成功后,所有皮肤就展示出来,每一个过长的皮肤都开始运行。

此时如果某个过长的皮肤在靠后的位置,当滑动到那张皮肤时他已经不在起始位置,而是滑到后面了。

这种效果不是我们期望的,我们期望的是滑到那一张的时候在开启滚动,滑过那一张就结束滚动。

具体解决方法也非常简单,将当前滑动的curIndex和这张卡片的index都传入到组件中进行比对。如果相等就渲染名称过长滚动,如果不相等渲染为null即可(或者设置一下是否滚动的开关)。

定时器清除

在每一次动画触发时,为了捕获动画结束的时机都会开启一个定时器。为了安全以及一些不必要的内存占用和问题等,可以在这个定时器执行后将他清除。

为皮肤设置加载失败的默认皮肤

如果因为图片链接配置错误,静态资源服务器挂了等问题访问不到皮肤图片,展示会变得非常难看。因此为了避免这种情况的出现最好为两张切换的皮肤设置加载出错时的默认皮肤图片。

具体如何设置,可以参考我的另一篇文章:一文学会图片加载失败时设置兜底图片。

以上就是全部内容啦!码字不易,如果看完文章有所收获的话,烦请动动小手点个赞哦~

您的支持就是对我最大的鼓励❤️❤️❤️

相关推荐
miss38 分钟前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan1 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真1 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment1 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan2 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么2 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技2 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥2 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易3 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭3 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter