最近刷 b 站网页端时候,发现首页横幅可以随鼠标移动而变化,尤其 2233 娘,玩了很久比较上头。遂尝试复刻。

先上链接:https://lihaobhsfer.github.io/bilibili-banner/

具体实现思路

首先,打开检查器,首页 HTML 代码复制一波。只留必要的 banner 部分,剩下的全部删掉。

然后,下载一下 banner 里用到的图片。

仔细观察这些图片发现,主要景色的图层,长宽都是一样的,背景也是相同位置,可以猜到,大致的实现思路就是,需要几个场景,就画几张对应的图。根据鼠标的位置,实现几张图片的同步位移,并修改相应图片的透明度,达到场景切换的效果。

用到的交互有两种:鼠标向左滑动和鼠标向右滑动。

图片大概分三部分:默认图片,左滑、右滑图片。

加载时,默认显示的图片透明度设为1,表示可见。左滑图片也可见,不过由于最先渲染,所以被靠后渲染的默认图片挡住了。右滑部分设置初始可见度为 0,隐藏起来。

左滑时,隐藏默认图片,此时下方的左滑图片就可见了。

右滑时,让这部分图片可见度变为 1,覆盖左滑图片。

鼠标移出区域后,初始化所有值。

来看代码

主要展示一下 js
首次加载时,找到相应的区域和图层,并指定哪些图层是左滑部分,哪些是右滑

let banner = document.querySelector(".bili-banner");
let layers = document.querySelectorAll(".layer");

const invisibleWhenMovingLeft = [layers[6], layers[2]];
const visibleWhenMovingRight = [layers[3], layers[4], layers[7]];

添加事件监听,这里需要两个,一个 mouseenter, 一个 mousemove

mouseenter 事件触发时,读取鼠标进入时的位置,并删除 transition 的效果,来实现画面与鼠标的同步移动,避免延迟。

banner.addEventListener("mouseenter", (e) => {
  enterX = e.clientX;
  enterY = e.clientY;

  for (const layer of layers) {
    let img = layer.querySelector("img");
    if (!img) {
      img = layer.querySelector("video");
    }
    let s = img.getAttribute("style");
    s = s.replace("transition: all 0.3s;", "");
    img.setAttribute("style", s);
  }
});

mousemove 事件触发时,对比每次触发与初始位置,计算位移与可见度
这部分代码目前还是又臭又长,急需优化 hhh

banner.addEventListener("mousemove", (e2) => {
  let posX = e2.clientX;
  let posY = e2.clientY;

  for (const layer of layers) {
    //
    let img = layer.querySelector("img");
    //

    if (img) {
      let s = img.getAttribute("style");
      let translateRegex = /translate\(-?[0-9]+px, -?[0-9]+px\)/i;
      s = s.replace(
        translateRegex,
        "translate(" + Math.floor((enterX - posX) / 15) + "px, " + 0 + "px)"
      );
      img.setAttribute("style", s);
    }
    let video = layer.querySelector("video");
    //

    if (video) {
      let s = video.getAttribute("style");
      let translateRegex = /translate\(-?[0-9]+px, -?[0-9]+px\)/i;
      s = s.replace(
        translateRegex,
        "translate(" + Math.floor((enterX - posX) / 15) + "px, " + 0 + "px)"
      );
      video.setAttribute("style", s);
    }
  }

  for (const layer of invisibleWhenMovingLeft) {
    let img = layer.querySelector("img");
    let opacityRegex = /opacity: -?[0-9]+.?[0-9]*;/i;
    let s = img.getAttribute("style");
    s = s.replace(
      opacityRegex,
      "opacity: " +
        (posX > enterX - 50
          ? 1
          : 1 - (Math.floor(enterX - posX) / enterX) * 2) +
        ";"
    );

    img.setAttribute("style", s);
  }

  let imgMid = layers[1].querySelector("img");
  let opacityRegex = /opacity: -?[0-9]+.?[0-9]*;/i;
  let midStyle = imgMid.getAttribute("style");
  midStyle = midStyle.replace(
    opacityRegex,
    "opacity: " +
      (posX > enterX - 50 && posX < enterX + 50
        ? 1
        : 1 - (Math.floor(enterX - posX) / enterX) * 2) +
      ";"
  );
  imgMid.setAttribute("style", midStyle);

  for (const layer of visibleWhenMovingRight) {
    let imgRight = layer.querySelector("img");
    let video;
    if (!imgRight) {
      imgRight = layer.querySelector("video");
      video = imgRight;
    }
    let s = imgRight.getAttribute("style");
    s = s.replace(
      opacityRegex,
      "opacity: " + (posX < enterX ? 0 : ((posX - enterX) / enterX) * 2) + ";"
    );

    if (video) {
      if (posX > enterX + 15) {
        video.play();
      } else {
        video.pause();
      }
    }

    imgRight.setAttribute("style", s);
  }
});

最后是 mouseleave 事件,初始化所有值

banner.addEventListener("mouseleave", (e) => {
  let posX = e.clientX;
  let posY = e.clientY;

  for (const layer of layers) {
    //
    let img = layer.querySelector("img");
    //

    if (!img) {
      img = layer.querySelector("video");
    }
    let s = img.getAttribute("style");
    let translateRegex = /translate\(-?[0-9]+px, -?[0-9]+px\)/i;
    s = s.replace(translateRegex, "translate(0px, 0px)");
    img.setAttribute("style", s + "transition: all 0.3s;");
  }

  let imgMid = layers[1].querySelector("img");
  let opacityRegex = /opacity: -?[0-9]+.?[0-9]*;/i;
  let midStyle = imgMid.getAttribute("style");
  midStyle = midStyle.replace(opacityRegex, "opacity: 1;");

  imgMid.setAttribute("style", midStyle);

  let imgRight = layers[3].querySelector("video");
  let rightStyle = imgRight.getAttribute("style");
  rightStyle = rightStyle.replace(opacityRegex, "opacity: 0;");

  imgRight.setAttribute("style", rightStyle);

  for (const layer of invisibleWhenMovingLeft) {
    let img = layer.querySelector("img");
    let opacityRegex = /opacity: -?[0-9]+.?[0-9]*;/i;
    let s = img.getAttribute("style");
    s = s.replace(opacityRegex, "opacity: 1;");

    img.setAttribute("style", s);
  }
  for (const layer of visibleWhenMovingRight) {
    let imgRight = layer.querySelector("img");
    let video;
    if (!imgRight) {
      imgRight = layer.querySelector("video");
      video = imgRight;
    }
    let s = imgRight.getAttribute("style");
    s = s.replace(opacityRegex, "opacity: 0;");

    if (video) {
      video.pause();
    }

    imgRight.setAttribute("style", s);
  }
});

JS 部分的动效实现了,剩下的 css 布局,比较简单,可以自行研究实现一下。

写在后面

当然,这仅仅是我对 B 站首页横幅动画的实现方式的猜测,提供了一种思路,并不代表其实际的实现方式。所有图片素材的版权仍为 B 站所有。

并且,还有一些效果没有实现,比如雪花效果。根据 B 站网页代码猜测,应该是加了 canvas 然后实现相应的动画效果。