博客头部组件开发

最近写博客头部组件时,遇到了一些问题。
1、使用图片作为头部的话,有些单调
2、使用纯色背景,不太好看
3、使用3D渲染框架 + 粒子背景:技术难度高,性能要求也高

偶然间想到了B站的头部组件

B站的头部组件大概分为春夏秋冬四个季节,每个季节都会去替换不同的图片。B站的头部在鼠标划上去的时候,还会左右移动,交互性也挺舒适的。于是我默默按下了F12。

技术点解析

1、B站的头部大概有 10 多张图片和一个视频组成
2、图片和视频应该是由专业的美术把图给切下来了,几乎每张图片都是把一段内容扣下来,其他地方补齐透明色,这样的好处就是前端拿到直接放上去就好了,不用调位置,只有一两张很小的图需要特殊处理位置。
3、切好的图片按照一定的顺序堆叠在一一起后,每张图片增加一个鼠标方向的位移动画就可以了。

难点

1、由于增加了图片唯一,所以图片的边可能会在屏幕内部,就会有一部分的缝隙,B站我不知道如何解决的,我是采用了缩放,把图片大小 * 1.1 后,图片的边框移动也不会跑到视口内。
2、计算位移,我的做法是当用户鼠标进入图片容器时,记录初始位置,然后根据鼠标移动后和初始位置的距离来计算图片需要移动的位移。
3、鼠标移出后,需要恢复图片的位置,不然下次鼠标进入就会重新计算位置,导致图片位置闪动。这里在图片位置恢复时,加入一个过渡效果,就能慢慢的让图片回去。

代码

文件图片在博客源码的 assets/bilibili 下

<template>
  <header ref="headerRef">
    <div><img src="../assets//bilibili/banner.webp" /></div>
    <div><img src="../assets//bilibili/cloud.webp" /></div>
    <div><img src="../assets//bilibili/banner1.webp" /></div>
    <div><img src="../assets//bilibili/banner3.webp" /></div>
    <div><img src="../assets//bilibili/banner4.webp" /></div>
    <div><img src="../assets//bilibili/banner5.webp" /></div>
    <div><img src="../assets//bilibili/banner7.webp" /></div>
    <div><img src="../assets//bilibili/banner8.webp" /></div>
    <div><img class="car" src="../assets//bilibili/car.webp" /></div>
    <div><img class="person" src="../assets//bilibili/characterSmall.webp" /></div>
    <div><img src="../assets//bilibili/characterBig.webp" /></div>
    <div><img src="../assets//bilibili/fence.webp" /></div>
    <div><img src="../assets//bilibili/leftBottomGrass.webp" /></div>
    <div><img src="../assets//bilibili/leftTopGrass.webp" /></div>
    <div><img src="../assets//bilibili/rabbit.webp" /></div>
    <div><img src="../assets//bilibili/banner2.webp" /></div>
    <div><img src="../assets//bilibili/banner6.webp" /></div>
    <div>
      <video loop autoplay muted playsinline>
        <source src="../assets/bilibili/video.webm" type="video/webm" />
        您的浏览器不支持 video 标签。
      </video>
    </div>
    <div>
      <h1 class="title leading-tight font-bold mb-6">{{ title }}</h1>
    </div>
  </header>
</template>

<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';

defineProps({
  title: {
    type: String,
    default: '',
  },
});

const headerRef = ref<HTMLElement | null>(null);
const startX = ref(0);

const handleMouseEnter = (e: MouseEvent) => {
  startX.value = e.clientX; // 计算开始位移

  const images = document.querySelectorAll<HTMLElement>('header > div > img');
  images.forEach(image => {
    // 移除在鼠标离开后添加的恢复图片原始位置的过渡
    image.classList.remove('smooth-transition');
  });
};

const handleMouseMove = (e: MouseEvent) => {
  const images = document.querySelectorAll<HTMLElement>('header > div > img');

  const percentage = (e.clientX - startX.value) / window.outerWidth; // 计算位移百分比
  let xOffset = percentage,
    yOffset = percentage;

  images.forEach(image => {
    xOffset *= 1.3;
    yOffset *= 1.1;

    // 设置元素的位移
    image.style.setProperty('--xOffset', `${xOffset}px`);
    image.style.setProperty('--yOffset', `${yOffset}px`);
    image.style.setProperty('--personXoffset', `${-250 + xOffset}px`);
    image.style.setProperty('--personYoffset', `${-30 + yOffset}px`);
    image.style.setProperty('--carXoffset', `${-100 + xOffset}px`);
    image.style.setProperty('--carYoffset', `${20 + yOffset}px`);
  });
};

const handleMouseLeave = () => {
  const images = document.querySelectorAll<HTMLElement>('header > div > img');
  images.forEach(image => {
    image.classList.add('smooth-transition');
  });
  images.forEach(image => {
    image.style.setProperty('--xOffset', `${0}px`);
    image.style.setProperty('--yOffset', `${0}px`);
    image.style.setProperty('--personXoffset', `${-250}px`);
    image.style.setProperty('--personYoffset', `${-30}px`);
    image.style.setProperty('--carXoffset', `${-100}px`);
    image.style.setProperty('--carYoffset', `${20}px`);
  });
};

onMounted(() => {
  if (headerRef.value) {
    headerRef.value.addEventListener('mouseenter', handleMouseEnter);
    headerRef.value.addEventListener('mousemove', handleMouseMove);
    headerRef.value.addEventListener('mouseleave', handleMouseLeave);
  }
});

onBeforeUnmount(() => {
  if (headerRef.value) {
    headerRef.value.removeEventListener('mouseenter', handleMouseEnter);
    headerRef.value.removeEventListener('mousemove', handleMouseMove);
    headerRef.value.removeEventListener('mouseleave', handleMouseLeave);
  }
});
</script>

<style lang="scss" scoped>
header {
  height: 155px;
  position: relative;
  overflow: hidden;

  &::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 40%; // 遮罩高度可调整
    background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent);
    pointer-events: none; // 确保不影响鼠标事件
    z-index: 10; // 确保遮罩在最上层
  }
}

.title {
  color: #fff;
  letter-spacing: 3px;
}

header > div {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;

  --xOffset: 0px;
  --yOffset: 0px;
  --personXoffset: -250px;
  --personYoffset: -30px;
  --carXoffset: -100px;
  --carYoffset: 20px;
}

header > div > img,
header > div > video {
  display: block;
  object-fit: cover;
  height: 100%;
  width: 100%;
  transform: translate(var(--xOffset), var(--yOffset)) scale(1.1);
}

.smooth-transition {
  transition: transform 0.3s ease-out !important;
}

.person {
  width: 75px;
  height: 60px;
  transform: translate(var(--personXoffset), var(--personYoffset)) !important;
}

.car {
  transform: translate(var(--carXoffset), var(--carYoffset)) !important;
}
</style>