Skip to content
Go back

划词翻译面板为什么难做

前两篇写 Parallel Translate 时,我分别讲了两个比较大的问题:如何把网页变成可恢复的双语阅读视图,以及如何组织 AI 翻译扩展里的缓存、批处理和 Provider。

这篇想写一个看起来小得多的入口:划词翻译面板。

用户眼里,它只是选中一个单词或一句话后出现的小浮层。里面有释义、翻译、发音、复制、加入生词本,最多再加一个 AI 句子详解按钮。

但真正做起来后,我发现划词面板并不是“给 mouseup 绑一个弹窗”那么简单。它处在几个系统的交界处:

如果整页翻译考验的是“如何改造页面”,划词面板考验的就是“如何在不拥有页面的情况下,做一个稳定、即时、可中断的小工具”。

难点不是弹出,而是解释选区

浏览器提供了 Selection API,看起来很直接:

const selection = window.getSelection()const text = selection?.toString().trim()

有了文本,再把文本丢给翻译服务,似乎就完成了。

真实情况要复杂很多。

首先,用户选中的不一定是自然语言。它可能是一个单词、一段句子、代码片段、按钮文案、导航链接,甚至是跨段落、跨链接、跨多个 DOM 节点的残缺文本。

其次,同一段选中文本在产品里对应两种完全不同的意图:

选中一个单词 / 短语  -> 查词、音标、词性、例句、加入生词本、标记已掌握选中一句话 / 一段话  -> 翻译、朗读、复制、AI 句子结构分析

这两类面板不只是 UI 不同,背后的数据链路也不同。单词面板更像词典入口,要关心规范词形、词典语言、生词状态和查询次数;句子面板更像一个局部阅读助手,要关心翻译结果、原句展示、AI 详解和复制内容。

所以第一步不是“显示面板”,而是把选区解释成一个稳定的面板状态:

type SelectionPanelKind = "word" | "sentence"interface SelectionPanelState {  kind: SelectionPanelKind  text: string  rect: DOMRect  pinned: boolean  loading: boolean  error?: string}

这个状态必须比 DOM 选区更稳定。因为用户一旦点击面板,页面原来的 selection 可能已经消失;如果面板还依赖 window.getSelection() 重新读取文本,它会在最普通的点击操作里丢失上下文。

因此,选区发生时就要把文本、位置、上下文和面板类型保存下来,之后的所有交互都围绕这份快照运行。

定位不能只看鼠标坐标

浮层定位是另一个容易低估的问题。

最简单的做法是监听 mouseup,把面板放在鼠标位置附近:

document.addEventListener("mouseup", event => {  showPanel(event.clientX, event.clientY)})

这个方案在演示页面上很好看,在真实网页里很快会出问题。

用户可能通过键盘选中文本,鼠标位置没有意义;用户可能从右向左拖选,鼠标停在选区左侧;用户可能选中多行文本,鼠标点并不是视觉上最适合放面板的位置;移动端和触控板上的选择行为也不一定有可靠的 mouseup 语义。

更稳定的定位依据应该来自选区本身:

const range = selection.getRangeAt(0)const rect = range.getBoundingClientRect()

Range#getBoundingClientRect() 也不是终点。跨行选区的外接矩形可能很高,跨节点选区可能得到不符合直觉的边界;页面滚动、缩放、固定头部、窄屏视口都会影响面板是否越界。

我最后更倾向于把定位拆成两个层次:

选区层  -> 计算当前选区或词汇标注的 anchor rect布局层  -> 根据面板尺寸、视口边界、滚动位置决定最终 top / left

也就是说,选区只回答“面板应该贴近哪里”,布局算法再回答“面板怎样出现在可见区域内”。这两个问题混在一起,代码很容易变成一堆临时偏移量。

划词面板还需要处理“固定”状态。用户只是快速查一个词时,面板应该轻;用户想慢慢看句子详解时,面板就不应该因为鼠标移动或选区消失而关闭。

所以定位逻辑还要尊重状态:

未固定  -> 跟随当前选区  -> 点击外部或失焦后自动隐藏已固定  -> 保留当前位置  -> 允许拖拽  -> 直到用户主动关闭

这时它已经不再是一个 tooltip,而是一个临时工具窗口。

单词和句子是两条不同链路

划词面板最容易做乱的地方,是把所有能力都塞进一个“翻译选中文本”的函数里。

但单词和句子的产品语义完全不同。

单词面板关心的是词汇资产:

flowchart LR
  A["选中单词"] --> B["规范化 word"]
  B --> C["查询词典"]
  C --> D["展示音标 / 词性 / 释义"]
  D --> E["加入生词本"]
  D --> F["标记已掌握"]
  D --> G["发音"]

句子面板关心的是阅读理解:

flowchart LR
  A["选中句子"] --> B["局部翻译"]
  B --> C["展示原文与译文"]
  C --> D["AI 句子详解"]
  C --> E["朗读原文"]
  C --> F["复制结果"]

如果这两条链路共用太多状态,后面会很难维护。例如:

我更愿意在状态模型里明确区分:

type SelectionPanelState =  | {      kind: "word"      text: string      word?: WordExplanation      addedToNewWords: boolean      markedMastered: boolean    }  | {      kind: "sentence"      text: string      translated?: string      sentenceExplanationStatus: "idle" | "loading" | "ready" | "error"    }

这会让组件里多一些分支,但每个分支都有明确的产品边界。相比之下,用一个大对象承载所有字段,看起来统一,实际会让每个按钮都要猜“当前状态下我该不该出现”。

请求要能并行,也要能取消

划词之后,用户期待面板几乎立刻出现。如果等所有远端请求都完成后再渲染,体验会显得迟钝。

更合理的顺序是:

选区成立  -> 立即显示面板骨架  -> 本地判断单词 / 句子  -> 读取本地缓存或用户词库状态  -> 发起词典 / 翻译请求  -> 用户需要时再发起 AI 详解  -> 用户点击发音时再发起 TTS

这里的重点是:不同能力不要互相阻塞。

单词释义加载失败,不应该影响“加入生词本”按钮的状态;AI 句子详解不可用,不应该影响基础翻译;发音服务失败,也不应该让面板关闭。

因此面板内部需要多个独立状态,而不是一个全局 loading

panel.loadingword.loadingsentenceTranslation.loadingsentenceExplanation.loadingspeech.statusvocabulary.status

用户交互还会引入取消问题。比如用户连续划选三个词,如果第一个词的请求最慢返回,就不能把第一个词的释义覆盖到第三个词的面板里。

常见做法是给每次选区生成一个请求标识:

selectionRequestId = 当前时间 + 随机数请求返回时:  如果 requestId 不是当前面板的 requestId    -> 丢弃结果  否则    -> 更新面板

TTS 更明显。用户正在听一句话,又点击另一个单词的发音,系统应该中断旧请求或旧播放,而不是让两个声音叠在一起。

一个小面板里同时存在缓存、并行请求、过期结果丢弃和最新请求优先。它不像页面主流程那样显眼,但如果这些边界没处理好,用户会非常直接地感受到混乱。

发音不是调用一下 speechSynthesis

发音功能看起来也很小。浏览器有 Web Speech API:

speechSynthesis.speak(new SpeechSynthesisUtterance(text))

如果只做本地朗读,这确实可以起步。但扩展环境下会遇到几个现实问题:

所以我把发音理解成一条独立链路:

flowchart LR
  A["面板点击发音"] --> B["Content Script"]
  B --> C["Background 合成 / 缓存"]
  C --> D["IndexedDB 音频 chunk"]
  C --> E["Offscreen Document 播放"]
  E --> F["播放状态事件"]
  F --> B
  B --> G["面板按钮状态"]

这样做的好处是面板只关心“正在播放、暂停、恢复、停止”,不需要知道音频到底来自浏览器本地 voice,还是远程 TTS 合成。

当然,这也意味着一个小喇叭按钮背后会多出一套请求取消、分块缓存、播放状态广播和 UI 同步逻辑。用户看到的是一个图标,工程上却不能把它当图标处理。

在别人的页面上渲染 UI,要克制

划词面板运行在网页里,但它不属于网页。

这是浏览器扩展 Content Script 的典型矛盾:你必须把 UI 插进当前页面,才能贴近用户正在阅读的内容;但你又不能让宿主页面的 CSS 影响面板,也不能让扩展样式污染宿主页面。

Shadow DOM 很适合这个场景。面板、tooltip、按钮、侧栏这类扩展 UI 可以放在 Shadow Root 里:

document.body  └── parallel-shadow-host        └── shadowRoot              └── SelectionPanel

这样做可以隔离大部分样式冲突。页面里的 button { all: unset; } 不会把面板按钮洗掉,扩展里的 Tailwind 样式也不会改掉网页按钮。

但 Shadow DOM 不是万能的。它只能帮你隔离样式和 DOM 作用域,不能替你处理这些问题:

这些问题最终都要求划词面板有自己的边界意识。它必须像一个外来组件一样小心进入页面:只处理自己的事件,只占用必要空间,只在用户需要时出现,关闭后不留下痕迹。

生词状态让面板变成系统入口

如果划词面板只是一个临时翻译框,它的生命周期很短:选中、请求、展示、关闭。

但一旦加入“生词本”和“已掌握”,它就变成了学习系统的入口。

用户在面板里把一个词加入生词本,页面正文里的生词标注要同步;用户标记已掌握,后续页面里的高亮应该弱化或隐藏;用户在生词侧栏里查看同一个词,面板和侧栏不应显示互相矛盾的状态。

这要求状态不能只存在于面板组件里:

flowchart LR
  A["Selection Panel"] --> B["Content Controller"]
  B --> C["Background Vocabulary Service"]
  C --> D["IndexedDB userVocabulary"]
  D --> C
  C --> B
  B --> E["正文标注"]
  B --> F["生词侧栏"]

这里最容易出问题的是“局部乐观更新”。为了让按钮反馈更快,点击加入生词后可以先把按钮点亮;但如果后台写入失败,状态又要回滚或提示。为了让页面标注及时变化,Content Script 还要知道用户词库刚刚更新过。

小项目里可以先选择保守方案:后台写入成功后再刷新相关状态。这样即时性稍弱,但一致性更容易保证。等交互稳定后,再做局部乐观更新。

这也是我做这个面板时逐渐明确的边界:面板不应该直接操作 IndexedDB 表,也不应该自己维护一套长期词库状态。它只发出“加入新词”“标记已掌握”这类意图,由 Background 统一写入和广播。

AI 详解要被关在明确的栏目里

现在很多工具一接入 AI,就会把所有功能做成聊天框。划词面板也很容易走向这个方向:用户选中一句话后,允许追问、改写、总结、造句、解释语法,最后面板变成一个迷你聊天应用。

这和 Parallel Translate 的目标不一致。

划词面板是阅读中的临时支架,不应该把用户从阅读带到对话里。所以我更倾向于让 AI 句子详解输出固定栏目:

句子结构语法要点学习提示

这有几个好处:

同样,单词详情里的 AI 增强也应该服务于词汇学习,而不是输出一篇百科文章。比如当前语境释义、用法要点、易混辨析、例句分析,这些都比“随便问 AI”更适合嵌入阅读流程。

AI 能力越强,产品越需要收窄它的入口。否则一个安静的划词面板很快就会变成注意力黑洞。

一个小面板,实际上横跨四个上下文

从架构上看,划词面板至少横跨四个地方:

flowchart TD
  A["网页 DOM<br/>Selection / Range / TextNode"] --> B["Content Script<br/>面板控制器"]
  B --> C["Shadow DOM<br/>React UI"]
  B --> D["Background<br/>词典 / 翻译 / TTS / 词库"]
  D --> E["IndexedDB<br/>缓存与用户词库"]
  D --> F["外部服务<br/>翻译 / 词典 / AI / 语音"]
  D --> G["Offscreen Document<br/>音频播放"]
  G --> B

这就是它难做的根本原因:用户看到的是一个浮层,工程上它不是一个孤立组件。

它要从网页 DOM 获得上下文,在 Shadow DOM 里渲染 UI,通过扩展消息找 Background 做请求编排,把结果写进 IndexedDB,还要让 TTS 在 offscreen document 里播放,再把状态同步回面板。

如果这些职责没有拆开,代码很快会变成一个巨大的 content script:一边处理 selection,一边发 fetch,一边读写 IndexedDB,一边拼 React 状态,一边控制音频播放。

我更能接受的边界是:

这样的拆分不一定最少代码,但它能让每个问题停留在自己的层里。

结语

划词翻译面板难做,不是因为 UI 多复杂,也不是因为翻译 API 多难调。

它难在“即时”和“可靠”同时成立。

用户划一下词,面板要立刻出现;但它不能误判选区,不能跑到屏幕外,不能被网页样式污染,不能因为一个请求失败就整体不可用,不能让过期请求覆盖新结果,不能让发音状态和按钮状态脱节,也不能让生词状态在页面、侧栏和设置页之间互相打架。

做 Parallel Translate 之前,我会把划词面板理解成翻译插件的附属功能。现在我更愿意把它看成一个微型阅读工作台:它贴着文本出现,帮助用户解决眼前这一处理解障碍,然后尽快退回到背景里。

这种克制比功能堆叠更难。

因为真正好的划词面板,不是让用户觉得“这里有很多 AI 能力”,而是让用户在阅读中几乎不需要思考工具本身。


Share this post on: