🌸🌸🌸通过CSS+JS手撸一个3D轮播

780 阅读12分钟

写在开头

哈喽,各位好吖!😋

轮播动效,这也算是一个 "”老生常谈" 问题了。几年前,但凡踏入前端领域的小伙伴,想必都有过编写轮播效果的经历,无论是 2D 还是 3D 版本。😂

那时,"轮播" 似乎是成为前端入门的必备技能之一。但如今,轮播效果在各类 UI 组件库中屡见不鲜,已然成为标配组件。多数情况下,产品经理所要求的也不过是 2D 轮播,直接调用组件库中的组件即可,无需我们再劳心费神。

然而,掌握自行编写轮播代码的技能亦颇具价值,特别是 3D 轮播,可以给自己博客增加特效,或者用于吹吹牛嘛。😋

本次要分享的就是一个3D的轮播效果,并且是一步一步手撸实现,期望能得诸位青睐,嘿嘿,请诸君按需食用。

20241121-1.gif

基础布局

首先,咱先把结构和样式整上:

<!DOCTYPE html>
<html>
<head>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            padding: 0;
            margin: 0;
            background-color: #333;
            height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            flex-direction: column;
        }
        .container {
            width: 300px;
            height: 200px;
            position: relative;
            perspective: 1000px;
        }
        .carousel {
            width: 100%;
            height: 100%;
            position: absolute;
            transform-style: preserve-3d;
            transition: transform 1s;
            transform: translateZ(-210px) rotateY(0deg);
            /* 后续拖动操作时,避免文案的影响 */
            user-select: none;
            /* 后续拖动操作时,改变鼠标形状 */
            cursor: grab;
        }
        .cell {
            position: absolute;
            width: 280px;
            height: 180px;
            left: 10px;
            top: 10px;
            border: 2px solid #000;
            background-color: rgba(255, 255, 255, 0.8);
            display: flex;
            justify-content: center;
            align-items: center;
            font-size: 60px;
            font-weight: bold;
        }
        .cell:nth-child(1) {
            transform: rotateY(0deg) translateZ(210px);
        }
        .cell:nth-child(2) {
            transform: rotateY(72deg) translateZ(210px);
        }
        .cell:nth-child(3) {
            transform: rotateY(144deg) translateZ(210px);
        }
        .cell:nth-child(4) {
            transform: rotateY(216deg) translateZ(210px);
        }
        .cell:nth-child(5) {
            transform: rotateY(288deg) translateZ(210px);
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="carousel">
            <div class="cell">1</div>
            <div class="cell">2</div>
            <div class="cell">3</div>
            <div class="cell">4</div>
            <div class="cell">5</div>
        </div>
    </div>
</body>
</html>

效果:

image.png

这...没啥可说的,基础的HTML+CSS布局。😋

不过,其中值得留意的是每个轮播子项的 transform: rotateY(deg) translateZ(px); 这一属性的设置。👀

上述代码中,对于 rotate 的值小编是通过 360 / 5 计算得到的,因为是五个元素要围成一个圆嘛。而 translateZ 的值小编则是通过最笨的方式,手动去不断调整得到的。

如果,当前咱们要再增加一个轮播子项,那么,每个子项的样式就不得不重新进行调整,这...就挺麻烦的。😬

我们来通过 JS 解决这个问题:

<script>
    const carousel = document.querySelector('.carousel');
    const carouselWidth = carousel.offsetWidth;
    let cells = [];

    // 记录一共有多少个轮播的子项,保持与实际的DOM数量一致,也可以直接使用cells.length
    let total = 5;
    // 计算单个子项占据的角度
    let occupyDeg = 0;
    // 计算轮播子项的在 3D 空间中沿Z轴应该平移的距离(即半径值)
    let translateZRadius = undefined;

    function init() {
        // 获取所有轮播子项的DOM
        cells = carousel.querySelectorAll('.cell');
        // 由于是3D轮播,可以想象成一个圆,计算每个轮播子项占据一个圆360度的多少
        occupyDeg = 360 / total;
        // 看下面详解👇👇👇
        translateZRadius = Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));

        cells.forEach((cellDOM, i) => {
            if (i < total) {
                const cellAngle = occupyDeg * i;
                // 给每个子项设置对应的值
                cellDOM.style.transform = `rotateY(${cellAngle}deg) translateZ(${translateZRadius}px)`;
            } else {
                // 多余的就隐藏
                cellDOM.style.transform = "none";
            }
        });
    }
    init();
</script>

使用 JS 去动态计算每个轮播子项的 rotateYtranslateZ 值,咱们就可以把它们各自设置的 transform 属性的样式给删除了;新增的轮播子项也能自动生成属性值,不用咱们手动设置了,挺棒。😀

rotateY 值的计算应该是比较好理解的,而 translateZ 值的计算就稍微复杂一些,它本质就等同于计算这个 "圆" 的半径,这点可以理解吧❓ 如图:

image.png

再来细致分析一下计算过程:

translateZRadius = Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));

  • Math.PI:这个都认识吧,圆周率 π(约等于 3.14159),一个数学常量。
  • Math.PI / total:这里的 total 应该是表示轮播子项的总数。用圆周率除以子项总数,得到的结果是将一个完整的圆按照子项数量进行等分后,每一份所对应的弧度值
  • Math.tan(Math.PI / total)Math.tan() 是正切函数,三角函数应该都晓得啦,它接受一个弧度值作为参数,并返回该弧度对应的正切值。
  • carouselWidth / 2carouselWidth 表示轮播容器的宽度。将容器宽度除以 2,是因为在以容器中心为参考点进行 3D 布局时,我们通常是从中心向两侧分布子项,所以先求出一半的宽度值,以便后续结合正切值等计算出子项沿 Z 轴的合适距离(即半径值)。
  • carouselWidth / 2 / Math.tan(Math.PI / total):将前面求出的轮播容器宽度的一半,除以每一份弧度对应的正切值,这样就得到了一个与子项在 3D 空间中沿 Z 轴分布相关的距离值。这个值的大小决定了子项距离中心在 Z 轴方向上的远近程度,从而影响它们在 3D 视觉上的布局效果。
  • Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));:最后,使用 Math.round() 函数对前面计算得到的距离值进行四舍五入取整操作。

应该都能懂哈❗😋完成了轮播子项样式的动态计算。实际上,子项的 DOM 结构同样能够借助代码动态生成,这点可以自己搞一下。

点击切换功能

布局有了,接下来,咱们得让它能动起来,能进行轮播切换,这才是核心功能。

也不难,直接贴代码瞧瞧:

<!DOCTYPE html>
<html>
<body>
    <!-- ... -->
    <div style="display: flex;margin-top: 10px;">
        <button style="margin-right: 10px;" class="previous">上一页</button>
        <button class="next">下一页</button>
    </div>
    <script>
        // ...
        let index = 0;
        
        function init() {
            // ...
            
            // 初始化轮播容器的样式
            carouseChange();
        }
        init();

        const previousButton = document.querySelector('.previous');
        const nextButton = document.querySelector('.next');

        previousButton.addEventListener('click', () => {
            index--;
            carouseChange();
        });
        nextButton.addEventListener('click', () => {
            index++;
            carouseChange();
        });
        function carouseChange() {
            // 计算当前轮播子项应旋转的角度
            const angle = occupyDeg * index * -1;
            // 更新轮播容器的transform属性以实现切换效果
            carousel.style.transform = `translateZ(${-translateZRadius}px) rotateY(${angle}deg)`;
        }
    </script>
</body>
</html>

效果:

20241121-2.gif

轮播的切换功能咱们是通过改变轮播容器.carousel)的旋转来完成的,每次把容器旋转到对应轮播子项占据的角度,如下图:

image.png

image.png

init 函数中调用 carouseChange 函数,能顺便给轮播容器(.carousel)的 transform 属性进行初始化,它的 translateZ 值也需要动态根据轮播子项的个数生成。

