Skip to content
Go back

把网页变成双语阅读视图:我在浏览器扩展里处理 DOM 的方法

我最近在做一个叫 Parallel Translate 的浏览器扩展。它的目标不是让用户“一键看懂”外语页面,而是把真实网页变成适合持续阅读的双语材料:原文仍然在,母语翻译作为理解支架安静地出现在旁边。

这个定位看起来只差一句产品文案,实现上却和普通网页翻译非常不同。

如果目标只是得到中文内容,抓取全文、翻译、替换页面似乎就够了。但如果目标是阅读学习,原文的位置、段落关系、链接、代码片段、页面滚动体验,甚至关闭功能后能否恢复原页面,都会直接影响产品是否可用。

我目前越来越确定一件事:浏览器双语阅读的核心问题,不是翻译 API,而是如何在别人的 DOM 上建立一个克制、可恢复的阅读层。

翻译不是把整页文字替换掉

典型的阅读页面并不只有正文。它可能包含导航、推荐列表、评论、目录、代码块、引用、脚注、按钮,以及随着滚动不断加载的新内容。

如果简单地把页面上的每一个文本节点都作为翻译请求,会很快遇到几个问题:

因此我把学习视图拆成一条更谨慎的链路:

flowchart LR
  A["当前网页"] --> B["确定正文作用域"]
  B --> C["发现可翻译文本单元"]
  C --> D["进入视口时请求翻译"]
  D --> E["插入双语阅读层"]
  E --> F["监听页面增量变化"]
  F --> C
  E --> G["关闭时还原原 DOM"]

这条链路最重要的含义是:扩展不拥有页面,它只是暂时借用页面的一部分来呈现另一种阅读方式。

先确定“哪里值得翻译”

通用翻译插件很容易落入“发现文字就处理”的思路。但阅读体验需要先区分内容和页面外壳。

我采用的是“站点规则加通用兜底”的方式。对于结构稳定、学习价值高的网站,可以指定正文容器以及应排除的目录、脚注或编辑控件;对未适配的网站,则退回到 document.body,再使用保守的通用排除规则。

抽象后的结构很简单:

interface TranslationScope {  root: Element;  excludeSelectors: string[];  ruleName: string;  isFallback: boolean;}

这里不需要试图维护一份覆盖整个互联网的规则库。规则层只解决最重要的边界:从哪里开始扫描,以及哪些明显的页面噪音不要进入翻译链路。

更细的问题交给 DOM 扫描器处理。例如隐藏元素、表单输入、脚本和样式节点不应处理;带有正常正文的块级区域可以成为候选;代码或时间标签可以保留在上下文中,但不能被当成自然语言任意改写。

这是第一处取舍:宁可漏掉少量边缘内容,也不要把阅读界面变成被翻译文本淹没的页面。

我最初试过平铺枚举元素

最开始,我考虑的实现非常直观:用 querySelectorAll('*') 平铺拿到正文下的所有元素,再判断哪些元素像段落。为了避免父节点和子节点重复翻译,可以只收集“没有块级子元素的块”,或者当一个父元素被收集后,把它的后代标记成已处理。

对于很干净的文章 DOM,这个方案能工作:

<article>  <h1>A Practical Guide</h1>  <p>Read the <a href="/guide">documentation</a> first.</p>  <p>Then start building.</p></article>

h1 和两个 p 都是明确的叶子块。a 虽然是子元素,但它属于整个段落的句意,不需要单独成为翻译单元。

问题出现在真实网站常见的父子嵌套结构中:

<section class="card">  A short introduction.  <p>The first paragraph has a <a href="/term">linked term</a>.</p>  <p>The second paragraph continues the idea.</p>  Updated recently.</section>

如果收集整个 section,两个 p 会被父节点一起吞进去,子段落再处理时就会重复翻译;如果因为 section 含有块级子元素而只收集两个 pA short introduction.Updated recently. 这两段属于父容器自身的文本又会丢掉。

这让我意识到,“哪些元素看起来像段落”不是最稳妥的起点。页面最终要翻译的是文字,而文字所在的最近阅读块才决定了它应该在哪里显示译文。

先向下找文字,再向上找容器

现在我的扫描过程反过来了:不再从一份平铺的元素列表里挑容器,而是从正文作用域里的 Text 节点开始发现内容。

浏览器提供的 TreeWalker 很适合做这件事。它会深入遍历 root 下的文本后代;过滤器只让真正可能需要翻译的文本通过,例如跳过空白、纯标点、扩展自己生成的内容,以及位于脚本、输入框、隐藏区域或明确排除区域里的文本。

