返回

Vue/Nuxt.js 滚动动画:Canvas 与 Video 实现详解

vue.js

Vue/Nuxt.js 中的滚动动画实现方案

该问题的核心是在 Vue/Nuxt.js 环境下复现使用原生 HTML, CSS 和 JavaScript 实现的基于滚动条位置的背景视频播放效果。难点在于如何在 Vue 的生命周期中管理 DOM 操作、状态,以及与 Nuxt.js 的路由和布局进行协调。主要问题拆解为:如何控制视频帧播放、如何集成到组件以及如何在 Nuxt.js 中使用。

方案一: 基于 Canvas 的图像序列播放

该方案直接翻译原始代码的逻辑,将图片序列加载并用 Canvas 进行渲染。

原理: 该方案的核心在于根据滚动位置,动态计算并更新 canvas 元素上显示的图像帧,从而模拟视频播放。 这样做的好处是可以精准地控制每一帧的展示,同时也可以更有效地控制动画播放方向,包括反向播放。

实现步骤:

  1. 创建 Vue 组件:
    创建一个名为 ScrollAnimation.vue 的组件。组件的结构基本复制原始 HTML 结构,使用 canvas 标签渲染图片序列,其他元素作为覆盖层,需要进行相应的 vue 语法调整。

    <template>
      <div class="wrapper">
          <div class="overlay"></div>
          <div class="content">
            <h1>Some overlaying Text</h1>
             <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Aspernatur, deserunt.</p>
             <a href="#firstEl">Skip animation</a>
          </div>
         <canvas ref="canvas" id="hero-lightpass"></canvas>
     </div>
    <div id="firstEl" class="moreStuff">
        <p>#1 Lorem ipsum dolor, sit amet consectetur adipisicing elit. In sed consequuntur excepturi sapiente cupiditate dicta eos ut officiis a consequatur, labore nam velit enim, quibusdam error tenetur voluptatibus corrupti quasi asperiores</p>
    </div>
    </template>
    
     <script>
    export default {
      data() {
         return {
           frameCount: 100,
           img: null,
           context: null
        }
      },
      mounted() {
         this.canvas = this.$refs.canvas;
         this.context = this.canvas.getContext("2d");
    
         this.canvas.width = 1920;
         this.canvas.height = 1080;
         this.img = new Image();
         this.img.onload = () => {
             this.context.drawImage(this.img, 0, 0);
          }
          this.img.src = this.currentFrame(1);
    
          this.preloadImages();
         window.addEventListener("scroll", this.handleScroll);
    
       },
      beforeDestroy() {
         window.removeEventListener('scroll', this.handleScroll)
       },
       methods:{
            currentFrame(index){
                 return `https://medgeclients.com/majestic/seq/${index.toString().padStart(4, "")}.jpg`
             },
            preloadImages(){
             for (let i = 1; i < this.frameCount; i++) {
             const img = new Image();
               img.src = this.currentFrame(i);
            }
           },
            updateImage(index){
              this.img.src = this.currentFrame(index);
             this.context.drawImage(this.img, 0, 0);
           },
            handleScroll(){
               const html = document.documentElement;
              const scrollTop = html.scrollTop;
              const maxScrollTop = html.scrollHeight - window.innerHeight;
              const scrollFraction = scrollTop / maxScrollTop;
              const frameIndex = Math.min(this.frameCount - 1, Math.ceil(scrollFraction * this.frameCount));
    
             requestAnimationFrame(() => this.updateImage(frameIndex + 1));
             },
        }
     };
     </script>
     <style scoped>
    

:root {
--fromTop: calc((28826 / 1.1) * 1px);
}

  • {
    box-sizing: border-box;
    }
    html {
    scroll-behavior: smooth;
    }

    body {
    margin: 0;
    padding: 0;
    position: relative;
    }

    canvas {
    width: 100%;
    height: 100%;
    object-fit: cover;
    z-index: 1;
    }

.wrapper {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
}

.overlay {
background:linear-gradient(to right, rgba(13, 14, 53, 0.9), rgba(13, 14, 53, 0.5), transparent);
position: absolute;
left: 0;
top: 0;
width: 70%;
height: 100%;
z-index: 2;
}

.content {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 3;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 5vw;
}

.content * {
 color: red;

text-shadow: 0 0 8px #050529;
}

.moreStuff {
background: #ffffff;
 position: relative;
 left: 0;
top: var(--fromTop);
 width: 100%;
 height: 100%;
padding: 100px 50px 1500px;

z-index: 99999;
}

2.  **在页面或布局中使用该组件:** 

在 Nuxt.js 中,可以将该组件导入到页面(`pages` 目录下),或直接放到布局(`layouts` 目录下)中。

例如:如果要在默认布局中使用,修改 `layouts/default.vue`:
   ```vue
    <template>
       <div>
            <ScrollAnimation />
            <nuxt />
        </div>
   </template>

    <script>
   import ScrollAnimation from '@/components/ScrollAnimation.vue'
      export default {
          components:{
           ScrollAnimation
            }
         }
     </script>
  ```
**注意事项:** 

