最近想写一个收缩展开的菜单特效,希望用原生的 JavaScript 实现,不用 jQuery 或者 CSS3,思来想去居然毫无头绪,然后想起了以前看过的运动系列教程,于是又从头看了一遍,大体掌握了使用 JavaScript 编写一些常用运动的方式。这系列的博文就是我学习过程中的一些总结。
运动
物理学公式告诉俺们:路程 = 速度(平均速度) * 时间,就是在某一个时间段内,用某个速度走完某段路程。完成一项运动,路程、时间和速度这三个要素都不可或缺。
JavaScript 实现运动
JavaScript 实现运动的原理,就是通过定时器不断改变元素的位置,直至到达目标点后停止运动。通常,要让元素动起来,我们会通过改变元素的 left 和 top 值来改变元素的相对位置。这两句话看似简单,实际上有很多细节需要我们处理,其中也涉及到一些数学和物理的知识。常见的运动形式有:匀速运动、缓冲运动、弹性运动和碰撞运动。本系列博文将依次总结这些运动,首先是匀速运动。
场景搭建
先来搭建运动场景:
我们准备让小滑块从大盒子左侧匀速运动到大盒子右侧,下面是基础的布局代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>匀速运动</title>
<style>
#par{
width: 600px;
height: 300px;
border: 1px solid #333;
margin:50px auto;
position: relative;
text-align: center;
padding-top: 10px;
}
#inner{
width: 100px;
height: 100px;
background: orange;
position: absolute;
left: 0;
top: 50%;
margin-top: -50px;
text-align: center;
line-height: 100px;
}
</style>
</head>
<body>
<div id="par">
<button>开始</button>
<div id="inner">小滑块</div>
</div>
</body>
</html>
JavaScript代码
要完成运动效果,我们需要这些要素:
- 让哪一个元素运动
- 元素运动是需要改变哪一个属性
- 运动的目标点
- 运动的速度
在运动过程中,元素的某个属性是不断变化的,首先需要一个函数来获取元素的属性:
function getCurrentStyle(ele,attr = ""){
return ele.currentStyle?ele.currentStyle[attr]:getComputedStyle(ele,false)[attr];
}
接下来编写运动函数:
function animate(ele = null,attr = "",target = 0,speed = 0){
ele.timer = setInterval(()=>{
// 获取当前的样式
let currentStyle = Number.parseInt(getCurrentStyle(ele,attr));
// 运动到目标点后清除定时器
if(currentStyle >= target){
clearInterval(ele.timer)
}else{
// 根据当前样式动态改变物体的样式
ele.style[attr] = (currentStyle + speed) + "px";
}
},30);
}
给开始按钮添加事件,调用运动函数:
...
<body>
<div id="par">
<button onclick = "start()">开始</button>
<div id="inner">小滑块</div>
</div>
</body>
<script src="animate.js"></script>
<script>
const ele = document.getElementById("inner");
function start(){
const target = (600 - ele.offsetWidth);
animate(ele,"left",target,10);
}
</script>
...
效果如图所示:
现在,我们让小滑块动起来了,当然,还有一些问题需要处理:
- 重复点击开始按钮,速度越来越快
- 改变速度值后,物体可能超出边界
看一下效果:
1)重复点击,速度越来越快
2)改变速度后超出边界
...
const ele = document.getElementById("inner");
function start(){
const target = (600 - ele.offsetWidth);
animate(ele,"left",target,21);
}
...
原因分析:
1)关于速度越来越快的问题,是因为每次点击都会开启一个定时器,导致定时器中的回调函数多次执行,因此速度就越来越快,解决方案是函数一开始执行时就清除定时器
function animate(ele = null,attr = "",target = 0,speed = 0){
// 清除定时器
clearInterval(ele.timer);
ele.timer = setInterval(()=>{
// 获取当前的样式
let currentStyle = Number.parseInt(getCurrentStyle(ele,attr));
// 运动到目标点后清除定时器
if(currentStyle >= target){
clearInterval(ele.timer)
}else{
// 根据当前样式动态改变物体的样式
ele.style[attr] = (currentStyle + speed) + "px";
}
},30);
}
看下效果:
2)关于改变速度后物体超出边界,是由于当前样式(currentStyle)加上速度(speed)后,不一定刚好等于目标距离,而我们判断运动停止的条件是当前样式 (currentStyle)大于等于目标距离(target),这个算法并不能限制物体刚好达到边界。
为什么会超出边界呢?我们拿速度 21 举例:
运动次数 | 位置 | 目标距离 |
---|---|---|
1 | 21 | 550 |
2 | 42 | 550 |
... | ... | 550 |
26 | 546 | 550 |
27 | 561 | 550 |
当进行第26次运动时,小滑块的位置是546,由于546<550,因此小滑块会以继续以21的速度向前运动,直到进行到第27次运动,此时小滑块的位置大于目标距离,运动停止。
正确的食用方式:
事实上,当小滑块进行第26次运动以后,他将无法再进行一次完整的运动了。此时小滑块右侧到边界的距离小于一个速度值。因此我们对代码进行如下修改:
function animate(ele = null,attr = "",target = 0,speed = 0){
// 清除定时器
clearInterval(ele.timer);
ele.timer = setInterval(()=>{
// 获取当前的样式
let currentStyle = Number.parseInt(getCurrentStyle(ele,attr));
// 运动到目标点后清除定时器
if(Math.abs(target - currentStyle) < Math.abs(speed)){
ele.style[attr] = target + "px";
clearInterval(ele.timer);
}else{
// 根据当前样式动态改变物体的样式
ele.style[attr] = (currentStyle + speed) + "px";
}
},30);
}
现在看下效果:
反方向运动
如果想要小滑块从右向左运动呢?这时就需要反向的速度,使小滑块的 left 值不断变小。
CSS 代码:
...
#inner{
...
left: 500px;
...
}
调用运动函数:
...
<script>
const ele = document.getElementById("inner");
function start(){
const target = 0;
// 负值表示反向运动
animate(ele,"left",target,-21);
}
</script>
...
效果图:
从实际应用的角度考虑,我们可能不太愿意指定速度的方向,只希望指定速度的值,因此我们对 animate 函数做一些修改,在函数内部判断速度的方向:
function animate(ele = null,attr = "",target = 0,speed = 0){
// 清除定时器
clearInterval(ele.timer);
let currentStyle = Number.parseInt(getCurrentStyle(ele,attr));
// 根据当前样式值和目标位置的差值判断速度方向
if(currentStyle - target > 0){
speed = -speed;
}
ele.timer = setInterval(()=>{
// 获取当前的样式
let currentStyle = Number.parseInt(getCurrentStyle(ele,attr));
// 运动到目标点后清除定时器
if(Math.abs(target - currentStyle) < Math.abs(speed)){
ele.style[attr] = target + "px";
clearInterval(ele.timer);
}else{
// 根据当前样式动态改变物体的样式
ele.style[attr] = (currentStyle + speed) + "px";
}
},30);
}
透明度处理
上边的运动属性都是带 px 单位的,而透明度是没有单位的,因此需要特殊服务。改变小滑块的样式:
#inner{
...
opacity: 0.3;
}
修改 animate 函数,增加透明度判断:
function animate(ele = null,attr = "",target = 0,speed = 0){
// 清除定时器
clearInterval(ele.timer);
// 获取当前的样式
let currentStyle = (attr === "opacity")?(Number.parseInt(Number.parseFloat(getCurrentStyle(ele,attr))*100)):Number.parseInt(getCurrentStyle(ele,attr));
// 如果改变的样式是 opacity,target乘以100
if(attr === "opacity"){
target *= 100;
}
// 根据当前样式值和目标位置的差值判断速度方向
if(currentStyle - target > 0){
speed = -speed;
}
ele.timer = setInterval(()=>{
// 运动到目标点后清除定时器
if(Math.abs(target - currentStyle) < Math.abs(speed)){
ele.style[attr] = (attr === "opacity")? target / 100 : target + "px";
clearInterval(ele.timer);
}else{
// 根据当前样式动态改变物体的样式
ele.style[attr] = (attr === "opacity")?( currentStyle + speed)/100:(currentStyle + speed) + "px";
currentStyle += speed;
}
},30);
}
调用运动函数:
...
<script>
const ele = document.getElementById("inner");
function start(){
const target = 0;
animate(ele,"opacity",1,5);
}
</script>
...
效果如下:
为何需要将透明度的值乘以100?
因为浮点数并不是精确存储的,我们通过 getCurrentStyle 方法获取的透明度是浮点数,因此在运算的过程中是不精确的,所以需要将透明度转为整数进行计算,在设置样式时再除以100。在后面的其他运动形式中,还会看到很多这样的处理。
总结
这篇文章开始,我们初步接触了匀速运动,并解决了以下问题:
- 重复点击速度加快问题:通过每次调用函数时清除定时器解决
- 运动越界问题:使用绝对值进行判断处理
- 反方向问题:根据当前位置和目标位置的差值判断速度方向
- 透明度问题:对透明度进行特殊处理
- 小数精度问题:将透明度转换为整数进行处理
下篇文章,我们将在匀速运动的基础上,继续完善 animate 函数,包括:
- 多个属性值同时运动
- 链式运动
- 利用 async/await 和 Promise 解决链式运动多层回调问题
完。