Web 前端序列帧动画总结

前言

最近做一个项目要做到类似 Apple 官网的 macbook 产品展示的效果。

代码展示效果:

class ProductDetailImage {
    constructor(canvasId) {
        this.canvasId = canvasId

        // 绑定 this 上下文
        this.handleScroll = this.handleScroll.bind(this)
    }

    init() {
        var mainClass = document.querySelector('main.main').classList
        if (mainClass.contains('product_detail-main')) {
            mainClass.remove('product_detail-main')
        }
        if (document.getElementById(this.canvasId)) {
            this.initData()
            this.initImages()
            this.bindEvents()
        }
    }

    initData() {
        this.canvas2 = document.getElementById(this.canvasId)
        this.context = this.canvas2.getContext('2d')
        this.MAX_LEN = this.canvasId === 'product-detail1' ? 99 : 89
        this.imgs = []
        this.start = 1
        this.oldStart = -1
        this.addN = 1
        this.interval = 20 // 控制刷新率
        this.leftY = 0
        this.curScrollY = this.canvasId === 'product-detail1' ?
            document.querySelector('.js-product-scroll').offsetTop + 800 :
            document.querySelector('.js-product-scroll-small').offsetTop
        this.startPos = this.curScrollY
        this.lastPos = this.curScrollY
        this.isStop = false
    }

    initImages() {
        for (var i = 0; i <= this.MAX_LEN; i++) {
            var img = new Image()
            // img.onload = () => this.imgs[i] = img
            img.src = this.getImage(i)
            // 不管加载否 保证顺序
            this.imgs.push(img)
        }
    }

    bindEvents() {
        window.addEventListener('scroll', this.handleScroll)
    }

    unbindEvents() {
        window.removeEventListener('scroll', this.handleScroll)
    }

    getScrollTop() {
        return window.scrollY || 0
    }

    getImage(num) {
        // console.assert(Number.isInteger(num) && num > -1 && num < this.MAX_LEN)
        if (this.canvasId === 'product-detail1') {
            return '/assets/png/cat-wipe/catwipe_' + ('' + num).padStart(3, '0') + '.png'
        }

        if (this.canvasId === 'product-detail2') {
            return '/assets/png/cat-wipe2/catwipe_' + ('' + num).padStart(3, '0') + '.png'
        }
    }

    isOver() {
        return this.start < 0 || this.start > this.MAX_LEN - 1
    }

    handleScroll() {
        var scrollY = this.getScrollTop()
        var delta = scrollY - this.curScrollY
        var isDown = delta > 0

        delta = Math.abs(delta) + this.leftY
        this.curScrollY = scrollY

        if (
            this.isStop &&
            isDown === this.needDown &&
            ((isDown && this.curScrollY > this.lastPos) || (!isDown && this.curScrollY < this.lastPos))
        ) {
            // console.log('start')
            document.querySelector('main.main').classList.add('product_detail-main')
            this.isStop = false
        }
        if (this.isStop) return

        // good idea: 补偿 相对之前,解决了动画过快或过慢 根滚动条的速度(滚动条有快慢加速度感应,停止滚动也会有余动)来换帧
        var alpha = Math.floor(delta / this.interval) * this.addN || 0
        this.leftY = delta % this.interval

        isDown ? (this.start += alpha) : (this.start -= alpha)
        if (this.isOver() && !this.isStop) {
            // this.lastPos = scrollY
            this.isStop = true
            // console.log('stop')
            // console.log(this.start, scrollY, this.lastPos)

            // TODO: 多个 canvas 用 opacity 切换
            // this.canvas2.style.cssText = `opacity: 0`
        }

        if (this.start < 0) this.start = 0
        if (this.start > this.MAX_LEN - 1) this.start = this.MAX_LEN - 1
        if (this.startPos >= scrollY) this.start = 0
        if (this.oldStart === this.start) return
        this.oldStart = this.start
        // good idea:记录
        this.lastPos = scrollY
        this.needDown = !isDown

        this.drawCanvas(this.start)
    }

