拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Web Speech API实现浏览器语音朗读插件:完整实战记录

用Web Speech API实现浏览器语音朗读插件:完整实战记录

最近有个做产品的朋友来找我,说想给他们的资讯站加一个"朗读"功能,让用户能一边看一边听。我第一反应是——这不就是浏览器语音朗读插件吗?前端做这个真不是什么玄学,浏览器早就内置了 Web Speech API,SpeechSynthesis 这个接口可以直接把文本转成语音。我花了一个周末把这个功能做成了一个小插件,从划词朗读、长文分段到朗读高亮跟随,整体体验已经接近系统级的"朗读模式"。这篇文章就把整个实现过程、踩过的坑和优化思路完整记录下来。如果你正打算在网页里做语音朗读功能,或者想搞清楚浏览器到底是怎么"开口说话"的,这篇应该能帮你省不少时间。

1. 为什么非要自己写一个朗读插件:先把需求拆透

1.1 "听文章"不是伪需求,是真实场景

先别急着说这东西没什么用。我统计了一下自己平时在浏览器里的阅读习惯,每天至少有 40 分钟是在刷长文:产品文档、技术博客、行业资讯。这些内容盯着屏幕看一小时,眼睛疲劳感非常明显。而一旦变成"听",你就可以在地铁上、做饭时、跑步机上继续"读"文章。对视力障碍用户来说,语音朗读更是刚需功能。归纳一下,朗读插件的需求场景大概有四类:

  • 多任务场景:写代码、做家务、通勤时听文章,眼睛腾出来干别的
  • 长内容场景:数千字的文档、小说、行业报告,听比看省力得多
  • 辅助功能场景:弱视、阅读障碍用户获取内容的重要渠道
  • 语言学习场景:打开英文原文让它读出来,顺便练听力、学发音

这些场景共同指向一个结论:用户想要的是"把当前页面里的正文内容流畅地读出来",而不是掏出手机打开一个单独的听书 App。所以这个功能天然应该长在浏览器里,也就是一个前端插件该干的活。

1.2 现成方案不少,但都不够"可控"

市面上已经有不少方案。Chrome 有自带的"朗读模式",Edge 有"大声朗读",还有一堆第三方扩展。我在动手之前特意试用了一遍,发现它们有几个共同问题:

  • 交互割裂:要么只能朗读整个页面,要么只朗读选中文本,两者操作逻辑不统一,用户要学两套交互
  • 不可定制:无法调整高亮样式、语速策略、分段规则,更没法把这个能力集成到自己的产品里
  • 数据不可控:第三方扩展的隐私策略参差不齐,部分扩展会上报浏览数据
  • 扩展能力受限:系统级朗读模式经常忽略页面上的特殊 DOM 结构,比如代码块、公式、表格,读出来一团糟

调研完之后我决定自己实现。技术选型上,核心方案锁定为 Web Speech API,也就是浏览器原生的 speechSynthesis 接口。为什么不用云厂商的 TTS 服务?原因有三:一是免费且不需要申请 API Key,二是完全离线可用,不依赖网络,三是延迟极低,不需要等网络请求返回。它当然也有缺点,比如音质一般、稳定性受浏览器实现影响,但对于前端集成场景来说,性价比是最高的。

2. Web Speech API 核心机制:浏览器到底是怎么"说话"的

2.1 两个主角:SpeechSynthesis 和 SpeechSynthesisUtterance

很多同学第一次接触 Web Speech API 时会被两个对象绕晕。简单说:

  • window.speechSynthesis 是浏览器的"语音总控台",负责调度、暂停、恢复、取消
  • SpeechSynthesisUtterance 是"一句话任务",描述要说什么、用什么声音、多快、多大声

每次调用 speechSynthesis.speak(utterance),相当于把一句话任务丢给总控台,浏览器会把它交给本地的语音引擎(Windows 上是 SAPI,macOS 上是 NSSpeechSynthesizer,Chrome 在部分平台上使用自己的语音服务)去发声。一个最基础的朗读大概长这样:

const utterance = new SpeechSynthesisUtterance('你好,世界'); utterance.lang = 'zh-CN'; utterance.rate = 1; window.speechSynthesis.speak(utterance);

就这么三五行代码,浏览器就能开口说话。但真实场景远没有这么简单,因为浏览器对语音引擎的管理策略相当"有自己的想法",不加处理直接上生产环境,各种诡异问题会接踵而来。

