这篇文章上次修改于 262 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

昨日收到 @Dorasees 反馈,他发现我的小窝音乐播放器有个 Bug,进度条在点击播放按钮后未正常进行滚动。而通过点击页面歌曲链接和切歌(上一首、下一首)按钮则没有出现问题。这个 Bug 一出现,我就想着尽快解决修复。也好奇究竟是什么原因导致的。

既然只有点击播放按钮会出现这种问题,那问题根源也肯定和它有关系。首先我找到设置播放按钮的函数,看看它是怎么写的:

elements.toggle.onclick = function () {
    elements.player.paused ? elements.player.play() : elements.player.pause();
};

可以看到,这个按钮主要的功能仅仅是切换播放器的播放状态而已。那么操作正常的方式(切歌)又是怎么写的呢?

elements.prev.onclick = actions.prev;
elements.next.onclick = actions.next;

它调用了一个名为 actions.next() 的函数,那么看看这个函数究竟干了什么?

next: function () {
    status.playing < that.list.length - 1 ? status.playing++ : status.playing = 0;
    that.play();
}

可以看到,切歌仅仅只是修改了定位到播放列表数组中的索引值,之后执行 that.play() 方法。这个方法用于向服务器发起 AJAX 请求,装载歌曲信息。很明显这个就是异步执行,ks.ajax() 没有使用 Promise,采用的依旧是回调函数。播放器会在装载完成后自动开始播放的操作。

这些逻辑看起来都没有问题呀,那我们再看看出事的进度条,它是怎么开始进行滚动的?我们都知道,Audio 对象有很多事件,给播放器设置 play 事件会让播放器开始播放音乐的时候,执行我们想要完成的操作。

我的播放器在 play 事件里调用 sel_int() 方法,使得播放器开始每隔 500 毫秒更新一次进度条的位置。

elements.player.addEventListener("play", function () {
    if(!elements.player.src){
        that.play();
    }

    that.sel_int();
    if(status.lyric) that.sel_lyric();
    elements.cover.wrap.classList.add("active");
    elements.toggle.classList.add("pause");
});

this.sel_int = function () {
    if(!intv){
        intv = setInterval(function () {
            elements.bar.played.style.width = (elements.player.currentTime / elements.player.duration) * 100 + "%";
        }, 500);
    }
};

可以看到,在 sel_int 这个函数里面主要有两个变量,分别是 elements.player.currentTimeelements.player.duration,它们其实就是播放器的当前播放时间和歌曲总时长,我们在这里用 console.log() 输出一下看看什么情况?

this.sel_int = function () {
    if(!intv){
        console.log(elements.player.currentTime, elements.player.duration);
        intv = setInterval(function () {
            elements.bar.played.style.width = (elements.player.currentTime / elements.player.duration) * 100 + "%";
        }, 500);
    }
};

0 NaN

浏览器返回的是 NaN,说明歌曲还没有加载成功,就开始了循环。但是我正常切歌之后得到的结果和这个是完全一致的,即便有这种问题,也理应能正常执行 setInterval() 里面的函数吧。那么给它里面 console.log 一下呢?

intv = setInterval(function () {
    console.log(elements.player.currentTime, elements.player.duration);
    elements.bar.played.style.width = (elements.player.currentTime / elements.player.duration) * 100 + "%";
}, 500);

重新操作复现 Bug,发现浏览器并没有和我预期想法一致,没有给我输出任何内容。这就很迷了啊!我又重新开始寻找答案,发现点击切歌按钮和播放按钮的差异,主要是前者经过了 that.play() 装载歌曲信息,而后者却没有。目前点击按钮能播放的原因,其实是因为点击播放按钮触发了 Audio 对象的 play 事件,里面有一个判断语句,它调用了 actions.next() 函数,用于切到下一首歌。

