用原生JavaScript写出类似jQuery中slideUp和slideDown效果

沈曜灿
2023-12-01

JavaScript是本人自学的第一门语言,也是本人目前最喜欢的一门语言。由于是自学,加上没有做过任何项目(只是偶尔自己做一些小效果玩玩),所以水平不咋地,写得不好之处,欢迎各位指正。

前言

在我自学JavaScript的时候,一直想实现类似安卓手机状态栏那种下拉上滑效果。在网上搜索一番后,我知道了jQueryslideUp()slideDown()方法。这两个方法让我很着迷,我迫不及待知道他是如何实现的。可当我尝试在网上寻找答案时,得到答案几乎都是:你去看jQuery源码不就知道了吗?于是我就去看了jQuery源码,但是面对几万行代码,让我望而却步。所以至今我也不知道slideUp()和slideDown()这两个方法在jQuery中到底是怎样实现的。

第一次尝试

在进一步学习JavaScript后,我了解了setTimeoutsetInterval,并知道可以利用它们来逐渐的改变元素的属性,以此达到动画效果。于是我开始着手借助setInterval来实现我自己的下拉上滑效果。让我们以下面的HTML代码为基础,开始聊聊我的第一次实现:

文件名:Silder.html

<!doctype html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Slider</title>
</head>
<body>
    <button id="btn">Button</button>
    <div id="panel" style="width:600px;height:400px;background:red;"></div>
    <script src="Slider.js"></script>
</body>
</html>

上面代码布局很简单,会显示一个idbtn的按钮;和一个宽为600px,高为400px,背景为红色,idpanel的div。最后我引入了一个文件名为Slider.js的JavaScript文件,这个文件目前还未创建,我将在下面给出。

为了方便描述,我把idbtn的按钮简记为btn,同理也把红色div简记为panel

现在我想实现一个效果:当我点击btn时,如果panel当前可见,就执行一个上滑动画来将它慢慢收起,从而让它变成不可见;如果当前不可见,那就执行一个下拉动画将它慢慢展开,从而让它变成可见。整个流程如下:

Created with Raphaël 2.1.2 开始 点击btn panel是否可见? 上滑动画 结束 下拉动画 yes no

整个流程清楚了,现在就开始写JS代码了,以下是Slider.js中的内容:

window.onload = function() {
    // 获取btn和panel
    var btn   = document.getElementById("btn"),
        panel = document.getElementById("panel");

    // 为btn绑定onclick事件
    btn.onclick = function() {
        // 通过panel的offsetHeight来判断元素是否可见
        if (panel.offsetHeight === 0) {
            // 不可见,调用下拉函数:在300毫秒内下拉
            slideDown(panel, 300);
        } else {
            // 可见,调用上滑函数:在300毫秒内上滑
            slideUp(panel, 300);
        }
    };
};

function slideDown(element, time) {
    // 等待实现
}

function slideUp(element, time) {
    // 等待实现
}

以上代码注释地很清楚,这里不再赘述。我们要注意的是其中有两个等待实现的函数slideDown(element, time)slideUp(element, time) 。这两个函数分别用于执行下滑上拉动画,他们都接受一个Element类型的element和一个Number类型的time为参数,其中element表示要执行动画的元素,time表示执行动画一个需要多少时间(毫秒)。

我们先来实现slideUp(element, time)。我的思路是这样的:用一个setInterval定时器,每隔10毫秒执行一个函数,每次执行这个函数时就把elementheight属性(element.style.height)减去一部分。举个例子,如果我们传入的time参数为500的话,这个函数就会执行(500/10) = 50次。也就是说我们要分50次把element.style.height减为0,那么每次要减去的值就是 element.offsetHeight/50 了(其中element.offsetHeight为元素的高度)。来看看slidUp的实现:

function slideUp(element, time) {
    // 获取元素总高度
    var totalHeight = element.offsetHeight;
    // 定义一个变量保存元素当前高度
    var currentHeight = totalHeight;
    // 计算每次减去的值
    var decrement = totalHeight/ (time/10);
    // 开始循环定时器
    var timer = setInterval(function() {
        // 减去当前高度的一部分
        currentHeight = currentHeight - decrement;
        // 把当前高度赋值给height属性
        element.style.height = currentHeight + "px";
        // 如果当前高度小于等于0,就关闭定时器
        if (currentHeight <= 0) {
            // 关闭定时器
            clearInterval(timer);
            // 把元素display设置为none
            element.style.display = "none";
            // 把元素高度值还原
            element.style.height = totalHeight + "px";
        }
    }, 10);
}
// ...

