
我最近在做一个叫 Parallel Translate 的浏览器扩展。它的目标不是让用户“一键看懂”外语页面,而是把真实网页变成适合持续阅读的双语材料:原文仍然在,母语翻译作为理解支架安静地出现在旁边。
这个定位看起来只差一句产品文案,实现上却和普通网页翻译非常不同。
如果目标只是得到中文内容,抓取全文、翻译、替换页面似乎就够了。但如果目标是阅读学习,原文的位置、段落关系、链接、代码片段、页面滚动体验,甚至关闭功能后能否恢复原页面,都会直接影响产品是否可用。
我目前越来越确定一件事:浏览器双语阅读的核心问题,不是翻译 API,而是如何在别人的 DOM 上建立一个克制、可恢复的阅读层。
翻译不是把整页文字替换掉
典型的阅读页面并不只有正文。它可能包含导航、推荐列表、评论、目录、代码块、引用、脚注、按钮,以及随着滚动不断加载的新内容。
如果简单地把页面上的每一个文本节点都作为翻译请求,会很快遇到几个问题:
- 导航和操作区一起被翻译,读者看到的是噪音而不是正文
- 一段话里的链接被拆散,翻译之后无法继续点击
- 长页面在刚开启功能时就发出大量请求,首屏反而变慢
- React、Vue 或内容站点动态更新 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 含有块级子元素而只收集两个 p,A 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];}
这里要区分两件事:
- 扫描确实需要深入 DOM 子树,否则找不到被多层
span、a或组件包装的正文;我使用的是TreeWalker,而不是手写深度优先递归。 - 扫描发现的是文本,但最终交给观察器和渲染器的是去重后的块级容器,避免一句话里的多个文本节点触发多份译文。
每发现一个有效文本节点,我会从它的父元素向上回溯,找到最近的块级容器作为候选翻译单元:
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 里的裸 Text 和 a 分别翻译,句意会断开。我的做法是:在已经确定的一组直接子节点内部,递归提取行内后代的文本;如果遇到链接,就将链接文字替换为一个受控的内部标记,并单独记录 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 树,它可能无法正确跟随段落换行和链接布局。
我现在更偏向按职责分开:
- 独立面板和交互控件使用 Shadow DOM 隔离
- 与正文共同排版的译文节点插入文档流,并使用极少、命名隔离的基础样式
- 无论哪种节点,都打上明确标记,以便扫描器跳过、关闭时清理
这不是追求某种“纯粹”的技术方案,而是让阅读排版和样式边界各自得到更合适的处理。
页面能力必须转化为用户信任
一个能在任意页面工作、并调用翻译服务的浏览器扩展,天然涉及权限和数据说明。技术上能读取正文,不意味着产品可以含糊地处理正文。
对使用者而言,至少应当能清楚回答这些问题:
- 扩展在什么情况下读取当前页面文本
- 页面内容是否、以及何时会发往用户选择的翻译服务
- API Key 保存在哪里,是否会被发送到非必要服务
- 为什么需要站点访问权限,能否进一步按需授权
- 用户关闭功能或清理数据时,缓存如何处理
这些内容不是附加的隐私文案,而是架构边界的一部分。权限越大、可访问的内容越多,扩展越应该让用户清楚控制行为发生的时机。
结语
做 Parallel Translate 之前,我以为双语翻译扩展的主要工作会集中在模型选择和提示词质量上。实际动手之后,我花更多时间思考的却是:哪些文字该进入链路、什么时候翻译、怎样保持链接、怎样跟上动态页面,以及怎样不留痕迹地退出。
模型决定一句译文够不够自然;DOM 策略决定用户愿不愿意在页面里一直读下去。
对阅读类浏览器扩展来说,后者才是产品真正站得住的地方。