js 计时器实现无缝滚动

需求

在机顶盒上实现跑马灯或滚动的效果,使用 marquee 标签是比较好的方法。

这个 js 的实现主要在于 marquee 标签尝试了很多方式及其 api 都没能

让其从指定的位置开始滚动,以及设置延时滚动的效果。

需求和 marquee 对比:

img

实现思路

  • 无缝衔接问题

    要做到无缝衔接,并且做到用其开头部分去衔接结尾部分,通过一个 div1 元素时不可能做到的,

    那么就得复制出一个新的 div2 来模拟原始的 div1 滚动。

  • 中间间隔及其可配问题

    然而又要实现中间的间隔问题,如果使用两个 div 就得去计算两个 div 之间的固定间隔,

    这样会麻烦点,那么我们可以再复制一个 div3 出来(或者新建一个空白元素作为填充)。

  • 初始位置控制及位置变更规则

    那么现在我们有了三个 div ,它们的位置变更是滚动的重点。关键点在于要将滚出可视区域的

    元素追加到其他两个元素后面,而它的位置计算是关键,由于中间的缝隙是可配置的也就是说和两个

    有效元素的高度是不一样的(如果是一样的就很容易控制)。

  • 方向,速度,延时等属性可配置

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 初始化滚动参照物及其属性
* @param {string} dir 滚动方向,上下左右
* @param {integer} height 参照元素的实际高度
* @param {integer} width 参照元素的实际宽度
* @param {domelement} el 实际参照元素
* ...
* @returns {this} 链式调用
*/
init({
dir = DIR_UP,
height = 0,
width = 0,
el = null,
times = 0, // 滚动次数
parent = null,
gap = -1, // 滚动之间的空白间隙,默认为 parent 的高度
delay = 0 // 延时启动时间(默认为0,立即启动)
}) {
// 非法参数
if (!el || !parent ||
((dir === DIR_UP || dir === DIR_DOWN) && height <= 0) ||
((dir === DIR_LEFT || dir === DIR_RIGHT) && width <= 0)
) return this

this.delay = delay // 延时启动
this.gap = gap // 中间间隔的空隙
this.times = times
this.width = width
this.height = height
this.dir = dir
this.clone(el, parent)
return this
}

克隆元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

/**
* 克隆出两个 el,用来做衔接使用
* @param {element} el 被复制的元素
* @param {element} parent 被赋值元素 el 的父元素
*/
clone(el, parent) {
this.el = el
this.parent = parent

const frag = document.createDocumentFragment()
frag.appendChild(this.el)
// 第二个元素用来放在中间作为透明夹层使用
const second = this.el.cloneNode(true)
// 要设置隐藏,模拟单个元素的无缝滚动
second.style.visibility = 'hidden'
// 根据水平或垂直滚动方向,设置初始间隔,gap为外部设置的间隔值
if (this.isHori()) {
second.style.width = `${this.gap > 0 ? this.gap : parent.offsetWidth}px`
} else {
second.style.height = `${this.gap > 0 ? this.gap : parent.offsetHeight}px`
}
frag.appendChild(second)
// 第三个元素用来假装成 el
frag.appendChild(this.el.cloneNode(true))

this.parent.innerHTML = ''
this.parent.appendChild(frag)

// 初始化位置信息(需要决定定位)
this.initPos()
}

这里使用 createDocumentFragment 是为了一次创建一次 append

注意这一段:

1
2
3
4
5
if (this.isHori()) {
second.style.width = `${this.gap > 0 ? this.gap : parent.offsetWidth}px`
} else {
second.style.height = `${this.gap > 0 ? this.gap : parent.offsetHeight}px`
}

初始化间隙的距离,水平滚动为宽度,垂直则为高度。

this.gap 用来外部控制间隙的值。

位置初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

/**
* 初始化三个 El 的定位信息
*/
initPos() {
let val = 0
this.els = this.getEls()
this.els.map((el, i) => {
const height = el.offsetHeight
const width = el.offsetWidth
// 将高度宽度等信息事先存储起来,避免在计时器中频繁调用 offset 属性
this.attrs.push({
el, height, width
})
// 根据实际高度来设置顶部距离
val += i === 0 ? 0 : (this.isHori() ? width : height)
this.configElPos(el, val)
})
}

