【原生JS】当我再一次写轮播图时我在想什么

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

最近遇到一个需求,是实现一个3D轮播图的需求,有点像旋转木马,基于css3手写一个轮播图,反反复复的思考了很多东西,写下此文记录自己一个心路旅程

1、布局

  • 首先,我们需要一个最外层容器container。
  • 其次,我们需要一个“卡片盒子”,他在容器的中心位置。
  • 再来,卡片盒子里有N张卡片。
  • 最后,我们在给其添加点样式。
<div class="box-container">
    <div class="box-wrapper">
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
    </div>
</div>
.box-container {
    width: 320px;
    height: 300px;
    position: relative;
    display:flex;
    align-items: center;
    justify-content: center;
    margin: 0 auto;
    border: 1px solid red;
}
.box-wrapper {
    width: 150px;
    height: 200px;
}
.box {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border: 1px solid blue;

}

image.png

2、样式

  1. 这里我们需要想象下,我们可以将其想象成一个”圆柱体“,而每个面都在试圆柱体上旋转。
  2. 最外层容器的宽其实就是圆柱体的直径。
  3. 每张在”卡片盒子“里的卡片,先依照Y轴旋转自己特定角度,假设一共有6个面,则每个面旋转的角度就是360 / 6 * 下标。
  4. 旋转完后,再向Z轴平移一个半径的距离,就到了自己各自的位置。
  5. "卡片盒子“开启3d视觉
class RotateBox {
    constructor(container) {
        this.container = this.renderContainer(container)
        this.r = this.container.clientWidth / 2 // 半径
        this.cardBox = this.renderCardBox(this.container)
        this.cardList = this.renderCardList(this.cardBox, this.r)
    }
    // 容器添加3d效果看上去更明显一些
    renderContainer(container) {
        container.style.transformStyle = 'preserve-3d'
        container.style.transform = 'rotateX(-15deg) rotateY(15deg)'
        return container
    }
    // 卡片盒子需要添加transformStyle,才可以看出每个卡片在3d效果上的移动
    renderCardBox(container) {
        const selector = '.box-wrapper'
        const cardBox = container.querySelector(selector)
        cardBox.style.transformStyle = 'preserve-3d'
        return cardBox
    }
    // 渲染每个卡片,先在Y轴上旋转,再向Z轴平移)
    renderCardList(cardBox, r) {
        const selector = '.box'
        const cardList = [...cardBox.querySelectorAll(selector)]
        cardList.forEach((el, index) => {
            const angle = 360 / cardList.length * index
            el.style.transform = `rotateY(${angle}deg) translateZ(${r}px)`
        })
        return cardList
    }
}
// 示例化
new RotateBox(document.querySelector('.box-container'))

image.png

3、旋转:基础功能

需要旋转这个”圆柱体“,其实只要旋转”卡片盒子“即可,因为每个卡片的旋转角度都是基于”卡片盒子“的。我们丰富一些辅助的属性与方法,后期可以直接赋值或者调用方便处理。

  • 构造函数里添加一个属性rotate,默认值为0,这个属性将表示当前我们“卡片盒子”旋转了多少度。
  • 提供一个辅助方法,rotateTo,调用这个方法并传入角度,会改变”卡片盒子“的角度样式。
  • 给”卡片盒子“添加css的过渡动画transition,也在构造器里设置一个过渡动画的动画时长
class RotateBox {
    constructor(container) {
        ...
        this.transitionTimeout = 0.15 // 过渡动画所需时长(s)
        this.cardBox = this.renderCardBox(this.container, this.transitionTimeout)
        this.cardList = this.renderCardList(this.cardBox, this.r)
        this.rotate = 0 // 当前旋转角度
    }
    ...
    // 卡片盒子需要添加transformStyle,才可以看出每个卡片在3d效果上的移动
    renderCardBox(container, transitionTimeout) {
        const selector = '.box-wrapper'
        const cardBox = container.querySelector(selector)
        cardBox.style.transformStyle = 'preserve-3d'
        cardBox.style.transition = `transform ${transitionTimeout}s linear`
        return cardBox
    }
    ...
    // 旋转到指定角度
    rotateTo(rotate) {
        this.rotate = parseInt(rotate)
        this.cardBox.style.transform = `rotateY(${this.rotate}deg)`
    }
}

4、旋转:自动旋转

这是个很正常的需求,轮播图每隔一段时间会自动旋转一下,但我希望在轮播图这个类里面处理这个逻辑,我希望这是个可选择的附属功能,于是我想到了插件的概念,我对我这个插件有一下几点要求:

  1. 插件与主体的数据流向一定要保持清晰(单项数据流)。
  2. 插件尽量在内部处理逻辑,对外只暴露一些简单明了的事件或方法,供主体调用。
  3. 插件不会引起一些不必要的意外情况,插件不会修改主体的属性与行为(例如旋转)。