2.2 语音引擎不是"随时待命"的

这里有个关键点:speechSynthesis.getVoices() 方法用于获取当前可用的语音列表,但它在页面刚加载时返回的往往是空数组。语音引擎是异步加载的,只有等 voiceschanged 事件触发后,语音列表才真正可用。这个细节我一开始没注意,结果在页面初始化时直接调用 getVoices(),拿到一个空列表,导致语音选择逻辑全部失效。

正确做法是同时监听 voiceschanged,并且把"语音就绪"当成一个独立状态来管理:

let voices = []; let voicesReady = false; function loadVoices() { voices = window.speechSynthesis.getVoices(); if (voices.length > 0) { voicesReady = true; } } loadVoices(); window.speechSynthesis.onvoiceschanged = loadVoices;

在实际页面里,语音列表可能不止触发一次变化,比如系统装了新的语音包之后再次加载页面,所以这个回调要稳定挂载,不要用 once 模式。

2.3 一个 utterance 的完整生命周期

SpeechSynthesisUtterance 上有几个事件非常有用:start、end、pause、resume、error。我在实现朗读高亮时,主要靠 utterance 的事件和 speechSynthesis 的暂停恢复状态配合。需要注意,utterance 的 onboundary 事件在部分浏览器(尤其 Safari)下不会触发,所以逐字高亮不能完全依赖它,后面我会讲我是怎么绕开的。

另外,语音对象的参数也需要认真挑选。我常用的配置罗列如下:

属性作用我的推荐值
lang朗读语言,如 zh-CN根据文本检测结果动态设置
voice具体语音引擎,来自 getVoices()优先选本地中文语音
rate语速,0.1 到 10默认 1,用户可调 0.8~1.5
pitch音调,0 到 2保持 1,不动反而自然
volume音量,0 到 10.9 左右,避免上限爆音

这些参数不是随便填的。比如 rate 我建议在 1 附近浮动,超过 1.5 中文听起来会非常赶,低于 0.8 又显得拖沓。用户在实际使用中最常调的就是语速,所以我把它单独做成了快捷键和滑动条两套入口,后面会细说。

3. 核心功能实现:从"选中即读"到"高亮跟随"

3.1 划词朗读:选区文本的获取与清洗

朗读插件最自然的交互就是"我选中哪段,它就读哪段"。核心 API 是 window.getSelection(),它返回一个 Selection 对象,里面包含 Range 信息。但我实际使用中发现,直接从 Selection 拿文本有几个问题:

  • 把多个 DOM 节点的文本直接拼接,中间可能丢失空格
  • 会把隐藏元素里的文字也算进来
  • 浏览器对跨段落选区的 toString() 结果并不完全一致

所以我做了一个清洗函数:先取 range 做边界处理,再遍历 range 内的文本节点,跳过不可见节点,最后合并文本并压缩多余空白。

function getSelectedTextClean() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return ''; const range = selection.getRangeAt(0); const container = document.createElement('div'); container.appendChild(range.cloneContents()); // 移除不可见元素的内容 container.querySelectorAll('script, style, [aria-hidden="true"]').forEach(el => el.remove()); container.querySelectorAll('*').forEach(el => { const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden') { el.remove(); } }); return container.innerText.replace(/\s+/g, ' ').trim(); }

这里用 range.cloneContents() 而不是直接 selection.toString(),是为了保留 DOM 结构,方便做清洗和后续的原文定位。清洗后的文本如果为空,我就不启动朗读,避免读出一堆无意义的空白。这个函数是所有朗读入口的"净化器",划词、整篇、右键菜单三个入口都复用它。

3.2 长文本分段:绕开 Chrome 的自动暂停限制

这是整个项目里最值得说的一段。Chrome 的 speechSynthesis 有一个著名的问题:当一段朗读超过约 15 秒时,它会"自动暂停"并且不触发任何事件,导致朗读卡死。这个问题从 Chrome 很早的版本就存在,至今没有彻底修复,网上搜"speechSynthesis 15 seconds"能翻出一堆讨论。

解决方案是"化整为零":把长文本切成小段,每段都创建一个 utterance,依次放进队列朗读。切分规则我采用了"先按段落分、再按句子分、最后按长度兜底"的三级策略:

  1. 按换行符和空行把文本拆成段落
  2. 每个段落按中文句号、感叹号、问号、分号拆成句子
  3. 超过 200 字的句子再按逗号、顿号切碎