拖动切换功能

轮播组件,在PC端上最常见的交互方式为点击切换,此外,还常常具备自动切换的功能。例如,可以设定每三秒切换一次,但实现自动切换的逻辑较为简单,只需开启一个定时器(setInterval),在定时器的回调函数中不断改变 index 的值,并调用 carouseChange 函数即可,注意清除定时器哦,这里小编就不多讲啦。😁

值得一提的是,轮播组件有时还会带有拖动功能,这种功能在移动端更为常见。然而,3D 轮播图独特的视觉效果本身就容易激发用户的拖动操作欲望。(是吧是吧?反正小编看着就想拖动玩玩)

接下来,咱们就来看看这个拖动切换功能要如何完成。

<!DOCTYPE html>
<html>
<body>
    <!-- ... -->
    <script>
        // ...
        
        // 是否在拖动中
        let isMouseDown = false;
        // 记录开始拖动的位置
        let startX = 0;

        carousel.addEventListener('mousedown', event => {
            isMouseDown = true;
            startX = event.clientX;
        });
        
        // 记录鼠标拖动的距离
        let diffX = 0;
        carousel.addEventListener('mousemove', event => {
            if (isMouseDown) {
                let currentX = event.clientX;
                // 计算本次移动与上一次移动的位移差
                diffX = currentX - startX;
            }
        });
        // 必须是全局的mouseup事件,要不当拖动在轮播容器.carousel外松开就会出现bug了
        document.body.addEventListener('mouseup', () => {
            if (isMouseDown) {
                // 拖动的距离超过轮播容器的一半就进行切换
                if (Math.abs(diffX) > carouselWidth / 2) {
                    if (diffX > 0) {
                        // 右
                        index--;
                        carouseChange();
                    } else {
                        // 左
                        index++;
                        carouseChange();
                    }
                }
            }
            // 鼠标抬起时,清空位移差,否则会出现拖动切换后,点击子项就立马会完成拖动切换
            diffX = 0;
            isMouseDown = false;
        });
    </script>
</body>
</html>

效果:

20241122-1.gif

代码量并不多哈😋,都写有注释,其核心在于对鼠标三兄弟事件,即 mousedownmousemove 与 mouseup 进行监听并加以处理。就拖动相关的事件处理而言,小编此前已撰写了诸多文章,涵盖了形形色色的拖动场景。倘若有感兴趣了解更多细节的,不妨去翻阅一下小编之前发布过的文章瞧瞧哈。

不过,这拖动切换功能还没做完❗

当前实现的拖动功能略显生硬,虽然也能用,但交互效果不是很好,还需要进行优化。目前的情况是,仅在鼠标松开时才会执行轮播的切换操作,倘若鼠标的 "拖动距离" 不足时,轮播便不会产生任何变化。

而在友好的拖动切换交互设计中,应当是每产生微小的拖动距离,都能即时反馈在轮播展示上。当鼠标松开时,若拖动距离达到了预先设定的切换阈值,则顺利进行轮播切换;反之,若未达到该阈值,轮播则应平滑地回弹至上一次位置。

Em...这才能算是比较好的交互,才能真正赢得用户的青睐,提升用户的好感度与使用体验。当然,这都是后话了,反正,咱们就来加上这么一个小功能吧。😁

