首页/文章/八股文

定时器按顺序播放多个音频,切后台音频会乱

2026-08-27
20287 分钟
...

setTimeout 本身不会补触发,但它会被系统级冻结——iOS Safari 后台完全停止计时,切回前台后只补执行一次(不是不补,而是"缺失的中间状态补不上")。间隔短的不会出大问题,间隔长的会出现"音频流被压缩"——切回来后本该播 5 分钟的间隙,实际只过了 3 分钟,结果整段对不上。

核心原则:定时器只能用来"提醒一次",真正决定"该不该播"的是绝对时间戳

setTimeout 在后台会发生什么(按平台)

平台后台行为切回前台行为
iOS Safari完全停止计时补一次(按剩余时长算)
Android Chrome大幅降频到 1Hz补执行累计的回调
桌面 Chrome继续计时正常
微信内置类似 iOS补一次

iOS 后台冻结的典型场景

你的逻辑
 A ( 2) → setTimeout 30 BsetTimeout 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 后台行为对比

行为setIntervalsetTimeout
后台补触发⚠️ 一次性补 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 大前端视频播放器:播放器选型参考

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
八股文2026-08-27
线上项目白屏的原因
白屏的本质是 渲染管线某一环断了 ——可能是资源、JS、接口、路由、样式、兼容性任一环节出问题。排查按"控制台 → 网络 → DOM → 环境"四步定位。 本质(前端类比) 把网页想成一栋楼: 白屏 ≠ 一定是同一种原因——这是面试想听的层次。 六大类原因(按出现频率) | 类别 | 典型表现 | 真实案例 | | : | : | : | | ① 资源加载失败 | DOM 是空的 | 入口 JS 404、CDN 挂了、CSS 阻塞 |...
面试
八股文2026-08-27
背景图就是 1MB 大图,怎么优化
1MB 大背景图优化分 三步 :压缩体积(10 30x)、按需加载(按设备/视口/网速)、渲染期优化(GPU 合成)。背景图跟 不同——它是 CSS,不走浏览器的原生懒加载机制,得手动处理。 背景图 vs 的关键区别(先讲清楚这个) 这是面试官想听的"针对性认知"——背景图不是普通图片,不能套通用方案。 三步优化(按优先级) 第 1 步:压缩体积(最重要,立竿见影) 1MB 的来源一般是这几种 ,对应解决方案: | 原始问题 | 体积来...
面试
八股文2026-08-27
项目里很多图片和视频,怎么优化
图片视频优化分 四层 :网络层(CDN/格式)、加载层(懒加载/预加载)、渲染层(解码/缓存)、业务层(按需/降级)。面试要把这四层都讲到位才算有体系。 四层优化模型 第 1 层:网络层(省钱、省时间) 核心目标:让资源体积小、让用户拿到资源快 | 手段 | 作用 | 关键点 | | : | : | : | | 图片格式 | WebP/AVIF 比 JPEG 小 25 50% | 兼容 fallback | | 视频格式 | H.265...
面试
八股文2026-08-20
Embedding 向量模型:从语义表示到相似度计算
前言 大模型「读懂」文字靠的是 token,但 token 之间只有离散的编号关系,模型并不知道「苹果」和「李子」在语义上很近。要让程序能「理解」两段文字的相似程度,必须先把文本映射成一个 高维向量 ,再用几何方法比较。这一步就是 Embedding。 本篇基于我本地 今天的真实代码,从语义表示讲到余弦相似度,并复盘几个踩过的真实坑。 一、为什么需要 Embedding 传统关键词检索是「字面匹配」: Embedding 做的是「语义匹...
面试
八股文2026-08-19
前端面试100题
前端面试 100 题 适用方向:中高级前端 / React / Vue / Next.js / Nuxt / TypeScript / 工程化 / 实时通信 / Electron / Node.js / AI 应用前端 使用方式:优先掌握“标准回答”,再练“面试官追问”,最后把“结合你的简历怎么答”组织成自己的项目故事。 说明 “结合你的简历怎么答”只使用你简历中已经出现的项目与技术事实。 “标准回答 / 追问”属于通用前端知识总结,用...
面试
八股文2026-08-19
深入理解 JavaScript 原型与原型链:从关系图到核心逻辑 - heshanwan - 博客园
在 JavaScript 世界里, 原型(Prototype)和原型链(Prototype Chain) 是理解对象继承、属性查找机制的基石。很多开发者初学时对它们 “又爱又恨”,这篇文章将结合经典关系图,用通俗易懂的方式拆解原型与原型链的核心逻辑,帮你彻底掌握这套机制! &nbsp; 一、先搞懂几个核心概念 在分析关系图前,先明确 JavaScript 中与原型相关的关键概念,避免后续混淆: 1\. 函数对象与普通对象 函数对象 :由...
面试

评论

请登录后发表评论

去登录
加载评论中...

目录