这样每段文本朗读时长控制在 3~5 秒,远小于 Chrome 的 15 秒阈值。更重要的是,分段朗读还带来一个额外好处:高亮精度更高了,因为我知道当前正在读的是哪个句子。

function splitText(text, maxLen = 200) { const paragraphs = text.split(/\n+/).filter(p => p.trim().length > 0); const segments = []; for (const paragraph of paragraphs) { const sentences = paragraph.split(/(?<=[。!?;])/); let current = ''; for (const sentence of sentences) { if ((current + sentence).length > maxLen) { if (current.trim()) segments.push(current.trim()); current = sentence; } else { current += sentence; } } if (current.trim()) segments.push(current.trim()); } return segments; }

分段后,每个 utterance 的 onend 事件会把队列指针后移,继续朗读下一段。全部结束后再触发一个"整篇朗读结束"事件,用来恢复高亮状态、收起控制条。注意这里有一个反直觉的细节:千万不要一次性把所有 utterance 都塞给 speechSynthesis,而是要"读一段、投一段",否则在某些系统上会出现跳跃和串台词的问题。

3.3 朗读高亮:让文字真正"活"起来

高亮是本插件体验上最"黑科技"的部分。它要解决的核心问题是:我知道当前在朗读第几个分段,但怎么让用户看到对应的文字?

我的做法是给每个分段对应的 DOM 节点加上高亮 class。第一步,在分段时记录每个 segment 的原始 DOM 位置。这里的关键是 range.cloneContents() 只适合取内容,定位原文需要用 TreeWalker 遍历文本节点。我实现了一个文本节点索引,把整篇文章的文本节点平铺成一个数组,分段切分时记录起止索引,朗读时就能反查节点。

function buildTextIndex(rootNode) { const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT); const nodes = []; let current = walker.nextNode(); while (current) { if (current.nodeValue.trim().length > 0) { nodes.push(current); } current = walker.nextNode(); } return nodes; }

第二步,用 CSS class 控制高亮样式。我用了一个渐变背景加文字着色的方案,视觉上类似于荧光笔划过,比单纯变背景色更柔和:

.tts-reading { background: linear-gradient(180deg, transparent 60%, rgba(255, 213, 79, 0.5) 60%); border-radius: 2px; transition: background 0.2s ease; }

第三步,朗读开始时把当前段落滚动到视野中间。这里要注意,不能粗暴地调用 scrollIntoView,因为用户可能正在阅读别的位置,强制滚动会抢走控制权。我采用了"如果当前段落不在视口内才滚动"的策略:

function ensureVisible(el) { const rect = el.getBoundingClientRect(); const viewportHeight = window.innerHeight; if (rect.top < 0 || rect.bottom > viewportHeight) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }

关于高亮还有一个建议:不要试图做到"逐字高亮"。逐字高亮需要依赖 onboundary 事件,但 Safari 不支持它,而且 Chrome 的 boundary 事件在中文文本上的表现也不稳定。逐句高亮已经足够让用户跟上朗读节奏,而且实现简单得多。真正需要逐字高亮的场景,我建议等主流浏览器支持更完善后再做,性价比会更高。

4. 踩坑实录:文档里查不到的五个大坑

4.1 坑一:语音列表为空导致的声音异常

我第一次在 Firefox 上测试时,发现 getVoices() 返回的语音列表里居然没有中文语音。在英文系统的 Chrome 上也会出现同样的问题。处理方式是在语音加载后,按优先级选择:首先是 lang 以 zh 开头的语音,其次是名称里包含 Chinese 或 Mandarin 的语音,最后才回退到浏览器默认语音。

function pickChineseVoice(voiceList) { const zh = voiceList.find(v => /^zh[-_]CN/i.test(v.lang)) || voiceList.find(v => /^zh/i.test(v.lang)) || voiceList.find(v => /Chinese|Mandarin/i.test(v.name)) || voiceList.find(v => /^en/i.test(v.lang)); return zh || null; }

这个"最后回退到英文语音"的逻辑非常重要——中文文本用英文语音朗读会非常难听,但至少不会完全没声音。宁可读得难听,也不能让用户以为插件坏了。如果语音列表确实为空,我会在界面上给一个明确的提示,而不是静默失败。

