先看看效果唄
起因
逛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
代碼分析
animatedBanner.vue
<template>
<div class="animated-banner" ref="container" />
</template>
先簡單的寫一個div來作為整個banner的容器,接下來便是逐步完成整個頁面的填充
- 第一步需要各類圖片素材吼和,先將他們引入,為了省事我就選擇本地引入靜態(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'),
…………
}
}
},
從引入的圖片可以看到,圖片素材都是擦除背景的png文件莱革,可以通過我們的排列組合最后才能顯示一幅畫面
- 配置信息
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
}],
…………
}
- 頁面掛載鉤子桐筏,在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 //獲取配置信息
}
}
- 圖片的加載
// 等待頁面加載完成
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
以便于后面生成圖片元素
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
})
})
- 初始化圖層
// 初始化圖層
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ò)展
- 監(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)
}
}
- 初始化圖層內(nèi)圖片和幀動畫
// 初始化圖層內(nèi)圖片和幀動畫
layerConfig.map((v, i) => {
const a = v.resources[0].el
layers[i].appendChild(a)
requestAnimationFrame(af)
})
this.$emit('change', true)
- 定義鼠標(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)
- 在組件銷毀前移除監(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 = []
}
},
- 擴(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>
- 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