Vue 3中基于SpeechSynthesis的文字转语音播放实现

发布时间:2026/10/15 3:05:14
Vue 3中基于SpeechSynthesis的文字转语音播放实现 很多时候项目里需要文字转语音这个能力并不是为了做那种花哨的语音助手而是非常朴素的功能需求——比如后台管理系统里来了新订单要自动语音播报一声比如盲人辅助模式下朗读当前页面内容再比如知识付费项目里把文章段落转成音频试听。我最早遇到这个需求是在一个移动端的H5商城项目里运营那边提了个需求抢购活动开始前页面要自动用语音倒数活动开始时喊一句开始抢购。当时第一时间想到的是接第三方语音合成接口但仔细一想为了喊三秒钟的话去申请一个语音服务的Key还要考虑并发、计费、鉴权实在有点小题大做。后来翻了一下文档发现浏览器本身早就内置了语音合成能力而且Vue 3的组合式API封装起来非常顺手。这篇文章就围绕Vue项目实现文字转换成语音播放功能这个主题把从原生API调研、模块封装、播放控制到跨端兼容的完整过程都梳理一遍。适用对象是正在做Web端或H5应用的开发者特别是那些功能需求听起来简单、但实际落地时容易被各种细节绊倒的人。1. 先弄清楚浏览器自带的SpeechSynthesis到底够不够用动手写代码之前得先把技术选型的问题想清楚。现在很多开发者一听到语音合成第一反应就是去接某个云厂商的语音服务。这当然是一条路但未必是最优解。浏览器内置的window.speechSynthesis接口其实已经是一个相当完整的TTS引擎它不需要联网、不需要申请密钥、不需要考虑并发计费而且Vue项目里没有额外的依赖。它的问题在于音色相对单一、机器感偏重以及在不同浏览器里的表现差异比较大。但在系统提示音阅读辅助简短播报这类场景下完全够用。我建议的决策标准是这样的如果文本内容不长、时效性强、不需要特殊音色优先用原生SpeechSynthesis如果是整本书级别的长文本朗读、需要多音色多情感、或者要做音频文件落地分发那才需要考虑商业TTS服务。先别急着上重型方案很多时候项目走到一半你会发现原生方案就满足了90%的需求。decision原生API优先这是纯前端方案里成本最低的一条路。它最大的好处是零依赖Vue项目里不需要引入任何npm包甚至连nextTick都不用多考虑直接调用浏览器能力就行。上限也很清晰音色是一本正经的电子音没有真人感而且在不同系统上声音表现完全不同。那么原生方案在Vue项目里的定位就清楚了——负责那些话说清楚就行的场景。比如表单校验失败时提示请输入正确的手机号后台来订单时播报您有一笔新订单请注意处理这些场景没人关心声音好不好听只关心听不听得清。而如果项目里有专门的音频内容产品或者要做AI语音助手那才需要把商业TTS引入架构。另外一个容易忽略的点是SpeechSynthesis虽然是浏览器原生能力但它内部是否走了系统引擎、是否支持离线合成、支持多少种语言这些都取决于用户的操作系统和浏览器。比如Windows上的Edge用的是系统接的在线语音服务而Chrome在部分移动端上可能只有默认的一条语音。这个差异直接影响了我们要不要做一个语音列表选择器后面会详细展开。2. 在Vue 3组合式API里封装一个useSpeechSynthesis工具确定走原生方案之后接下来就是封装。我强烈建议不要在业务组件里直接散写speechSynthesis.speak()而是抽成一个组合式函数useSpeechSynthesis。原因很简单语音合成涉及不少状态——当前是否在朗读、是否暂停、支持哪些语言、选了哪个音色、语速音量是多少——这些状态如果散落在组件里很容易出现一个页面里多个播报入口互相打断的情况。封装以后状态统一管理任何组件调用时都走同一套逻辑。2.1 核心封装代码我基于Vue 3的reactive和onBeforeUnmount写了一个可复用的版本。不完全是最精简的而是在项目里真正跑过的版本包含语音列表刷新、参数调节、暂停/继续/取消这几个核心操作import { reactive, ref, onBeforeUnmount } from vue /** * 文字转语音播放的组合式函数 * 封装浏览器原生 speechSynthesis 能力 */ export function useSpeechSynthesis() { // 当前朗读状态 const isSpeaking ref(false) const isPaused ref(false) // 播放参数 const config reactive({ text: , volume: 1, rate: 1, pitch: 1, voice: null as SpeechSynthesisVoice | null, }) // 浏览器支持的语音列表 const voices refSpeechSynthesisVoice[]([]) const currentLang ref(zh-CN) // 刷新语音列表 function loadVoices() { voices.value window.speechSynthesis.getVoices() } // 语音列表是异步加载的首次调用可能拿不到需要在事件里再取一次 if (speechSynthesis in window) { loadVoices() window.speechSynthesis.onvoiceschanged () { loadVoices() } } // 根据当前语言选取一个合适的默认声音 function pickVoice(lang?: string) { const targetLang lang || currentLang.value // 优先匹配精确语言找不到再匹配同语言前缀 const matched voices.value.find(v v.lang.replace(_, -) targetLang) || voices.value.find(v v.lang.replace(_, -).startsWith(targetLang.split(-)[0])) config.voice matched || null } // 初始化默认参数 function init() { const defaultVoice voices.value.find(v v.default) || null config.voice defaultVoice if (!config.voice) { pickVoice(zh-CN) } } // 开始朗读 function speak(text?: string) { if (!(speechSynthesis in window)) { console.warn(当前浏览器不支持 speechSynthesis) return } // 如果正在朗读先取消避免叠加 if (isSpeaking.value) { window.speechSynthesis.cancel() } const content text ?? config.text if (!content) return const utterance new SpeechSynthesisUtterance(content) utterance.volume config.volume utterance.rate config.rate utterance.pitch config.pitch utterance.voice config.voice utterance.onstart () { isSpeaking.value true isPaused.value false } utterance.onend () { isSpeaking.value false isPaused.value false } utterance.onerror () { isSpeaking.value false isPaused.value false } window.speechSynthesis.speak(utterance) } // 暂停 function pause() { if (isSpeaking.value !isPaused.value) { window.speechSynthesis.pause() isPaused.value true } } // 继续 function resume() { if (isSpeaking.value isPaused.value) { window.speechSynthesis.resume() isPaused.value false } } // 取消 function cancel() { if (isSpeaking.value) { window.speechSynthesis.cancel() isSpeaking.value false isPaused.value false } } // 切换到指定语言 function setLang(lang: string) { currentLang.value lang pickVoice(lang) } // 设置播放速率 function setRate(rate: number) { config.rate rate } // 设置音量 function setVolume(volume: number) { config.volume volume } // 设置音高 function setPitch(pitch: number) { config.pitch pitch } // 组件卸载时自动取消朗读避免后台还在发声 onBeforeUnmount(() { if (speechSynthesis in window) { window.speechSynthesis.cancel() } }) // 初始化入口 init() return { voices, isSpeaking, isPaused, config, speak, pause, resume, cancel, setLang, setRate, setVolume, setPitch, } }这段代码有几个地方需要特别解释。首先是loadVoices为什么要有两次调用。getVoices()返回的数组在页面初始化时往往是空的因为浏览器从本地或网络加载语音列表是异步的只有触发了onvoiceschanged事件以后列表才真正可用。如果你在初始化阶段就必须拿到语音列表那就要在事件回调里再调用一次getVoices()。其次pickVoice里我特意先精确匹配语言再退而求其次匹配语言前缀这是为了避免想要普通话却匹配到一个台湾口音甚至英文语音的尴尬。2.2 在组件里怎么用封装好了之后在Vue组件里调用就非常简单了。比如做一个朗读当前通知内容的功能只需要这样template div div{{ notice.content }}/div button clickspeak(notice.content)播放/button button v-ifisSpeaking clickpause暂停/button button v-ifisPaused clickresume继续/button button clickcancel停止/button /div /template script setup langts import { ref, onMounted } from vue import { useSpeechSynthesis } from /composables/useSpeechSynthesis const { speak, pause, resume, cancel, isSpeaking, isPaused } useSpeechSynthesis() const notice ref({ content: 车主您好您的车辆已通过年检请及时领取年检标志。 }) /script如果是Vue 2项目组合式函数没办法直接用但思路完全一致——把同样逻辑写在一个公共JS模块里用data里的字段保存状态在beforeDestroy生命周期里调用cancel()。只是Vue 3的组合式API可以把状态和生命周期顺带管理起来代码内聚度更高。3. 播放控制里最容易踩的三个坑语音合成API的调用方式看起来简单无非是创建一个utterance对象然后speak一下但真实业务里播放控制是一个绕不开的复杂话题。下面三个坑都是我实测踩过的不处理的话功能演示时会当场翻车。3.1 连续朗读时utterance对象互相覆盖很多场景下会连续播报多条文本比如订单列表里批量处理了三条订单系统要依次播报处理结果。最容易出的问题就是第二次调用speak()时第一个utterance还在朗读队列里两个任务叠加起来声音变得含糊不清甚至直接没声了。我的处理方式是每次speak()开头先调用一次window.speechSynthesis.cancel()把之前的朗读全部清掉再放新的进去。这就相当于新任务优先旧的立刻终止。如果你的业务需求恰恰相反——需要排队朗读那就别直接cancel而是创建一个任务队列把utterance依次放进去在onend事件里用递归或shift的方式播放下一条。3.2 语速过块导致文本被截断这个坑特别隐蔽。Chrome里如果rate设置成1一段60个字的文本可以完整读出来但如果把rate调到2甚至3某些系统语音引擎可能会出现读到一半就静音的情况。这是因为引擎为了赶时间直接把后半段丢弃了而不是加速读完。解决办法也比较土但很有效把文本切片按标点符号拆分成短句每读一句就暂停一下。我写了一个简单的文本切分逻辑按逗号、句号、感叹号、问号切分每次只投喂一句给speak()下一句在上一个onend之后继续。function splitTextByPunctuation(text: string): string[] { return text .replace(/([。、])/g, $1|) .split(|) .map(s s.trim()) .filter(s s.length 0) }这样即使语速拉到2.5每一句都足够短引擎还来不及丢就已经结束了。顺带说一句切片还有个额外好处你可以像阅读器一样高亮当前正在朗读的这句话这在教学类和内容浏览类项目里是个很受欢迎的加分功能。3.3 语音列表在动态场景下加载不到前面提到getVoices()是异步的但这还不够。在实际项目里用户可能是在视频播放器的子页面里初次打开朗读功能。如果这个页面的组件在初始化时speechSynthesis还没触发onvoiceschanged那么即便你在onMounted里调用init()voices.value也可能是空的。结果就是选了中文语音但实际朗读出来是英文。我的经验是把语音加载的触发时机尽量提前。可以在main.ts入口文件里页面一启动就主动调用一次window.speechSynthesis.getVoices()并监听onvoiceschanged把语音列表缓存到一个全局单例里去。这样等到任意组件真正需要使用语音时列表早就准备好了。// main.ts 或应用启动入口 const synth window.speechSynthesis if (synth) { synth.getVoices() // 主动触发加载 synth.onvoiceschanged () { synth.getVoices() } }然后在组合式函数里直接读取全局缓存即可。类似语音列表只有一个但调它的地方有七八个的情况全局缓存是最合理的解法。另外暂停和继续也有一个使用细节pause()之后如果不调用resume()而是直接调用cancel()再调用speak()会出现一个奇怪的现象——新文本要么不播放、要么播到一半又停了。这其实是浏览器的一个已知状态机问题pause状态没有完全复位。稳妥的做法是在speak()之前无论当前什么状态都先执行cancel()逻辑上强制恢复到一个干净的状态。我在封装里也是这么处理的。4. 跨浏览器和真实环境下的兼容性处理SpeechSynthesis API虽然名义上是Web标准但它的实现质量真的是一个浏览器一个样。我在Windows、macOS、安卓、iOS上都测过一轮这里把重要差异整理一下。4.1 各平台行为差异对照环境语音数量离线性典型问题Windows Chrome较多部分在线默认语音可能是Microsoft在线语音需要网络Windows Edge较多部分在线语音质量高但依赖网络macOS Safari较少支持较好本地语音离线可用Android Chrome少一般中文语音经常只有一个iOS Safari少一般首次必须由用户手势触发这张表的意义在于如果你在开发环境用macOS测试一切正常打包给客户测试时用的是一台Windows电脑很可能出现有声音但音色完全不对网络断开后没声音的情况。所以做语音功能测试矩阵一定要宽。4.2 HTTPS环境是个硬约束还有一个很多人容易忽略的点speechSynthesis在HTTPS环境下工作正常但在HTTP环境下尤其是内网部署的老系统里它有时会被浏览器视为不安全的声音产生源而直接禁用。有一种很典型的情况开发时跑在http://localhost没问题一旦部署到内网IP的HTTP服务上语音就无声无息地失灵了。规避方案有两个层面。一是能上HTTPS就尽量上HTTPS哪怕用自签名证书也比纯HTTP稳妥。二是在代码里做一个可用性检测在入口处判断window.speechSynthesis是否存在不存在时用Toast提示当前浏览器不支持语音播报而不是静默失败。4.3 移动端自动播报的限制移动端的浏览器和WebView对未经用户交互就播放语音有严格限制。比如在H5页面加载完成后立即自动播报一段广告语音在iOS上会被直接拦截在Android的Chrome上也有概率被屏蔽。想要绕过这类限制需要把触发时机放在用户的第一个点击事件之后。我之前在商城项目里做进入页面自动朗读活动规则第一次测试iOS直接没声音。后来调整成页面弹出活动弹窗弹窗里有一个朗读按钮用户必须主动点一次之后哪怕切后台再回来语音都能正常播放。这个改动虽然多了一步用户操作但在合规和体验之间是平衡点。如果你非要进入页面自动播报可以引导用户在页面第一个交互事件里先调用一次speak()预初始化后续真正的播报大概率能绕过这个限制。4.4 WebView内置的坑Electron、或者各类原生App里内嵌的WebView对SpeechSynthesis的支持更不可控。部分WebView是关闭语音合成模块的window.speechSynthesis存在但speak()之后毫无反应。我遇到过一次某个国产安卓机型自带的WebViewgetVoices()能返回数据但speak()就是不响。最后排查发现是系统自带TTS引擎被用户停用了浏览器调用底层TTS时抛了异常。针对这种场景只能做兜底在speak()调用后设置一个兜底定时器比如500ms后如果isSpeaking还是false且没有触发onstart就判定为底层引擎不可用提示用户去系统设置里开启TTS服务。定期器在onstart触发后及时清除避免误判。let fallbackTimer: number | undefined function speak(text?: string) { // ...省略前面的逻辑... const onStartCalled () { if (fallbackTimer) { window.clearTimeout(fallbackTimer) fallbackTimer undefined } } utterance.onstart () { onStartCalled() isSpeaking.value true isPaused.value false } window.speechSynthesis.speak(utterance) fallbackTimer window.setTimeout(() { if (!isSpeaking.value) { console.warn(语音合成未能在预期时间内启动可能底层TTS不可用) } }, 500) }这套兜底逻辑虽然不能真正修复系统问题但至少可以把无声这种模糊状态转变成一个可读的错误提示对线上排查有很大帮助。5. 当原生方案撑不住时换商业TTS的思路虽然我前面反复强调原生方案够用但必须承认有些场景它真的顶不住。比如你希望有真人的声音质感要支持四川话、粤语、儿童音色或者要一次性合成10分钟的音频文件供用户下载那必须换商业TTS。而且这里有个设计师思维与其在原生引擎里拼参数调音色不如一开始就考虑清楚方案的演进路径。5.1 什么时候换我归纳成一个简单的三选一判断音色要求高——机械音用户不接受必须像真人主播内容要在后续被剪辑、合成、分发——比如语音内容要被转成mp3文件存进素材库需要多语种、多方言、多情感——原生引擎根本做不到这个形态。只要命中其中一条就值得引入商业TTS。5.2 替换时怎么保持架构干净我在封装useSpeechSynthesis的时候特意把对浏览器原生的调用都收敛在了函数内部没有散落在业务组件里。这样做有一个额外的好处将来换商业TTS时业务组件几乎不用改只换组合式函数的内部实现。具体做法是把组合式函数内部设计成对ISpeechSynthesizer这个接口的适配器。接口定义三个方法speak(text)、pause()、cancel()。默认实现用的是window.speechSynthesis商业化实现则调用云服务的WebSocket接口。export interface ITextToSpeech { speak(text: string): Promisevoid pause(): void resume(): void cancel(): void }将来换服务时写一个CommercialSpeechService implements ITextToSpeech内部把用户输入转发到云服务拿到音频流后通过HTMLAudioElement播放。useSpeechSynthesis里只需要做一个简单的工厂切换比如根据环境变量的配置实例化不同的服务实现。5.3 商业方案的性能与成本考量切到商业TTS后需要考虑三个新问题网络延迟、文本长度限制、并发QPS。云厂商的合成接口一般都有单次请求的文本长度上限比如限制为500字符超过就要做切片分批请求。并发上限通常在几十QPS如果业务高峰期有上百个用户同时触发语音播放很容易被打到限流。我的建议是加一层本地缓存把文本内容 - 合成结果的映射做成一个Map每次播放前先查一下缓存命中就直接播缓存音频节省一次网络请求。缓存的有效期按业务场景定比如活动通知播报缓存10分钟就行。另外由于此类服务按时长计费缓存层对于控制成本非常有价值。前端解决方案有一个常见误区一开始就堆商业TTS忽略了浏览器原生能力。其实好多业务场景只需要有个声音就行原生方案免费、无延迟、无配额拿来就用。这篇内容里封装的useSpeechSynthesis组合式函数加上语音列表预加载、播放控制、兜底检测这几个关键处理已经能覆盖90%的文字转语音播放需求。最后分享一个小技巧在做一个带朗读功能的内容页时把朗读的高亮样式和文本切片逻辑绑定在一起用户每次听到的句子都能在页面上同步标出来这个小体验的加分效果极其明显——同一段文字只看文字和边看边听用户的停留时长完全是两码事。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询