位置初始化我们直接取出三个元素的实际宽高即可,因为在 clone 里面已经知道了每个元素的宽高,

且它们的位置又和彼此密切相关。

其中这一段:

1
2
3
this.attrs.push({
el, height, width
})

目的是为了存储每个元素的实际高度,避免在计时器中去频繁调取 offsetWidthoffsetHeight

导致性能问题。然后结果 filter 工具找出想要的元素及其属性值。

元素位移

通过 configElPos 来实际控制或获取实时的位置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

/**
* 根据方向获取当前元素的参考值
* @param {htmlelement} el
* @returns {int} 偏移量
*/
configElPos(el, val) {
if (typeof val !== 'undefined') {
if (this.isHori()) {
el.style.left = `${val}px`
} else {
el.style.top = `${val}px`
}
return 0
}
return parseInt(
this.isHori() ?
el.style.left :
el.style.top,
10
)
}

启动滚动

启动函数有两个,一个是 start 外部调用,一个是 _start 供内部使用。

start 主要是为了能控制 delay 属性, _start 用来启动动画帧

requestAnimationFrame (polyfill 版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 外部启动函数,可设置延时启动
* @returns {}
*/
start() {
if (!this.parent || !this.el) return false

if (this.delay > 0) {
window.clearTimeout(this.delayTimer)
this.delayTimer = window.setTimeout(
() => this._start(), this.delay * 1000
)
} else {
this._start()
}

return true
},

/**
* 内部启动函数
*/
_start() {
this.timer = window.requestAnimationFrame(() => this.changePos())
},

start 第一行必须加控制,因为 init 里面无论成功失败都会返回 this 来达到链式调用,

所以 start 里面需要控制下,防止初始化出问题导致 start 执行异常。

改变位移

这里是重点,通过计算溢出元素,然后调整它的位置来达到滚动衔接效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

/**
* 在计时器中调用,改变位移
* @returns {}
*/
changePos() {
this.els.map(el => {
let val = 0
const currTop = this.configElPos(el)
// 取出溢出元素
const overEl = this.getOverEl()
if (overEl === el) {
val = this.getOthsHeight(overEl)
} else {
val = currTop - this.step
}
this.configElPos(el, val)
})

// 请求下一帧
this._start()

return true
}

changePos 里面用到了两个函数:

  1. getOverEl() 获取当前已经溢出的元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

/**
* 过滤出已滚出的元素,将来会被追加到其他元素后面
* @returns {}
*/
getOverEl() {
const els = filter(this.els, el => {
const currTop = this.configElPos(el)
const elObj = filter(this.attrs, item => item.el === el)[0]
if (!elObj) return false
if (currTop <= -1 * elObj.height) {
return true
}

return false
})

return els && els.length && els[0] || null
}

filter(arr, fn) 函数的功能是,针对数组 arr 中的每个元素执行回调函数 fn

然后根据 fn 执行的结果为 truefalse 去筛选满足条件的元素。

  1. getOthsHeight() 得到除溢出元素之外的其他两个元素的高度之和去设置溢出元素的位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14

/**
* 计算未溢出元素的高度之和
* @param {element} target 溢出的元素
* @returns {integer} 未溢出元素的属性值之和
*/
getOthsHeight(target) {
let total = 0
exec(
filter(this.attrs, el => el !== target),
item => total += (this.isHori() ? item.width : item.height)
)
return total
}

获取其他元素的高度之和,这里结合了 filter(arr, fn)exec(arr, fn) 的使用。

exec 函数功能是对 arr 的每个元素执行一次 fn ,这里我们是将每个元素的高度去和 total

相加然后返回 total 这样就得到了每个元素的高度之和。

Bug

第一次滚动完成之后最后一个元素的位置多出了一个可视区域的大小。

总结

这个实现还是比较简单,写下这篇文章作为记录和思路的整理。

干机顶盒太痛苦了,想去扒点现成的东西来用,又怕其他人在里面用到了一些新的功能和 api 导致盒子

无法兼容,所以没得拌饭遇到比较特殊的需求就只能自己去动手写点东西。

本文标题:js 计时器实现无缝滚动

文章作者:ZhiCheng Lee

发布时间:2019年04月22日 - 16:01:45

最后更新:2019年06月16日 - 20:27:12

原始链接:http://blog.gcl666.com/2019/04/22/js-native-seamless-scroll/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

0%