1. 测试环境
  2. 不支持autoplay/preload
  3. 只有用户手动触发的事件能调用play/load
  4. 时间限制
  5. 再次触发
  6. src延迟加载
  7. 其他限制
  8. 在Vue中使用audio的示例
  9. 参考

移动端浏览器对audio标签的限制总结

往往在桌面写好的播放功能,到移动端各种出错.
这是因为在移动端,对于audio的使用有很多限制,特别是safari.
而且现在的各种框架, 极大加深了程序的复杂程度,使调试变得异常困难.

测试环境

本文测试环境为iPad ios12,没有条件进行iphone/桌面端safari环境下audio的表现

不支持autoplay/preload

大部分移动端浏览器都不允许网页自动播放/预加载音乐.
例如下面这个示例, 桌面浏览器可以自动播放,而移动浏览器没有任何反应.

1
<audio controls src="./music/1.mp3" autoplay></audio>

除非用户点了播放按钮/手动触发了某个事件(这个事件里调用了audio.play()方法)
为啥这么设计?据说是怕网页偷跑流量.

只有用户手动触发的事件能调用play/load

既然autoplay不能用,那能不能用play方法来自动播放呢? 很遗憾的是, 不行. 例如下面这样,在移动端没效果:

1
2
3
4
5
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector('audio')
audioEl.play()
</script>

先来看看水果的文档:

In Safari on iOS (for all devices, including iPad), where the user may be on a cellular network and be charged per data unit, preload and autoplay are disabled. No data is loaded until the user initiates it. This means the JavaScript play() and load() methods are also inactive until the user initiates playback, unless the play() or load() method is triggered by user action. In other words, a user-initiated Play button works, but an onLoad="play()" event does not.

iOS-Specific Considerations

也就是说,只有当用户手动触发某个事件时,audio.play才能被调用.

1
2
3
4
click由用户触发, 有效
<input type="button" value="Play" onclick="document.myMovie.play()">
onload事件不是用户触发的,无效
<body onload="document.myMovie.play()">

因此,可以给外层元素绑定一个click事件.
只要用户点了这个元素,就能播放音乐.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div>
<audio controls src="./music/1.mp3"></audio>
</div>

<script>
const audioEl = document.querySelector('audio')
const played = false
document.querySelector('div').addEventListener('click', ()=> {
if (!played) {
audioEl.play()
played = true
}
})
</script>

对于safari浏览器需要注意,不要把播放事件绑定在document.body元素上.(document.documentElement没有这个问题).
例如下面的代码, 将play()事件绑在了document.body.
这样做android的chrome可以正常播放, 但safari没有任何效果.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<style>
body {
height: 100vh;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector('audio')
const bodyEl = document.body // 注意这里
bodyEl.addEventListener('click', ()=> {
audioEl.play()
})
</script>

奇怪的是,如果把播放事件绑定在document.documentElement元素上的话,倒是可以成功触发play事件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector('audio')
const documentEl = document.documentElement // 注意这里
documentEl.addEventListener('click', ()=> {
audioEl.play()
})
</script>

另外要注意, html/body可能比屏幕小,这点很容易被人遗忘.
2.png
1.png

时间限制

即使是用户触发的事件,也有时间限制.
这个时间限制无论android的chrome还是safari,推测都是1000ms以上. 例如下面在1001ms时调用play方法, 播放失败.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement; // 注意这里
documentEl.addEventListener("click", () => {
setTimeout(()=> { //注意这里
audioEl.play();
}, 1001)
});
</script>

而把setTimeout的延迟时间改成1000,则能正常播放音乐.

1
2
3
setTimeout(()=> { 
audioEl.play();
}, 1000)

再次触发

实际上对于同一段音频而言, 只有在首次播放时, 需要由用户手动触发,而再次触发则没有这个限制. 下面是一个例子, 使用setTimeout在3秒时暂停播放, 在6s时恢复播放.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
audioEl.play(); // 首次播放
setTimeout(() => {
audioEl.pause(); // 3秒后暂停
setTimeout(() => audioEl.play(), 3000); // 6秒后再次恢复播放
}, 3000);
});
</script>

1.gif

注意gif中的播放器,其在第3秒时停止, 又过了一段时间后,自动恢复播放.
在此期间, 并没有手动触发任何事件,再次播放的行为完全是由js控制来完成的.

即便把setTimeout放在外面,仍然可以正常播放,例如下面的这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
audioEl.play(); // 首次播放
});
// 把暂停放在外面,点击事件一点关系都没有
setTimeout(() => {
audioEl.pause(); // 5秒后暂停
setTimeout(() => audioEl.play(), 3000); // 6秒后再次播放
}, 5000);
</script>

那么, 如果在播放途中更改了音频地址(audio.src),还能不能调用audio.play呢?
下面代码在1秒后暂停播放音频, 然后再5秒后更换音频地址,播放新的音频.在safari和chrome(android)下都能正常工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
audioEl.play(); // 首次播放
setTimeout(() => {
audioEl.pause() // 1秒后暂停
setTimeout(() => { // 5秒后更改音频地址, 播放新的音频
audioEl.src="./music/2.mp3"
audioEl.play()
}, 5000)
}, 1000);
});
</script>

这似乎可以得出一个结论, 当网页加载后,只有第一次需要用户来触发audio.play,再这之后就不需要了. 真的是这样吗?如果把audio元素删了,再新建一个audio,会怎么样?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
audioEl.play(); // 首次播放
setTimeout(() => {
audioEl.parentElement.removeChild(audioEl) // 从Dom中删除audio元素
setTimeout(() => {
const newAduioEl = new Audio() // 新建audio
newAduioEl.src = "./music/1.mp3"
newAduioEl.controls = true
document.body.appendChild(newAduioEl)
newAduioEl.play() // 企图再次播放, 但没有成功
}, 3000)
}, 2000);
});
</script>