    drawCanvas(sequence) {
        // 当前序列帧
        var imgTemp = this.imgs[sequence]
        var canvas = this.canvas2

        canvas.width = this.canvasId === 'product-detail1' ?
            document.querySelector('.js-product-width').clientWidth :
            document.querySelector('.js-product-width-small').clientWidth
        canvas.height = this.canvasId === 'product-detail1' ?
            document.querySelector('.js-product-height').clientHeight :
            document.querySelector('.js-product-height-small').clientHeight

        // 计算图片的缩放比例
        var scaleX = canvas.width / imgTemp.width
        var scaleY = canvas.height / imgTemp.height
        var scale = Math.max(scaleX, scaleY)

        // 计算绘制图片的位置,使其铺满整个Canvas
        var offsetX = (canvas.width - imgTemp.width * scale) / 2
        var offsetY = (canvas.height - imgTemp.height * scale) / 2

        this.context.drawImage(imgTemp, offsetX, offsetY, imgTemp.width * scale, imgTemp.height * scale)
    }
}
<div class="row rel z3 product_detail-reveal1 | js-product-width">
  <div class="product_detail-scroll-sequence | js-product-scroll">
    <div class="product_detail-sequence-container">
      <div class="product_detail-image-sequence">
        <div class="product_detail-canvas-container | js-product-height">
          <div class="product_detail-canvas-wrapper">
            <canvas id="product-detail"></canvas>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
.product_detail-reveal1 {
    width: 100%;
}

.product_detail-reveal1 img {
    height: 54.84375rem;
}

.product_detail-reveal1-title {
    left: 0;
    top: 3.5rem;
}

.product_detail-reveal1-logo {
    right: 0;
    top: 3.5rem;
}

.product_detail-reveal1-logo img {
    width: 8rem;
    height: 8rem;
}

.product_detail-features {}

.product_detail-features-info {
    width: 50%;
}

.product_detail-features-info-desc {
    margin-top: 1.1875rem;
    padding-right: 8rem;
}

.product_detail-features-info-desc p {
    font-size: 1.125rem;
    font-weight: 500;
    line-height: 1.8rem;
    margin-top: 0.5rem;
    margin-bottom: 0.5rem;
}

.product_detail-features-img {
    width: 50%;
}

.product_detail-features-img img {
    width: 80%;
    height: auto;
}

.product_detail-scroll-sequence {
    position: relative;
    width: 100%;
    height: calc(54.84375rem + 100 * 20px);
}

.product_detail-sequence-container {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 100%;
}

.product_detail-image-sequence {
    position: sticky;
    position: -webkit-sticky;
    top: 0;
    overflow: hidden;
}

.product_detail-canvas-container {
    position: relative;
    width: 100%;
    height: 54.84375rem;
}

.product_detail-canvas-wrapper {
    transition: all 300ms;
    will-change: transform;
}

上面是第一版本的代码,第一版本的业务代码主要是图片去实现逐帧效果,但是这样带来一个问题就是图片过多不好管理,虽然现在 http2 可以满足大量文件加载,可惜公司业务需求需要推翻此版本。

第二版

如果在意 Apple 的逐帧内容载体的时候,其实是用视频实现的而不是图片,视频文件确实比图片实现起来更简单更好管理,只需要注意视频帧率即可。

一般情况视频帧率是25 帧左右,即一秒视频中有 25 张图片组成的,那么简单的逻辑显示差不多如下:

// 获取视频元素
var video = document.getElementById('videoPlayer');

// 监听滚动条滚动事件
window.addEventListener('scroll', function() {
// 获取滚动条的滚动距离
var scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;

// 计算视频播放的进度(示例中假设视频长度为 10 秒)
var videoDuration = 10; // 视频长度(秒)
var scrollMax = document.body.scrollHeight - window.innerHeight;
var videoCurrentTime = (scrollTop / scrollMax) * videoDuration;

// 更新视频播放的当前时间
video.currentTime = videoCurrentTime;
});