*   组件内部使用 `mounted` 钩子来获取 `canvas` 上下文。 `beforeDestroy` 钩子可以解除监听事件。
*  需要添加对应 css 代码,使用`scoped`属性是为了减少组件之间的影响。

### 方案二:使用 Video 标签配合滚动播放控制

该方案使用 HTML `video` 标签进行视频播放控制,配合 CSS `position: fixed` 让其始终处于页面顶层。这种方式能够更方便的使用一些 HTML video API 。

**原理:**  该方案不使用 Canvas, 而是直接采用HTML `<video>`标签播放预先准备的视频资源,通过监听滚动事件,计算当前播放的时间位置,配合视频标签的 api 快速seek,进而控制播放效果,也可以轻松地控制播放速度。

**实现步骤:** 

1. **创建 Video 组件:**  创建 `VideoScroll.vue` 组件。需要有一个 `video` 标签用于显示视频,其他的覆盖层如方案一。

```vue
 <template>
<div class="wrapper">
   <div class="overlay"></div>
     <div class="content">
     <h1>Some overlaying Text</h1>
      <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Aspernatur, deserunt.</p>
      <a href="#firstEl">Skip animation</a>
      </div>
     <video ref="video" class="hero-video" muted  playsinline  />
  </div>

    <div id="firstEl" class="moreStuff">
      <p>#1 Lorem ipsum dolor, sit amet consectetur adipisicing elit. In sed consequuntur excepturi sapiente cupiditate dicta eos ut officiis a consequatur, labore nam velit enim, quibusdam error tenetur voluptatibus corrupti quasi asperiores</p>
  </div>
  </template>

  <script>
 export default {
data() {
    return {
       videoUrl: '/video/scroll-animation.mp4',
       duration:0
   };
},
mounted() {
 this.video = this.$refs.video;
    this.video.src = this.videoUrl;

   this.video.addEventListener("loadedmetadata", this.handleMetadataLoaded);

     window.addEventListener("scroll", this.handleScroll);
    },
    beforeDestroy() {
    window.removeEventListener('scroll', this.handleScroll);
 },

 methods: {
   handleMetadataLoaded(){
   this.duration = this.video.duration;
      },
   handleScroll() {
   const html = document.documentElement;
    const scrollTop = html.scrollTop;
   const maxScrollTop = html.scrollHeight - window.innerHeight;
   const scrollFraction = scrollTop / maxScrollTop;

    const currentPlayTime = Math.max(0,  Math.min(this.duration ,scrollFraction * this.duration) ) ;

     this.video.currentTime =  this.duration- currentPlayTime ;
    },
 },
 };
 </script>
  <style scoped>
:root {
--fromTop: calc((28826 / 1.1) * 1px);
 }
* {
box-sizing: border-box;
 }
 html {
scroll-behavior: smooth;
 }
body {
margin: 0;
padding: 0;
position: relative;
}
.hero-video {
 width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.wrapper {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
 }

.overlay {
   background:linear-gradient(to right, rgba(13, 14, 53, 0.9), rgba(13, 14, 53, 0.5), transparent);
position: absolute;
left: 0;
 top: 0;
width: 70%;
height: 100%;
  z-index: 2;
}

.content {
  position: absolute;
 left: 0;
  top: 0;
 width: 100%;
  height: 100%;
  z-index: 3;
display: flex;
 flex-direction: column;
  justify-content: center;
 padding-left: 5vw;
 }

.content * {
 color: red;
text-shadow: 0 0 8px #050529;
 }
.moreStuff {
  background: #ffffff;
  position: relative;
 left: 0;
top: var(--fromTop);
width: 100%;
 height: 100%;
padding: 100px 50px 1500px;
z-index: 99999;
 }
  </style>
  1. 视频准备: 将提前准备好的 mp4 格式视频资源放到项目的 /static 文件夹,便于引入。如果想使用自定义的目录结构, 请在 nuxt.config.js 文件中配置相应的路径,并修改videoUrl属性为正确的路径。
  2. 引入组件: 如方案一所述,可以在需要的页面或者布局中引入 VideoScroll.vue 组件。

注意事项:

  • mounted 钩子需要等待视频元数据加载完毕 (loadedmetadata 事件)。
  • 使用了 playsinlinemuted 属性, 以防止在部分移动设备上的视频自动播放问题, 同时注意, 在大部分浏览器中, 使用 <video> 标签进行播放需要 muted 属性才能成功,并且可以通过 javascript 代码控制是否解除静音。
  • 可以添加视频循环播放的功能。

安全建议:

  • 考虑到浏览器性能,建议对预加载的图像进行优化。比如图像尺寸优化,图片资源压缩等方式。

方案选择:

两个方案都可以在 Vue/Nuxt.js 项目中实现基于滚动的视频播放。方案一使用 canvas, 能够更精准的控制每一帧图像,比较适合细粒度的滚动动画,对开发人员的要求更高,调试比较复杂。方案二采用 Video 标签,更容易理解,代码更简洁,对控制精度要求不高的情况下更合适。

以上两个方案都演示了如何在 Vue/Nuxt.js 项目中处理这种特殊的滚动动画效果。可以根据项目具体需求选择方案进行落地。