现在当我们点击btn时,panel就会慢慢被收起。而当我们再次点击btn时,却没有任何反应,因为我们还没有实现slideDown。有了slideUp为基础,slideDown就不算什么难事了,直接上代码:

//...
function slideDown(element, time) {
    // 获取元素总高度
    element.style.display = "block";            // 1.显示元素,元素变为可见
    var totalHeight = element.offsetHeight;     // 2.保存总高度
    element.style.height = "0px";               // 3.再将元素高度设置为0,元素又变为不可见
    // 定义一个变量保存元素当前高度
    var currentHeight = 0;                      // 当前元素高度为0
    // 计算每次增加的值
    var increment = totalHeight / (time/10);
    // 开始循环定时器
    var timer = setInterval(function () {
        // 增加一部分高度
        currentHeight = currentHeight + increment;
        // 把当前高度赋值给height属性
        element.style.height = currentHeight + "px";
        // 如果当前高度大于或等于总高度则关闭定时器
        if (currentHeight >= totalHeight) {
            // 关闭定时器
            clearInterval(timer);
            // 把高度设置为总高度
            element.style.height = totalHeight + "px";
        }
    }, 10);
}
//...

OK,大功告成。现在来看看效果如何。首先点击btn,我们会看到panel慢慢被收起。等待收起动画完成后,再次点击btnpanel又会被慢慢展开。看起来很不错,达到了我们预期的效果。可是一切真的这么简单吗?请注意,我方才刻意强调了等待收起动画完成后再点击btn,但是如果我们快速连续点击btn又会出现什么情况呢?答案是我们的动画会崩溃!你可以亲自试一试,看看是不是如我所说。

综上所述,我的第一次尝试是失败的,或者说是不完美的。因为当用户快速点击按钮时我们的动画会崩溃,这显然不是用户想要的。

第一次失败的思考

以上动画为什么会失败呢?那是因为我想当然的认为:JavaScript中的定时器会一个接一个的按着顺序执行。因为JavaScript是单线程,所以也这个认为看起来似乎并不是错的。但是,事实证明这是错的,比如从下面的列子中就可以看出:

// test.js
window.onload = function() {
    timer1();
    timer2();
    timer3();
};

// 依次弹出 1 2 3
function timer1() {
    var num = 0;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 3) {
            clearInterval(timer);
        }
    }, 10);
}

// 依次弹出 4 5 6
function timer2() {
    var num = 3;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 6) {
            clearInterval(timer);
        }
    }, 10);
}

// 依次弹出 7 8 9
function timer3() {
    var num = 6;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 9) {
            clearInterval(timer);
        }
    }, 10);
}

从上面代码可以看出,我们使用三个定时器,希望浏览器依次按顺序弹出1-9这9个数字(每隔10毫秒弹出一个)。把Slider.html 中的Slider.js改为test.js,执行以下看看结果。结果我们会发现,弹出的顺序根本毫无规律可言。这也再次说明了,JavaScript中定时器并不会按代码顺序依次执行。至于为什么,我在这里不做深入研究,在此,我们只需记住这个结论即可。

其实,根本不用做上面的实验,我们也能轻易得出这个结论。因为很多网页上不只有一个动画,如果所有的动画都按顺序一个接一个的执行的话,那岂不是说在该网页上同时至多只能有一个动画在执行,与此同时其余动画都是静止的(因为还未轮到它们执行),这显然和我们看到的不一致。

让定时器乖乖就范

既然定时器如此顽皮地不按顺序执行,所以我们必需得想个法子让它乖乖就范。

要让定时器按顺序执行,那就必需使用回调。也就是说,在一个函数执行函数完成后去调用另一个函数。具体到刚刚那个test.js,要让timer1()timer2()timer3()三个定时器依次执行。我们可以在timer1()执行完成后主动去调用timer2()timer2()完成后又主动去调用timer3()。这样一来,我们只需执行timer1(),三个定时器就都会被依次执行,就像下面一样:

// test.js
window.onload = function() {
    timer1();
};

// 依次弹出 1 2 3
function timer1() {
    var num = 0;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 3) {
            clearInterval(timer);
            // 主动调用timer2();
            timer2();
        }
    }, 10);
}

// 依次弹出 4 5 6
function timer2() {
    var num = 3;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 6) {
            clearInterval(timer);
            // 主动调用 timer3();
            timer3();
        }
    }, 10);
}

// 依次弹出 7 8 9
function timer3() {
    var num = 6;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 9) {
            clearInterval(timer);
        }
    }, 10);
}