为了实现如下几个点,想到了观察者模式,就像js或者node中,很多功能都有on,请教了我们部门技术大佬马哥,很快找到一个叫EventEmitter3的js代码,粗略的研究了一番,自己写了一个简易的EventEmitter类

class EventEmitter {
    constructor() {
        this._events = {}
    }
    on(eventName, fn) {
        if(typeof fn !== 'function') {
            throw new TypeError('The listener must be a function');
        }
        if(!this._events[eventName]) {
            this._events[eventName] = [fn]
            return
        }
        this._events[eventName].push(fn)
    }
    emit(eventName, ...args) {
        if(!this._events[eventName]) {
            return
        }
        this._events[eventName].forEach(fn => {
            fn(...args)
        })
    }
}

基于这个类,我们开始一步一步的处理自动轮播, 先从主类开始

  1. 主类RotateBox添加属性angle表示每次旋转的角度
  2. 主类RotateBox添加属性direction表示旋转方向(clockwise顺时针,counterclockwise逆时针)
  3. 主类RotateBox添加属性autoPlayRotateTime表示每次自动旋转的间隔(秒)
  4. 主类RotateBox添加方法getPreviousAndNextRotate用以获取下一个或上一个的旋转角度
class RotateBox {
    constructor(container) {
       ...
        this.rotate = 0 // 当前旋转角度
        this.angle = 360 / this.cardList.length // 每次旋转的角度
        this.direction = 'counterclockwise' // 旋转方向(clockwise顺时针,counterclockwise逆时针)
        this.autoPlayRotateTimeout = 2000
    }
    // 获取前一个与后一个的坐标,当前角度(rotate), 每次旋转角度(angle),旋转方向(clockwise顺时针,counterclockwise逆时针)
    getPreviousAndNextRotate(rotate, angle, direction = 'counterclockwise') {
        let previous = 0
        let next = 0
        if(rotate === 0 || rotate % angle === 0) {
            next = rotate + angle
            previous = rotate - angle
        } else {
            if(rotate > 0) {
                previous = rotate - (rotate % angle)
                next = previous + angle
            }
            if(rotate < 0) {
                next = rotate - (rotate % angle)
                previous = next - angle
            }
        }

        switch (direction) {
            case 'counterclockwise':
                return {next, previous}
            case 'clockwise':
                return {next: previous, previous: next}
        }
    }
}

实现自动旋转插件类,这个类,轮播图主体会提供必要的信息给我们,例如每次旋转的角度,旋转方向,每次自动旋转的间隔,我们内部处理定时器与旋转角度的逻辑,每次间隔都会获取当前轮播图所处角度,并告知轮播图下一个旋转的角度,至于真实的旋转就是轮播图自己处理的,插件不做任何干涉。

// 自动轮播插件
class RotateSwiperAutoPlugin extends EventEmitter {
    constructor(rotateSwiper, angle, intervalTimeout = 2000, direction = 'counterclockwise') {
        super();
        this.rotateSwiper = rotateSwiper // 当前旋转实例
        this.angle = angle // 每次旋转的角度
        this.intervalTimeout = intervalTimeout // 每次旋转定时器的间隔
        this.intervalNo = null // 旋转定时器序号
        this.direction = direction // 旋转方向(clockwise顺时针,counterclockwise逆时针
    }
    // 告知旋转主体,要开始转到下一个了
    next(rotate) {
        const { next: nextRotate } = this.rotateSwiper.getPreviousAndNextRotate(rotate, this.angle, this.direction)
        this.emit('rotate', nextRotate)
    }
    // 开始
    start() {
        this.stop()
        this.intervalNo = setInterval(() => {
            const currentRotate = this.rotateSwiper.rotate
            this.next(currentRotate)
        }, this.intervalTimeout)
    }
    // 停止
    stop() {
        if(this.intervalNo) {
            clearInterval(this.intervalNo)
            this.intervalNo = null
        }
    }
}

主体只需要注册对应插件,调用插件实例的方法与监听插件的事件,就能做出很清晰的处理了

  • 主类添加方法initRotateSwiperAutoPlugin
  • 在这个方法中实例化自动轮播插件。
  • 监听轮播事件,在事件触发时,获取下一个要旋转的角度,并调用自身的rotateTo方法。
  • 最后返回这个自动轮播插件实例,获取到这个实例后,在调用这个实例的start启动方法。