<!DOCTYPE html>
<html>
<body>
    <!-- ... -->
    <script>
        // ...
        
        // 允许优先接收外部的值进行切换
        function carouseChange(angleOutside) {
            const angle = angleOutside || occupyDeg * index * -1;
            carousel.style.transform = `translateZ(${-translateZRadius}px) rotateY(${angle}deg)`;
        }
        
        let isMouseDown = false;
        let startX = 0;
        let diffX = 0;

        carousel.addEventListener('mousedown', event => { // ... });
        
        carousel.addEventListener('mousemove', event => {
            if (isMouseDown) {
                let currentX = event.clientX;
                diffX = currentX - startX;

                // 由当前的index计算出旋转的角度(可以把这个计算提取到mousedown)
                const angle = occupyDeg * index * -1;
                // 根据位移差计算角度变化
                const angleChange = diffX * (occupyDeg / carouselWidth);
                // 原本的角度与不断变化的角度
                carouseChange(angle + angleChange);
            }
        });
        document.body.addEventListener('mouseup', () => {
            if (isMouseDown) {
                if (Math.abs(diffX) > carouselWidth / 2) {
                    if (diffX > 0) {
                        // 右
                        index--;
                        carouseChange();
                    } else {
                        // 左
                        index++;
                        carouseChange();
                    }
                }else {
                    // 当拖动没有满足阈值时,切换成上一次的位置
                    carouseChange();
                }
            }
            diffX = 0;
            isMouseDown = false;
        });
    </script>
</body>
</html>

效果:

20241122-2.gif

呃...可能录的 GIF 效果不是很好😵,但是,应该是能看出相比上一个效果会有一些不同,鼠标开始拖动轮播也会开始旋转,拖动结束未达阈值也会回弹回去。

代码量增加不多,关键是这行:

const angleChange = diffX * (occupyDeg / carouselWidth);

这句代码的主要目的是根据鼠标的实时拖动距离(diffX),计算出对应的角度变化值(angleChange)。

  • diffX:鼠标拖动距离。
  • occupyDeg:每个轮播子项在整个 "圆" 360度中所占据的度数。
  • carouselWidth:轮播容器,它在计算角度变化与位移差的关系中起到一个比例换算的作用。

整个计算的原理是基于一种线性比例关系。假设轮播图是均匀分布在一个圆上的(从角度分布的角度来看),那么当鼠标在轮播图容器的宽度方向上移动时,移动的像素距离(diffX)应该与轮播图所对应的角度变化成一定的比例关系。

具体来说,occupyDeg / carouselWidth 这个比值表示每移动一个像素在水平方向上所对应的角度变化量。例如,如果 occupyDeg 是 72 度,carouselWidth 是 300px,那么每移动 1px 在水平方向上对应的角度变化就是 72 / 300 = 0.24 度。

然后,将这个每像素对应的角度变化量乘以实际的位移差(diffX),就可以得到总的角度变化值(angleChange)。

应该非常清楚啦❓😋

增加与删除

讲到这里,核心要点就阐述得差不多了。我们已经深入探讨了主要的实现逻辑,接下来的更多是一些附加功能方面的拓展内容。相信凭借你对前面内容的理解,完全有能力依据特定需求进一步扩展该功能。例如,小编在网上瞧见的 3D 轮播效果中,有结合 CSS 的缩放(scale)属性进行运用,便能营造出更加出色、更具视觉冲击力的轮播效果,这部分就你自己来啦。👻

最后,我们再来处理一下开头效果图中所涉及的增加与删除功能的实现。这部分相对较为简单,直接贴代码:

<!DOCTYPE html>
<html>
<body>
    <!-- ... -->
    <div style="display: flex; margin-top: 10px">
        <button style="margin-right: 10px" class="add">增加一个</button>
        <button class="reduce">减少一个</button>
    </div>
    <script>
        // ...
        
        const addButton = document.querySelector('.add');
        const reduceButton = document.querySelector('.reduce');

        addButton.addEventListener("click", () => {
            total += 1;
            // 追加一个新的轮播子项
            const cell = document.createElement('div');
            cell.classList.add('cell');
            cell.innerText = total;
            carousel.appendChild(cell);
            // 重新初始化
            init();
        });
        reduceButton.addEventListener("click", () => {
            if (total === 5) {
                // 只有5个就不给删除了,5个围成一个圆的效果已经接近极限了,少于5个就不是很好看了
                return;
            }
            total -= 1;
            // 移除最后一个轮播子项
            carousel.removeChild(carousel.lastElementChild);
            // 重新初始化
            init();
        });
    </script>
</body>
</html>

完整源码

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>通过CSS+JS手撸一个3D轮播</title>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            padding: 0;
            margin: 0;
            background-color: #333;
            height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            flex-direction: column;
        }
        .container {
            width: 300px;
            height: 200px;
            position: relative;
            perspective: 1000px;
        }
        .carousel {
            width: 100%;
            height: 100%;
            position: absolute;
            transform-style: preserve-3d;
            transition: transform 1s;
            user-select: none;
            cursor: grab;
        }
        .cell {
            position: absolute;
            width: 280px;
            height: 180px;
            left: 10px;
            top: 10px;
            border: 2px solid #000;
            background-color: rgba(255, 255, 255, 0.8);
            display: flex;
            justify-content: center;
            align-items: center;
            font-size: 60px;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="carousel">
            <div class="cell">1</div>
            <div class="cell">2</div>
            <div class="cell">3</div>
            <div class="cell">4</div>
            <div class="cell">5</div>
        </div>
    </div>
    <div style="display: flex;margin-top: 10px;">
        <button style="margin-right: 10px;" class="previous">上一页</button>
        <button class="next">下一页</button>
    </div>
    <div style="display: flex; margin-top: 10px">
        <button style="margin-right: 10px" class="add">增加一个</button>
        <button class="reduce">减少一个</button>
    </div>
    <script>
        const carousel = document.querySelector('.carousel');
        const carouselWidth = carousel.offsetWidth;
        const carouseHeight = carousel.offsetHeight;
        let cells = [];
        let total = 5;
        let occupyDeg = 0;
        let translateZRadius = undefined;
        let index = 0;
        function init() {
            cells = carousel.querySelectorAll('.cell');
            index = 0;
            occupyDeg = 360 / total;
            translateZRadius = Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));
            cells.forEach((cellDOM, i) => {
                if (i < total) {
                    const cellAngle = occupyDeg * i;
                    cellDOM.style.transform = `rotateY(${cellAngle}deg) translateZ(${translateZRadius}px)`;
                } else {
                    cellDOM.style.transform = "none";
                }
            });
            carouseChange();
        }
        init();
        // 点击切换
        const previousButton = document.querySelector('.previous');
        const nextButton = document.querySelector('.next');
        previousButton.addEventListener('click', () => {
            index--;
            carouseChange();
        });
        nextButton.addEventListener('click', () => {
            index++;
            carouseChange();
        });
        function carouseChange(angleOutside) {
            const angle = angleOutside || occupyDeg * index * -1;
            carousel.style.transform = `translateZ(${-translateZRadius}px) rotateY(${angle}deg)`;
        }
        // 拖动切换
        let isMouseDown = false;
        let startX = 0;
        let currentAngle = 0;
        carousel.addEventListener('mousedown', event => {
            isMouseDown = true;
            startX = event.clientX;
            currentAngle = occupyDeg * index * -1;
        });
        let diffX = 0;
        carousel.addEventListener('mousemove', event => {
            if (isMouseDown) {
                let currentX = event.clientX;
                diffX = currentX - startX;
                const angleChange = diffX * (occupyDeg / carouselWidth);
                carouseChange(currentAngle + angleChange);
            }
        });
        document.body.addEventListener('mouseup', () => {
            if (isMouseDown) {
                if (Math.abs(diffX) > carouselWidth / 2) {
                    if (diffX > 0) {
                        index--;
                        carouseChange();
                    } else {
                        index++;
                        carouseChange();
                    }
                }else {
                    carouseChange();
                }
            }
            diffX = 0;
            isMouseDown = false;
        });
        // 增加与删除
        const addButton = document.querySelector('.add');
        const reduceButton = document.querySelector('.reduce');
        addButton.addEventListener("click", () => {
            total += 1;
            const cell = document.createElement('div');
            cell.classList.add('cell');
            cell.innerText = total;
            carousel.appendChild(cell);
            init()
        });
        reduceButton.addEventListener("click", () => {
            if (total === 5) return;
            total -= 1;
            carousel.removeChild(carousel.lastElementChild);
            init()
        });
    </script>
</body>
</html>




至此,本篇文章就写完啦,撒花撒花。

image.png