手寫個嗶哩嗶哩春季banner

效果

先看看效果唄

github地址

起因

逛b站的時候發(fā)現(xiàn)bilibili不知什么時候換了banner嚎研,初看banner就是監(jiān)聽鼠標(biāo)移動來進(jìn)行圖片的移動和變換奸远,(看到2233在奔跑我不經(jīng)想起了我逝去的青春)心中覺得有趣(估計是我見得少)瞬沦,想仿制一個便有了這個項目。

項目結(jié)構(gòu)

由于是簡單的項目隨手用vue-cli搭建了一個

├── package.json
├── public
├── src
│   ├── App.vue
│   ├── components
│   │   ├── animatedBanner.vue
│   │   ├── cubicBezier.js
│   │   ├── extensions
│   │   │   ├── particle
│   │   │   │   ├── UniversalCamera.js
│   │   │   │   ├── index.js
│   │   │   │   ├── particle.js
│   │   │   │   ├── shader
│   │   │   │   │   ├── displayFrag.js
│   │   │   │   │   ├── displayVert.js
│   │   │   │   │   ├── flow1.png
│   │   │   │   │   ├── flow2.png
│   │   │   │   │   ├── updateFrag.js
│   │   │   │   │   └── updateVert.js
│   │   │   │   └── shader.js
│   │   │   ├── snow.js
│   │   │   ├── snowflake.png
│   │   │   └── utils.js
│   │   └── position.js
│   ├── main.js
│   └── static
└── vue.config.js

代碼分析

image.png

animatedBanner.vue

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

先簡單的寫一個div來作為整個banner的容器,接下來便是逐步完成整個頁面的填充

  1. 第一步需要各類圖片素材吼和,先將他們引入,為了省事我就選擇本地引入靜態(tài)圖片,也可以通過將圖片進(jìn)行托管后進(jìn)行引入影兽。
  • imgList作為一個數(shù)組來進(jìn)行圖片資源映射
export default {
  props: {
    config: { //外部傳入圖片配置
      required: true,
      default: {}
    }
  },
  data() {
    return {
      entered: false, //鼠標(biāo)進(jìn)入flag
      layerConfig: {},//圖片配置
      imgList: {
        '01': require('../static/01.png'),//引入本地圖片
        '02': require('../static/02.png'),
        …………
      }
    }
  },

image.png
image.png

從引入的圖片可以看到,圖片素材都是擦除背景的png文件莱革,可以通過我們的排列組合最后才能顯示一幅畫面

  1. 配置信息 position.js
    圖片的相關(guān)位置和大小我們通過一個json對象來報保存峻堰,一般通過后臺來返回給我們相關(guān)的信息,這里簡單演示便選擇引入本地的json對象,這里具體包含了圖片的縮放狀態(tài)盅视,位移距離捐名,透明度,高斯模糊等等屬性左冬。
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_天空"
  }, {
    "resources": [{
      "src": "02",
      "id": 0
    }],
    …………
  }
  1. 頁面掛載鉤子桐筏,在mounted函數(shù)上完成dom樹??的渲染和構(gòu)建
  • 這里的this.config就是前文傳入position.js中的相關(guān)圖片信息,通過他來構(gòu)建圖片
async mounted() {
    // 只有在啟用了動畫banner的配置拇砰,且瀏覽器支持css filter時才加載動畫banner的圖片資源
    this.animatedBannerSupport =
      typeof CSS !== 'undefined' &&
      CSS.supports &&
      CSS.supports('filter: blur(1px)') &&
      !/^((?!chrome|android).)*safari/i.test(navigator.userAgent) 
      // safari瀏覽器在mac屏幕上模糊效果有性能問題梅忌,不開啟

    if (!this.animatedBannerSupport) {
      return //不支持直接返回
    }
      this.layerConfig = this.config.layers //獲取配置信息
    }
}
  1. 圖片的加載
 // 等待頁面加載完成
    if (document.readyState !== 'complete') {
      await new Promise((resolve) => window.addEventListener('load', resolve))
    }
   
    try {
      // 加載所有圖片資源
      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] //獲取圖片資源url
                await new Promise((resolve) => (img.onload = resolve))
                v.resources[index].el = img //將每張圖讀取到后保留在el上
            })
          )
        })
      )
    } catch (e) {
      console.log('load animated banner images error', e)
      return
    }

每一個layerConfig的元素都包含圖片資源el以便于后面生成圖片元素

image.png
    const layerConfig = this.layerConfig
    if (!layerConfig.length && !this.config.extensions) {
      return //如果layerConfig沒有值就不進(jìn)行后面動態(tài)操作,直接展示靜態(tài)
    }
    //獲取元素設(shè)置寬高
    const container = this.$refs['container'] 
    let containerHeight = container.clientHeight
    let containerWidth = container.clientWidth
    let containerScale = containerHeight / 155
    //這里155是樣式上設(shè)置的最小高度

    layerConfig.forEach((v) => {
      v._initState = { //設(shè)置初始值
        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
        //用naturalHeight除破,naturalWidth來獲取圖像文件本身的高度和寬度
        //在圖片放大縮小牧氮,動態(tài)生成圖片用該方法更便捷
        el.dataset.height = el.naturalHeight
        el.dataset.width = el.naturalWidth
        const initial = v.scale?.initial === undefined ? 1 : v.scale?.initial
        el.height = el.dataset.height * containerScale * initial
        el.width = el.dataset.width * containerScale * initial
      })
    })
  1. 初始化圖層