在上面的代码中,我们在第2s时销毁了audio元素.然后在第5s新建了一个新的audio,然后将新audio添加到Dom, 同时调用aduio.play企图再次播放音频.
遗憾的是, 无论是chrome(android).还是safari,都无法接着播放了.

但是, 如果不销毁audio元素,只是将其从Dom节点中移除, 过一会在添回去的话,会怎样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src="./music/1.mp3"></audio>
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
audioEl.play(); // 首次播放
setTimeout(() => {
let audio = audioEl.parentElement.removeChild(audioEl) // 移除audio
setTimeout(() => {
document.body.appendChild(audio) // 再添回去
audio.play() // 再次播放
}, 3000)
}, 2000);
});
</script>

这次没有销毁/新建audio,只是把audio从Dom中移除储存在变量中, 然后在第5s时又放回了Dom,播放成功.

真的有人会闲的没事删掉audio元素在重新添加一个吗?
一般来讲,我们不会这么干,但有些框架就不一定了.
特别是一些虚拟路由,会在切换的时候自动销毁/重建Dom结构, 如果不小新把audio放到了里面, 就有可能被删掉重建, 引发异常.
但是虚拟路由的切换可能并不会刷新页面, 只是销毁/添加部分Dom结构.
如果audio偶尔不能播放,加上框架加大了调试难度, 有时候很难往这方面想.

src延迟加载

可能出于节省流量或是防抓取的目的,需要在用户点击播放按钮时, 才向服务器获取音频的地址.
如果网络状态很差, 过了2秒才返回数据.要怎么作呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<audio controls src=""></audio> //注意这里,初始src为空.
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
setTimeout(() => { // 用setTimeout模拟一个2秒的延迟
audioEl.src = "./music/1.mp3" //获取了src
audioEl.play(); // 企图再次播放
}, 2000);
audioEl.play(); // 先触发一次播放事件,如果网络不好超过1s是肯定无法播放的.
});
</script>

在上面的代码中, 先调用了一次audioEl.play, 然后在2s后才从服务器获取到audio.src地址.
这么写在chrome(android)下可以运行, 但是safari不行.

兼容safari的办法很简单,只要在初始状态下,把audio.src指向一个无声音频就可以了.
当用户第一次点击屏幕时, 依次调用audio.play和audio.pause.
这样作既解决了第一次点击的问题,同时用户还不会有任何感觉.
示例如下,兼容safari:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<style>
html {
height: 100%;
border: 1px solid;
}
</style>
<!-- 注意这里,先加载了一个无声音频 -->
<audio controls src="./music/1-second-of-silence.mp3"></audio>
<script>
const audioEl = document.querySelector("audio");
const documentEl = document.documentElement;
documentEl.addEventListener("click", () => {
audioEl.play(); // 用户点击屏幕后播放空白音频
audioEl.pause(); // 然后立刻暂停
audioEl.src= "" // 移除src, 防止之后播放空白音频
setTimeout(() => { // 用setTimeout模拟一个2秒的延迟
audioEl.src = "./music/1.mp3"
audioEl.play();
}, 2000);
});
</script>

其他限制

其他限制可能还有
1 不能用volume设置音量,且永远返回1
2 不能用playbackRate调节播放速度

在Vue中使用audio的示例

注意, vue 2.5.x版本的watch有bug.
需升级到vue 2.6.x或降级为2.4.x .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
export default {
mounted() {
// 解决移动端首次播放音频需要由用户手动触发的问题
// 具体方法:
// 在documentElement 绑定一个click事件
// 当用户首次点击屏幕时, 播放空白音频
const documentEl = document.documentElement
const firstPlay = () => {
this.audio = this.$refs.audio
this.audio.src = './1-second-of-silence.mp3'
this.audio.play()
this.audio.pause()
// 清除src,防止误将空白音频当成音乐地址,造成不断的调用next方法切换音乐
this.audio.src = ''
// 移除事件, 因为只需要执行一次
documentEl.removeEventListener('click', firstPlay)
}
documentEl.addEventListener('click', firstPlay)
},
computed: {
music() {
return this.getMusics[this.getMusicIndex]
},
// 我们用vuex跟踪歌曲信息, getMusics返回歌曲列表, getMusicIndex返回歌曲列表指针
...mapGetters(['getMusics', 'getMusicIndex'])
},
watch: {
music(newMusic) {
// 为什么不直接watch歌曲指针(getMuscicIndex)?
// 如果在播放某专辑中第一首歌曲时切换到另一个专辑, index不会变化(都是0)
// 必须同时监控歌曲列表(getMusics)和歌曲指针(getMuscicIndex)这两者的变化
const audioPlay = () => {
if (this.getPlayState) {
// 一定要在真实的Dom: audio.src被修改后,再调用audio.play方法
// 尽管写入了数据,但Vue要等到下一个tick才会更新Dom
this.$nextTick(() => {
this.$refs.audio.play()
})
}
}
// 有歌曲地址时,直接播放
if (newMusic.src) {
audioPlay()
return
}
// 没有歌曲地址时, 获取地址, 然后播放
getSongFile(newMusic.mid).then(res => {
const url = res.url
if (!url) {
// 数据源没有这首歌时, 自动切换到下一首
this.next()
} else {
// vue无法自动跟踪直接赋值的新属性
// music.src = res.url
// 如果需要添加新属性, 请使用$set
this.$set(newMusic, 'src', res.url)
audioPlay()
}
})
}
}

参考

iOS-Specific Considerations
document/audio_summary.md at master · wangjx9110/document · GitHub
Vue.js 升级踩坑小记 · Issue #24 · DDFE/DDFE-blog · GitHub