如何用原生 JS 复刻 Bilibili 首页头图的视差交互效果

5,876 阅读13分钟

最近发现 B 站的首页头图的交互效果非常有趣,如下图所示,当鼠标在画面中左右滑动时,海洋生物会栩栩如生地动起来,这可比刷视频得劲多了啊:

这是使用多张具有不同视角的图片,通过透视、位移等处理方式来实现的视差效果,在佩服 UI 与前端对网页交互效果方面的努力和探索之外,我也沉浸在这片“海洋”中疯狂摸鱼:尝试使用原生 JS 来复刻它,最终实现了非常还原的效果:

可点击图片进入 码上掘金 中体验完整效果。

本文将一步步介绍整个制作思路,并详解其中相关知识点,干货非常多,搬好你的小板凳,咱们话不多说直接开摸吧。

准备工作

打开浏览器控制台,查看B站头图的 HTML 结构:

不难看出,我们接下来的思路就是把 banner 中所有的图片用一个 .layer 的 div 包住堆叠起来,然后编写鼠标事件对每张图片应用相应的变换(transform)操作,由于接下来的操作我们都用 JS 来完成,所以布局很简单,只需要一个 div 来充当容器:

<div id="app">loading...</div>

把图片素材通过 JS 添加进容器中,我们创建一个数组来描述这些图片,数据的结构暂时如下所示:

const barnerImagesData = [
  {
    url: 'https://xxxx/abcdegfsa.webp',
  },
  {
    url: 'https://xxxx/dsaasdsaasdds.webp',
  }, ...........
]

注:完整数据在 code.juejin.cn/api/raw/726… ,这里包含了后续全部代码所需的内容

然后我们把 barnerImagesData 循环并添加到容器中:

const body = document.getElementById('app')
let layers = []

function initItems() {
    body.style.display = 'none'
    for (let i = 0; i < barnerImagesData.length; i++) {
      const item = barnerImagesData[i]
      // 创建 layer
      const layer = document.createElement('div')
      layer.classList.add('layer')
      // 创建 imgage
      const img = document.createElement('img')
      img.src = item.url
      // 将 layer 添加到容器中
      layer.appendChild(img)
      body.appendChild(layer)
    }
    body.style.display = ''
    // 把创建好的 layers 缓存起来,方便后续操作
    layers = document.querySelectorAll(".layer")
}

initItems()

这里先给容器设置 display: 'none' 的作用是,无论添加多少图片都只会回流重绘两次。

接着我们稍微完善一下样式,给容器设定一个高度,把每个图层都绝对定位堆在一起:

#app {
  position: relative;
  overflow: hidden;
  min-width: 1000px;
  min-height: 155px;
  height: 10vw;
  max-height: 240px;
}

.layer {
    position: absolute;
    left: 0;
    top: 0;
    height: 100%;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
}

准备工作就完成了,你会看到如下的界面,所有元素我们都添加了进来:

现在层次已经有了,图片位置还很乱,需要给它们设置上初始偏移值来调整位置,但先不急,让我们先看看贯穿整个交互方式的鼠标事件。

鼠标事件 & 执行动画

我们这里主要会用到三个鼠标事件,分别是 mouseovermousemovemouseleave,分别代表鼠标的进入事件移动事件以及离开事件,我们将在容器上绑定这三个事件监听,在鼠标进入时记录初始化位置,鼠标移动时减去初始值就得到偏移值,这个偏移值将是接下来所有变换的核心系数,这里我们取 clientXPageX 来计算偏移量,相关代码如下:

const body = document.getElementById('app')
let initX = 0 // 初始值
let moveX = 0 // 偏移值

body.addEventListener('mouseover', (e) => initX = e.pageX)
body.addEventListener('mousemove', (e) => {
  moveX = e.pageX - initX
})

获取到偏移值后,我想你已经迫不及待地想要让画面跟随鼠标动起来了,我们先来尝试一下吧,CSS 变换属性为 transform,它可以接收多个值,其中 translate() 可以让元素发生偏移,从而改变显示位置,接下来我们即是要将偏移值应用到其中,我们定义一个 animate 方法用于执行动画,该方法中循环取出所有元素并应用变换:

// 动画执行
function animate() {
  for (let i = 0; i < layers.length; i++) {
    const layer = layers[i];
    layer.style.transform = `translate(${moveX}px, 0)`
  }
}

接着在前面的 mousemove 回调事件中加入 animate(),此时画面里移动鼠标,所有图片应该都会紧紧跟随鼠标的位置而变化了,但在浏览器中,我们通常不会这么执行动画,而是采用 requestAnimationFrame 来辅助执行,它会通过浏览器的刷新频率来调度动画帧,自动以最佳的性能进行渲染,修改代码如下:

body.addEventListener('mousemove', (e) => {
  moveX = e.pageX - initX
  requestAnimationFrame(mouseMove)
})

function mouseMove() { // 滑动操作
  animate()
}

滑动就会看到如下效果:

到这里都还没什么难度,虽然离最终效果相距甚远,但基本就只剩下对细节的亿点处理了,我们来具体看看B站是怎么做的。

视差效果原理

在视差效果中,通常会使用多张具有不同视角的图片或分层的图像,通过透视、位移等处理方式,让观察者感受到物体的前后关系和深度差异。

我们打开控制台观察B站首页头图对应的 DOM 结构,会看到处理的对应变换包括了:平移(translate)、旋转(rotate)、缩放(scale)等,此外还有透明度可能也会随之改变。

通过鼠标移动产生的偏移值,我们可以按一定比例设置对应的变换属性来达到最终效果,不过这里我并不打算使用跟B站一样的实现方式,让我们来上点强度,只使用矩阵变换 matrix 来实现 transform 中的三种变换。

二维矩阵变换

很多人可能对 matrix 感到陌生,实际上平时我们常使用的 translaterotate 等变换操作都是语法糖,是为了更加符合开发直觉而设计出来的,最终它们都会被转化成矩阵进行二维变换,它的基本形式是这样的:

matrix(a, b, c, d, e, f)

这里的 abcdef 共6个参数,默认值是 1,0,0,1,0,0,按顺序拆开来分别是:

  • x 轴系数:(1,0)
  • y 轴系数:(0,1)
  • 偏移绝对值:(0,0)

我们把第一个坐标点表示在如下的坐标轴上:

第二个点是在 y 轴上:

通过这两个点与原点我们可以确定一个图形:(注意这里是倍数,1就是保持原样的意思)

如果我要把图形拉宽 2 倍那就是改变第一个坐标为 (2,0),同理,如果将图形变高 1.5 倍就是改变第二个坐标点为 (0,1.5),如下所示:

如此变换过程编写为 CSS 就是:

transform: matrix(2,0,0,1.5,0,0);

即等价于:

transform: scale(2, 1.5)

学会了如上这一基础变换,后面我们实现等比缩放的操作就非常简单了,往这两个系数乘上一个缩放倍数(假设为 s)即可,公式表示如下:

matrix(s * x, 0, 0, s * y, 0, 0)

而平移就更简单了,第三个坐标点即代表平移的 x y 值,例如我们将图形向右平移 100 个像素:

只需在 x 上增加 100 即可,前面两个点不需要动,CSS 编写为:

transform: matrix(1,0,0,1,100,0);

等价于:

transform: translateX(100px);

如果图形向左偏移,那么 x 就加上负的 100,如何上下平移相信也不用我多说了吧。

关于矩阵变换先搞懂这些就行,旋转后面会讲到,我们接着回到正题中。

调整初始位置

修改第一步的数组结构,添加一些初始值,大致结构如下:

const barnerImagesData = [
  {
    url: 'https://xxxx/xxxxxxx.webp',
    transform: [1, 0, 0, 1, 0, 0],
    width: 1950,
    blur: 0
  }, ...........
]

注:之后我也会像这样增加一些“参数”,将不再赘述,完整数据在这里 code.juejin.cn/api/raw/726…

其中 transform 值就是为这些海洋生物所提供的初始位置了,我们在前面的 initItems 方法中编写相应代码:

function initItems() {
    .........
      layer.classList.add('layer')
      layer.style = 'transform:' + new DOMMatrix(item.transform)
      // 创建 imgage
      const img = document.createElement('img')
      img.src = item.url
      img.style.filter = `blur(${item.blur}px)`
      img.style.width = `${item.width}px`
    ...........
}

我们用 new DOMMatrix 方法将数组实例化为 matrix,赋值给 CSS 的 transform 属性,同时我们也定义了一些图片的宽度和模糊值,这里使用 CSS filter: blur() 来实现高斯模糊,给靠前面的水草等几个图层添加模糊值,使场景更真实,更符合人眼聚焦画面主体时的环境感受。

代码编写完毕,对数据进行亿番调整后,画面已经基本和B站一致了:

平移与缩放

我们继续完善鼠标交互效果,让原本紧贴鼠标移动的图层按不同速度进行移动,以此实现最基本的视差效果,为此我添加了一个参数 a 用来代表加速度,不同图层有不同的加速度,加速度越快代表移动幅度越大,我们修改 animate 函数:

function animate() {
  for (let i = 0; i < layers.length; i++) {
    const layer = layers[i];
    const item = barnerImagesData[i]
    let m = new DOMMatrix(item.transform)
    let move = moveX * item.a // 移动X translateX
    m = m.multiply(new DOMMatrix([1, 0, 0, 1, move, 0]))
    layer.style.transform = m // 应用所有变换效果
  }
}

注意到了吗,矩阵变换的一个重要特性就是,它可以通过乘积进行多次变换

以往使用常规手段进行变换时,例如我先写了一个:transform: rotate(45deg); 进行旋转,之后想再进行平移就必须这么写:transform: rotate(45deg) translate(100, 0);,而不是直接写 translate 就行,不然前面的旋转角就丢失了。但是使用矩阵则不同,你可以把多次变换乘起来得到最终的变换结果。

前面的左右平移比较单一,接下来我继续添加参数 g 来表示重力,参数 f 表示每一帧的缩放幅度,以此来为画面增加一些动态细节,继续修改代码如下:

function animate() {
    .........
    let s = item.f ? item.f * moveX + 1 : 1 // 放大比例 Scale
    let g = moveX * (item.g || 0) // 移动Y translateY
    m = m.multiply(new DOMMatrix([m.a * s, m.b, m.c, m.d * s, move, g]))
    .........
}

如此根据重力不同,就可以控制鱼往上浮还是往下游:

而根据缩放系数不同,就可以模拟远近大小的变化,例如画面中的乌龟是往靠近镜头的方向游动的,那么它在视野中就会越来越大,反之则缩小:

透明度处理

我们看到画面中央的那撮气泡一开始就出现有点不太合理,我们需要在移动过程中改变透明度,在数据中添加参数 opacity 来表示透明度变化,它应该是一个区间比较合理,表示一个变化过程。例如 [0,1] 表示一开始是不可见的,随着移动过后逐渐显现,反之 [1,0] 则表示逐渐透明消失的过程,相关代码如下:

function initItems() {
  .......
  // 初始化时设置起始透明度
    item.opacity && (layer.style.opacity = item.opacity[0])
    ......
}
// 动画执行
function animate(progress) {
  ........
    if (item.opacity) { // 有透明度变化
      layer.style.opacity = isHoming && moveX > 0 ? lerp(item.opacity[1], item.opacity[0], progress) : lerp(item.opacity[0], item.opacity[1], moveX / window.innerWidth * 2)
    }
    ........
}

矩阵旋转

上面其实我们已经完成了 90% 的效果了,但和B站的效果相比还是有点差距,通过观察我发现乌龟在前进的过程中还带有一点旋转的角度。

再次为数据添加参数 deg 来表示每一帧的旋转角度,当存在角度时乘以新矩阵 [Math.cos(deg), Math.sin(deg), -Math.sin(deg), Math.cos(deg),0,0](旋转时不发生位移,所以第三个坐标应为 0,0),我们为 animate 函数增加如下代码:

function animate() {
  .........
    if (item.deg) { // 有旋转角度
      const deg = item.deg * moveX
      m = m.multiply(new DOMMatrix([Math.cos(deg), Math.sin(deg), -Math.sin(deg), Math.cos(deg), 0, 0]))
    }
    .........
}

来感受下加入一定的旋转角度后是什么效果:

画面更加灵动自然了,基本和B站的效果无差,感觉海洋生物们都栩栩如生起来了捏~

矩阵旋转推导过程

这里补充一下旋转的四个值是如何推导而来的,首先帮大家回忆一下中学时的三角函数,在如图所示的直角三角形中,我们有 A、B、C 三个角,每个角的对边我们记作小写 a、b、c:

然后我们会有边长比值的公式:

  • 正弦(sin):sin(A) = a / c,对边比斜边。
  • 余弦(cos):cos(A) = b / c,邻边比斜边。
  • 正切(tan):tan(A) = a / b,对边比领边。

当旋转一定角度 θ 时,我们画出图形的变化,如下图,矩阵的第一个点 ( x , y ) 变为 ( x‘ , y‘ ),要求得变化后的 x’y‘,我们先把它与 θ 角围成的三角形画出来,并标记其三条边:

代入参数可得:

正弦余弦

接着我们根据上面两条公式分别得出坐标点:

x' = x * cos(θ)

y' = x * sin(θ)

前面我们讲矩阵的时候已经知道了,这个( x , y )点其实就是 ( 1 , 0 ),代入 x = 1 我们得到 ( x‘ , y‘ ) 点坐标值为: ( cos(θ) , sin(θ) )

我们继续看另一个点,还是把变化与夹角的三角形画出来:

同样地,得到下面的正余弦:

正弦余弦

然后得出坐标点:

x' = y * sin(θ)

y' = y * cos(θ)

矩阵第二个坐标为 ( 0 , 1 ),将 y = 1 代入得到这个点的坐标为: ( -sin(θ) , cos(θ) ),注意这个点的 x 是在负半轴上,所以要加上负号。

以上,我们就推导出了二维矩阵的旋转变换为:

matrix(cosθ, sinθ, -sinθ, cosθ, 0, 0)

位置回正

到这里整个交互还没有结束,当前在鼠标离开时,画面会停滞住,这样鼠标下次进入画面时也会闪动,所以需在离开时自动回正到初始位置上才行,我们先注册相关事件:

// 鼠标已经离开了视窗或者切出浏览器,执行回正动画
body.addEventListener("mouseleave", leave)
window.onblur = leave

leave 函数里将初始的 matrix 逐一取出并应用到图像上的话,画面会瞬间闪动,这并不优雅,所以我们需要让它们平滑恢复到初始位置,通常我们可能会这么做:先设置一个样式比如 transition: all .3s; 这表示变换效果将会缓动并在 300ms 后完成,但是这个样式不能在一开始就写上,不然前面的画面移动效果也会受到影响,所以得在执行回正时才设置,其它情况下则移除。

这种方式虽然没什么问题,但需要额外利用 CSS 才能实现,能不能只用 JS 来做呢,我们先分析下 transition 中两个主要的参数:

  1. 持续时间
  2. 动画函数

其实只要搞懂这两个参数,我们就可以用 JS 来实现 CSS 中的平滑缓动效果。

动画进度

先看持续时间,这个参数表明动画在经过一个明确的时间后结束,虽然持续时间是个变量,但无论动画持续多久,都是一个从 0% 变化到 100% 的过程,所以我们要把时间转化为能被确定的进度

requestAnimationFrame 每一帧执行时回调函数会接收一个参数 timestamp,我们可以以此来计算出进度:

let startTime;
const duration = 300; // 动画持续时间(毫秒)

function leave() {
  startTime = 0; // 离开时初始值归零
  requestAnimationFrame(homing); // 开始回弹动画
}

function homing(timestamp) {
  !startTime &&( startTime = timestamp)
  const elapsed = timestamp - startTime // 计算经过时间
  const progress = Math.min(elapsed / duration, 1)
  progress < 1 && requestAnimationFrame(homing) // 继续下一帧
  console.log(progress)
}

elapsed 是经过的时间,duration 是过渡的总持续时间。通过将这两个值相除,可以得到一个 01 之间的进度比例,另外别忘了使用 Math.min 函数将进度值与 1 比较,取最小值,确保进度不会超过 1

我们依旧使用 animate 函数进行动画操作,为它传入 progress 参数,后面判断当 isHomingtrue 时即执行回正动画:

function homing(timestamp) {
  .........
  animate(progress) // 传递动画进度
}
function animate(progress) {
  // 若传入进度则判断为执行回正
  const isHoming = typeof progress === 'number'
  ............
}

线性差值

在 CSS 中,transition 属性包含多种动画函数,而我们当前场景没有那么复杂的动画需求,只需要在过渡期间保持匀速平滑运动即可。

线性插值是一种简单的插值方法,它使用线性函数来计算过渡过程中的值。简单来说,它是一种通过直线来连接两个点,在两个点之间按比例计算中间的数值。线性插值可以用于各种场景,比如在图形学中计算两个点之间的中间点,或者在动画中实现平滑的过渡效果。

// 计算线性插值
const lerp = (start, end, amt) => (1 - amt) * start + amt * end;

该函数接收一个起始值 start目标值 end,插值系数:amt是在 01 之间的值,表示过渡的进度比例。我们在回正动画处理中,通过每一帧的这三个入参,返回对应的计算结果应用到矩阵变换中:

function animate(progress) {
  ............
  if (isHoming) { // 回正时处理
      m.e = lerp(moveX * item.a + item.transform[4], item.transform[4], progress)
      move = 0
      s = lerp(item.f ? item.f * moveX + 1 : 1, 1, progress)
      g = lerp(item.g ? item.g * moveX : 0, 0, progress)
    }
  .........
  if (item.deg) { // 有旋转角度
      const deg = isHoming ? lerp(item.deg * moveX, 0, progress) : item.deg * moveX
  ...........
}

进度控制了动画过程,线性函数描述了动画曲线,缓动效果就这样实现了,至此,整个交互效果就和开头演示时一样了。

加餐

本来到这里就该结束了,但正好在文章写完那天,我登录B站时发现首页头图更新了。。那敢情好啊,我就把新出的效果也复刻一下吧!不过上面的代码是一行也不用改动的,只需要换一套数据就行了。

打开B站,把以下代码粘贴在控制台(可能需要滑动一下头图),回车。

const layersEl = document.getElementsByClassName('animated-banner')[0].getElementsByClassName('layer');
let layers = [];
const pattern = /translate\(([-.\d]+px), ([-.\d]+px)\)/;
for (let i = 0; i < layersEl.length; i++) {
    const {width, height, src, style} = layersEl[i].firstElementChild;
    const matches = style.transform.match(pattern);
    const transform = [1,0,0,1,...matches.slice(1).map(x => +x.replace('px', ''))]
    layers.push({width, height, url:src, transform, a: 0.01})
}
JSON.stringify(layers)

把打印的数据拷贝出来,图片链接自行保存后替换,接下来就是对着B站的效果微调变换参数啦,这次的更新整体比之前的还简单些,不一会就调校完毕了,鳄鱼那部分实现逻辑略有不同,但无伤大雅,看看效果吧:

image.png

完整代码

前往码上掘金查看完整代码:code.juejin.cn/pen/7267433…

核心代码只有几十行,你可以通过改变数据中的各项值来调整画面元素的交互变化程度及效果,大家觉得这波原生 JS 整活如何?欢迎在评论区说说你的想法~

最后让我们来回顾下,虽然整体效果看上去似乎也不算难,但本文知识点还是蛮多的,首先是如何利用鼠标事件计算以及执行动画;知道了什么是矩阵变换以及如何使用它实现平移旋转缩放等操作;利用三角函数推导了矩阵旋转的原理;使用线性差值函数实现了缓动回弹动画等。

后续更新

image.png

三分钟复刻B站首页动态Banner(代码上传到 Github)

拿来吧你!挑战一键复刻 B 站首页动态 Banner(自动化)

以上就是文章的全部内容了,感谢看到这里!如果觉得写得还不错,对你有所帮助或启发,别忘了点赞收藏关注“一键三连”哦~ 我是茶无味,一名平凡的前端 Developer,希望与你共同成长~