运行上面的代码,我们发现,浏览器会像我们期待的那样依次按顺序弹出9个数字。

更灵活的管理方案

虽然上面代码会让定时器依次执行,但这种方式并不灵活。想象一下,如果我们要添加一个新的定时器timer4(),我们就必须在timer3()中去调用它。如果要添加1000个呢?那工作量会相当可观。

现在我们用一个更为灵活的方案来管理定时器的执行。我们可以把所有要按一定顺序执行的定时器都保存在一个数组中,然后把这个数组当成一个队列使用,最后按顺序一个接一个的执行队列里面的定时器。

提示:JavaScript中的数组本身就可以当作队列使用(参见数组的shift()方法和push()方法),所以我们不要实现自己的队列数据结构。

在此,我们创建一个TimerManager对象来管理动画队列。其代码如下:

// test.js
// ...
// 声明TimerManager
var TimerManager = {};

TimerManager.timers = [];       // 用于保存定时器的数组(队列)
TimerManager.isFiring = false;  // 用于记录当前是否有定时器在执行

// 一个用于添加定时器的方法
TimerManager.add = function(timer) {
    // 把定时器存入队列
    this.timers.push(timer);
    // 调用fire()执行队列中第一个定时器
    this.fire();
};

// 一个用于执行队列中第一个定时器的方法
TimerManager.fire = function() {
    if ( !this.isFiring ) { // 如果当前没有定时器在执行
        var firstTimer = this.timers.shift();  // 取出队列中的第一项
        if (firstTimer) { // 如果第一个定时器存在, 就执行
            // 设置isFiring为true表明当前有定时器在执行
            this.isFiring = true; 
            firstTimer();
        }
    }
};

// 一个用于执行下一个定时器的方法
TimerManager.next = function() {
    // 先把isFiring设置为false,表明当前没有定时器在执行
    this.isFiring = false;
    // 调用fire()执行第一个定时器
    this.fire();
};

// ...

TimerManager一共有两个属性,timersisFiring;还有三个方法分别是add(timer)fire()next()。其中我们常用的是add(timer)和next()。

fire()是一个用来执行队列中第一个定时器的内部方法,执行的时候,它会先判断当前是否有定时器在执行,如果没有的话,它便会把第一个定时器从队列中取出来并立即执行;如果当前有定时器正在执行,它就什么都不做。

next()是一个用来执行队列中下一个动画的方法,它应该在定时器结束的时候被调用,以执行队列中下一个定时器。这也表明了,我们在写定时器时,必须在定时器结束时主动调用这个方法

add(timer)用于向队列中添加定时器。timer即要添加的函数名。

前面说过,必须在定时器结束时调用next()方法。所以我们把之前的定时器(timer1,timer2,timer3)都修改一下,改成下面这样:

// test.js
// ...
// 依次弹出 1 2 3
function timer1() {
    var num = 0;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 3) {
            clearInterval(timer);
            // 调用next();
            TimerManager.next();
        }
    }, 10);
}

// 依次弹出 4 5 6
function timer2() {
    var num = 3;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 6) {
            clearInterval(timer);
            // 调用next();
            TimerManager.next();
        }
    }, 10);
}

// 依次弹出 7 8 9
function timer3() {
    var num = 6;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 9) {
            clearInterval(timer);
            // 调用next();
            TimerManager.next();
        }
    }, 10);
}
// ... 

完成以上修改后,我就可以调用add(timer)方法把所有定时器添加进去。因为在add(timer)内部会主动调用fire()来执行队列中的第一个定时器,所以我们不用手动调用fire()。我们要做的只是把定时器添加进去,其他什么也不用做,定时器就会乖乖地排着队去会执行。以下便是test.js修改后全部代码:

// test.js

// 声明TimerManager
var TimerManager = {};

TimerManager.timers = [];       // 用于保存定时器的数组(队列)
TimerManager.isFiring = false;  // 用于记录当前是否有定时器在执行

// 一个用于添加定时器的方法
TimerManager.add = function(timer) {
    // 把定时器存入队列
    this.timers.push(timer);
    // 调用fire()执行队列中第一个定时器
    this.fire();
};

// 一个用于执行队列中第一个定时器的方法
TimerManager.fire = function() {
    if ( !this.isFiring ) { // 如果当前没有定时器在执行
        var firstTimer = this.timers.shift();  // 取出队列中的第一项
        if (firstTimer) { // 如果第一个定时器存在, 就执行
            // 设置isFiring为true表明当前有定时器在执行
            this.isFiring = true; 
            firstTimer();
        }
    }
};

