SSF0SSF0
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
  • HTML

    • html1
    • html2
  • CSS

    • Flex 布局常见问题与解决方案
  • JavaScript

    • 数据类型及引用问题
    • 处理 Blob 类型文件下载问题总结
    • localStorage 与 sessionStorage 区别
    • JavaScript 中的 script 标签问题详解
    • JavaScript 中的 this 指向问题详解
    • SessionStorage踩坑记录
    • 动态加载 JS 脚本方法对比
    • 浏览器页面关闭场景下的数据上报
    • HTTP流式接口详解与封装实践
    • JavaScript 中 this 绑定与解构:为什么 Pinia Store 可以解构,但 Class 实例方法不行?
  • es6

    • Promise
    • es6 模块导出方式全解析
    • reduce
    • forEach 中使用 async/await 的异步陷阱
  • Vue2

    • created VS mounted 发起异步请求
    • vue2-2
  • Vue3

    • Vite + Vue3 修改 element-plus 源码
    • Vue v-if 与 v-show
    • Vue3 ref 获取组件
    • Vue3 路由传参
    • 父子组件与组件里 Hooks 加载顺序
    • 第三方组件传入参数TS提示
    • Vue3 Props 在 Hooks 中的响应性处理
    • Vue Router 的两种历史模式及部署配置详解
    • 在Vue 3项目中顺利集成Tailwind CSS的完整指南
    • Vue Scoped CSS 深度解析
    • Vue 3 Teleport 组件完全指南:从原理到实战
    • Vant List 组件中双向绑定 loading 的注意事项
    • Vue 3 + Vite 中的路径别名与静态资源处理深度解析
    • 获取图片高度
    • 深入理解 Pinia:storeToRefs vs toRefs - 避开 TypeScript 类型推断的坑
  • webSocket

    • WebSocket 鉴权入门(实战版):握手票据 / 首帧鉴权 + 连接级 cid
  • Electron

    • 快速构建 electron + vue3 项目
  • TS

    • TS 泛型
    • 记录模板使用断言的问题
    • type 与 interface
  • WebPack

    • Webpack 介绍
  • Vite

    • Vite CLI 常见命令
    • vite 与 webpack 比较
    • Vite base 配置与 public 静态资源路径问题
  • 客户端数据库

    • IndexedDB 索引陷阱:字段重命名后索引返回旧数据
    • IndexedDB 事务陷阱与最佳实践:TransactionInactiveError 完全解析
  • UniApp

    • uni-app + Monorepo + 微信小程序热更新失效问题排查与结论总结
    • Monorepo + uni-app 开发中监听 / 热更新机制差异与解决方案
    • uni-app 运行与编译原理梳理
    • 为什么 uni-app 在 Monorepo 中必须做「环境区分」,而 Web 项目却不用?
  • 项目工程

    • 前端代码风格
    • Vue3 项目规范
    • npm 镜像问题
    • 包管理工具
    • 使用 engines 限制开发环境依赖
    • 打包与shell交互指定模式
    • Monorepo示例
    • PC 项目兼容 uni-app:基于 pnpm Monorepo 的工程化重构实践
    • Vue3 + Vite + pnpm Monorepo 迁移问题指南
    • 前端工程化:深入理解 npm 依赖管理机制
    • pnpm 修改依赖源码打包报错
    • 代码片段
  • 移动端

    • 浏览器跳转外部应用时请求状态码问题
  • 记录一下小程序
  • 控制浏览器正确保存网站账号密码的技巧
  • 信息泄露
  • 使用Gulp实现前端项目哈希化压缩打包
  • 前端缓存策略与文件哈希化的关联解析
  • 浏览器刷新方式与 Vue 生命周期、缓存行为解析
  • 浏览器消息铃声实现记录

浏览器消息铃声实现记录(刷新无交互 & Notification 兜底方案)

场景:客服系统 / IM 系统
目标:新消息到来时尽量播放网页铃声,刷新未交互时保证也能提醒


一、背景说明

一开始的需求非常简单:

新消息进来 → 播放一段 mp3 铃声

使用的是最常见的方式:

const audio = new Audio("/media/tinkle.mp3");
audio.play();

在正常使用过程中发现:

  • 用户已经操作过页面
  • 浏览器缩小 / 切换 Tab
  • 新消息进来

👉 铃声可以正常播放

所以最初认为这个方案是可行的。


二、问题出现:刷新页面 + 未交互

后来发现一个关键问题:

❌ 问题复现步骤

  1. 刷新页面
  2. 什么都不点
  3. WebSocket 收到新消息
  4. 调用 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 → 网页铃声 ✔ 页面刷新未交互 → 系统通知 + 系统声音 ✔ 完全遵守浏览器策略 ✔ 生产环境可用


八、最终总结一句话

能播网页铃声就播, 刷新没点过就交给系统通知兜底。

这不是妥协,而是正确使用浏览器能力边界。

最后更新时间:
贡献者: 何风顺
上一页
浏览器刷新方式与 Vue 生命周期、缓存行为解析