如何使用 Vue 3 和 Canvas 实现哔哩哔哩首页 banner?

有一个神奇的地方, 在那里你可以看番看剧看综艺, 也可以看短视频直播和游戏. 今天看看村霸去找哪个兄弟, 明天看看张三进了哪个监狱.

没错我说的就是哔哩哔哩弹幕网, 前段时间我在摸鱼的时候发现这个网站的 banner 换了, 人物的眼睛还会一眨一眨的, 鼠标移上去会有景深和位移的变化. 有丶意思, 我按 F12 观察了一波, 发现是用几张图片配合 CSS 实现的. 正好之前 Vue 3.0 发布了, 想搞个 demo 写一写, 于是就用 vite 简单搭了一个项目. 使用 Vue 3.0 的 Composition API 和 Canvas 实现了哔哩哔哩的这个 banner 效果. 具体效果可以去我的 GitHub 查看.

先使用 vite 创建一个项目

vite 是 vue 官方新出的一个构建工具, 它的特点就是一个字: . 快到什么程度呢, 就是你刚打开浏览器, 正准备脱裤子输入localhost呢, 就构建好了. 不知道是不是真的, 我瞎吹的

# 在你的命令行直接输入
npm init vite-app bilibili-autumn

这时会自动下载 vite 的 cli 工具并在当前目录下生成一个 bilibili-autumn 文件夹, 然后命令行会输出一些提示

Done. Now run:

  cd bilibili-autumn
  npm install (or `yarn`)
  npm run dev (or `yarn dev`)

按照提示将项目跑起来, 项目的整个目录结构是这样的

.
├── index.html
├── package.json
├── public
│   └── favicon.ico
└── src
    ├── App.vue
    ├── assets
    │   └── logo.png
    ├── components
    │   └── HelloWorld.vue
    ├── index.css
    └── main.js

4 directories, 8 files

接下来我们开始实现这个 banner 效果

收集必要的素材

按 F12 打开控制台, 查看 banner 的 DOM 结构, 将图片素材全部拔下来(右键 Open in new tab + Ctrl S 保存图片)

将保存下来的图片素材放到项目中的 src/assets 文件夹下

开始撸码

可以先打开 Vue 3.0 的文档 看看 API 都有哪些变化.

以下选取部分代码片段, 完整代码及效果可以点击 Github 查看

1. 创建一个 Banner.vue 组件

<template>
  <div ref="placeholder" class="banner">
    <img class="banner-placeholder" src="/src/assets/full-bg.png" />
    <canvas :ref="(el) => (layers.bg = el)" class="banner-layer"></canvas>
    <canvas :ref="(el) => (layers.twotwo = el)" class="banner-layer"></canvas>
    <canvas :ref="(el) => (layers.land = el)" class="banner-layer"></canvas>
    <canvas :ref="(el) => (layers.ground = el)" class="banner-layer"></canvas>
    <canvas
      :ref="(el) => (layers.threethree = el)"
      class="banner-layer"
    ></canvas>
    <canvas :ref="(el) => (layers.grass = el)" class="banner-layer"></canvas>
  </div>
</template>

主要的实现思路是用 Canvas 绘制每一层的图片, 这里使用 <img> 引入一个静态的背景图, 用于撑开父级 <div> 以实现 <canvas> 大小的响应式

2. 绘制图层的逻辑

通过给各层图片设置不同的 filter: blur() 来实现不同的景深, 通过绘制每一帧图片时设置不同的起始坐标来实现位移

function draw(image, config) {
  const { sx = 0, sy = 0, sw, sh, blur: b } = config || {}
  return {
    to(canvas) {
      const ctx = canvas.getContext('2d')
      ctx.imageSmoothingEnabled = true
      ctx.clearRect(0, 0, canvas.width, canvas.height)
      ctx.filter = `blur(${b}px)`
      ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height)
    },
  }
}

3. 在合适的生命周期获取图片和 Canvas 元素的引用, 并将图片绘制到 Canvas

// 注意这里 vite 会对 assets 的资源做处理, 返回的是资源的地址
import bg from '/src/assets/bg.png'

const images = reactive({
  bg: null,
})

function buildImage(src) {
  return new Promise((resolve) => {
    const image = new Image()
    image.onload = () => resolve(image)
    image.src = src
  })
}

onBeforeMount(() => {
  buildImage(bg).then((img) => (images.bg = img))
})

onMounted(() => {
  watch(
    () => images.bg,
    () => draw(images.bg, config.bg).to(layers.bg)
  )
})

4. 监听鼠标事件处理景深和位移变化

const enterPoint = {}
placeholder.value.addEventListener('mouseenter', (e) => {
  const { width } = placeholder.value.getBoundingClientRect()
  enterPoint.x = e.clientX
  enterPoint.w = width
})

// 鼠标移动时, 计算当前位置与初始位置的距离的比例
placeholder.value.addEventListener('mousemove', (e) => {
  const v = e.clientX - enterPoint.x
  const ratio = v / enterPoint.w

  requestAnimationFrame(() => render(ratio))
})

// 鼠标离开时, 缓慢恢复至初始帧状态(匀速地过渡)
placeholder.value.addEventListener('mouseout', (e) => {
  const v = e.clientX - enterPoint.x
  let ratio = v / enterPoint.w
  const gap = 0.08 * (ratio < 0 ? 1 : -1)

  requestAnimationFrame(tick)
  function tick() {
    if (gap * ratio < 0) {
      ratio = ratio + gap
      render(ratio)
      requestAnimationFrame(tick)
    } else {
      if (images.bg) {
        draw(images.bg, config.bg).to(layers.bg)
      }
    }
  }
})

// 根据鼠标移动时位置与初始位置的距离比例计算景深和位移
function render(ratio) {
  if (ratio < 0 && images.bg) {
    const c = { ...config.bg }
    c.blur = c.blur + ratio * c.blur
    draw(images.bg, c).to(layers.bg)
  }
}

5. 人物的眨眼帧的绘制

setTimeout(wink, 4800)
async function wink() {
  await new Promise((r) => setTimeout(r, 50))
  images.twotwoClosingEye &&
    draw(images.twotwoClosingEye, config.twotwo).to(layers.twotwo)
  await new Promise((r) => setTimeout(r, 50))
  images.twotwoCloseEye &&
    draw(images.twotwoCloseEye, config.twotwo).to(layers.twotwo)
  await new Promise((r) => setTimeout(r, 50))
  images.twotwoOpeningEye &&
    draw(images.twotwoOpeningEye, config.twotwo).to(layers.twotwo)
  await new Promise((r) => setTimeout(r, 50))
  images.twotwo && draw(images.twotwo, config.twotwo).to(layers.twotwo)
  setTimeout(wink, 4800)
}

6. 可视窗口大小变化时重新绘制

function resize(layers) {
  return {
    with({ width, height }) {
      for (const canvas of Object.values(layers)) {
        canvas.width = width
        canvas.height = height
      }
    },
  }
}

onMounted(async () => {
  resize(layers).with(placeholder.value.getBoundingClientRect())

  window.addEventListener('resize', () => {
    resize(layers).with(placeholder.value.getBoundingClientRect())
    images.bg && draw(images.bg, config.bg).to(layers.bg)
  })
})

实现效果

注意 GIF 有点大, 录制出来的效果也不是很明显

最后让我们猜一下 S10 LPL 能否再度夺冠呢

原文链接:juejin.im

上一篇:Vue响应式原理(二)Observer、Dep、Watcher
下一篇:JavaScript 的类型系统

相关推荐

官方社区

扫码加入 JavaScript 社区