所以在上面核心代码中进行扩展实现如下:

class ScrollBasedVideoFrameAnimation {
  constructor(videoSelector, containerSelector) {
    this.video = document.querySelector(videoSelector)
    this.container = document.querySelector(containerSelector)
    this.frameCount = 0 // 总帧数
    this.frameRate = 30 // 帧率
    this.maxScroll = 0

    this.handleCanPlay = this.handleCanPlay.bind(this)
    this.handleScroll = this.handleScroll.bind(this)

    this.init()
  }

  init() {
    if (this.video) {
      this.maxScroll = this.container.offsetTop + this.container.offsetHeight - window.innerHeight

      this.video.addEventListener('canplay', this.handleCanPlay)
      window.addEventListener('scroll', this.handleScroll)
    }
  }

  handleCanPlay() {
    this.frameCount = Math.round(this.video.duration * this.frameRate)
  }

  handleScroll() {
    if (!this.ticking) {
      requestAnimationFrame(() => {
        const scrollTop = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop
        if (scrollTop >= this.container.offsetTop && scrollTop <= this.maxScroll) {
          const frameIndex = Math.floor((scrollTop - this.container.offsetTop) / (this.maxScroll - this.container.offsetTop) * this.frameCount)
          // 更新视频当前时间
          this.video.currentTime = frameIndex / this.frameRate
        }

        this.ticking = false
      })

      this.ticking = true
    }
  }

  unbindEvents() {
    window.removeEventListener('scroll', this.handleScroll)
  }
}

// 实例对象:
const vObj = new ScrollBasedVideoFrameAnimation('#product-detail1', '.js-product-scroll')
// 销毁对象,用于 Pjax 以及单页面...
vObj.unbindEvents()

注意其中的视频帧率不一样,需要提前定义视频帧率,html 结构如下:

<div class="row rel z3 product_detail-reveal1 | js-product-scroll">
  <div class="product_detail-scroll-sequence">
    <div class="product_detail-sequence-container">
      <div class="product_detail-image-sequence">
        <div class="product_detail-canvas-container">
          <div class="product_detail-canvas-wrapper">
            <video id="product-detail1" :src="productDetail.productImage" loop muted playsinline preload="auto" />
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

css 代码如下:

/* 序列帧图 1 */
.product_detail-scroll-sequence {
    position: relative;
    width: 100%;
    height: calc(12rem + 200rem);
}

@media (min-width: 650px) {
    .product_detail-scroll-sequence {
        height: calc(37.831875rem + 200rem);
    }
}

.product_detail-sequence-container {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 100%;
}

.product_detail-image-sequence {
    position: sticky;
    position: -webkit-sticky;
    top: 30rem;
    overflow: hidden;
}

@media (min-width: 650px) {
    .product_detail-image-sequence {
        top: 10rem;
    }
}

.product_detail-canvas-container {
    position: relative;
    width: 100%;
    height: 12rem;
}

@media (min-width: 650px) {
    .product_detail-canvas-container {
        height: 37.831875rem;
    }
}

.product_detail-canvas-wrapper {
    transition: all 300ms;
    will-change: transform;
    text-align: center;
    margin: 0 auto;
    mix-blend-mode: screen;
}

.product_detail-canvas-wrapper video {
    object-fit: cover;
    mix-blend-mode: screen;
    width: 100%;
    height: 12rem;
}

@media (min-width: 650px) {
    .product_detail-canvas-wrapper video {
        height: 37.831875rem;
    }
}

这其中 .product_detail-image-sequence.product_detail-canvas-wrapper 样式相对重要:

  • position: sticky 此样式可以让视频容器固定位置,给视觉上带来页面滚动的时候,视频根据滚动来播放进度条内容。
  • will-change: transform 次样式可以让页面启用 GPU 渲染,提升页面性能。

第三版