class RotateBox {
    constructor(container) {
        this.container = this.renderContainer(container)
        this.r = this.container.clientWidth / 2 // 半径
        this.transitionTimeout = 0.15 // 过渡动画所需时长(s)
        this.cardBox = this.renderCardBox(this.container, this.transitionTimeout)
        this.cardList = this.renderCardList(this.cardBox, this.r)
        this.rotate = 0 // 当前旋转角度
        this.angle = 360 / this.cardList.length // 每次旋转的角度
        this.direction = 'counterclockwise' // 旋转方向(clockwise顺时针,counterclockwise逆时针)
        this.autoPlayRotateTimeout = 2000
        this.rotateSwiperAutoPlugin = this.initRotateSwiperAutoPlugin()
        this.rotateSwiperAutoPlugin.start() // 开始旋转
    }
    // 初始化自动轮播逻辑
    initRotateSwiperAutoPlugin() {
        const rotateSwiperAutoPlugin = new RotateSwiperAutoPlugin(this, this.angle, this.autoPlayRotateTimeout, this.direction)
        rotateSwiperAutoPlugin.on('rotate', rotate => {
            this.rotateTo(rotate)
        })
        return rotateSwiperAutoPlugin
    }
}

2022-06-05 16-11-11.2022-06-05 16_11_39.gif

扫描二维码关注公众号,回复: 14340841 查看本文章

5、旋转:拖拽旋转(touch)

因为这是一个h5的需求,所以做的是一个touch的处理,也是类似于自动轮播似的做了一个插件,插件不做过多干涉,只对外暴露事件与信息。

  1. 实现一个拖拽旋转插件类,继承EventEmitter,插件会获取到dom,并在touch事件时,处理相关逻辑,并在合适的时机触发事件并提供必要参数信息。
  2. 在主类实例化拖拽插件类,并监听对应事件,处理自身的逻辑。
  3. 拖拽完成后,会有一个如果不处于某个卡片的角度,则进行“吸附”。
  4. 拖拽时暂停自动轮播。(这里处理其实并不好,插件之间存在了耦合)
// 触控旋转插件
class RotateSwiperTouchPlugin extends EventEmitter{
    constructor(cardBox) {
        super();
        this.cardBox = cardBox
        this.addTouchEvent()
    }
    addTouchEvent() {
        this.cardBox.addEventListener('touchstart', event => {
            const { clientX: originX, clientY: originY } = event.touches[0]
            this.emit('touchstart', { originX, originY })
            this.cardBox.ontouchmove = event => {
                const { clientX: moveX, clientY: moveY } = event.touches[0]
                this.emit('touchmove', { originX, originY, moveX, moveY })
                return false
            }
            this.cardBox.ontouchend = () => {
                const { clientX: endX, clientY: endY } = event.touches[0]
                this.cardBox.ontouchmove = null
                this.emit('touchend', {endX, endY})
                return false
            }
            return false
        })
    }
}
class RotateBox {
    constructor(container) {
        ...
        this.rotateSwiperAutoPlugin.start() // 开始旋转
        // 初始化touch插件
        this.rotateSwiperTouchPlugin = this.initRotateSwiperTouchPlugin()
    }
    // 初始化自动轮播逻辑
    initRotateSwiperAutoPlugin() {
        const rotateSwiperAutoPlugin = new RotateSwiperAutoPlugin(this, this.angle, this.autoPlayRotateTimeout, this.direction)
        rotateSwiperAutoPlugin.on('rotate', rotate => {
            this.rotateTo(rotate)
        })
        return rotateSwiperAutoPlugin
    }
    // 初始化拖拽旋转逻辑
    initRotateSwiperTouchPlugin() {
        const rotateSwiperTouchPlugin = new RotateSwiperTouchPlugin(this.cardBox)
        let originRotate = 0
        rotateSwiperTouchPlugin.on('touchstart', () => {
            originRotate = this.rotate
            this.rotateSwiperAutoPlugin.stop()
        })
        rotateSwiperTouchPlugin.on('touchmove', ({ originX, moveX }) => {
            const diffX = originX - moveX
            const rotateY = originRotate - diffX
            this.rotateTo(rotateY)
        })
        rotateSwiperTouchPlugin.on('touchend', async () => {
            await this.rotateToNextOrPrevious(this.rotate)
            this.rotateSwiperAutoPlugin.start()

        })
        return rotateSwiperTouchPlugin
    }
    // 当拖拽松开时,如果处于两个卡片中间,则会判断,进行位置校准
    rotateToNextOrPrevious(rotate) {
        return new Promise(resolve => {
            const { next, previous } = this.getPreviousAndNextRotate(rotate, this.angle, this.direction)
            if(rotate / this.angle === 0) {
                return
            }
            this.rotateTo(next - rotate > rotate - previous ? previous : next)
            setTimeout(() => {
                resolve()
            }, this.transitionTimeout * 1000)
        })
    }

2022-06-05 16-32-42.2022-06-05 16_33_06.gif

整体效果就算是完成了,去除边框贴上背景图。 2022-06-05 16-36-19.2022-06-05 16_36_41.gif 整体代码如下:

<!doctype html>
<html lang="zh-cn">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<style>
    * {padding: 0; margin: 0}
    .box-container {
        width: 320px;
        height: 300px;
        position: relative;
        display:flex;
        align-items: center;
        justify-content: center;
        margin: 0 auto;
    }
    .box-wrapper {
        width: 150px;
        height: 200px;
        position: relative;
        /*transform-style: preserve-3d;*/
        transition: transform linear .15s;

    }
    .box {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: url('example/images/mouse.jpg') no-repeat center center;
        background-size: cover;

    }
</style>
<body>
<div class="box-container">
    <div class="box-wrapper" style="transform:rotateY(0deg)">
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
        <div class="box"></div>
    </div>
</div>
<!--<script src="./math.min.js"></script>-->
</body>
<script>
    class EventEmitter {
        constructor() {
            this._events = {}
        }
        on(eventName, fn) {
            if(typeof fn !== 'function') {
                throw new TypeError('The listener must be a function');
            }
            if(!this._events[eventName]) {
                this._events[eventName] = [fn]
                return
            }
            this._events[eventName].push(fn)
        }
        emit(eventName, ...args) {
            if(!this._events[eventName]) {
                return
            }
            this._events[eventName].forEach(fn => {
                fn(...args)
            })
        }
    }
    // 触控旋转插件
    class RotateSwiperTouchPlugin extends EventEmitter{
        constructor(cardBox) {
            super();
            this.cardBox = cardBox
            this.addTouchEvent()
        }
        addTouchEvent() {
            this.cardBox.addEventListener('touchstart', event => {
                const { clientX: originX, clientY: originY } = event.touches[0]
                this.emit('touchstart', { originX, originY })
                this.cardBox.ontouchmove = event => {
                    const { clientX: moveX, clientY: moveY } = event.touches[0]
                    this.emit('touchmove', { originX, originY, moveX, moveY })
                    return false
                }
                this.cardBox.ontouchend = () => {
                    const { clientX: endX, clientY: endY } = event.touches[0]
                    this.cardBox.ontouchmove = null
                    this.emit('touchend', {endX, endY})
                    return false
                }
                return false
            })
        }
    }
    // 自动轮播插件
    class RotateSwiperAutoPlugin extends EventEmitter {
        constructor(rotateSwiper, angle, intervalTimeout = 2000, direction = 'counterclockwise') {
            super();
            this.rotateSwiper = rotateSwiper // 当前旋转实例
            this.angle = angle // 每次旋转的角度
            this.intervalTimeout = intervalTimeout // 每次旋转定时器的间隔
            this.intervalNo = null // 旋转定时器序号
            this.direction = direction // 旋转方向(clockwise顺时针,counterclockwise逆时针
        }
        // 告知旋转主体,要开始转到下一个了
        next(rotate) {
            const { next: nextRotate } = this.rotateSwiper.getPreviousAndNextRotate(rotate, this.angle, this.direction)
            this.emit('rotate', nextRotate)
        }
        // 开始
        start() {
            this.stop()
            this.intervalNo = setInterval(() => {
                const currentRotate = this.rotateSwiper.rotate
                this.next(currentRotate)
            }, this.intervalTimeout)
        }
        // 停止
        stop() {
            if(this.intervalNo) {
                clearInterval(this.intervalNo)
                this.intervalNo = null
            }
        }
    }