// 初始化圖層
    const layers = layerConfig.map((v) => {
      const layer = document.createElement('div')
      layer.classList.add('layer')
      container.appendChild(layer)
      return layer
    })
    //定義變量
    let displace = 0 
    let enterX = 0 //鼠標(biāo)進(jìn)入的x坐標(biāo)
    let raf = 0
    let lastDisplace = NaN //最后離開值
    this.entered = false
    this.extensions = [] //插件擴(kuò)展
  1. 監(jiān)聽鼠標(biāo)移動方法
 // 根據(jù)鼠標(biāo)位置改變狀態(tài)
    const af = (t) => {
      try {
        if (lastDisplace === displace) {
          return
        }
        lastDisplace = displace
        layers.map((layer, i) => {
          const v = layerConfig[i]
          const a = layer.firstChild //img元素
          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
          }
          //為圖片元素添加style
          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)
      }
    }
  1. 初始化圖層內(nèi)圖片和幀動畫
 // 初始化圖層內(nèi)圖片和幀動畫
    layerConfig.map((v, i) => {
      const a = v.resources[0].el
      layers[i].appendChild(a)
      requestAnimationFrame(af)
    })
    this.$emit('change', true)
  1. 定義鼠標(biāo)事件
    // container 元素上有其他元素,需使用全局事件判斷鼠標(biāo)位置
    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) => {
          const el = 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)
  1. 在組件銷毀前移除監(jiān)聽
 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 = []
    }
  },
  1. 擴(kuò)展
    此處引用bilibli的櫻花下落js 有需要可以去github自取
//添加櫻花??
    // 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)
      }
    }

App.vue

banner通常作為一個組件來被其他頁面引用瑰枫,

<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>
  1. app頁面在掛載時優(yōu)先展示靜態(tài)的banner來適配不同瀏覽器差異
export default {
  name: 'App',
  data() {
    return {
      position, //圖片位置相關(guān)配置
      animatedBannerShow: false,    //是否顯示靜態(tài)banner
      animatedBannerEnabled: false  //是否可用
    }
  },
  components: {
    animatedBanner
  },
  computed: {
    bannerImg() {
      return require('./static/static.png')
    }
  },
  methods: {
    async animatedBanner() {
      // 優(yōu)先加載展示靜態(tài)banner
      const staticBannerImg = document.createElement('img')
      staticBannerImg.src = this.bannerImg
      await new Promise((resolve) => (staticBannerImg.onload = resolve()))
      this.animatedBannerEnabled = true
    }
  },
  mounted() {
    this.animatedBanner()
  }
}

寫在最后

其實(shí)這里關(guān)鍵還是鼠標(biāo)事件的監(jiān)聽和初始圖片的位置等等信息踱葛,如有幫助到你不勝榮幸。
demo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末光坝,一起剝皮案震驚了整個濱河市尸诽,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌盯另,老刑警劉巖性含,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異鸳惯,居然都是意外死亡商蕴,警方通過查閱死者的電腦和手機(jī)叠萍,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來绪商,“玉大人苛谷,你說我怎么就攤上這事「裼簦” “怎么了腹殿?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長理张。 經(jīng)常有香客問我赫蛇,道長,這世上最難降的妖魔是什么雾叭? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任悟耘,我火速辦了婚禮,結(jié)果婚禮上织狐,老公的妹妹穿的比我還像新娘暂幼。我一直安慰自己,他們只是感情好移迫,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布旺嬉。 她就那樣靜靜地躺著,像睡著了一般厨埋。 火紅的嫁衣襯著肌膚如雪邪媳。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天荡陷,我揣著相機(jī)與錄音雨效,去河邊找鬼。 笑死废赞,一個胖子當(dāng)著我的面吹牛徽龟,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播唉地,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼据悔,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了耘沼?” 一聲冷哼從身側(cè)響起极颓,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎群嗤,沒想到半個月后讼昆,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年浸赫,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片赃绊。...
    茶點(diǎn)故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡既峡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出碧查,到底是詐尸還是另有隱情运敢,我是刑警寧澤,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布忠售,位于F島的核電站传惠,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏稻扬。R本人自食惡果不足惜卦方,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望泰佳。 院中可真熱鬧盼砍,春花似錦、人聲如沸逝她。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春匠抗,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背搓扯。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工夯秃, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人积仗。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓疆拘,卻偏偏與公主長得像,于是被迫代替她去往敵國和親寂曹。 傳聞我的和親對象是個殘疾皇子哎迄,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評論 2 345