elements.player.addEventListener("play", function () {
    if(!elements.player.src){
        actions.next();
    }

难道是因为这个事件的原因?我把它对调到了点击按钮执行的函数,重新操作复现,结果依旧相同。

elements.toggle.onclick = function () {
    if(!elements.player.src){
        actions.next();
    }
    elements.player.paused ? elements.player.play() : elements.player.pause();
};

actions.next() 改成 that.play() 呢?貌似播放的歌变了,但是这个问题依旧复现(原来我一开始就写错了,直接开始播放第一首不就好,为什么要切换到下一首)还是很迷,难不成必须用 Promise?在 that.play() 方法执行后再调用后面的语句?

等等,我重新理一下思路!这个代码的主要用途是在没有设置音乐 src 路径的情况下使用的,既然 that.play() 已经可以让播放器开始播放了,那么把判断逻辑改一下试试?

elements.toggle.onclick = function () {
    if(elements.player.src){
        elements.player.paused ? elements.player.play() : elements.player.pause();
    }
    else{
        that.play();
    }
};

再一次进行复现测试,在这次测试中,播放进度条正常工作了起来!前面的 setInterval() 也正常工作和 log 了!虽然这个 Bug 已经解决,但是 setInterval() 不运行的这个情况是真的难以分析。这段代码修改后,其实就是在没有 src 的情况下不会强制让播放器开始播放。

难道可能和浏览器的处理机制有关联?只要是 setInterval 就不执行吗?我随便加了一个 setInterval 上去,它和设置进度条的函数区别在于是否进行了赋值。

var intv, intv_lyric;

this.sel_int = function () {
    if(!intv){
        setInterval(function () { console.log("test"); }, 1000);
        intv = setInterval(function () {
            elements.bar.played.style.width = (elements.player.currentTime / elements.player.duration) * 100 + "%";
        }, 500);
    }
};

测试了一波,发现该函数正常执行,控制台反复输出了 "test" 给我?我好像忘记了一点,在音乐播放暂停的时候,我们是需要让进度条停止滚动的,而我播放器肯定有地方触发了 clearInterval() 函数使得 interval 停止了。

答案已经渐渐浮现在我眼前了,我在停止进度条的函数里添加了一段 console.log(),并重新复现该 Bug。结果是先运行了一段 setInterval() 之后在它还没有给我们输出信息之前,迅速执行了 clearInterval(),最终呈现了刚才怎么 console.log() 都不给我们反馈的现象。

this.sel_int = function () {
    if(!intv){
        console.log("我执行了");
        intv = setInterval(function () {
            elements.bar.played.style.width = (elements.player.currentTime / elements.player.duration) * 100 + "%";
        }, 500);
    }
};

this.clear_int = function () {
    console.log("被重置了");
    intv = clearInterval(intv);
};

// 控制台输出 "我执行了" "被重置了"

进一步测试后发现,Audio 对象反复执行其 play() 方法,其 play 事件仅能触发一次,包括在没有 src 变为有 src 的情况下

在 Audio 对象没有设置 src 属性的情况下执行它的 play() 方法,其 paused 属性会从 true 变为 false,当然此时是没有音乐可以被播放的。

if(!elements.player.src){
    that.play();
    console.log(elements.player.paused);
}
elements.player.paused ? elements.player.play() : elements.player.pause();

这段代码在页面刚刚加载完后被我点击触发,由于没有 src,那个判断不成立,直接开始下一步操作,由于默认 pausedtrue,强行让播放器执行了 play() 方法。执行后,这个值变为了 false

在强制执行 play() 的瞬间,正常触发了 play 事件,之后调用 setInterval() 控制进度条,开始循环。可播放器的 pause 事件并不和 paused 属性一般见识,它自己主张在我 console.log() 前停止了循环。此处虽然 pause 事件触发了,但是 paused 属性依旧为 false

接着,我们的 that.play() 成功从服务器获取到了信息,准备第二次执行 play() 方法开始播放音乐,可这次却并没有重新触发到 play 事件,它翻脸不认人了。可能因为浏览器内核是通过 paused 属性的变动来触发 play 事件的,由于该值没有变动,于是就没有再次调用起进度条,最终呈现出了我们所见的 Bug 效果。

不过,没有设置 src 的 Audio 对象为什么能正常执行 play() 还不报错呢?如果是一个页面存在的 DOM 元素,我在尝试给它反复播放的时候,浏览器会返回这样的错误:

player.play();

<- Promise {<rejected>: DOMException: The element has no supported sources.}
VM553:1 Uncaught (in promise) DOMException: The element has no supported sources.

而虚拟创建的 Audio 却没有报错。

let player = new Audio();
player.play();

<- Promise {<pending>}

player.addEventListener("play", function () {
    console.log("play");
});
player.addEventListener("pause", function () {
    console.log("pause");
});
player.src = "https://api.paugram.com/netease?id=22826435&play=true";

// 音乐在设置完 src 后自动开始播放,没有触发 play 事件
// 如果再次更改 src,音乐将会停止,而不会触发 pause 事件,必须重新 play() 才行
// 你想触发 pause 事件,只能通过 pause() 方法实现

真是奇怪的 JavaScript!