定时器按顺序播放多个音频,切后台音频会乱
setTimeout 本身不会补触发,但它会被系统级冻结——iOS Safari 后台完全停止计时,切回前台后只补执行一次(不是不补,而是"缺失的中间状态补不上")。间隔短的不会出大问题,间隔长的会出现"音频流被压缩"——切回来后本该播 5 分钟的间隙,实际只过了 3 分钟,结果整段对不上。
核心原则:定时器只能用来"提醒一次",真正决定"该不该播"的是绝对时间戳。
setTimeout 在后台会发生什么(按平台)
| 平台 | 后台行为 | 切回前台行为 |
|---|---|---|
| iOS Safari | 完全停止计时 | 补一次(按剩余时长算) |
| Android Chrome | 大幅降频到 1Hz | 补执行累计的回调 |
| 桌面 Chrome | 继续计时 | 正常 |
| 微信内置 | 类似 iOS | 补一次 |
iOS 后台冻结的典型场景:
你的逻辑:
段 A (播 2 秒) → setTimeout 30 秒 → 段 B → setTimeout 60 秒 → 段 C → ...
↑
用户切后台 25 秒
↓
切回前台
↓
iOS 行为: setTimeout(60 秒) 冻结 25 秒,切回后剩 35 秒
你期望: 60 秒后播段 C
实际: 35 秒后就播了 → 段间对不上
现状诊断(你的代码场景)
// 你现在大概是这样的
const playlist = [
{ url: 'a.mp3', duration: 2000 },
{ url: 'b.mp3', duration: 3000 },
{ url: 'c.mp3', duration: 1500, gap: 5000 }, // 播完后等 5 秒
]
let idx = 0
function playLoop() {
const item = playlist[idx]
audio.src = item.url
audio.play()
// 段间间隔
setTimeout(() => {
idx = (idx + 1) % playlist.length
playLoop()
}, item.gap || 0)
}
playLoop()
问题诊断:
- ✅
setTimeout一次性延迟,不会补触发 - ❌ 整个 setTimeout 在 iOS 后台被冻结,不是补触发而是"完全停摆"
- ❌ 切回前台后 setTimeout 立即补一次(剩余时长),但期间错过的间隔全丢了
- ❌ 如果音频本身也停了,音频 + 定时器两条线全断,完全错位
setTimeout vs setInterval 后台行为对比
| 行为 | setInterval | setTimeout |
|---|---|---|
| 后台补触发 | ⚠️ 一次性补 N 次(多个音频同时响) | ✅ 只补一次(剩余时长) |
| 后台冻结 | 是 | 是 |
| 切回前台错位 | 严重(音频重叠) | 中等(间隔被压缩) |
| 适合做"定时播音频" | ❌ 完全不能用 | ⚠️ 可用但需要校正 |
关键点:用 setTimeout 一次性延迟,不用 setInterval——后者会被冻结后补触发,导致多个音频同时播放。
修复方案(按改动量排序)
方案 1:用 Date.now() 做主时钟(最小改动,推荐)
核心思路:定时器只负责"提醒一次",实际判断"该不该播"靠时间戳。
class AudioScheduler {
constructor(playlist) {
this.playlist = playlist
this.idx = 0
this.audio = new Audio()
this.nextPlayAt = null
this.sessionStart = null
this.audio.addEventListener('ended', () => this.onAudioEnded())
document.addEventListener('visibilitychange', () => this.onVisibilityChange())
}
start() {
this.sessionStart = Date.now()
this.play()
}
play() {
const item = this.playlist[this.idx]
this.audio.src = item.url
this.audio.play()
}
onAudioEnded() {
const item = this.playlist[this.idx]
const gap = item.gap || 0
// 记下目标时间
this.nextPlayAt = Date.now() + gap
setTimeout(() => this.advance(), gap)
}
advance() {
this.idx = (this.idx + 1) % this.playlist.length
this.play()
}
// 切回前台时校正
onVisibilityChange() {
if (document.visibilityState === 'visible' && this.nextPlayAt) {
const now = Date.now()
const drift = now - this.nextPlayAt
if (drift > 100) {
// 后台冻结了 drift 毫秒,期间可能落了多段
// 用累计时间反推当前该播第几段
const elapsed = now - this.sessionStart
let acc = 0
let newIdx = 0
for (let i = 0; i < this.playlist.length; i++) {
const item = this.playlist[i]
const itemEnd = acc + (item.duration || 0) + (item.gap || 0)
if (itemEnd > elapsed) {
newIdx = i
break
}
acc = itemEnd
newIdx = (i + 1) % this.playlist.length
}
if (newIdx !== this.idx) {
this.idx = newIdx
this.play()
}
}
}
}
}
方案 2:用 Web Audio API 调度(最稳但重)
Web Audio API 在 iOS 后台:
- audioCtx 会被 suspended
- 切回前台调用
audioCtx.resume()就能恢复 - 比
<audio>标签更稳,因为 audio thread 独立
const audioCtx = new AudioContext()
const gainNode = audioCtx.createGain()
gainNode.connect(audioCtx.destination)
async function loadBuffer(url) {
const res = await fetch(url)
const buf = await res.arrayBuffer()
return audioCtx.decodeAudioData(buf)
}
async function playBuffer(audioBuffer) {
if (audioCtx.state === 'suspended') {
await audioCtx.resume()
}
const source = audioCtx.createBufferSource()
source.buffer = audioBuffer
source.connect(gainNode)
source.start()
return new Promise(resolve => {
source.onended = resolve
})
}
// 主循环
async function playLoop() {
while (true) {
const buffer = await loadBuffer(playlist[idx].url)
await playBuffer(buffer)
const gap = playlist[idx].gap || 0
if (gap > 0) {
await new Promise(r => setTimeout(r, gap))
}
idx = (idx + 1) % playlist.length
}
}
playLoop()
方案 3:彻底事件驱动 + 时间戳(生产级)
完全用绝对时间戳,不依赖 setTimeout 准确性:
class PureEventScheduler {
constructor(playlist) {
this.playlist = playlist
this.idx = 0
this.audio = new Audio()
this.schedule = []
this.sessionStart = null
this.audio.addEventListener('ended', () => this.onEnded())
document.addEventListener('visibilitychange', () => this.check())
}
start() {
// 预计算每个段的"绝对开始时间戳"
this.sessionStart = Date.now()
let acc = 0
this.schedule = this.playlist.map((item, i) => {
const startAt = acc
acc += (item.duration || 0) + (item.gap || 0)
return { startAt, item }
})
this.play()
// 用一个不太频繁的 setInterval 检查(后台会被冻结,无所谓)
setInterval(() => this.check(), 1000)
}
check() {
if (!this.sessionStart) return
const elapsed = Date.now() - this.sessionStart
const current = this.schedule[this.idx]
if (current && elapsed >= current.startAt + (current.item.duration || 0)) {
this.onEnded()
}
}
onEnded() {
this.idx = (this.idx + 1) % this.playlist.length
this.play()
}
play() {
this.audio.src = this.playlist[this.idx].url
this.audio.play()
}
}
最小可工作版本(你的代码基础上的改动)
// 你的现有代码基础上加两件事:
// 1. 记下期望时间戳
let expectedTime = null
const audio = new Audio()
function playNext() {
// ... 你现有的逻辑
}
// 你的现有 setTimeout 改成:
audio.addEventListener('ended', () => {
const item = playlist[idx]
const gap = item.gap || 0
expectedTime = Date.now() + gap // ← 记下期望时间
setTimeout(playNext, gap)
})
// 2. 加 visibilitychange 监听
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && expectedTime) {
const drift = Date.now() - expectedTime
if (drift > 500) {
// 后台被冻结了,主动检查是否该播
syncToCorrectPosition() // 用累计时间算出当前该播第几段
}
expectedTime = null
}
})
只加这段,其他代码不动,就能解决"切后台后音频错位"的问题。
高频面试追问
Q1:为什么不用 setInterval?
setInterval 后台补触发,一次性执行 N 次,会导致多个音频同时响。setTimeout 是一次性任务,不会补触发,只会被冻结(这是可修复的)。
Q2:iOS 后台 setTimeout 真的完全停了吗?
大部分情况下停,但有上限保护——iOS Safari 冻结一段时间后会强制刷新页面(节省资源)。如果切后台超过几分钟,回来页面可能已经被 unload。
Q3:怎么测试后台行为?
// 在控制台手动模拟
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden' // 假装隐藏
})
document.dispatchEvent(new Event('visibilitychange'))
Q4:用 Page Visibility API + 校正就够了吗?
对
setTimeout场景够用。如果是setInterval多次循环,还得用方案 3 的"绝对时间戳"模型。
Q5:Web Audio API 在 iOS 后台真的更稳吗?
是的。
<audio>标签在 iOS 后台会被强制 pause,Web Audio API 的 audio thread 不在主线程上,冻结时存活率更高。但音频上下文本身仍会被 suspended,切回前台要 resume。
Q6:requestAnimationFrame 能做定时播放吗?
不能。rAF 在后台直接停止(60fps → 0fps),比 setInterval 还狠。音频场景绝对不能用 rAF。
一句话答题模板
定时器播放多个音频,切后台会乱是因为定时器在 iOS Safari 后台完全冻结(不是补触发——setTimeout 不会补触发,但 setInterval 会)。修复方案分三层:第一层事件驱动——用 audio 的
ended事件触发下一段,不依赖定时器精度;第二层时间戳校正——用Date.now()记下"期望下次播放的时间",切回前台时计算 drift(实际 - 期望),如果差太多就主动跳到正确位置;第三层 Web Audio API——音频在 audio thread 运行,iOS 后台存活率比<audio>标签高,切回前台调audioCtx.resume()。核心原则:定时器只能用来"提醒一次",真正决定"该不该播"的是绝对时间戳。
延伸阅读
浏览器失去焦点时 setTimeout 暂停问题及解决方案:setTimeout 后台暂停的通用机制事件循环与宏任务 / 微任务:定时器底层执行原理纯JS实现多个音频的拼接或者合并:音频拼接播放使用 Web Audio API 实现实时音频可视化:Web Audio API 入门全面横评 6 大前端视频播放器:播放器选型参考
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录