最近项目做了一个类似弹幕效果的功能,做之前看了一遍《Javascript设计模式与开发实践》,做完之后再去看代码,发现原来这些就是设计模式。

弹幕效果是这样的,所有的弹幕从右边向右边移动,自始至终,亘古不变。
效果

最初的想法是用 Canvas,但实现到一半的时候考虑再三还是决定改用 DOM 节点来实现,主要考虑是,Canvas 绘制 background,border-radius 以及加载图片,点击事件都比较麻烦。

我做弹幕效果的整体思路:

思路

控制器是调和关系和事件派发的中心,在 Raf 的每一帧去遍历跑道,找到 idle 状态的跑道,并且去内容中心获取内容,根据内容类型生成对应的载体子类,根据跑道初始化子类,并将该跑道置为 busy。

这个需求里面弹幕分为两种类型,文本弹幕和图片弹幕,这两种弹幕都继承自一个载体父类 Carrier,这个父类拥有初始化,边界判断,事件监听等方法,子类就只需要负责各自的 init 和 render 方法。

弹幕从起跑线开始出发,整体经过起跑线时触发 start_off 事件,并通知所有该事件监听者(现在的只有跑道),告诉跑道可以把状态改为空闲了。

弹幕整体经过终点时,触发 die 事件,再这个事件里面通知载体池将自己回收,把内容抛回给内容中心,等待再次被翻牌。

载体池是个载体生成工厂,根据处理器需要的类型返回对应的弹幕,返回的弹幕有两种来源,一种是通过 new 出来,一种是通过 recycle 。

拥有上帝视角的人看到的是这样的:
上帝视角

用到的设计模式

工厂模式和享元模式

载体都是在载体池被创建出来的,但是这里的载体池不关心创建的是什么,只是根据需要的类型和对应的构造器进行生产,对应关系是在载体池初始化的时候传入。这样做的好处是不需要把类型判断和构造函数放到控制器中。外界不需要知道怎么生产,想要什么我给你什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Poll.js
// ...
setup (map) {
this.factoryMap = Object.assign({}, this.factoryMap, map)
}
get (type) {
if (!this.factoryMap[type]) {
return null
}
this.pollMap[type] = this.pollMap[type] || []
let newObj = this.factoryMap[type]()
return newObj
}

载体池除了创建还有回收功能,因为 DOM 节点代价比较昂贵,我们不希望每一条独立的弹幕都是一个新的 DOM,所以载体池还会对生成弹幕进行共享,避免多余的 DOM 节点生成。

1
2
3
4
5
6
7
8
9
10
11
12
13
get (type) {
// ...
if (this.pollMap[type].length > 0) {
return this.pollMap[type].shift()
} else {
let newObj = this.factoryMap[type]()
return newObj
}
}
recycle (type, item) {
this.pollMap[type] = this.pollMap[type] || []
this.pollMap[type].push(item)
}

观察者模式

载体父类实现了简单的观察者模式,once 注册事件,notify 的时候清除所有监听,在每一帧的边缘检测里面去触发事件,通知监听者(这里都是 callback)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
edgeCheck (x) {
let distance = this.startX - x
if (!this.hasDie && distance > this.outerWidth + this.racingLength + this.runUpLength) {
this.hasDie = true
this.notify('die')
}
if (!this.hasLeftBarrel && distance > this.outerWidth) {
this.hasLeftBarrel = true
this.notify('start_off')
}
}
once (event, callback) {
this.listeners[event] = this.listeners[event] || []
this.listeners[event] = this.listeners[event].concat(callback)
}
notify (event) {
this.listeners[event] = this.listeners[event] || []
this.listeners[event] && this.listeners[event].forEach((callback) => {
callback()
})
this.listeners[event] = []
}

代理模式

由于使用的是 DOM 的方式,并且实现的是匀速弹幕效果,为了性能更好,我没有把 translate3d 的偏移放到了容器上来做,弹幕只在初始化的时候设置自己相对容器中的偏移值,这不算特别明显地代理模式~

代理模式是对于自身不方便处理的事务交给第三方处理,比如这里的点击事件,需求文档里面描述图片弹幕需要能点击查看大图,如果所有弹幕都绑定 click 事件,那么在弹幕被回收或者被移除的时候需要对事件进行解绑或替换。这时候可以考虑使用事件代理,只在容器上添加点击事件绑定,在点击事件回调里面根据 event.target 判断点中的弹幕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Controller.js bindEvents 
this.root.addEventListener('click', (event) => {
let findTarget = null
this.pipes.forEach((pipe) => {
for (let index = 0; index < pipe.carriers.length; index++) {
const element = pipe.carriers[index]
if (element.contains(event.target)) {
findTarget = element
return
}
}
})
if (findTarget && this.config.onClick) {
this.config.onClick({
label: findTarget.label
})
}
})

其他功能

支持拖拽

这是未雨绸缪,预料到老板会要求加拖拽弹幕的需求,提前实现,实现方法是容器监听 touch 事件,start 的时候 pause 原有定时器并启用 draggingTick ;move 事件里面移动容器,并执行原有定时器里面的 update 方法,这样弹幕事件该触发还是会触发;end 事件里面停掉 draggingTick resume 原来的定时器。
为了让拖拽效果不要太生硬,这里用了 lerp 大法:

1
2
3
4
5
lerp (value1, value2, amount) {
amount = amount < 0 ? 0 : amount
amount = amount > 1 ? 1 : amount
return value1 + (value2 - value1) * amount
}

拖拽效果

如需转载,请注明出处: http://w3ctrain.com / 2017/12/10/barrage-effect-and-design-pattern/

helkyle

我叫周晓楷

我现在是一名前端开发工程师,在编程的路上我还是个菜鸟,w3ctrain 是我用来记录学习和成长的地方。