// 一个用于执行下一个定时器的方法
TimerManager.next = function() {
    // 先把isFiring设置为false,表明当前没有定时器在执行
    this.isFiring = false;
    // 调用fire()执行第一个定时器
    this.fire();
};


window.onload = function() {
    // 调用add(timer)添加定时器
    TimerManager.add(timer1);
    TimerManager.add(timer2);
    TimerManager.add(timer3);
};

// 依次弹出 1 2 3
function timer1() {
    var num = 0;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 3) {
            clearInterval(timer);
            // 调用next();
            TimerManager.next();
        }
    }, 10);
}

// 依次弹出 4 5 6
function timer2() {
    var num = 3;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 6) {
            clearInterval(timer);
            // 调用next();
            TimerManager.next();
        }
    }, 10);
}

// 依次弹出 7 8 9
function timer3() {
    var num = 6;
    var timer = setInterval(function() {
        num ++;
        alert(num);
        if (num == 9) {
            clearInterval(timer);
            // 调用next();
            TimerManager.next();
        }
    }, 10);
}

运行上述代码后我们会得到一样的运行结果,不同的是我们采用了更为灵活的管理方式。

最终实现

上面说了这么多,好像偏题了。但是,实现定时器的有序执行对实现我们的下拉上滑动画来说的确十分重要。现在,我们就利用上面的成果,来完成我们的下拉上滑。为了方便描述,我们新建一个JS文件Slider2.js。我们会在这个文件中实现一个Slider对象,它包含一个slideUp(element, time)和一个slideDown(element, time)方法。以下是这个文件的结构:

window.Slider = (function() {
    var Slider = {};
    // 等待实现
    return Slider;
})();

这里创建了一个匿名函数,创建并返回一个对象(Slider),我们所有的代码都将在这个闭包中完成,最后只提供两个接口 —— slideUp(element, time)slideDown(element, time)

在给出最终实现代码前,先来说说我的思路:

I. 首先,由于一个网页中往往有多个绑定动画的元素,我们要求每个元素的动画单独连续执行,各个元素的动画的执行相互独立。所以我们不能用一个TimerManger来管理所有元素的动画队列,因此我们需要做的是为每个动画元素分配一个唯一的TimerManger。于是我们先要定义一个TimerManager类。

II. 我们要修改之前的动画函数:在动画结束的时候,获取动画元素的TimerManager,并调用它的next()方法

III. 用Slider对象把TimerManger与动画函数整合起来,并提供外部访问接口

以下是Slider2.js中所有代码:

