Let’s see what happens first

  • The demo address

  • The project address

The cause of

When I went to b station, I found that Bilibili changed the banner sometime. At first, the banner was to monitor the mouse movement to move and transform the picture. (Seeing 2233 running, I did not think of my lost youth) I found it interesting in my heart (it is estimated that I saw little of it), so I wanted to copy one and had this project.

The project structure

Because it is a simple project with vue-CLI built a

├ ─ ─ package. Json ├ ─ ─ public ├ ─ ─ the SRC │ ├ ─ ─ App. Vue │ ├ ─ ─ components │ │ ├ ─ ─ animatedBanner. Vue │ │ ├ ─ ─ cubicBezier. Js │ │ ├ ─ ─ extensions │ │ │ ├ ─ ─ particle │ │ │ │ ├ ─ ─ UniversalCamera. Js │ │ │ │ ├ ─ ─ index. The js │ │ │ │ ├ ─ ─ particle. The js │ │ │ │ ├ ─ ─ shader │ │ │ │ │ ├ ─ ─ displayFrag. Js │ │ │ │ │ ├ ─ ─ displayVert. Js │ │ │ │ │ ├ ─ ─ flow1. PNG │ │ │ │ │ ├ ─ ─ flow2. PNG │ │ │ │ │ ├ ─ ─ updateFrag. Js │ │ │ │ │ └ ─ ─ updateVert. Js │ │ │ │ └ ─ ─ shader. Js │ │ │ ├ ─ ─ snow. Js │ │ │ ├ ─ ─ snowflake. PNG │ │ │ └ ─ ─ utils. Js │ │ └ ─ ─ the position. The js │ ├ ─ ─ the main, js │ └ ─ ─static└ ─ ─ vue. Config. JsCopy the code

The code analysis

animatedBanner.vue

<template>
  <div class="animated-banner" ref="container" />
</template>
Copy the code

Simply write a div as a container for the entire banner, then step by step fill the entire page

  1. The first step is to import all kinds of image materials. In order to save trouble, I choose to import static images locally, or I can import images by hosting them.
  • ImgList acts as an array for image resource mapping