4.2 坑二:Chrome 朗读 15 秒后自动暂停

前面已经提到了分段方案,这里我再补充一个细节。即便分好段,如果每段之间间隔过短,Chrome 可能会把多个 utterance 合并处理,仍然触发 15 秒问题。所以我实现的是串行队列而非并行投递:每段朗读的 onend 里才 speak 下一段,而不是一次性把所有 utterance 都塞给 speechSynthesis。这个区别在实测中稳定性差距非常大。我曾经图省事,把 30 个 utterance 一次性投递出去,结果读到第 4 段就卡死了。

串行队列的骨架大概是这样的:

class SpeechQueue { constructor(segments) { this.segments = segments; this.index = 0; } playNext() { if (this.index >= this.segments.length) { this.onComplete(); return; } const utterance = this.buildUtterance(this.segments[this.index]); utterance.onend = () => { this.index++; this.playNext(); }; window.speechSynthesis.speak(utterance); } }

4.3 坑三:切换标签页后声音消失

这是个很诡异的 bug。用户在标签页 A 点击朗读,切到标签页 B 工作,再切回 A,发现声音已经停了,但没有触发任何事件。后来查资料发现,Chrome 对后台标签页的语音合成有节流策略,长时间后台运行会暂停合成任务。我的处理方式是监听 visibilitychange:

document.addEventListener('visibilitychange', () => { if (document.hidden) { // 记住当前状态,切回时恢复 wasReading = isReading; } else { if (wasReading && !window.speechSynthesis.speaking) { resumeReading(); } } });

注意,恢复朗读时要判断当前还有没有未完成的队列,如果队列已经清空,那就把最后一段重读一遍。虽然不够完美,但至少用户体验上不会出现"切回来发现朗读没声音"的困惑。

4.4 坑四:iOS Safari 需要用户手势才能发声

移动端的限制比桌面端严格得多。iOS Safari 要求在用户手势(click/touch)回调里调用 speak(),否则语音会被直接忽略。这本来是 Web 平台的自动播放策略,但在朗读场景里特别容易踩雷——比如用户点击了"朗读"按钮,但代码里因为异步加载语音列表,真正调用 speak() 时已经跳出了手势上下文,导致声音出不来。

解决方案是把"开始朗读"的操作做成同步链路:点击按钮后,立刻创建一个 utterance 并调用 speak(),哪怕语音列表还没完全加载,先用默认语音顶一下,等语音就绪后再切换。这样既满足了手势要求,也保证了语音选择不影响启动。

function startReading(text) { // 同步调用,确保处于用户手势上下文内 const utterance = new SpeechSynthesisUtterance(text.slice(0, 50)); window.speechSynthesis.speak(utterance); // 等语音就绪后再正式启动队列 waitForVoices().then(() => { window.speechSynthesis.cancel(); speechQueue.play(); }); }

4.5 坑五:多语言文本的语音串台

一篇混排中英文的文章,如果只设置 lang='zh-CN',英文部分经常会被中文语音朗读,听起来非常奇怪。我在分段时增加了一个简单的语言检测:统计段落中的字符类型,如果英文字母占比超过 30%,就给这个 utterance 单独设置 lang='en-US' 并选择对应的英文语音。绝大多数桌面浏览器都同时支持中英文语音,足够应对。

function detectLang(text) { const letters = (text.match(/[a-zA-Z]/g) || []).length; const total = text.replace(/\s/g, '').length; return letters / total > 0.3 ? 'en-US' : 'zh-CN'; }

这个规则比较粗糙,但对于朗读场景已经够用。更精准的做法是引入轻量级语言检测库,不过那会额外增加插件体积,我觉得不太值。如果用户反馈某篇文章语言判断不对,再针对具体场景优化也不迟。

5. 进阶优化:把插件从"能用"推向"好用"

5.1 语速自适应和朗读偏好记忆

朗读体验的第一杀手是语速不合适。每个用户习惯的语速差异很大,我做了一个"语速自适应"的方案:在控制面板里提供三档语速(慢速 0.8、正常 1.0、快速 1.3),并用 localStorage 记住用户的选择。下次打开页面直接沿用,不需要重新设置。

const SPEED_KEY = 'tts_speed'; function getSpeed() { return parseFloat(localStorage.getItem(SPEED_KEY) || '1'); } function setSpeed(rate) { localStorage.setItem(SPEED_KEY, String(rate)); // 如果正在朗读,实时调整后续段落的语速 if (isReading) { speechQueue.updateRate(rate); } }

这里我还做了一件事:根据段落长度动态微调语速。长段落稍快(1.1),短段落稍慢(0.9),整体节奏感会更好。需要特别注意,微调幅度必须控制在 ±0.1 以内,否则用户会明显感觉到速度忽快忽慢,反而干扰听感。

5.2 快捷键和浮层控制条

朗读功能一旦启动,用户不可能一直拿着鼠标去点按钮。我加入了一组快捷键,覆盖最常用的操作:

  • Alt+R:开始朗读或停止
  • Alt+P:暂停或继续
  • Alt+]:语速加快一档
  • Alt+[:语速减慢一档

同时,朗读过程中页面右下角会浮现一个半透明控制条,包含暂停/继续、上一段、下一段、停止四个按钮。控制条样式我特意做得比较小、可折叠,避免遮挡正文。上一段和下一段的功能在串行队列里实现起来非常自然,就是 index 的加减,再 cancel 当前 utterance 重新播放指定段。

5.3 把插件打包成浏览器扩展

最后说说分发。目前这个插件我打包成了两种形态使用:

  • 在自研产品里以 npm 组件形式引入,通过 init 方法挂载到任意页面
  • 打包成 Chrome 扩展(Manifest V3),安装后对所有网页生效

扩展形态下有个额外问题:content script 运行在页面上下文,popup 里直接操作 speechSynthesis 会有权限隔离问题。我采用的方式是:popup 通过 chrome.tabs.sendMessage 向 content script 发指令,由 content script 在页面上下文中执行朗读操作。否则声音可能从扩展的独立上下文发出,导致页面上的高亮逻辑拿不到状态,出现"声音在响、高亮不动"的割裂体验。

还有一个必须提醒的坑:Manifest V3 的 background service worker 不支持长时间 speechSynthesis 调用,所以千万不要在 background 里做核心朗读逻辑。核心逻辑必须放在 content script 的页面上下文里,background 只做消息中转。这是很多扩展作者容易掉进去的坑。

6. 实际效果复盘与后续扩展思路

到这里,核心功能就全部完成了。我把这个插件部署到了一个内部资讯站上,跑了大概两周,收集到的反馈是:大多数用户对"划词朗读"和高亮跟随的体验是认可的,吐槽主要集中在两点:一是音质不如真人配音,二是偶尔会有个别句子朗读吞字。

音质问题短期内无解,浏览器本地 TTS 的质量上限就在那里。如果要更好的音质,就得走云端 TTS 服务(比如各类大厂的语音合成 API),但那样会引入网络延迟和成本。我的建议是:如果产品定位是"辅助阅读",原生 Web Speech API 完全够用;如果要拿朗读当产品核心卖点,那就得认真评估云端方案。

吞字问题我后来定位到一个原因:某些系统字体字形不完整,导致 TTS 引擎把个别文字跳过。处理方式是朗读前对文本做一次 Unicode 规范化,把一些兼容字符统一成标准字符(比如全角转半角、统一引号、统一破折号),实测吞字率明显下降。这个细节看起来不起眼,但对听感的影响非常大。

关于后续扩展,目前我在尝试两个方向。一是加入"朗读速度自动跟随内容难度"的规则,代码和文档类内容放慢,新闻类内容放快。二是把朗读片段直接导出为音频文件,用 MediaRecorder 捕获音频流,让用户把文章"下载成有声书"离线听。第二个方向目前只做了原型,还未完全跑通,等有新进展我会再单独写一篇。

最后说点个人体会。做这个插件最大的收获不是学会了某个 API,而是意识到浏览器里很多"看似底层"的能力其实都封装在了原生接口里,Web Speech API 就是典型代表。它的实现思路不算复杂,但要把细节做扎实——分段策略、状态管理、跨浏览器兼容、用户交互——每一环都直接影响最终体验。现在的代码量大概在 600 行左右,核心逻辑集中在一个模块里,后续维护起来也不费劲。希望这篇实战记录能帮你少走一些弯路。如果你在实现过程中有更好的想法,比如怎么处理代码块的朗读停顿、怎么做多声部对话效果,欢迎一起交流。

返回列表