浏览器消息铃声实现记录(刷新无交互 & Notification 兜底方案)
场景:客服系统 / IM 系统
目标:新消息到来时尽量播放网页铃声,刷新未交互时保证也能提醒
一、背景说明
一开始的需求非常简单:
新消息进来 → 播放一段 mp3 铃声
使用的是最常见的方式:
const audio = new Audio("/media/tinkle.mp3");
audio.play();在正常使用过程中发现:
- 用户已经操作过页面
- 浏览器缩小 / 切换 Tab
- 新消息进来
👉 铃声可以正常播放
所以最初认为这个方案是可行的。
二、问题出现:刷新页面 + 未交互
后来发现一个关键问题:
❌ 问题复现步骤
- 刷新页面
- 什么都不点
- WebSocket 收到新消息
- 调用
audio.play()
结果:
铃声不播放,play() 被浏览器拒绝
三、原因确认(结论版)
问题并不是代码错误,而是浏览器规则:
❗ 页面刷新后,如果用户没有任何交互,浏览器禁止播放有声音的音频
但只要满足以下任一条件:
- 点击过页面
- 输入过内容
- 登录、点过会话
之后:
- 浏览器缩小
- 切换标签页
👉 网页铃声都是允许播放的
所以结论是:
Audio 本身没问题,只是“刷新无交互”是一个特殊受限状态
四、最终解决方案(不强求 Audio)
既然:
- Audio 在“刷新未交互”场景一定失败
- 但系统通知(Notification)不受此限制
那么最终采用:
👉 Audio + Notification 双通道方案
| 场景 | 提醒方式 |
|---|---|
| 页面已交互 | 网页铃声(Audio) |
| 页面刷新未交互 | 系统通知(Notification) |
五、完整实现代码(生产可用)
下面是一个 完整的消息提醒服务,包含:
- 单例模式
- Audio 初始化
- Audio 解锁
- Notification 兜底
- 统一的消息提醒入口
MessageNotifyService.ts
/**
* 消息提醒服务
* 1. 页面已交互 → 使用网页铃声
* 2. 页面刷新未交互 → 使用系统通知兜底
*/
export class MessageNotifyService {
// ================= 单例 =================
private static instance: MessageNotifyService | null = null;
/**
* 获取单例实例
*/
public static getInstance(): MessageNotifyService {
if (!MessageNotifyService.instance) {
MessageNotifyService.instance = new MessageNotifyService();
}
return MessageNotifyService.instance;
}
// ================= Audio =================
/** 音频对象 */
private audio: HTMLAudioElement | null = null;
/** 是否正在播放,防止重复触发 */
private isPlaying = false;
/** 音频是否已被浏览器解锁 */
private audioUnlocked = false;
// ================= Notification =================
/** 是否已获得通知权限 */
private notifyEnabled = false;
/**
* 构造函数私有化(单例)
*/
private constructor() {
this.initAudio();
this.initNotification();
this.bindAudioUnlock();
}
// ================= 初始化 =================
/**
* 初始化音频对象(仅创建,不播放)
*/
private initAudio(): void {
this.audio = new Audio("/media/tinkle.mp3");
this.audio.volume = 0.5;
// 播放结束后恢复状态
this.audio.addEventListener("ended", () => {
this.isPlaying = false;
});
// 加载或播放错误兜底
this.audio.addEventListener("error", (e) => {
console.error("铃声加载失败:", e);
this.isPlaying = false;
});
}
/**
* 初始化通知权限状态(不会主动弹窗)
*/
private initNotification(): void {
if (!("Notification" in window)) return;
this.notifyEnabled = Notification.permission === "granted";
}
/**
* 监听一次用户交互,用于解锁 Audio
*/
private bindAudioUnlock(): void {
const unlock = () => this.unlockAudio();
// 任意一次点击或键盘操作即可解锁
window.addEventListener("click", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
}
/**
* 解锁音频播放权限
* ⚠️ 必须在用户交互事件中调用
*/
private unlockAudio(): void {
if (!this.audio || this.audioUnlocked) return;
// 静音播放一次,用于解锁
this.audio.muted = true;
this.audio
.play()
.then(() => {
this.audio?.pause();
this.audio!.currentTime = 0;
this.audio!.muted = false;
this.audioUnlocked = true;
console.log("音频已解锁");
})
.catch(() => {
console.warn("音频解锁失败");
});
}
// ================= 对外接口 =================
/**
* 新消息统一提醒入口
*/
public notifyNewMessage(content: string): void {
if (this.audioUnlocked) {
this.playNotificationSound();
} else {
this.showSystemNotification(content);
}
}
/**
* 页面初始化 / 登录成功后调用一次
* 用于申请系统通知权限
*/
public async requestNotificationPermission(): Promise<void> {
if (!("Notification" in window)) return;
if (Notification.permission === "default") {
const result = await Notification.requestPermission();
this.notifyEnabled = result === "granted";
}
}
// ================= 内部实现 =================
/**
* 播放网页铃声
*/
private playNotificationSound(): void {
if (!this.audio || this.isPlaying) return;
this.isPlaying = true;
this.audio.currentTime = 0;
this.audio.play().catch(() => {
this.isPlaying = false;
});
}
/**
* 显示系统通知(刷新未交互时兜底)
*/
private showSystemNotification(content: string): void {
if (!this.notifyEnabled) return;
new Notification("新消息提醒", {
body: content,
silent: false, // 使用系统提示音
});
}
}六、使用方式示例
1️⃣ 页面初始化 / 登录成功后
MessageNotifyService.getInstance().requestNotificationPermission();只需要调用一次
2️⃣ WebSocket / SSE 收到消息
MessageNotifyService.getInstance().notifyNewMessage("客户发来新消息");七、最终效果总结
✔ 页面正常使用 → 网页铃声 ✔ 浏览器缩小 / 切 Tab → 网页铃声 ✔ 页面刷新未交互 → 系统通知 + 系统声音 ✔ 完全遵守浏览器策略 ✔ 生产环境可用
八、最终总结一句话
能播网页铃声就播, 刷新没点过就交给系统通知兜底。
这不是妥协,而是正确使用浏览器能力边界。