思路
1.首先弹幕效果其实就是动画效果,刚开始的时候,自己比较容易想到的方法是,用定时器加绝对定位改变left top的方法实现这种效果,但这种方案的话一直改变Left,top,会有很多的repaint和reflow,渲染性能很差,而且定时器是异步的,不一定会每次都在同样的相隔的时间触发,而且定时的时间也不能写死,因为不同的显示器刷新频率不一样,写死的话会让不同显示器显示出来都会不一样,可能会有掉帧的现象
2.接着上网看了一下有一个requestAnimationFrame方法,它是根据系统来决定回调函数的执行时机,可以保证不同的显示器在每次刷新的时候调用一次,然后就开始用这个方法做了,首先把外层的容器设为相对定位,接着访问后台拿到全部的弹幕信息,形成一个map对象,在监听视频的每一秒的时候,就去看这一秒有没有对应的弹幕数据,有的话就初始化新建一个dom,把它设置为相对定位和,随机的高度,把它放到最右边,接着就利用requestAnimationFrame配合结合transform:translate来进行移动,移动的条件是,移动的距离小于容器的宽度加上弹幕的宽度,就递归执行requestAnimationFrame,而一旦超过就cancel掉,然后把dom删掉,这样就形成了一个基本的弹幕效果。
3.本来觉得这样就差不多了,但之后发现又有一些问题,1. 首先是随机高度,这个和平时看的弹幕不太一样,正常的弹幕都是一行接一行的,而且当数据量很大的时候,在那一秒的弹幕肯定会发生重叠的,这个版本就没有进行碰撞检测,也没有进行弹幕的缓存,这样数据量很大的时候,弹幕就会很乱,2. 不断地重复新增和删除dom,对页面性能渲染有很大的影响
4.接着就从之前的问题入手做第二个版本,不再是随机高度,也考虑到更多的性能问题。首先要设置层级关系,要设置通道,每层通道的高度都是一样的,每一层通道只能存放6条弹幕,这样看起来弹幕就会更加整洁一些,接着还要设置一个弹幕缓冲池,是一个数组,这个是用来拿数据发射弹幕,同时缓冲没办法立刻发送的弹幕
5.接着针对不断操作dom,要设置一个缓存dom数组,这个数据的子元素也是数组,表示每一条通道的可用的dom集合,dom数组在初始化的时候一次性生成所有通道可以允许动画的弹幕dom元素,加到fragment里面,再一次加到容器里面,之后每一次进行弹幕移动的时候都复用这些dom进行动画
6.接着观察每一秒的弹幕数据,如果有弹幕,就加到弹幕池里面,如果弹幕池是有弹幕的,而且这么多通道里面,有可以用的缓存dom,那么就用这个dom去承载数据进行动画,可以用transition: transform CSS3样式来进行过渡动画,也可以用requestAnimationFrame进行动画,等到弹幕完全出现的时候,这一行的弹幕通道就可以用,可以播放下一条的弹幕,等到弹幕播放完之后,就把dom初始化,重新放回右边去
common-video.vue 基础组件
<template>
<div>
<video ref="Rvideo" :width="width" :height="height" controls @timeupdate="getSeconds">
<source src="./../assets/video/1.mp4" type="video/mp4">
</video>
</div>
</template>
<script>
export default {
data() {
return {
timeDisplay: '',
duration: 0
}
},
props: {
width: {
type: String,
default: '800'
},
height: {
type: String,
default: '600'
}
},
watch: {
timeDisplay(newVal) {
if (!newVal) {
return
}
this.$emit('watchTime', newVal, Math.floor(this.duration))
}
},
methods: {
getSeconds() {
let video = this.$refs['Rvideo']
this.duration = video.duration
this.timeDisplay = Math.floor(video.currentTime)
},
getCurrentTime() {
let video = this.$refs['Rvideo']
return Math.floor(video.currentTime)
}
}
}
</script>
父组件
<template>
<div id="app">
<div class="container" ref="wrapper">
<common-video ref="commonVideo" @watchTime="watchTime"></common-video>
</div>
<div class="btm">
<input type="text" v-model="input">
<button @click="insert">发射</button>
</div>
</div>
</template>
<script>
import commonVideo from '@/components/commonVideo'
import Barrage from '@/common/Barrage/Barrage'
export default {
name: 'App',
data() {
return {
input: '',
barrages: [
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '1', time: 1 },
{ text: '122', time: 1 },
{ text: 'eqqqqqqqqqqqqqq', time: 1 },
{ text: 'yhtty', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: 'asd', time: 1 },
{ text: '2', time: 2 },
{ text: '2', time: 2 },
{ text: '你好啊你好啊你好啊你好啊你好啊你', time: 1 },
{ text: '你你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你', time: 1 },
{ text: 'sadsadsad', time: 1 },
{ text: 'sssssssssssssssssssssssssssssssssssss', time: 1 },
{ text: 'asdsadasddddddddddddddddd', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '32432', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: '1213', time: 1 },
{ text: '你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊你好啊', time: 1 },
{ text: 'sadsa', time: 1 },
{ text: '2', time: 2 },
],
barragesMap: null,
barrage: null
}
},
components: {
commonVideo
},
mounted() {
this.initBarrageWrapper()
this.initBarragesMap()
},
methods: {
// 初始化wrapper
initBarrageWrapper() {
this.barrage = new Barrage({
wrapper: this.$refs['wrapper'],
sameSpeed: true
})
},
// 初始化map
initBarragesMap() {
let map = new Map()
for (let item of this.barrages) {
if (!map.get(item.time)) {
map.set(item.time, [item])
} else {
let arr = map.get(item.time)
arr.push(item)
map.set(item.time, arr)
}
}
this.barragesMap = map
},
// 视频每隔一秒,就看这一秒有没有新的弹幕
watchTime(time, endTime) {
if (time === endTime) {
this.barrage.clearTimer()
return
}
let currentTimeBarrages = this.barragesMap.get(Number(time))
if (currentTimeBarrages && currentTimeBarrages.length > 0) {
this.barrage.insertBarrages(currentTimeBarrages)
}
},
insert() {
let barrageObj = {
time: this.$refs['commonVideo'].getCurrentTime(),
text: this.input
}
let insertArr = [barrageObj]
this.barrage.insertBarrages(insertArr)
this.input = ''
}
}
}
</script>
<style>
.container {
width: 800px;
height: 600px;
background: #000;
}
.btm {
margin-top: 20px;
display: flex;
align-items: center;
}
.btm input {
width: 300px;
height: 30px;
}
.btm button {
width: 100px;
height: 36px;
}
</style>
核心弹幕类
const MAX_DM_COUNT = 6;
const CHANNEL_COUNT = 10;
class Barrage {
constructor({
wrapper,
sameSpeed = false,
speedTime = '7s',
speed = 100
}) {
// 用于缓存60个dom的dom池
this.domPool = []
// 所有的弹幕obj的弹幕池
this.danmuPool = []
// 存储通道是否可用
this.hasPosition = []
// 初始化外层容器的信息
this.wrapper = wrapper
this.wrapperWidth = ''
this.wrapperHeight = ''
this.initWrapper()
// 初始化dom池和初始化通道情况
this.initBarragesPool()
this.timer = null
this.sameSpeed = sameSpeed
this.speedTime = speedTime
this.speed = speed
}
// setInterval查找有没有新的数据
intervalInsert() {
this.timer = setInterval(() => {
let channel = this.getChannel()
if (this.danmuPool.length && channel !== -1) {
let dom = this.domPool[channel].shift()
let danmu = this.danmuPool.shift()
this.shoot(dom, danmu, channel)
}
}, 1)
}
// 初始化外层容器wrapper
initWrapper() {
if (this.wrapper.style.position == '' || this.wrapper.style.position == 'static') {
this.wrapper.style.position = 'relative'
}
this.wrapper.style.overflow = 'hidden'
const rectObj = this.wrapper.getBoundingClientRect()
this.wrapperWidth = rectObj.width
this.wrapperHeight = rectObj.height
// 初始化之后就可以查有没有弹幕了
this.intervalInsert()
}
// 初始化弹幕dom
initDom(i) {
let dom = document.createElement('div')
dom.style.cssText = `
position: absolute;
top: ${i*30}px;
visibility: hidden;
white-space: nowrap;
transform: translateX(800px);
color: #fff;
`
return dom
}
// 初始化弹幕dom池
initBarragesPool() {
// 先缓存60个dom结点,不断复用,提高性能,动画完又设到右上角
for (let i = 0; i < CHANNEL_COUNT; i++) {
let doms = []
for (let j = 0; j < MAX_DM_COUNT; j++) {
// 创建dom元素并放到右边
let dom = this.initDom(i)
this.wrapper.appendChild(dom)
doms.push(dom)
dom.addEventListener('transitionend', () => {
dom.style.cssText = `
position: absolute;
top: ${i*30}px;
visibility: hidden;
white-space: nowrap;
transform: translateX(800px);
color: #fff;
`
})
}
this.domPool[i] = doms
// 初始化通道
this.hasPosition[i] = true
}
}
// 看有没有可以用的通道
getChannel() {
for (let i = 0; i < CHANNEL_COUNT; i++) {
if (this.hasPosition[i] && this.domPool[i].length) {
return i
}
}
return -1
}
// 输入弹幕数组
insertBarrages(arr) {
if (arr && arr.length > 0) {
this.danmuPool = this.danmuPool.concat(arr)
}
}
// 发送单个弹幕
shoot(dom, barrage, channel) {
dom.innerText = barrage.text
let domRect = dom.getBoundingClientRect()
let speed = this.sameSpeed
? `${Math.ceil((Number(this.wrapperWidth) + Number(domRect.width)) / this.speed)}s`
: this.speedTime
dom.style.cssText = `
top: ${channel*30}px;
position: absolute;
white-space: nowrap;
user-select: none;
transition: transform ${speed} linear;
color: #fff;
`
dom.style.transform = `translateX(${-domRect.width}px)`
this.hasPosition[channel] = false
// 弹幕全部显示之后 才能开始下一条弹幕
// 大概 dom.clientWidth * 10 的时间 该条弹幕就从右边全部划出到可见区域 再加1秒保证弹幕之间距离
let time = domRect.width < 50 ? domRect.width * 70 : domRect.width * 15
setTimeout(() => {
this.hasPosition[channel] = true
}, time)
}
// 清除定时器
clearTimer() {
this.timer = null
}
}
export default Barrage
如何检测碰撞?
用CSS3来做的,因为每条弹幕的长度不一样,而transition的时间都一样,是肯定会有碰撞的,因为宽度长的弹幕速度肯定会很快,如果一定要不碰撞,那么只能控制发射的速率一致,但是这样的话当弹幕数量很多的时候会有很多弹幕积压在弹幕池,可能不能完全显示出来,所以如果数据量比较小的时候,可以用相同速率,数据量比较大的时候用相同时间