下面是这部分逻辑的简化版本:

function collectTranslationUnits(root: HTMLElement): HTMLElement[] {  const units = new Set<HTMLElement>();  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {    acceptNode(node) {      const text = node.textContent?.trim();      const parent = node.parentElement;      if (!text || !parent) return NodeFilter.FILTER_REJECT;      if (isInvalidText(text) || shouldSkipElement(parent)) {        return NodeFilter.FILTER_REJECT;      }      return NodeFilter.FILTER_ACCEPT;    },  });  let node = walker.nextNode();  while (node) {    const unit = findNearestBlock(node as Text, root);    if (unit) units.add(unit);    node = walker.nextNode();  }  return [...units];}

这里要区分两件事:

每发现一个有效文本节点,我会从它的父元素向上回溯,找到最近的块级容器作为候选翻译单元:

function findNearestBlock(textNode: Text, root: HTMLElement) {  let current: HTMLElement | null = textNode.parentElement;  let unit: HTMLElement | null = current;  while (current && current !== root) {    if (shouldSkipElement(current)) return null;    if (isShallowBlockHTMLElement(current)) return current;    unit = current;    current = current.parentElement;  }  return unit && root.contains(unit) ? unit : null;}

比如 p > a > span > Text 里的文字最终仍归属于 p;而前面那个 section 中直接出现的 A short introduction. 会归属于 section,两个 p 中的文字各自归属于各自的 p。这样父容器自身的文本不会丢失,子段落也仍有自己的边界。

发现父容器,不代表翻译整个父容器

到这里还没有完全解决父子嵌套。因为 section 已经成为一个候选单元,如果直接读取它的全部 textContent,它仍然会包含两个 p 的文字,重复问题只是被推迟了。

因此,真正处理一个候选单元时,我只遍历它的直接子节点,将连续的文本和行内元素组合成一组;一旦遇到块级子元素,就中断当前组,且不递归把这个块的文本吞进来。那个子块会由扫描阶段作为自己的单元处理。

function processUnit(unit: HTMLElement) {  let group: ChildNode[] = [];  const flush = () => {    if (!group.length) return;    translateNodeGroup(group, unit);    group = [];  };  for (const child of unit.childNodes) {    if (      child instanceof HTMLElement &&      (isShallowBlockHTMLElement(child) || shouldSkipElement(child))    ) {      flush();      continue;    }    group.push(child);  }  flush();}

放回刚才的结构中,得到的翻译请求边界会是:

section 直接文本组  -> "A short introduction."p                    -> "The first paragraph has a linked term."p                    -> "The second paragraph continues the idea."section 直接文本组  -> "Updated recently."

这里的关键不是“所有块都只翻译一次”,而是每一段文字只由距离它最近、并且能够承载阅读布局的块负责

递归只用于拼回同一句话里的行内结构

嵌套块不能被父节点递归吞掉,但行内结构必须被保留。一句话经常被链接、加粗、斜体或样式 span 切成多个 DOM 节点:

<p>Read the <a href="/guide">documentation</a> before getting started.</p>

如果把 p 里的裸 Texta 分别翻译,句意会断开。我的做法是:在已经确定的一组直接子节点内部,递归提取行内后代的文本;如果遇到链接,就将链接文字替换为一个受控的内部标记,并单独记录 href 等属性。

function extractSourceText(node: Node, links: LinkInfo[]): string {  if (node instanceof Text) return node.textContent ?? "";  if (!(node instanceof HTMLElement)) return "";  if (node.tagName === "BR") return "\n";  if (shouldSkipElement(node)) return "";  if (node.tagName === "A") {    const text = [...node.childNodes]      .map(child => extractSourceText(child, []))      .join("");    const id = links.push({ href: node.href, text }) - 1;    return `{{PT_LINK_${id}}}${text}{{/PT_LINK_${id}}}`;  }  return [...node.childNodes]    .map(child => extractSourceText(child, links))    .join("");}

翻译返回后,扩展解析自己定义的链接标记,通过 DOM API 创建译文节点和可点击的 a 节点,而不是把外部返回结果直接塞进 innerHTML。这样模型只处理语言,链接跳转和页面结构仍由本地逻辑掌控。

这个边界很关键。只要外部返回的文本可以直接成为 HTML,阅读插件就不仅是在翻译内容,也开始承担注入风险与页面破坏风险。

整体而言,这套 DOM 策略可以总结为四步:

TreeWalker 向下发现有效 Text  -> 从 Text 向上回溯最近的 block 容器并去重  -> 处理 block 时只组合直接的行内子节点,跳过嵌套 block  -> 在一个行内组内部递归提取文字,并受控保留链接