window.Slider = (function() {
    // 定义Slider对象
    var Slider = {};

    // I.定义一个TimerManager类

    // 1)构造函数
    function TimerManager() {
        this.timers = [];       // 保存定时器
        this.args = [];         // 保存定时器的参数
        this.isFiring = false;
    }

    // 2)静态方法:为element添加一个TimerManager实例
    TimerManager.makeInstance = function(element) {
        // 如果element.__TimerManager__上没有TimerManager,就为其添加一个
        if (!element.__TimerManager__ || element.__TimerManager__.constructor != TimerManager) {
            element.__TimerManager__ = new TimerManager();
        }
    };

    // 3)扩展TimerManager原型,分别实现add,fire,next方法
    TimerManager.prototype.add = function(timer, args) {
        this.timers.push(timer);
        this.args.push(args);
        this.fire();
    };

    TimerManager.prototype.fire = function() {
        if ( !this.isFiring ) {
            var timer = this.timers.shift(),        // 取出定时器
                args  = this.args.shift();          // 取出定时器参数
            if (timer && args) {
                this.isFiring = true;
                // 传入参数,执行定时器函数
                timer(args[0], args[1]);
            }
        }
    };

    TimerManager.prototype.next = function() {
        this.isFiring = false;
        this.fire();
    };

    // II. 修改动画函数并在定时器结束后调用element.__TimerManager__.next()

    // 1)下滑函数
    function fnSlideDown(element, time) {
        if (element.offsetHeight == 0) {  //如果当前高度为0,则执行下拉动画
            // 获取元素总高度
            element.style.display = "block";            // 1.显示元素,元素变为可见
            var totalHeight = element.offsetHeight;     // 2.保存总高度
            element.style.height = "0px";               // 3.再将元素高度设置为0,元素又变为不可见
            // 定义一个变量保存元素当前高度
            var currentHeight = 0;                      // 当前元素高度为0
            // 计算每次增加的值
            var increment = totalHeight / (time/10);
            // 开始循环定时器
            var timer = setInterval(function () {
                // 增加一部分高度
                currentHeight = currentHeight + increment;
                // 把当前高度赋值给height属性
                element.style.height = currentHeight + "px";
                // 如果当前高度大于或等于总高度则关闭定时器
                if (currentHeight >= totalHeight) {
                    // 关闭定时器
                    clearInterval(timer);
                    // 把高度设置为总高度
                    element.style.height = totalHeight + "px";
                    if (element.__TimerManager__ && element.__TimerManager__.constructor == TimerManager) {
                        element.__TimerManager__.next();
                    }
                }
            }, 10);
        } else {  // 如果当前高度不为0,则直接执行队列里的下一个函数
            if (element.__TimerManager__ && element.__TimerManager__.constructor == TimerManager) {
                element.__TimerManager__.next();
            }
        }
    }

    // 2)上拉函数
    function fnSlideUp(element, time) {
        if (element.offsetHeight > 0) {  // 如果当前高度不为0,则执行上滑动画
            // 获取元素总高度
            var totalHeight = element.offsetHeight;
            // 定义一个变量保存元素当前高度
            var currentHeight = totalHeight;
            // 计算每次减去的值
            var decrement = totalHeight / (time/10);
            // 开始循环定时器
            var timer = setInterval(function() {
                // 减去当前高度的一部分
                currentHeight = currentHeight - decrement;
                // 把当前高度赋值给height属性
                element.style.height = currentHeight + "px";
                // 如果当前高度小于等于0,就关闭定时器
                if (currentHeight <= 0) {
                    // 关闭定时器
                    clearInterval(timer);
                    // 把元素display设置为none
                    element.style.display = "none";
                    // 把元素高度值还原
                    element.style.height = totalHeight + "px";
                    if (element.__TimerManager__ && element.__TimerManager__.constructor == TimerManager) {
                        element.__TimerManager__.next();
                    }
                }
            }, 10);
        } else {  // 如果当前高度为0, 则直接执行队列里的下一个函数
            if (element.__TimerManager__ && element.__TimerManager__.constructor == TimerManager) {
                element.__TimerManager__.next();
            }
        }
    }

    // III.定义外部访问接口

    // 1)下拉接口
    Slider.slideDown = function(element, time) {
        TimerManager.makeInstance(element);
        element.__TimerManager__.add(fnSlideDown, arguments);
        return this;
    };

    // 2)上滑接口
    Slider.slideUp = function(element, time) {
        TimerManager.makeInstance(element);
        element.__TimerManager__.add(fnSlideUp, arguments);
        return this;
    };

    // 返回Slider对象
    return Slider;
})();

以上代码注释相当清楚(之前描述过的实现在此不再注释),这里不再赘述。

Slider对象完成了,现在新建一个test2.js来调用Slider的方法。以下为test2.js的代码:

window.onload = function() {
    // 获取btn和panel
    var btn   = document.getElementById("btn"),
        panel = document.getElementById("panel");

    // 为btn绑定onclick事件
    btn.onclick = function() {
        // 通过panel的offsetHeight来判断元素是否可见
        if (panel.offsetHeight === 0) {
            // 不可见,调用Slider.slideDown函数:在300毫秒内下拉
            Slider.slideDown(panel, 300);
        } else {
            // 可见,调用Slider.slideUp函数:在300毫秒内上滑
            Slider.slideUp(panel, 300);
        }
    };
};

现在有了Slider2.jstest2.js,我们只要修改一下Slider.html引入这两个JS文件就行了。以下为Slider.html的代码:

<!doctype html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Slider</title>
</head>
<body>
    <button id="btn">Button</button>
    <div id="panel" style="width:600px;height:400px;background:red;"></div>
    <script src="Slider2.js"></script>
    <script src="test2.js"></script>
</body>
</html>

大功告成,运行以上代码,现在无论我们怎样疯狂的点击btn,我们的动画始终会正确执行。

至此我们已经完成了我们的目标,只不过我们的动画函数还不够优秀,因为它们都是一些简单的匀速运动。不过限于篇幅,这里就不再深究,以后有机会再来实现一些复杂的变速运动,当然,如果读者有兴趣的话也可以自行实现。

还有就是当我们在一个周期内(也就是分别执行一次上拉和下滑所用的全部时间内)多次点击btn时,动画最多执行两次,而不会执行一共点击的次数。当然,这也不能算是一个bug,因为这是笔者刻意为之。如果需要响应多次点击的话,也可以通过简单的修改来实现,不过限于篇幅,笔者也不再深究,留给有心的读者实现。

 类似资料: