刚开始折腾网页翻译这件事动机特别朴素每天要啃几十篇外文资料浏览器自带的整页翻译经常把排版搞乱专业术语还翻得莫名其妙读起来比看原文还费劲。后来我干脆自己动手做了一个基于 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 会暴露你正在访问的页面。backgroundservice 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 classtr-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: AltT }, 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 交互入口快捷键、右键菜单与划词三个入口覆盖三种使用习惯。快捷键AltT触发整页翻译开关在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 的用法和它带来的麻烦现在的网页大多是动态渲染的滚动加载、单页路由切换、评论区异步插入一次采集根本覆盖不全。所以必须挂MutationObserverconst 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被调用时重置状态、重新采集并且清掉旧页面的译文节点按>