后续在 Edge 浏览器测试发现在某些电脑会有蜜汁卡顿,看了第二版的业务代码感觉也没啥问题,无奈将这问题归纳给 requestAnimationFrame 可能某些机器在特定的比如省电模式情况下出现卡顿问题,总之这问题让我毫无头绪,那只好换个方法去写了,第三版本的写法核心和第一版本差不多:

class ScrollBasedVideoFrameAnimation {
  constructor(videoSelector, containerSelector) {
    this.video = document.querySelector(videoSelector)
    this.container = document.querySelector(containerSelector)
    // this.sequence = document.querySelector(sequenceSelector)
    // this.opts = opt || {
    //   desktop: '37.831875rem',
    //   mobile: '12rem',
    // }

    this.init()
  }

  init() {
    const mainClass = document.querySelector('main.main').classList
    if (mainClass.contains('product_detail-main'))
      mainClass.remove('product_detail-main')

    if (this.video) {
      this.start = 1
      this.oldStart = -1
      this.addN = 1
      this.interval = 30 // 控制刷新率
      this.curScrollY = this.container.offsetTop
      this.leftY = 0
      this.startPos = this.curScrollY
      this.lastPos = this.curScrollY
      this.isStop = false
      this.needDown = false

      this.frameCount = 0 // 总帧数
      this.frameRate = 30 // 帧率

      this.handleCanPlay = this.handleCanPlay.bind(this)
      this.handleScroll = this.handleScroll.bind(this)

      document.querySelector('main.main').classList.add('product_detail-main')

      // fixed safari
      this.video.load()
      this.video.addEventListener('canplay', this.handleCanPlay)

      window.addEventListener('scroll', this.handleScroll)
    }
  }

  handleCanPlay() {
    this.frameCount = Math.round(this.video.duration * this.frameRate)

    // console.log(this.video, this.frameCount * this.interval)
    // 设置画布高度
    // this.sequence.style.height = `calc(${document.body.classList.contains('is-phone') ? this.opts.mobile : this.opts.desktop} + ${this.frameCount * this.interval}px)`
  }

  getScrollTop() {
    return window.scrollY || 0
  }

  isOver() {
    return this.start < 0 || this.start > this.frameCount - 1
  }

  handleScroll() {
    const scrollY = this.getScrollTop()
    // console.log(scrollY)
    let delta = scrollY - this.curScrollY
    const isDown = delta > 0

    delta = Math.abs(delta) + this.leftY
    this.curScrollY = scrollY

    if (
      this.isStop
      && isDown === this.needDown
      && ((isDown && this.curScrollY > this.lastPos) || (!isDown && this.curScrollY < this.lastPos))
    ) {
      // console.log('start')
      this.isStop = false
    }
    if (this.isStop)
      return

    // good idea: 补偿 相对之前,解决了动画过快或过慢 根滚动条的速度(滚动条有快慢加速度感应,停止滚动也会有余动)来换帧
    const alpha = Math.floor(delta / this.interval) * this.addN || 0
    this.leftY = delta % this.interval

    isDown ? (this.start += alpha) : (this.start -= alpha)
    if (this.isOver() && !this.isStop)
      this.isStop = true

    if (this.start < 0)
      this.start = 0
    if (this.start > this.frameCount - 1)
      this.start = this.frameCount - 1
    if (this.startPos >= scrollY)
      this.start = 0
    if (this.oldStart === this.start)
      return
    this.oldStart = this.start
    // good idea:记录
    this.lastPos = scrollY
    this.needDown = !isDown

    this.video.currentTime = this.start / this.frameRate
  }

  unbindEvents() {
    window.removeEventListener('scroll', this.handleScroll)
  }
}

然后去实例化对象即可:

this.productDetailImage1 = new ScrollBasedVideoFrameAnimation(
    '#product-detail1',
    '.js-product-scroll'
);
this.productDetailImage2 = new ScrollBasedVideoFrameAnimation(
    '#product-detail2',
    '.js-product-scroll-small'
);