# Chrome 扩展实现网页文字与语音同步高亮

把网页文字"读出来"并不难。浏览器提供了 Web Speech API,创建一个 SpeechSynthesisUtterance,再调用 speechSynthesis.speak() 就能播放语音。

真正麻烦的是另一件事:怎样让页面中的文字随着语音播放同步高亮?

在开发 CastReader 的过程中,我希望实现一种接近有声书的体验:语音读到哪里,页面就高亮到哪里;用户可以暂停、继续、调整语速,同时页面自动跟随当前内容。

这篇文章将用一个简化版 Chrome 扩展说明完整思路,包括:

  • 从复杂网页中提取可朗读文本
  • 建立文本与 DOM 节点之间的映射
  • 监听语音播放位置
  • 使用 Range 实现非破坏性高亮
  • 自动滚动到当前段落
  • 处理暂停、切换页面和动态内容

一、扩展的基本架构

在 Manifest V3 中,可以把扩展拆成三部分:

  1. Popup:提供播放、暂停和停止按钮
  2. Content Script:读取网页文本并负责高亮
  3. 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、朗读文本和音频时间轴。把这三者稳定地映射起来,才是整个功能真正有意思的地方。

相关推荐
人月神话Lee2 小时前
我做了个不要账号、不要定位权限的旅行 App,聊聊那些「不做」的决定
ios·ai编程·产品
怕浪猫4 小时前
第8章 从0到1全流程产品设计
产品经理·产品·资讯
星期一研究室4 小时前
告别文档「图片灾难排版」!3大黄金法则,让你的文档从杂乱无章到杂志风🧩
微服务·产品·设计
怕浪猫1 天前
第7章 产品需求文档(PRD)撰写
产品经理·产品·资讯
星期一研究室1 天前
飞书多维表格的「色彩规则」,让数据流转像红绿灯一样清晰!
微服务·产品·设计
ElevenWang2 天前
我为什么开始用语音输入代替键盘打字
aigc·产品
怕浪猫2 天前
第6章 交互原型设计 — Axure 实战
产品经理·产品·资讯
星期一研究室2 天前
代码块:长文中的‘荧光浮标’!让「关键内容」无损高亮呈现
微服务·产品·设计
南巷羽3 天前
用 TRAE Work 把 20 条 GitHub Issue 变成可复核的需求优先级清单
github·产品