刚开始折腾网页翻译这件事,动机特别朴素:每天要啃几十篇外文资料,浏览器自带的整页翻译经常把排版搞乱,专业术语还翻得莫名其妙,读起来比看原文还费劲。后来我干脆自己动手,做了一个基于 DeepL 翻译能力的浏览器插件,划词能翻、整页能翻、双语对照保留原文,看技术文档的时候终于不用在几个标签页之间来回跳了。这篇就把我这套“DeepL 翻译插件”从选型、实现到调参的完整过程摊开讲,目标很明确——让你在 5 分钟内把插件跑起来,实现真正的网页无障碍阅读。不管你只是想要一个顺手的翻译工具,还是想搞明白浏览器扩展到底是怎么处理页面 DOM 的,下面这些内容都能直接抄。适合的人群大概三类:天天读外文资料的从业者、想入门浏览器扩展开发的新手、以及被整页翻译毁过排版、想自己掌控翻译效果的人。
1. 先想清楚要什么:需求拆解与方案选型
1.1 网页阅读的真实痛点,远比“看不懂”复杂
很多人以为翻译工具解决的是“不认识单词”,实际上真正卡住阅读效率的是另外几件事。第一是语境断裂:整页翻译把原文全替换掉,读到一半想核对某个术语的原始表述,得刷新页面重新来一遍。第二是排版摧毁:不少整页翻译方案直接改文本节点,遇到 flex 布局、表格、代码块,元素宽度一变,整个页面就塌了。第三是术语漂移:同一篇文章里同一个词被翻成三种说法,读技术文档时这种伤害是致命的。第四是隐私顾虑:把整页 HTML 连同登录态、内网路径一起丢给第三方服务,很多人心里是发毛的。
我最初用现成插件,最大的抱怨是“不可控”——翻哪些、怎么翻、翻完长什么样,全由插件决定。所以自己做的时候,我把需求拆成了四条硬指标:划词翻译要快(不打断阅读节奏)、整页翻译要双语对照(原文可查)、译文注入不能污染原页面(样式隔离)、只发文本不发结构(降低隐私面)。这四条定下来,后面所有的技术选择其实都被约束住了。
还有个容易被忽略的点:翻译的粒度。一个<p>里可能有 200 个字符,也可能只有 3 个字符的按钮文案。前者适合整段翻译保语境,后者单独翻译反而更准。所以采集环节必须能识别文本节点的“上下文边界”,不能无脑把整个 body 的 innerText 丢出去。
1.2 三条实现路线的取舍,别一上来就选最重的
做网页翻译,市面上大致有三条路线,我挨个试过,最后选了第三条。
| 路线 | 做法 | 优点 | 主要问题 |
|---|---|---|---|
| 代理式整页翻译 | 服务端抓页面、翻译后返回 | 效果统一,不依赖本地 | 需要中转服务,登录态页面基本废掉,延迟高 |
| 油猴脚本 | 单文件注入,改文本节点 | 上手最快,几十行能跑 | 权限粗放、样式冲突难隔离、多标签管理乱 |
| 浏览器扩展 | content script 接管 DOM | 权限可声明、样式可隔离、能常驻后台做队列 | 需要理解 manifest 和消息通信 |
代理式方案我最早排除。它天然拿不到需要登录才能看的内容,而我的阅读场景里,内部文档、付费资料占了一大半。油猴脚本我写过一版,能用,但踩了两个坑:一是样式完全没法隔离,页面自身的 CSS 会把我插入的译文标签改得面目全非;二是所有逻辑挤在一个文件里,做请求节流和缓存的时候代码迅速失控。
浏览器扩展的优势在于分层清晰:content script 只管 DOM 读写,后台负责网络请求和队列,popup 只管交互。这个结构一开始搭对,后面加功能就是往格子里填东西。代价是要多写几个文件、多理解一层消息通信,但这个成本在我看来完全值得。
1.3 为什么优先用扩展而不是独立客户端
有人会问,装个桌面翻译软件不行吗?行,但体验差一截。桌面软件拿不到你当前浏览的页面,只能靠复制粘贴或者全局热键取词,中间多了一次“切窗口”的动作。别小看这一下,读长文的时候一天几百次切换,注意力碎得非常厉害。
扩展还有个隐性好处:它能知道你在看什么页面。这带来两个能力——按域名设置白名单(比如公司内网页面不翻译)、按站点记忆翻译偏好(技术站点默认双语对照,新闻站点默认整页替换)。这些是桌面软件做不到的。当然,这也意味着权限声明必须克制,后面第 5 章会专门讲权限边界的问题。
2. 核心机制拆解:一个翻译插件到底在做什么
2.1 三段式架构:谁负责什么,边界必须清楚
整个插件就三块,职责划清楚之后代码非常好维护。content script注入到页面里,负责采集文本节点、发请求指令、把译文写回 DOM。它不直接调翻译接口——这是关键决策,原因是页面上下文的网络请求会受站点 CSP 限制,而且请求头里的 Referer 会暴露你正在访问的页面。background(service worker)负责真正发请求、做队列调度、管缓存,它处在扩展自己的上下文里,不受页面 CSP 约束,也不携带页面来源信息。popup只是一个控制面板,开关、模式切换、字数统计。
消息通信走chrome.runtime.sendMessage,我把它封装成 Promise 形式,避免回调地狱:
// content.js 里的通信封装 function callBackground(payload) { return new Promise((resolve, reject) => { chrome.runtime.sendMessage(payload, (res) => { if (chrome.runtime.lastError) return reject(chrome.runtime.lastError); if (!res || res.ok !== true) return reject(new Error(res && res.error)); resolve(res.data); }); }); }这个封装看起来简单,但省掉了后面几十处错误处理。踩坑提醒:service worker 有个众所周知的坑——闲置约 30 秒会被浏览器挂起,挂起后内存里的队列和缓存全丢。所以队列状态和缓存必须落盘到chrome.storage.local,不能只放内存变量。我第一版就是因为这个,翻译到一半队列凭空消失,排查了半天才反应过来。
2.2 文本节点采集:哪些该翻,哪些必须绕开
这是整个项目最容易写错的地方。新手的第一反应通常是document.body.innerText,然后整段发出去。这个做法有三个硬伤:丢失 DOM 结构导致译文没法回填、把导航栏和页脚一起翻了浪费额度、遇到代码块直接翻成灾难。
正确做法是用TreeWalker遍历文本节点,边遍历边过滤。我总结的过滤规则是这样的:
- 节点类型:只要
Node.TEXT_NODE,元素节点一律不进。 - 祖先黑名单:
script、style、noscript、code、pre、textarea、svg、kbd、samp。代码块和终端输出翻译了纯属添乱。 - 可编辑区域:向上找
contenteditable或input,用户正在输入的内容碰都别碰。 - 可见性:
offsetParent === null且getClientRects().length === 0的元素跳过,隐藏的下拉菜单不用翻。 - 内容质量:纯数字、纯符号、长度小于 2 的跳过;已经带翻译标记的节点跳过(防止自己触发自己)。
- 语言判定:如果目标语言是中文,纯 ASCII 且长度小于 4 的(比如按钮上的 “OK”)按需跳过,省额度。
这套规则写下来大概 40 行,但能砍掉整页 30% 到 50% 的无谓请求。我实测过一个技术文档页,原始文本节点 1800 多个,过滤后只剩 260 个真正需要翻译的,字符数从 12 万降到 4.1 万,额度消耗直接少了三分之二。
2.3 译文回填:双语对照怎么插才不破坏布局
回填有两个流派。替换式是把文本节点的nodeValue直接改成译文,简单但原文丢了,而且容器宽度变化会让布局抖动。追加式是保留原文,在它后面插一个兄弟节点放译文。我选追加式,因为双语对照是刚需,而且对布局的冲击更可控。
实际操作是这样:找到文本节点的父元素,创建一个<span class="tr-node">,把译文塞进去,insertBefore到原节点之后。有个细节必须注意——如果父元素是display: inline的<a>或者<strong>,直接插块级元素会撑破行内盒模型,所以我统一把译文 span 设成display: block,同时把父元素的行内样式临时提升为inline-block?不行,这招副作用太大。更稳的做法是:译文 span 用display: block,但只在父元素的display计算值是block、flex、grid、list-item时才插入;如果父元素是纯行内元素,就把译文插到最近的块级祖先之后,用>{ "manifest_version": 3, "name": "网页双语阅读助手", "version": "1.0.0", "description": "划词与整页翻译,保留原文做双语对照", "permissions": ["storage", "contextMenus", "activeTab", "scripting"], "host_permissions": ["https://api.example.com/*"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "css": ["content.css"], "run_at": "document_idle" } ], "action": { "default_popup": "popup.html" }, "commands": { "toggle-translate": { "suggested_key": { "default": "Alt+T" }, "description": "整页翻译开关" } } }
几个参数值得单独说。run_at选document_idle而不是document_start,是因为整页翻译需要 DOM 基本就绪;如果你要做的是划词翻译,document_start更合适,能更早绑定选区事件。host_permissions里只填你实际调用的翻译服务域名,千万不要图省事写<all_urls>,一是审核会卡,二是权限面积太大容易出问题。上面那个api.example.com是占位符,换成你所使用服务的官方接口地址即可。
3.2 文本采集与回填的核心代码
这是整个插件最值钱的一段。我把采集和过滤合成一个函数,返回结构化的任务列表:
// content.js const SKIP_TAGS = new Set(['SCRIPT','STYLE','NOSCRIPT','CODE','PRE', 'TEXTAREA','SVG','KBD','SAMP','CANVAS','IFRAME']); function collectNodes(root = document.body) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const text = node.nodeValue.trim(); if (text.length < 2) return NodeFilter.FILTER_REJECT; if (!/[\p{L}]/u.test(text)) return NodeFilter.FILTER_REJECT; const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; if (parent.closest('[data-tr-done]')) return NodeFilter.FILTER_REJECT; for (let el = parent; el; el = el.parentElement) { if (SKIP_TAGS.has(el.tagName)) return NodeFilter.FILTER_REJECT; if (el.isContentEditable) return NodeFilter.FILTER_REJECT; } if (parent.offsetParent === null && parent.getClientRects().length === 0) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }); const out = []; let n; while ((n = walker.nextNode())) out.push(n); return out; }回填函数要点在于标记与幂等。每个翻译过的节点打上>function injectTranslation(textNode, translated) { const parent = textNode.parentElement; if (!parent) return; const span = document.createElement('span'); span.className = 'tr-node'; span.textContent = translated; span.setAttribute('data-tr-for', String(textNode.__trId)); const blockLevel = ['block','flex','grid','list-item'] .includes(getComputedStyle(parent).display); if (blockLevel) { parent.insertBefore(span, textNode.nextSibling); } else { const anchor = parent.closest('p,li,td,dd,blockquote,div') || parent; anchor.appendChild(span); } parent.setAttribute('data-tr-done', '1'); }
配套的 CSS 必须克制,两三条就够,写多了反而容易和站点样式打架:
.tr-node { display: block; margin: 2px 0 6px; padding-left: 8px; border-left: 2px solid #c9d6e3; opacity: 0.92; font-size: 0.96em; line-height: 1.6; }用左边框做视觉区分,比加背景色安全得多——背景色会和站点自己的高亮样式冲突,左边框基本没人用。
3.3 后台队列与接口调用
background 里我做了一个极简队列,核心是并发控制加缓存命中:
// background.js const CONCURRENCY = 3; let running = 0; const pending = []; function enqueue(task) { return new Promise((resolve, reject) => { pending.push({ task, resolve, reject }); pump(); }); } function pump() { while (running < CONCURRENCY && pending.length) { const { task, resolve, reject } = pending.shift(); running++; task().then(resolve, reject).finally(() => { running--; pump(); }); } }调用接口时,把一批文本拼成带分隔符的字符串发出去,返回后按分隔符切开,序号对齐。分隔符的选择有讲究,用\n<<<>>>\n这类不易在正文出现的组合,切分后再校验条数是否一致,不一致就降级成逐条请求。这个兜底必须有,我遇到过原文里本身就含特殊符号导致切分错位,没有校验的话译文会张冠李戴,那种错误比不翻译还可怕。
host_permissions里声明的域名必须在后台请求时严格匹配,请求体只带文本内容和目标语言,不带 URL、不带标题、不带任何页面标识。这一条我写在代码注释里,就是为了防止以后加功能时手滑。
3.4 交互入口:快捷键、右键菜单与划词
三个入口覆盖三种使用习惯。快捷键Alt+T触发整页翻译开关,在commands里声明,background 监听chrome.commands.onCommand后转发给当前活动标签页。右键菜单在 background 里用chrome.contextMenus.create注册,选中文本后右键“翻译选中内容”,拿到info.selectionText直接走翻译,成本比整页低得多。划词气泡是 content script 监听mouseup,判断选区非空且长度在合理区间就弹一个浮动小卡片。
划词气泡有个细节:点击气泡本身会触发mouseup,导致气泡被反复重建。解决办法是在气泡元素上event.stopPropagation(),并且判断event.target.closest('.tr-popup')时直接返回。另外记得监听scroll和resize时把气泡隐藏,否则页面滚动后气泡会飘在奇怪的位置。
3.5 动态页面:MutationObserver 的用法和它带来的麻烦
现在的网页大多是动态渲染的,滚动加载、单页路由切换、评论区异步插入,一次采集根本覆盖不全。所以必须挂MutationObserver:
const observer = new MutationObserver((mutations) => { if (!state.translateOn) return; const fresh = []; for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === Node.TEXT_NODE) fresh.push(node); else if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList && node.classList.contains('tr-node')) continue; fresh.push(...collectNodes(node)); } } } if (fresh.length) scheduleTranslate(fresh); }); observer.observe(document.body, { childList: true, subtree: true, characterData: false });两个参数特意关掉:characterData关掉是因为我们自己插入译文也会触发字符变化,开着必然死循环;attributes关掉是因为站点频繁改 class 做动画,开着会导致观察者被高频唤醒,CPU 直接起飞。
即便如此还是要有防抖。我给scheduleTranslate加了 300ms 的延迟合并,把短时间内的多次变动攒成一批。实测一个无限滚动的信息流页面,不做防抖时每秒触发观察者十几次,做了之后降到两三次,翻译体验顺滑很多。
4. 性能与成本:让方案真正跑得动
4.1 先算清楚额度,再谈方案可行性
很多人卡在“免费额度够不够用”上。我拿实际数据算给你看。官方提供的免费接口通常按月给一定字符额度,业界常见的一档是每月 50 万字符。那么一篇中文 3000 字的技术文章,折算字符数大约 3000(中文字符按 1 计),加上标点和少量英文术语,按 3500 算,一个月能翻约 140 篇。如果是英文长文 8000 字符,一个月约 62 篇。
这个量对轻度阅读够用,对重度用户肯定不够。所以过滤和缓存不是优化项,是必需品。前面提的过滤规则把 12 万字符砍到 4.1 万,相当于额度翻了近三倍。缓存则能干掉重复访问的成本——同一篇文章刷新三次,只有第一次消耗额度。两项加起来,我自己的实际消耗大概是理论值的四成。
4.2 缓存设计:key 怎么算,存在哪
缓存 key 用原文内容而非节点位置,因为同一个句子在页面里可能出现在多处。简单哈希就够:
function hashKey(str, targetLang) { let h = 5381; for (let i = 0; i < str.length; i++) { h = ((h << 5) + h + str.charCodeAt(i)) | 0; } return `${targetLang}:${(h >>> 0).toString(36)}:${str.length}`; }带上目标语言和长度是很重要的。只哈希内容的话,中译英和英译中的缓存会撞车;带长度则能进一步降低哈希碰撞后的误用概率,虽然理论上仍有碰撞可能,但配合长度校验后实际项目中基本不会出问题。
存储分层:内存 Map存本次会话的热点(无 IO 开销,秒回),chrome.storage.local存跨会话的持久缓存,写入时做一次容量控制——超过 5000 条就按插入顺序淘汰最老的一批。别用localStorage,那是页面域的,扩展的 service worker 里访问不到。
还有个小优化:缓存命中时给节点打标记,跳过后续所有处理,这样重复触发整页翻译几乎零成本。
4.3 渲染性能:别让 DOM 操作卡住主线程
一次整页翻译插入几百个节点,如果在一个同步循环里做完,页面会明显卡顿一下。解决办法是分片渲染,每帧只处理 20 到 30 个节点:
function renderChunk(items, cursor = 0) { const end = Math.min(cursor + 25, items.length); for (let i = cursor; i < end; i++) injectTranslation(items[i].node, items[i].text); if (end < items.length) requestAnimationFrame(() => renderChunk(items, end)); }用requestAnimationFrame而不是setTimeout,是因为它和浏览器刷新节奏对齐,视觉上更顺。我对比过,600 个节点的页面,一次性插入会有约 300ms 的白屏卡顿,分片之后基本感知不到。
另外提醒一句:别在滚动过程中做翻译回填。节点插入会改变页面高度,滚动位置跟着跳,用户会以为页面出问题了。我的做法是滚动时挂起渲染队列,停稳 500ms 后再继续。
5. 常见问题与排查实录
5.1 译文错位、布局崩坏的三种典型场景
这类问题占了我在实际使用中遇到的故障一半以上,整理成表方便对照:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 译文跑到单元格外面 | 表格内td的 display 计算值特殊 | 回填前判断closest('td,th'),插入到单元格内部末尾 |
| 列表项之间间距突然变大 | 译文 span 的 margin 与li原有 margin 叠加 | 译文 span 用margin: 0,间距交给父元素 |
| 按钮文字换行成两行 | 行内元素里插入了块级译文 | 判断父元素为行内时,改插入到最近块级祖先 |
还有一种不太好归类的:某些站点用display: contents做布局,这种元素的getClientRects()返回空,会被我的可见性判断误杀,导致部分内容翻不到。处理办法是把display: contents的元素从可见性检查里排除掉,让它的子元素自己判断。
5.2 翻不动、只翻一半的定位思路
排查这类问题我有一套固定顺序,基本五分钟内能定位。
第一步看控制台报错。最常见的是权限不足(host_permissions没配对)和 429 限流,前者报错很直白,后者需要看响应头。
第二步看采集数量。在 content script 里打一条日志输出collectNodes返回的节点数。如果是 0,问题在过滤规则,通常是某个黑名单标签误伤了主要内容区;如果数量正常但页面没动静,问题在消息通信或渲染环节。
第三步看批次完整性。批量请求时如果响应条数和请求条数对不上,说明分隔符切分失败,这是我踩过最隐蔽的坑——表面上看翻译“完成”了,实际上部分节点拿到的是错位的译文。
第四步看SPA 路由。单页应用切换路由时不会重新加载页面,content script 不会重跑,观察者如果被之前的清理逻辑断开,新页面就完全不翻。我的处理是在popstate和pushState被调用时重置状态、重新采集,并且清掉旧页面的译文节点(按>