前两篇写 Parallel Translate 时,我分别讲了两个比较大的问题:如何把网页变成可恢复的双语阅读视图,以及如何组织 AI 翻译扩展里的缓存、批处理和 Provider。
这篇想写一个看起来小得多的入口:划词翻译面板。
用户眼里,它只是选中一个单词或一句话后出现的小浮层。里面有释义、翻译、发音、复制、加入生词本,最多再加一个 AI 句子详解按钮。
但真正做起来后,我发现划词面板并不是“给 mouseup 绑一个弹窗”那么简单。它处在几个系统的交界处:
- 它依赖网页里的真实选区
- 它要在陌生页面上渲染自己的 UI
- 它要判断当前选中的是单词、短语还是句子
- 它要同时调用词典、翻译、AI 和语音能力
- 它还要和用户词库、学习状态、页面标注保持一致
如果整页翻译考验的是“如何改造页面”,划词面板考验的就是“如何在不拥有页面的情况下,做一个稳定、即时、可中断的小工具”。
难点不是弹出,而是解释选区
浏览器提供了 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["复制结果"]
如果这两条链路共用太多状态,后面会很难维护。例如:
- 单词可以加入生词本,句子不应该出现这个动作
- 句子可以展开 AI 结构分析,单词不应该变成聊天窗口
- 单词查询可以优先走词典,句子翻译更适合走翻译 Provider
- 单词面板适合紧凑展示,句子面板需要容纳更长内容
我更愿意在状态模型里明确区分:
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))
如果只做本地朗读,这确实可以起步。但扩展环境下会遇到几个现实问题:
- 不同系统和浏览器可用 voice 差异很大
- 页面本身的媒体策略和 CSP 可能干扰播放
- Manifest V3 的 Background Service Worker 不能像普通页面一样长期持有音频状态
- 远程 TTS 需要缓存,否则重复点击同一个词会浪费请求
- 长句需要分块,否则合成请求和播放控制都不稳定
所以我把发音理解成一条独立链路:
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 作用域,不能替你处理这些问题:
- 面板的
z-index是否高过页面浮层 - fixed 定位是否被宿主页面 transform 影响
- 点击面板时是否应该阻止页面自己的 selection 逻辑
- 面板内 tooltip、popover 是否能正确挂载到 Shadow Root
- 页面滚动和 resize 后是否要重新定位
这些问题最终都要求划词面板有自己的边界意识。它必须像一个外来组件一样小心进入页面:只处理自己的事件,只占用必要空间,只在用户需要时出现,关闭后不留下痕迹。
生词状态让面板变成系统入口
如果划词面板只是一个临时翻译框,它的生命周期很短:选中、请求、展示、关闭。
但一旦加入“生词本”和“已掌握”,它就变成了学习系统的入口。
用户在面板里把一个词加入生词本,页面正文里的生词标注要同步;用户标记已掌握,后续页面里的高亮应该弱化或隐藏;用户在生词侧栏里查看同一个词,面板和侧栏不应显示互相矛盾的状态。
这要求状态不能只存在于面板组件里:
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 句子详解输出固定栏目:
句子结构语法要点学习提示
这有几个好处:
- 用户不用理解 prompt,也不用选择 AI 动作
- UI 可以稳定设计,不会被任意长回答撑坏
- 缓存键和结果结构更明确
- 失败时可以局部隐藏,不影响基础翻译
- 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 状态,一边控制音频播放。
我更能接受的边界是:
- Content Script 负责选区、定位、页面上下文和生命周期
- React 面板负责展示和用户操作
- Background 负责词典、翻译、AI、TTS、缓存和用户词库
- IndexedDB 只通过窄接口读写,不暴露给 UI 组件
- 外部 Provider 通过 Adapter 接入,不进入面板分支
这样的拆分不一定最少代码,但它能让每个问题停留在自己的层里。
结语
划词翻译面板难做,不是因为 UI 多复杂,也不是因为翻译 API 多难调。
它难在“即时”和“可靠”同时成立。
用户划一下词,面板要立刻出现;但它不能误判选区,不能跑到屏幕外,不能被网页样式污染,不能因为一个请求失败就整体不可用,不能让过期请求覆盖新结果,不能让发音状态和按钮状态脱节,也不能让生词状态在页面、侧栏和设置页之间互相打架。
做 Parallel Translate 之前,我会把划词面板理解成翻译插件的附属功能。现在我更愿意把它看成一个微型阅读工作台:它贴着文本出现,帮助用户解决眼前这一处理解障碍,然后尽快退回到背景里。
这种克制比功能堆叠更难。
因为真正好的划词面板,不是让用户觉得“这里有很多 AI 能力”,而是让用户在阅读中几乎不需要思考工具本身。