把网页文字"读出来"并不难。浏览器提供了 Web Speech API,创建一个 SpeechSynthesisUtterance,再调用 speechSynthesis.speak() 就能播放语音。
真正麻烦的是另一件事:怎样让页面中的文字随着语音播放同步高亮?
在开发 CastReader 的过程中,我希望实现一种接近有声书的体验:语音读到哪里,页面就高亮到哪里;用户可以暂停、继续、调整语速,同时页面自动跟随当前内容。
这篇文章将用一个简化版 Chrome 扩展说明完整思路,包括:
- 从复杂网页中提取可朗读文本
- 建立文本与 DOM 节点之间的映射
- 监听语音播放位置
- 使用
Range实现非破坏性高亮 - 自动滚动到当前段落
- 处理暂停、切换页面和动态内容
一、扩展的基本架构
在 Manifest V3 中,可以把扩展拆成三部分:
- Popup:提供播放、暂停和停止按钮
- Content Script:读取网页文本并负责高亮
- Service Worker:处理扩展事件和跨页面状态
由于高亮必须直接操作页面 DOM,核心逻辑应放在 Content Script 中。Chrome 的 Content Script 默认运行在独立环境中,可以访问并修改网页 DOM,但不会与页面原有 JavaScript 变量互相污染。
一个最小化的 manifest.json 如下:
css
{
"manifest_version": 3,
"name": "Web Voice Highlighter",
"version": "1.0.0",
"permissions": ["activeTab", "scripting"],
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "service-worker.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"css": ["highlight.css"],
"run_at": "document_idle"
}
]
}
Chrome 官方文档说明,Content Script 可以读取和修改匹配页面中的 DOM,并且默认运行于隔离环境。Chrome Content Scripts 文档
二、不要直接读取 document.body.innerText
最简单的文本提取方式是:
ini
const text = document.body.innerText;
但真实网页里包含导航栏、按钮、页脚、隐藏内容和广告。直接朗读整个 body,用户很快就会听到:
首页,登录,注册,分享,上一页,下一页......
更合理的做法是遍历文本节点,只保留可见且有意义的内容。
ini
function collectReadableText(root = document.body) {
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const text = node.nodeValue.trim();
if (!text) {
return NodeFilter.FILTER_REJECT;
}
const parent = node.parentElement;
if (!parent) {
return NodeFilter.FILTER_REJECT;
}
const ignoredTags = [
"SCRIPT",
"STYLE",
"NOSCRIPT",
"BUTTON",
"NAV"
];
if (ignoredTags.includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
const style = getComputedStyle(parent);
if (
style.display === "none" ||
style.visibility === "hidden"
) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const nodes = [];
let current;
while ((current = walker.nextNode())) {
nodes.push(current);
}
return nodes;
}
对于文章、电子书或文档页面,还可以先寻找更准确的内容容器:
dart
const root =
document.querySelector("article") ||
document.querySelector("main") ||
document.body;
生产环境中还需要根据网站结构排除目录、脚注和工具栏。对于 SPA 页面,则要使用 MutationObserver 监听页面更新,重新构建文本索引。
三、建立"语音字符"与"DOM 位置"的映射
同步高亮最关键的一步,不是语音播放,而是建立映射。
假设网页包含三个文本节点:
arduino
节点 A:"Chrome 扩展"
节点 B:"可以读取网页内容,"
节点 C:"并将文字转换为语音。"
语音引擎接收的是一个连续字符串:
Chrome 扩展可以读取网页内容,并将文字转换为语音。
当语音事件告诉我们"当前读到第 12 个字符"时,必须反推出这个字符属于哪个文本节点,以及它在节点内部的偏移量。
可以在拼接文本时记录每个节点的起止位置:
ini
function buildTextIndex(nodes) {
let fullText = "";
const segments = [];
for (const node of nodes) {
const text = node.nodeValue;
const start = fullText.length;
fullText += text;
const end = fullText.length;
segments.push({
node,
start,
end
});
}
return {
fullText,
segments
};
}
得到的数据类似:
yaml
[
{ node: nodeA, start: 0, end: 9 },
{ node: nodeB, start: 9, end: 19 },
{ node: nodeC, start: 19, end: 30 }
]
之后便可以根据全局字符位置寻找对应节点:
lua
function findSegment(segments, charIndex) {
return segments.find(
segment =>
charIndex >= segment.start &&
charIndex < segment.end
);
}
页面很长时,不建议每次都调用 find() 从头遍历。可以保存当前段落索引,或者用二分查找将查询复杂度降到 O(log n)。
四、通过 boundary 事件获得朗读位置
Web Speech API 的 SpeechSynthesisUtterance 会在朗读到单词或句子边界时触发 boundary 事件。
事件中的 charIndex 表示当前朗读内容在 utterance.text 中的字符位置。
ini
function speak(text, onBoundary) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 1;
utterance.pitch = 1;
utterance.addEventListener("boundary", event => {
onBoundary(event.charIndex);
});
speechSynthesis.speak(utterance);
return utterance;
}
调用时,把字符位置映射回 DOM:
ini
const nodes = collectReadableText();
const { fullText, segments } = buildTextIndex(nodes);
speak(fullText, charIndex => {
const segment = findSegment(segments, charIndex);
if (!segment) return;
const localOffset = charIndex - segment.start;
highlightWord(segment.node, localOffset);
});
需要注意,boundary 事件存在浏览器兼容性差异,不应假设所有语音或平台都会以完全相同的频率触发。MDN 也将该事件标记为兼容性有限。MDN:SpeechSynthesisUtterance boundary 事件
如果使用云端 TTS,通常不能依赖 Web Speech API 的 boundary,而要让服务端返回单词或句子的时间戳,再用音频播放器的 currentTime 驱动高亮。
五、使用 Range 高亮当前单词
一个常见方案是修改节点的 innerHTML,把当前单词包进 <span>:
arduino
<span class="reading-highlight">word</span>
但这种方式容易破坏网页原来的事件监听、React/Vue 虚拟 DOM 和文字选择状态。
更安全的方案是使用浏览器的 Range 和 CSS Custom Highlight API。
ini
function getWordRange(textNode, offset) {
const text = textNode.nodeValue;
let start = offset;
let end = offset;
while (start > 0 && !/\s/.test(text[start - 1])) {
start--;
}
while (end < text.length && !/\s/.test(text[end])) {
end++;
}
const range = new Range();
range.setStart(textNode, start);
range.setEnd(textNode, end);
return range;
}
更新高亮:
ini
function highlightWord(textNode, offset) {
const range = getWordRange(textNode, offset);
const highlight = new Highlight(range);
CSS.highlights.set("spoken-word", highlight);
scrollIntoViewIfNeeded(range);
}
CSS:
css
::highlight(spoken-word) {
color: #111827;
background: #fde68a;
text-decoration: underline;
text-decoration-thickness: 2px;
}
这种方式不会向原页面插入额外标签,也不会重写 DOM,比较适合 React、Vue 等动态页面。
不过,按空格寻找单词只适合英文等以空格分词的语言。中文、日文等语言需要使用 Intl.Segmenter:
arduino
const segmenter = new Intl.Segmenter("zh-CN", {
granularity: "word"
});
function findWordWithSegmenter(text, offset) {
const words = [...segmenter.segment(text)];
return words.find(word => {
const start = word.index;
const end = start + word.segment.length;
return offset >= start && offset < end;
});
}
六、让页面自动跟随朗读位置
每读一个单词都调用 scrollIntoView(),页面会频繁抖动。更好的策略是:只有当当前高亮区域离开可视区域时才滚动。
ini
function scrollIntoViewIfNeeded(range) {
const rect = range.getBoundingClientRect();
const topBoundary = window.innerHeight * 0.2;
const bottomBoundary = window.innerHeight * 0.8;
const outsideViewport =
rect.top < topBoundary ||
rect.bottom > bottomBoundary;
if (!outsideViewport) return;
const element = range.startContainer.parentElement;
element?.scrollIntoView({
behavior: "smooth",
block: "center"
});
}
此外,还应当降低滚动频率。例如记录最后一次滚动时间,两次自动滚动至少间隔 500 毫秒。这样用户阅读时不会感觉页面一直被扩展"抢走"。
七、暂停、继续与停止
基本控制可以直接使用 Web Speech API:
scss
function pauseReading() {
speechSynthesis.pause();
}
function resumeReading() {
speechSynthesis.resume();
}
function stopReading() {
speechSynthesis.cancel();
CSS.highlights.delete("spoken-word");
}
Popup 与 Content Script 之间可以通过 Chrome 消息机制通信:
go
chrome.runtime.onMessage.addListener(message => {
switch (message.type) {
case "PLAY":
startReading();
break;
case "PAUSE":
pauseReading();
break;
case "RESUME":
resumeReading();
break;
case "STOP":
stopReading();
break;
}
});
Chrome 扩展支持一次性消息和长连接两种通信方式。播放、暂停这类简单命令使用 runtime.sendMessage() 或 tabs.sendMessage() 就足够了。Chrome 扩展消息通信文档
八、长文章不能一次性全部朗读
把一本书或一篇几万字的文章全部塞进一个 SpeechSynthesisUtterance,容易出现:
- 启动等待时间过长
- 暂停恢复不稳定
boundary事件中断- 页面切换后状态无法恢复
- 修改语速时必须从头开始
更稳妥的做法是按段落或句子分块:
ini
const chunks = paragraphs.map((node, index) => ({
id: index,
node,
text: node.textContent.trim()
}));
每次只播放一个 chunk,播放结束后自动进入下一段:
ini
function playChunk(index) {
const chunk = chunks[index];
if (!chunk) {
clearHighlight();
return;
}
const utterance =
new SpeechSynthesisUtterance(chunk.text);
utterance.onboundary = event => {
highlightWord(chunk.node, event.charIndex);
};
utterance.onend = () => {
playChunk(index + 1);
};
speechSynthesis.speak(utterance);
}
这种结构也更方便保存进度。扩展只需记录:
yaml
{
pageUrl: location.href,
chunkIndex: 12,
charIndex: 86
}
用户下次打开页面时,就可以从相近位置继续播放。
九、实际开发中的几个坑
1. 页面会动态更新
很多阅读网站是单页应用。翻页后 URL 可能没变,但正文 DOM 已经替换。可以使用 MutationObserver 监听正文区域;检测到内容变化后,停止旧语音并重建索引。
2. DOM 与语音文本可能不一致
如果为了朗读效果清理了多余空格、特殊符号或脚注,语音文本的字符位置就不再等于原始 DOM 的字符位置。
因此不能只保存 DOM 节点,还要保存"原始文本位置"和"清洗后文本位置"的转换关系。
3. iframe 需要单独处理
正文可能位于 iframe 中。此时 Content Script 默认只进入顶层页面,需要根据页面结构配置 all_frames 或动态注入脚本,并注意跨域限制。
4. 不要过度申请权限
如果扩展只在用户主动点击后读取当前页面,优先使用 activeTab,不要一开始就申请访问所有网站。权限越清晰,用户越容易信任。
5. 页面文字属于不可信输入
网页可能包含恶意内容。不要把读取到的文字直接写入 innerHTML,也不要用 eval() 处理页面数据。Chrome 官方同样建议对来自 Content Script 的消息进行验证,并使用 innerText 等更安全的 DOM API。
十、从"能朗读"到"愿意长期使用"
完成文字提取、语音播放和同步高亮以后,一个网页朗读扩展才刚刚达到"能用"。
真正影响体验的细节还包括:
- 是否能准确跳过导航和广告
- 长文播放是否稳定
- 切换章节后能否继续
- 高亮是否遮挡原文字体
- 自动滚动是否自然
- 中英文混排时能否选择正确语音
- 用户刷新页面后能否恢复进度
- 暂停几分钟后还能不能正常继续
我在开发 CastReader 时,也一直围绕这些细节迭代。它希望解决的不是简单地调用一次 TTS API,而是让 Kindle 网页和长篇内容真正拥有接近有声书的连续收听体验。
如果只是制作一个原型,SpeechSynthesisUtterance + boundary + Range 已经足够;如果准备做成长期产品,则需要进一步处理分块播放、状态恢复、多语言分词、动态 DOM 和不同语音引擎的时间戳差异。
同步高亮看起来只是一个视觉效果,但它连接了三套完全不同的坐标系统:网页 DOM、朗读文本和音频时间轴。把这三者稳定地映射起来,才是整个功能真正有意思的地方。