 Promise到事件驱动UI的核心控制)
做播放器系列写到第三篇前两篇已经把HTML骨架和CSS样式跑通了这篇开始正经用JavaScript写交互。目标很直接用HTML5CSSJavaScript实现一个mp3音乐播放器的核心控制功能——播放、暂停、重听、停止。如果你现在点击播放按钮毫无反应或者听到声音了但按钮状态乱七八糟别急这篇文章就是奔着这些实际问题去的。我会从几个最容易踩的坑说起再给出可以直接抄走的代码最后聊聊怎么把交互代码从一堆全局函数收敛成后续能继续扩展的结构。无论你是刚接触前端的新人还是想快速给现有项目加一个音频控制模块的老手这篇都能帮你省下不少排查时间。1. 为什么点击按钮没反应是新手第一个拦路虎1.1 浏览器自动播放限制与play()的Promise我见过最多的求助帖不是代码写错而是页面一打开就自动播放了0.1秒或者干脆没声音。我当年第一次做播放器demo也栽在这里直接在script区域里写audio.play()结果Chrome控制台报错页面没有任何声音。这不是你的代码问题而是浏览器把音频播放权牢牢握在用户手里。浏览器设计了一套自动播放策略细节上各家略有差异但共同点是未经过用户手势直接调用play()大概率会被拒绝。背后的逻辑很简单就是防止网页在用户完全不知情的情况下突然发出声音。所以当我把play()从页面加载时执行挪到按钮click事件里执行之后问题立刻就消失了——因为浏览器认为点击按钮是用户明确表达我想播放的意图。不过这里还藏着一个新手很容易忽略的细节HTML5 的audio.play()不是立即返回成功结果的它返回一个Promise。如果你不在Promise链上处理失败情况就会在控制台看到Unhandled promise rejection。我第一次看到这个报错时还以为是自己语法写错了其实只是自动播放被拦截后Promise走向了失败分支。所以最保底的写法是const audio document.getElementById(player); const playBtn document.getElementById(play-btn); playBtn.addEventListener(click, function () { audio.play().catch(function (err) { console.warn(播放失败, err); }); });这段代码把play()放在用户点击手势内调用同时用catch吃掉可能的异常。这样就算播放被拦截也不会出现点击没反应但控制台刷红的诡异状态。1.2 你操作的audio对象到底在哪很多教程会直接写let audio new Audio()注意这样创建的audio实例是不存在于页面DOM里的你没法在页面上看到它也没法和CSS样式直接挂钩。对于做一个完整的mp3播放器界面我更推荐在HTML里放一个真实的audio标签然后用JS获取它。这样后面做进度条、音量控制、播放列表时处理的都是同一个对象逻辑非常统一。div classplayer button idplay-btn播放/button span idstatus已停止/span audio idplayer srcmusic/song.mp3 preloadmetadata/audio /div这里有个小细节preload属性我建议设置成metadata。它的意思是页面加载时不会把整首mp3下载下来只提前读取时长、音频格式等元数据。这样既能快速拿到duration总时长用于显示又不会浪费网络资源和流量。等用户真正点击播放时浏览器再按需加载音频数据。1.3 把播放暂停做成一个toggle入口播放器的UI通常只有一个播放/暂停切换按钮所以不需要为播放和暂停分别绑定两个事件只需要绑定一次click然后在处理函数里判断audio.paused。paused是一个只读属性true表示当前处于暂停状态包括还没开始播放false表示正在播放。function togglePlay() { if (audio.paused) { audio.play(); } else { audio.pause(); } } playBtn.addEventListener(click, togglePlay);如果你还想顺手把键盘空格键接上让用户按空格也能播放/暂停这也是很常见的交互设计。注意空格键默认会滚动页面所以要调用e.preventDefault()阻止默认行为。键盘事件同样属于用户手势在事件处理函数里调用play()是安全的document.addEventListener(keydown, function (e) { if (e.code Space document.activeElement ! playBtn) { e.preventDefault(); togglePlay(); } });这段代码里我加了一个判断如果当前焦点已经在播放按钮上空格就交给按钮自己去触发click避免重复处理。这种细节看起来不起眼但在真实使用中特别影响体验。2. 从API入手play、pause、currentTime这三个方法吃透就够用2.1 控制音乐前先认识四个关键属性写播放器本质上是在操作一个媒体状态机。HTML5audio元素上的这几个属性和方法是这篇文章所有功能的基石成员类型作用play()方法开始播放返回Promisepause()方法暂停播放保留当前位置currentTime属性可读写当前播放位置单位是秒paused属性只读是否处于暂停状态ended属性只读是否播放到结尾duration属性只读音频总时长单位是秒currentTime是一个既能读也能写的属性这是实现重听和停止的关键。把currentTime设置为0就相当于把播放头拖回开头duration在loadedmetadata事件触发之前经常是NaN所以页面初始化时不要急着读取它否则会显示成NaN:NaN这种尴尬的时间。2.2 播放暂停重听停止的4个核心函数掌握了上面的API四个功能其实就四行核心逻辑的事。需要注意的一点是HTML5媒体元素并没有提供stop()方法。想实现停止标准做法是pause()加上currentTime 0也就是暂停后把播放位置归零。const audio document.getElementById(player); const playBtn document.getElementById(play-btn); function togglePlay() { if (audio.paused) { audio.play(); return; } audio.pause(); } function replay() { audio.currentTime 0; if (audio.paused) { audio.play(); } } function stop() { audio.pause(); audio.currentTime 0; } playBtn.addEventListener(click, togglePlay); stopBtn.addEventListener(click, stop); replayBtn.addEventListener(click, replay);replay()里我为什么要先判断audio.paused再决定要不要调用play()因为如果当前本来就在播放中设置currentTime 0会自动让音乐回到开头并继续播放不需要再画蛇添足。如果无脑再调用一次play()在部分浏览器里会产生一个短暂重复的播放请求虽然最终能自愈但没必要制造这种心跳。反过来说如果当前处于暂停状态只设置currentTime 0而不调用play()音乐就会安静地蹲在开头不动这显然不是重听想要的。提示stop()和replay()在代码上只差一个是否调用play()但它们是两个完全不同的交互。停止对应的是听完/不想听了回到开头待命重听对应的是这首歌不错再从开头放一遍。别为了省代码把两者合并成一个按钮行为语义不同会让用户困惑。2.3 停止和重听对UI状态的影响完全不同代码层面二者只差一行但UI状态就差得远了。停止之后播放按钮要变回播放图标进度条要归零状态文字要显示已停止重听之后按钮应该继续保持暂停或播放中的样式进度条回到开头后立刻往前跑。如果你只在click回调里手动改playBtn.textContent很快就会发现状态对不上用户点了停止按钮确实变成播放了但进度条可能还停在暂停前的位置或者状态文字写着播放中。这就是下一节要解决的核心问题——播放器的UI状态不能靠click事件手动维护要靠audio元素自身的事件驱动。3. 事件驱动UI播放状态实时同步按钮不能只是点一下变一下3.1 play和playing是两个不同的信号刚开始写播放器时我天真地以为监听play事件就万事大吉。后来在弱网环境下测试发现点击播放后按钮立刻变成了播放中但声音迟迟不出来进度条也纹丝不动。这是因为play事件在play()被调用后马上就会触发它代表的只是播放请求已经发出而playing事件才在媒体真正开始播放、currentTime开始往下走时触发。所以做UI状态同步时我一直建议以playing为准把play当成一个兜底信号。监听playing能让按钮状态和真实的声音输出保持同步不会出现按钮在播但音箱静默的尴尬。audio.addEventListener(playing, function () { // 这里才应该把按钮切成暂停样式 });3.2 停止操作之后按钮状态靠谁恢复这是我踩过的一个很典型的坑。有段时间我习惯在click回调里手动改按钮文案逻辑是点击时如果audio.paused就把按钮改成暂停否则改回播放。表面看没问题但用户点停止时我调用了audio.pause()这会触发pause事件所以当时的pause事件处理器把按钮改回了播放——这倒是蒙对了。真正的问题出在进度条归零上进度条是跟着timeupdate事件走的而pause()之后timeupdate不会频繁触发currentTime回到0的这次变化很可能来不及刷新界面。换句话说停止之后进度条可能还停留在暂停前的位置看起来就像程序坏掉了一样。解决办法是把所有UI同步集中到一个函数里并且在多个关键事件中都调用它const playBtn document.getElementById(play-btn); const statusText document.getElementById(status); const progressBar document.getElementById(progress); function syncUI() { const isPlaying !audio.paused !audio.ended; const isStopped audio.ended || (audio.paused audio.currentTime 0); playBtn.textContent isPlaying ? 暂停 : 播放; statusText.textContent isStopped ? 已停止 : isPlaying ? 播放中 : 已暂停; progressBar.value (audio.currentTime || 0); } [play, playing, pause, ended, timeupdate].forEach(function (eventName) { audio.addEventListener(eventName, syncUI); });这个syncUI函数是幂等的不管被哪个事件触发最终渲染出来的状态都一样。绑定多个事件后它会被执行多次但结果一致这正是我想要的。比如停止时pause事件先触发syncUI会把按钮改回播放同时因为timeupdate也监听了syncUI当currentTime归零时进度条也会跟着归零。两次执行互相配合UI不会出现半途卡死的状态。3.3 顺带把播放时间显示出来既然timeupdate已经监听了顺便把播放时间也更新出来很自然。这里有个小知识timeupdate的触发频率在不同的浏览器里并不一致大致在4到66次每秒之间。所以在这个事件里不要做复杂的计算只做赋值类的更新操作否则会出现卡顿。const timeLabel document.getElementById(time-label); timeUpdateHandler function () { const current formatTime(audio.currentTime); const total isFinite(audio.duration) ? formatTime(audio.duration) : 00:00; timeLabel.textContent current / total; }; function formatTime(seconds) { const min Math.floor(seconds / 60); const sec Math.floor(seconds % 60); return String(min).padStart(2, 0) : String(sec).padStart(2, 0); }看到没时间文本的更新只是对两个值做格式化然后写入DOM不做任何重计算。进度条拖动、点击跳转这些更精细的交互是后面几篇文章的内容但数据基础在这里已经就位了。4. 实战踩坑复盘我在调试里浪费最多时间的四件事4.1 快速连点把状态搞糊以及AbortError这个问题我愿称之为播放器交互第一坑。用户快速连点播放按钮播放器像抽风一样状态在播放和暂停之间来回跳控制台还会出现类似The play() request was interrupted by a call to pause()的报错。原因在于play()返回的Promise是异步的当你连续点击时上一次play()请求可能还没完成新的操作就来了新旧请求互相打断。这并不是浏览器坏了而是AbortError意思是上次的播放请求被新的操作中止了。解决办法是在外部记录一个pendingPlay标志当存在未完成的播放请求时忽略后续的切换请求let pendingPlay null; function togglePlay() { if (pendingPlay) return; if (audio.paused) { pendingPlay audio.play(); pendingPlay.then(function () { pendingPlay null; }).catch(function (err) { pendingPlay null; if (err.name ! AbortError) { throw err; } }); } else { audio.pause(); } }这个锁的核心思想是一个时间点只允许一个播放请求在途。用户再怎么狂点最多只有第一次生效后续的点击要么被忽略要么等当前请求完成后再处理。AbortError本身不是致命的我在catch里放行它只有其他异常才往外抛这样既不会污染控制台也不会把真正的问题藏起来。4.2 加载慢时出现假播放状态弱网环境下mp3文件比较大时我遇到过按钮已经变播放中但音箱一直没声音的假播放状态。原因前面说过play()请求被接受了但媒体数据还没到位声音自然出不来。处理办法有两个。第一个是我们前面讲过的用playing事件作为UI的触发点而不是在click回调里立刻改样式。第二个如果你想更精细控制可以先检查audio.readyState不够时监听canplay事件再开始播function requestPlay() { if (audio.readyState 3) { audio.addEventListener(canplay, startPlay, { once: true }); audio.load(); } else { startPlay(); } } function startPlay() { audio.play().catch(function (err) { console.warn(播放失败, err); }); }readyState为3对应HAVE_FUTURE_DATA意思是已经加载到当前位置之后的数据可以播放了。这套逻辑适合网络不太稳定、想尽量做到点击即有声音的场景。但有个底线千万别碰不要为了等加载而把play()挪进一个异步回调里兜圈。因为浏览器要求播放行为必须来自用户手势如果你在click事件里等了很久才调用play()部分移动浏览器的策略会判定这次播放失去用户意图直接拒绝。实测下来稳妥做法是点击时立刻调用一次play()即使暂时没声音浏览器的播放请求也已经处于激活状态后续数据够了自然会继续。4.3 事件叠加导致的重复UI回调我见过一些切歌逻辑每次点击新歌都会重新执行一遍audio.addEventListener(timeupdate, handler)。问题是addEventListener不会自动去重页面运行时间越长同一个回调被绑定的次数越多。等切了十首歌syncUI可能被触发十几次进度条每秒跳十几下肉眼可见地抽风。解决事件叠加有两个方向。第一把事件绑定放在初始化函数里只执行一次切歌时只改audio.src和调用audio.load()不重新绑定事件。第二如果播放列表是动态生成的优先用事件委托把监听器挂在父容器上利用事件冒泡机制统一处理const listEl document.querySelector(.playlist); listEl.addEventListener(click, function (e) { const item e.target.closest(li[data-src]); if (!item) return; audio.src item.dataset.src; audio.play(); });事件委托的好处非常明显不管列表里的li如何增删监听器始终只存在于父容器上永远不会重复绑定。这也是我处理动态列表的标准姿势。4.4 移动端手势限制比桌面端更严格桌面Chrome对自动播放的限制已经比较严格了移动端更狠。iOS Safari在部分版本里要求play()必须直接写在用户事件的处理栈里中间不能隔一层setTimeout也不能先await一个异步操作再调用play()。所以我的建议是播放器项目里尽量避免点击后延迟500ms再播放这类交互创意。真需要做播放前的倒计时或加载动画也要在用户点击的同一事件栈里先执行audio.load()或者做必要准备把play()留到最后一步直接调用。跨设备的调试经验总结下来就一句话能用同步手势就不要制造异步间隔。5. 可维护性升级从一个函数堆到底到封装PlayController5.1 为什么需要一个控制器而不是一堆全局函数做到这里功能已经全部跑通但代码组织还比较原始几个全局函数几个全局变量后续还要加进度条、音量、上一首下一首、播放列表……如果继续用全局函数堆叠变量会越来越多函数之间互相引用排查依赖关系会非常痛苦。比如stop()和replay()都要操作audio后面加音量滑条时又要引用audio加进度条拖动时再引用一次。写过两个版本之后我果断把交互逻辑收敛成一个小控制器用一个class统一管理状态、方法和事件锁。5.2 一个基础的PlayController结构class PlayController { constructor(audio) { this.audio audio; this.pendingPlay null; } toggle() { if (this.pendingPlay) return; if (this.audio.paused) { this.pendingPlay this.audio.play(); this.pendingPlay.catch(() {}); this.pendingPlay.finally(() { this.pendingPlay null; }); } else { this.audio.pause(); } } stop() { this.audio.pause(); this.audio.currentTime 0; } replay() { this.audio.currentTime 0; if (this.audio.paused) { this.audio.play().catch(() {}); } } seek(seconds) { this.audio.currentTime seconds; } } const player new PlayController(document.getElementById(player));pendingPlay锁被收进类内部toggle()、stop()、replay()成为清晰的方法外部按钮只需调用player.toggle()这种语义化接口。额外加的seek()方法为后续拖动进度条提前占位。有一点需要说明finally方法在部分较老的浏览器里可能存在兼容性风险如果你担心可以把它换成thencatch里都赋值this.pendingPlay null的等价写法。我个人在新项目里会直接用finally但做面向更广泛用户的项目时会确认目标浏览器的支持情况。5.3 为后续功能预留的扩展点这个控制器的价值在于面向扩展开放。下一篇要做进度条点击跳转时只需要调用player.seek(value)做音量控制时加一个setVolume(value)方法做播放列表切歌时加一个loadAndPlay(src)方法。UI部分则通过回调或事件去刷新不用再把已有的播放暂停逻辑重新读一遍。从纯技术角度讲这也是从功能能跑走向代码能养的关键一步。写播放器系列文章时我刻意在第三个版本就把控制器结构引入就是为了后面几篇不越写越乱。等你把播放器做到进度条拖动、音量记忆、歌词同步这些功能时回过头会庆幸当初没把所有逻辑堆在全局作用域里。最后说一个我自己坚持到现在的测试习惯。每写完一版播放器交互我不会只按一遍播放→暂停→播放这种最顺的流程而是刻意去点快速连点、立刻停止、马上重听、切歌后再暂停这些异常路径。这类操作最容易暴露异步时序问题也最容易在真机上表现不同。另外建议每次在电脑上验证完再用手机浏览器跑一遍重点检查iOS的自动播放限制和弱网下的假播放状态。把这几个场景跑顺了一个播放器的基础交互才算真正立住了。