export default {
  props: {
    config: { // External incoming image configuration
      required: true.default: {}}}.data() {
    return {
      entered: false.// mouse into flag
      layerConfig: {},// Image configuration
      imgList: {
        '01': require('.. /static/01.png'),// Introduce local images
        '02': require('.. /static/02.png'),..................... }}},Copy the code

From the introduced pictures, we can see that the image materials are PNG files with the background erased, which can be arranged and combined to display a picture at last

  1. Configuration informationposition.js

The relevant position and size of the picture is saved by a JSON object. Generally, relevant information is returned to us through the background. In this simple demonstration, we choose to introduce the local JSON object, which contains the zoom state, displacement distance, transparency, Gaussian blur and other attributes of the picture.

export default {
  "version": "1"."layers": [{
    "resources": [{
      "src": "01"."id": 0}]."scale": {
      "initial": 0.5
    },
    "rotate": {},
    "translate": {
      "initial": [0.- 30]."offset": [- 200..0]},"blur": {},
    "opacity": {},
    "id": 16."name": "15 _ the sky"
  }, {
    "resources": [{
      "src": "."."id": 0}],..................... }Copy the code
  1. Mounted function to complete the dom tree 🌲 rendering and construction
  • Here,this.configThat’s the previous incomingposition.jsThe relevant picture information, through which to build the picture
async mounted() {
    // The image resource of the animated banner is loaded only when the configuration of the animated banner is enabled and the browser supports CSS Filter
    this.animatedBannerSupport =
      typeofCSS ! = ='undefined' &&
      CSS.supports &&
      CSS.supports('filter: blur(1px)') &&!/ ^ ((? ! chrome|android).) *safari/i.test(navigator.userAgent) 
      // Safari has a performance problem with the blur effect on the MAC screen

    if (!this.animatedBannerSupport) {
      return // Direct return is not supported
    }
      this.layerConfig = this.config.layers // Get the configuration information}}Copy the code
  1. Image loading
 // Wait for the page to load
    if (document.readyState ! = ='complete') {
      await new Promise((resolve) = > window.addEventListener('load', resolve))
    }
   
    try {
      // Load all image resources
      await Promise.all(
        this.layerConfig.map(async (v) => {
          return Promise.all(
            v.resources.map(async (i, index) => {
                const img = document.createElement('img')
                img.src = this.imgList[i.src] // Get the image resource URL
                await new Promise((resolve) = > (img.onload = resolve))
                v.resources[index].el = img // Read each image and save it on el}))}))}catch (e) {
      console.log('load animated banner images error', e)
      return
    }
Copy the code

Each element of layerConfig contains the image resource EL for later generation of image elements

    const layerConfig = this.layerConfig
    if(! layerConfig.length && !this.config.extensions) {
      return // If layerConfig does not have a value, it will be static instead of dynamic
    }
    // Get the width and height of the element setting
    const container = this.$refs['container'] 
    let containerHeight = container.clientHeight
    let containerWidth = container.clientWidth
    let containerScale = containerHeight / 155
    // 155 is the minimum height set in the style

    layerConfig.forEach((v) = > {
      v._initState = { // Set the initial value
        scale: 1.rotate: v.rotate? .initial ||0.translate: v.translate? .initial || [0.0].blur: v.blur? .initial ||0.opacity: v.opacity? .initial ===undefined ? 1 : v.opacity.initial
      }
      v.resources.forEach((i, index) = > {
        const el = v.resources[index].el
        // Use naturalHeight, naturalWidth to get the height and width of the image file itself
        // It is more convenient to use this method to dynamically generate pictures when zooming in and out of pictures
        el.dataset.height = el.naturalHeight
        el.dataset.width = el.naturalWidth
        constinitial = v.scale? .initial ===undefined ? 1 : v.scale?.initial
        el.height = el.dataset.height * containerScale * initial
        el.width = el.dataset.width * containerScale * initial
      })
    })
Copy the code
  1. Initialize the layer
// Initialize the layer
    const layers = layerConfig.map((v) = > {
      const layer = document.createElement('div')
      layer.classList.add('layer')
      container.appendChild(layer)
      return layer
    })
    // Define variables
    let displace = 0 
    let enterX = 0 // The mouse enters the x coordinate
    let raf = 0
    let lastDisplace = NaN // Finally leave the value
    this.entered = false
    this.extensions = [] // Plugin extension
Copy the code
  1. Listen for mouse movement methods
 // Change the state according to mouse position
    const af = (t) = > {
      try {
        if (lastDisplace === displace) {
          return
        }
        lastDisplace = displace
        layers.map((layer, i) = > {
          const v = layerConfig[i]
          const a = layer.firstChild / / img element
          if(! a) {return
          }

          const transform = {
            scale: v._initState.scale,
            rotate: v._initState.rotate,
            translate: v._initState.translate
          }
          if (v.scale) {
            const x = v.scale.offset || 0
            const offset = x * displace
            transform.scale = v._initState.scale + offset
          }
          if (v.rotate) {
            const x = v.rotate.offset || 0
            const offset = x * displace
            transform.rotate = v._initState.rotate + offset
          }
          if (v.translate) {
            const x = v.translate.offset || [0.0]
            const offset = x.map((v) = > displace * v)
            const translate = v._initState.translate.map(
              (x, i) = >(x + offset[i]) * containerScale * (v.scale? .initial ||1)
            )
            transform.translate = translate
          }
          // Add style to the image element
          a.style.transform =
            `scale(${transform.scale}) ` +
            `translate(${transform.translate[0]}px, ${transform.translate[1]}px)` +
            `rotate(${transform.rotate}deg)`
          if (v.blur) {
            const x = v.blur.offset || 0
            const blurOffset = x * displace

            let res = 0
            if(! v.blur.wrap || v.blur.wrap ==='clamp') {
              res = Math.max(0, v._initState.blur + blurOffset)
            } else if (v.blur.wrap === 'alternate') {
              res = Math.abs(v._initState.blur + blurOffset)
            }
            a.style.filter = res < 1e-4 ? ' ' : `blur(${res}px)`
          }

          if (v.opacity) {
            const x = v.opacity.offset || 0
            const opacityOffset = x * displace
            const initial = v._initState.opacity
            if(! v.opacity.wrap || v.opacity.wrap ==='clamp') {
              a.style.opacity = Math.max(
                0.Math.min(1, initial + opacityOffset)
              )
            } else if (v.opacity.wrap === 'alternate') {
              const x = initial + opacityOffset
              let y = Math.abs(x % 1)
              if (Math.abs(x % 2) > =1) {
                y = 1 - y
              }
              a.style.opacity = y
            }
          }
        })
      } catch (e) {
        console.error(e)
        this.$emit('change'.false)}}Copy the code
  1. Initialize the image and frame animation within the layer
 // Initialize the image and frame animation within the layer
    layerConfig.map((v, i) = > {
      const a = v.resources[0].el
      layers[i].appendChild(a)
      requestAnimationFrame(af)
    })
    this.$emit('change'.true)
Copy the code
  1. Defining mouse Events
    // There are other elements on the container element. Use global events to determine the mouse position
    const handleLeave = () = > {
      const now = performance.now()
      const timeout = 200
      const tempDisplace = displace
      cancelAnimationFrame(raf)
      const leaveAF = (t) = > {
        if (t - now < timeout) {
          displace = tempDisplace * (1 - (t - now) / 200)
          af(t)
          requestAnimationFrame(leaveAF)
        } else {
          displace = 0
          af(t)
        }
      }
      raf = requestAnimationFrame(leaveAF)
    }
    this.handleMouseLeave = (e) = > {
      this.entered = false
      handleLeave()
    }
    this.handleMouseMove = (e) = > {
      const offsetY = document.documentElement.scrollTop + e.clientY
      if (offsetY < containerHeight) {
        if (!this.entered) {
          this.entered = true
          enterX = e.clientX
        }
        displace = (e.clientX - enterX) / containerWidth
        cancelAnimationFrame(raf)
        raf = requestAnimationFrame(af)
      } else {
        if (this.entered) {
          this.entered = false
          handleLeave()
        }
      }

      this.extensions.map((v) = >v.handleMouseMove? .({ e, displace })) }this.handleResize = (e) = > {
      containerHeight = container.clientHeight
      containerWidth = container.clientWidth
      containerScale = containerHeight / 155
      layerConfig.forEach((lc) = > {
        lc.resources.forEach((i) = > {
          constel = i.el el.height = el.dataset.height * containerScale * (lc.scale? .initial ||1) el.width = el.dataset.width * containerScale * (lc.scale? .initial ||1)
        })
      })
      cancelAnimationFrame(raf)
      raf = requestAnimationFrame((t) = > {
        af(t)
      })
      this.extensions.map((v) = >v.handleResize? .(e)) }document.addEventListener('mouseleave'.this.handleMouseLeave)
    window.addEventListener('mousemove'.this.handleMouseMove)
    window.addEventListener('resize'.this.handleResize)
Copy the code
  1. Remove the listener before component destruction
 beforeDestroy() {
    document.removeEventListener('mouseleave'.this.handleMouseLeave)
    window.removeEventListener('mousemove'.this.handleMouseMove)
    window.removeEventListener('resize'.this.handleResize)
    if (this.extensions) {
      this.extensions.map((v) = > v.destory?.())
      this.extensions = []
    }
  },
Copy the code
  1. extension

Here reference Bilibli’s cherry blossom whereabouts JS if necessary, you can go to Github to get

// Add sakura 🌸
    // if (this.config.extensions? .snow) {
    // const snow = (
    // await import(
    // /* webpackChunkName: 'animated-banner-snow' */ './extensions/snow.js'
    / /)
    // ).default
    // this.extensions.push(await snow(this.$refs['container']))
    // }
    if (this.config.extensions? .petals) {try {
        const petals = (await import('./extensions/particle/index.js').default
        this.extensions.push(await petals(this.$refs['container'))}catch (e) {
        console.error(e)
      }
    }
Copy the code

App.vue

The banner is usually referenced by other pages as a component,

<template>
  <div id="app">
    <animatedBanner
      v-if="animatedBannerEnabled"
      :config="position"
      @change="(v) => (animatedBannerShow = v)"
      :style="animatedBannerShow ? '' : `background-image: url(${bannerImg})`"
      :class="animatedBannerShow ? '' : 'staticImg'"
    />
  </div>
</template>
Copy the code

  1. The APP page preferentially displays static banners when mounting to suit different browsers
export default {
  name: 'App'.data() {
    return {
      position, // Image location configuration
      animatedBannerShow: false.// Whether to display static banners
      animatedBannerEnabled: false  // Whether it is available}},components: {
    animatedBanner
  },
  computed: {
    bannerImg() {
      return require('./static/static.png')}},methods: {
    async animatedBanner() {
      // Display static banner first
      const staticBannerImg = document.createElement('img')
      staticBannerImg.src = this.bannerImg
      await new Promise((resolve) = > (staticBannerImg.onload = resolve()))
      this.animatedBannerEnabled = true}},mounted() {
    this.animatedBanner()
  }
}
Copy the code

Write in the last

In fact, the key here is the monitoring of mouse events and the location of the initial picture and other information, if you can help you feel honored. demo