    class RotateBox {
        constructor(container) {
            this.container = this.renderContainer(container)
            this.r = this.container.clientWidth / 2 // 半径
            this.transitionTimeout = 0.15 // 过渡动画所需时长(s)
            this.cardBox = this.renderCardBox(this.container, this.transitionTimeout)
            this.cardList = this.renderCardList(this.cardBox, this.r)
            this.rotate = 0 // 当前旋转角度
            this.angle = 360 / this.cardList.length // 每次旋转的角度
            this.direction = 'counterclockwise' // 旋转方向(clockwise顺时针,counterclockwise逆时针)
            this.autoPlayRotateTimeout = 2000
            this.rotateSwiperAutoPlugin = this.initRotateSwiperAutoPlugin()
            this.rotateSwiperAutoPlugin.start() // 开始旋转
            // 初始化touch插件
            this.rotateSwiperTouchPlugin = this.initRotateSwiperTouchPlugin()
        }
        // 初始化自动轮播逻辑
        initRotateSwiperAutoPlugin() {
            const rotateSwiperAutoPlugin = new RotateSwiperAutoPlugin(this, this.angle, this.autoPlayRotateTimeout, this.direction)
            rotateSwiperAutoPlugin.on('rotate', rotate => {
                this.rotateTo(rotate)
            })
            return rotateSwiperAutoPlugin
        }
        // 容器添加3d效果看上去更明显一些
        renderContainer(container) {
            container.style.transformStyle = 'preserve-3d'
            container.style.transform = 'rotateX(-15deg) rotateY(15deg)'
            return container
        }
        // 卡片盒子需要添加transformStyle,才可以看出每个卡片在3d效果上的移动
        renderCardBox(container, transitionTimeout) {
            const selector = '.box-wrapper'
            const cardBox = container.querySelector(selector)
            cardBox.style.transformStyle = 'preserve-3d'
            cardBox.style.transition = `transform ${transitionTimeout}s linear`
            return cardBox
        }
        // 渲染每个卡片,先在Y轴上旋转,再向Z轴平移)
        renderCardList(cardBox, r) {
            const selector = '.box'
            const cardList = [...cardBox.querySelectorAll(selector)]
            cardList.forEach((el, index) => {
                const angle = 360 / cardList.length * index
                el.style.transform = `rotateY(${angle}deg) translateZ(${r}px)`
            })
            return cardList
        }
        // 初始化拖拽旋转逻辑
        initRotateSwiperTouchPlugin() {
            const rotateSwiperTouchPlugin = new RotateSwiperTouchPlugin(this.cardBox)
            let originRotate = 0
            rotateSwiperTouchPlugin.on('touchstart', () => {
                originRotate = this.rotate
                this.rotateSwiperAutoPlugin.stop()
            })
            rotateSwiperTouchPlugin.on('touchmove', ({ originX, moveX }) => {
                const diffX = originX - moveX
                const rotateY = originRotate - diffX
                this.rotateTo(rotateY)
            })
            rotateSwiperTouchPlugin.on('touchend', async () => {
                await this.rotateToNextOrPrevious(this.rotate)
                this.rotateSwiperAutoPlugin.start()

            })
            return rotateSwiperTouchPlugin
        }
        // 当拖拽松开时,如果处于两个卡片中间,则会判断,进行位置校准
        rotateToNextOrPrevious(rotate) {
            return new Promise(resolve => {
                const { next, previous } = this.getPreviousAndNextRotate(rotate, this.angle, this.direction)
                if(rotate / this.angle === 0) {
                    return
                }
                this.rotateTo(next - rotate > rotate - previous ? previous : next)
                setTimeout(() => {
                    resolve()
                }, this.transitionTimeout * 1000)
            })
        }
        // 获取前一个与后一个的坐标,当前角度(rotate), 每次旋转角度(angle),旋转方向(clockwise顺时针,counterclockwise逆时针)
        getPreviousAndNextRotate(rotate, angle, direction = 'counterclockwise') {
            let previous = 0
            let next = 0
            if(rotate === 0 || rotate % angle === 0) {
                next = rotate + angle
                previous = rotate - angle
            } else {
                if(rotate > 0) {
                    previous = rotate - (rotate % angle)
                    next = previous + angle
                }
                if(rotate < 0) {
                    next = rotate - (rotate % angle)
                    previous = next - angle
                }
            }

            switch (direction) {
                case 'counterclockwise':
                    return {next, previous}
                case 'clockwise':
                    return {next: previous, previous: next}
            }
        }
        // 旋转到指定角度
        rotateTo(rotate) {
            this.rotate = parseInt(rotate)
            this.cardBox.style.transform = `rotateY(${this.rotate}deg)`
        }
    }
    const rotateSwiper = new RotateBox(document.querySelector('.box-container'))

</script>
</html>

6、待续未完...

后期我们可能需要更多的功能,比如将touch替换成mouse相关的事件,比如可以进入到这个轮播图的内部,比如图片从平面变成曲面,我们可以继续增加开发更多的插件。 这里我只开发了进入到轮播图内部的插件: 2022-06-05 16-40-15.2022-06-05 16_40_43.gif

曲面的逻辑可能会把每一个面也作为一个对象,新建一个Card类,在新增一个Card曲面插件类,来实现这一逻辑。

感谢大家的观看

2022年6月5日(端午节)

猜你喜欢

转载自juejin.im/post/7105676443639611405