它比“找出所有看起来像段落的元素”多了几步,但父子嵌套网页中,正是这几步决定了译文会不会重复、漏掉或破坏原有阅读结构。

长页面不能在启动瞬间全部翻译

一篇短文章里,全量翻译可能感觉不到代价。但真实使用中,页面可能是很长的文档、知识库条目或无限滚动的信息流。开启学习视图后立即处理全部文本,会带来三个明显损耗:

浏览器本身已经提供了合适的信号:IntersectionObserver

扩展可以先扫描并注册候选内容,只有当某个文本区域进入视口附近时才发送翻译请求。这里的“附近”比“真正可见”更实用:在用户读到下一段之前提前少量预加载,滚动体验会平滑许多。

const observer = new IntersectionObserver(onVisible, {  rootMargin: `${preloadMargin}px 0px`,  threshold: 0.01,});

这种策略把请求优先级自然地交给阅读路径:用户先看到哪里,哪里就先得到翻译。

同时,需要用 WeakSet 一类的结构记录已经观察、正在处理和已经处理的节点,避免滚动或重新扫描导致重复请求。它们只服务当前页面生命周期,不必成为复杂的全局状态。

动态页面需要增量处理,而不是一次扫描

现代网页很少在首次加载后保持静止。文章评论可以展开,路由可以无刷新切换,内容可以懒加载,框架也可能重绘部分节点。

如果学习视图只运行一次扫描,几秒钟后它就可能和用户眼前的页面不一致。因此我在正文作用域上监听 MutationObserver,只针对新增或真正改变的区域安排重新扫描。

这里有一个容易踩中的问题:扩展自己插入翻译节点,也会触发 DOM 变化。如果不区分来源,观察器会把自己的翻译当成新的原文再次翻译,形成循环。

我的做法是显式记录由扩展插入或主动修改的节点,在 mutation 回调里跳过它们;对外部变化则先移除相关翻译包装,再对受影响的小区域重新处理。

这比每次变化后重新扫描整页更复杂一点,但复杂度是值得的:动态页面里,稳定性往往来自“只重做受影响的部分”。

可关闭、可恢复,才是一个阅读模式

双语模式可以有两种呈现方式:原文下方增加译文,或者暂时以译文替代原文。前一种更适合学习,后一种在某些场景下也有价值。

无论选择哪种,扩展都不能把页面修改视作一次性的操作。用户随时可能关闭学习视图,也可能切换显示方式。

因此,插入式呈现需要能准确删除扩展生成的包装节点;替换式呈现则要在隐藏原节点前记录它的原始文字或显示样式,在关闭时恢复。

开启:  原节点保持或临时隐藏  + 插入带标记的译文节点关闭:  移除所有译文节点  + 恢复被隐藏或变更的原节点  + 释放观察器和页面级状态

一个看起来很小的“关闭”按钮,实际检验了整个 DOM 策略是否健康。如果关闭之后必须刷新页面才能恢复,说明扩展修改了它不真正拥有的状态。

样式隔离不等于把一切放进 Shadow DOM

扩展 UI 很适合使用 Shadow DOM,例如划词面板、设置浮层或固定工具条。它能避免宿主站点的 CSS 影响控件,也能减少扩展样式泄漏到页面。

但行间译文需要自然地参与原文排版。如果把每段译文都放入独立的悬浮层或复杂 Shadow 树,它可能无法正确跟随段落换行和链接布局。

我现在更偏向按职责分开:

这不是追求某种“纯粹”的技术方案,而是让阅读排版和样式边界各自得到更合适的处理。

页面能力必须转化为用户信任

一个能在任意页面工作、并调用翻译服务的浏览器扩展,天然涉及权限和数据说明。技术上能读取正文,不意味着产品可以含糊地处理正文。

对使用者而言,至少应当能清楚回答这些问题:

这些内容不是附加的隐私文案,而是架构边界的一部分。权限越大、可访问的内容越多,扩展越应该让用户清楚控制行为发生的时机。

结语

做 Parallel Translate 之前,我以为双语翻译扩展的主要工作会集中在模型选择和提示词质量上。实际动手之后,我花更多时间思考的却是:哪些文字该进入链路、什么时候翻译、怎样保持链接、怎样跟上动态页面,以及怎样不留痕迹地退出。

模型决定一句译文够不够自然;DOM 策略决定用户愿不愿意在页面里一直读下去。

对阅读类浏览器扩展来说,后者才是产品真正站得住的地方。


Share this post on: