把智能体塞进浏览器侧边栏:我在 MV3 里踩的 5 个坑

众所周知,目前agent潮流兴起,workflow,harness,rag词汇到处飞舞。借助前期开源的浏览器插件模板,我尝试做了一款浏览器智能插件,可以帮助我快速问答,记录,以及翻译等工作,后续会结合新型技术,逐步实现高收益化有价值的功能,从实践中学习,如果你有兴趣,也可以发起pr


开场

承接上一篇浏览器插件agent已经过去三个多月,没更新是因为博主实习太忙了,加上自己在搞其他的内容,现在又回来继续更新这款插件了。 先贴一个github仓库地址,如果你不了解浏览器插件,或者想简单上手AI相关内容,可以参考参考(虽然我也是小菜鸡xd)地址:Maxkim321/mcp-browser-analyzer: AI可调用 Chrome 插件,实现截图、页面信息、日志捕获、Lighthouse 性能分析(基于 MCP 协议,Vue3+Node 开发)

我写了个 Chrome 插件,功能是"浏览器侧边栏 AI 助手":不切页面就能问 AI、划词翻译、总结整篇文章。当时我想,这不就是个嵌在浏览器里的对话窗口嘛,能有多难。

结果从第三天开始,我陆续踩了 5 个坑。每个坑都让我觉得"Chrome 这 API 设计得也太不讲道理了",但排查到根因之后,又不得不承认:人家这么设计,确实有道理。

这篇文章不讲"怎么开发插件"的教程,就讲我踩过的这 5 个坑------现象、排查过程、根因、以及最终的解决方案。这些坑绝大多数网上搜不到现成答案,只能靠日志和试错慢慢磨出来。

如果你没写过 Chrome 扩展,建议先花 5 分钟读下面的前置知识,否则后面的"content-script""MAIN world"这些词会像天书。写过的可以直接跳过。


前置知识:Chrome 扩展是怎么组织的(5 分钟入门)

扩展 = 一个"声明文件" + 三个"角色"

一个扩展的根目录里有个 manifest.json(清单文件)。MV3 就是 Manifest V3,即清单文件的第三版规范,Chrome 现在主推的版本。它声明了扩展叫什么、要什么权限、有哪些"角色"。

一个 MV3 扩展通常有三个主要角色:

角色 跑在哪 干什么
content-script 网页里(被浏览器"空投"进去的一段脚本) 读写页面 DOM:提取正文、监听划词、在页面上弹工具条
background(Service Worker) 扩展的后台,独立于任何网页 相当于扩展的"服务器":监听全局事件、转发消息、管理侧边栏。MV3 里它是 Service Worker,浏览器随时可能休眠它
sidepanel 浏览器侧边栏(Chrome 114+) 扩展自己的 UI 页面------我们这个 AI 助手的聊天界面就住在这里

它们的关系一句话:content-script 活在"网页的世界",background 活在"扩展的世界",sidepanel 是扩展自己的脸

两个世界:isolated world 和 MAIN world

这是全文最重要的概念,坑 1 就踩在这。

content-script 默认跑在 isolated world(隔离世界)

  • 在这里,chrome.* API(比如 chrome.runtime)是浏览器单独注入 给脚本的,页面上的 JS 摸不到、删不掉
  • 同时,页面自己的全局变量,content-script 也碰不到(页面 DOM 能读写,但全局变量是两个隔离的沙箱)。

如果配置成 MAIN world(主世界) ,content-script 就和页面 JS 共享同一个全局作用域------能互相读全局变量,但代价是"页面能改你的东西"(坑 1 就是这么炸的)。

三个角色怎么通信:chrome.runtime 消息

content-script 要给 background 传话,用 chrome.runtime.sendMessage() 发;background 用 chrome.runtime.onMessage.addListener() 收。这是插件内部的"对讲机"。

注意:这个"对讲机"是异步的------消息发出去要排队、跨上下文传递。坑 2 的手势问题就出在"传话太慢"。

插件的"大脑"在哪

插件自己干不了 AI 的活,它只是浏览器里的一个壳。我们的方案是:background 用 WebSocket 连本地 ws://localhost:9999 上的 Node 服务(agent-server,跑在你电脑上),agent-server 再调 DeepSeek 的 API。

整条链路是:

css 复制代码
网页(content-script) → 内部消息 → background → WebSocket → agent-server → DeepSeek

后续 5 个坑,全发生在这条链路的各个节点上。

先看整体架构图,方便对照:

整体架构图


坑 1:掘金页面把我的 content-script 搞崩了

现象

划词工具条在普通网站一切正常,一到掘金就完全没反应。控制台干干净净,没有报错------这反而最吓人。

排查

往下看,是掘金自己的全局错误捕获先拦到了:

js 复制代码
全局错误捕获: TypeError: Cannot read properties of undefined (reading 'onMessage')
    at index.js:2:33031

根因

我的 content-script 配置了 "world": "MAIN"------意味着它和页面 JS 共享同一个全局作用域 。掘金这类站点会做安全防护:主动删除 window.chrome 对象(防止被检测/被调用)。

于是问题链条是这样的:

  1. content-script 在 MAIN world 里跑,它眼里的 window 就是页面的 window
  2. 掘金把 window.chrome 删了,所以 chrome 在页面里是 undefined
  3. 我的脚本一上来就执行 chrome.runtime.onMessage(访问 undefined.runtime),直接抛 TypeError整个脚本中断------工具条自然永远弹不出来。

而"普通网站正常"是因为:那些网站没删 window.chrome

顺带一个好消息:现在 Chrome 已经把 window.chrome 设成不可删除的属性了,所以这个坑现在复现不出来。但理解"MAIN world = 和页面共享全局"这个机制仍然重要。

解决

content-script 改回默认的 isolated world ------world 字段省略时默认就是 ISOLATED,这里显式写出来,是为了让读者看清"默认值到底是啥":

js 复制代码
{
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content-script.js"],
    "run_at": "document_idle",
    "world": "ISOLATED"
  }]
}

注意:manifest 是标准 JSON(不支持注释),所以 world 的作用只能写在这段正文里。

isolated world 里,chrome.* API 是浏览器单独注入的,页面 JS 摸不到、删不掉。代价是不能和页面共享全局变量------但我的 Readability 提取、划词监听都不需要共享,纯赚。

这期间我还走了一次弯路 :一开始在 MAIN world 里改用 window.postMessage + 单独写一个 isolated 桥文件转发,确实也能用,但多了一层复杂度。后来想明白:除非必须操作页面自己的全局变量(比如页面框架暴露的对象),否则别用 MAIN world。删掉桥,一身轻松。


坑 2:sidePanel.open() 说我只配在"用户手势"里调用

现象

划词点「翻译」→ 日志报错,侧边栏死活不弹:

js 复制代码
handleTextAction failed: Error: `sidePanel.open()` may only be called in response to a user gesture.

什么是"用户手势"?

用户手势(user gesture)指的是"用户真的点了鼠标/键盘"这个动作。浏览器为了防滥用,规定某些能力(比如 chrome.sidePanel.open() 打开侧边栏)只能在用户手势的同步调用链里执行 ------也就是说,从"用户点击"到"调用 open",中间不能有 await、不能有异步消息。只要你 await 了一下,浏览器就认为"这不是用户直接操作的",拒绝执行。

排查

翻文档,MV3 里 chrome.sidePanel.open() 确实只能这样调用。我的链路是:

js 复制代码
content-script 点击 → runtime.sendMessage(异步) → background → await storage → 才调 open()

问题就在 runtime.sendMessage------消息是异步派发的,手势上下文在消息传递过程中就丢了 。我甚至在 background 里 await 了 storage 才去 open,手势早凉透了。

解决

用消息自带的 sender.tab.windowId,在同步阶段就调 open(零 await),storage 写入改成 fire-and-forget:

js 复制代码
// ===== background 侧 =====
// sender 由 Chrome 注入,无需定义:
// 消息来自 content-script 时,sender.tab 就是当前标签页,自带 windowId------不用自己传
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type !== 'text_action') return;

  // ① 同步阶段调 open(零 await)------任何 await 都会让手势上下文丢失
  chrome.sidePanel.open({ windowId: sender.tab.windowId }).catch(() => {});

  // ② 广播照发:已打开的侧边栏实时收到
  // ③ storage 兜底:刚触发 open、侧边栏监听器还没挂好时广播会丢,
  //    侧边栏 onMounted 时兜底读取;广播 + storage 两路用 msg.id 去重
  chrome.runtime.sendMessage({ type: 'text_action_relay', ...msg });
  chrome.storage.session.set({ [msg.id]: msg }); // fire-and-forget,不 await
});

解释两个关键点:

  • sender.tab.windowId 为什么"自带"senderonMessage 回调的第二个参数,Chrome 运行时自动注入。消息来自 content-script 时,sender.tab 就是那个标签页对象,windowId 天然在里面------所以"打开哪个窗口的侧边栏"根本不用自己传。
  • fire-and-forget 是什么 :只发消息、不等结果、不 await。storage 的写入结果我们不在乎,只求它别阻塞手势链路。

顺手还修了一个隐藏竞态:侧边栏刚打开时监听器还没挂好,广播会丢 。于是 background 先写 chrome.storage.session,侧边栏 onMounted 时兜底读取,广播 + storage 两路用唯一 id 去重。这是典型的"跨页面异步路由"问题:接收方未就绪,就用持久化存储兜底 + 幂等去重。


坑 3:Shadow DOM 的 contains() 陷阱

现象

工具条用 Shadow DOM 做的(隔离页面样式污染)。划词后点工具条里的按钮,第一次完全没反应

什么是 Shadow DOM?

Shadow DOM 是浏览器提供的一种"组件样式/结构隔离"机制:你可以在一个普通元素(叫 shadow host )上挂一棵独立的 DOM 树(叫 shadow root ),这棵树内部的元素,外部 CSS 管不着,外部 JS 直接 querySelector 也找不到。很多插件的浮层 UI 用它来防止被页面样式污染。

排查

工具条明明在页面上,点击事件却被吞了。加日志发现:mousedown 时我在判断"点的是不是工具条内部",判断结果是,于是把工具条隐藏了------按钮自然点不中。

根因

判断代码长这样:

js 复制代码
document.addEventListener('mousedown', (e) => {
  if (hostEl.contains(e.target)) return;  // 在工具条内,不隐藏
  hideSelectionBar();
});

问题:e.target 是 shadow root 内部的元素,而 hostEl.contains() 用的是普通 DOM 的 contains() 。对 Shadow DOM 内部节点,parentNode 关系链是断的------contains() 返回 false,工具条被误判为"点击了外部"。

解决

event.composedPath()(影子 DOM 穿越函数):

js 复制代码
document.addEventListener('mousedown', (e) => {
  if (e.composedPath().includes(hostEl)) return;  // 穿越 shadow 边界
  hideSelectionBar();
});

composedPath() 会返回包含 shadow root 内部节点在内的完整事件路径。这是所有在页面里用 Shadow DOM 做 UI 的插件迟早会踩的坑


坑 4:SSE 流式输出,差点把架构拆了

什么是 SSE?

SSE(Server-Sent Events)是"服务器单向推送"的协议:浏览器保持一个连接,服务器按块把数据推过来。做 AI 聊天要的"打字机效果",本质就是"token 一个一个流过来"。DeepSeek 这类大模型 API 支持这种流式输出。

现象

要实现打字机效果,得从后端流式拿 token。最省事的做法是:流式模式单独走一条 API,Agent 主循环特殊处理。但那样代码会分裂成两套。

设计

我的方案是:流式接口 chatStream 的返回结构,和非流式的 chat 完全一致 ------只是多了一个 onToken 回调,token 增量实时推给前端,最终结果照旧:

js 复制代码
// llm.js ------ chat() 和 chatStream() 返回结构一致
const response = await llm.chatStream(messages, tools, {
  onToken: (delta) => ws.send({ type: 'token', content: delta })
});
// response 和普通 chat 一样:{ content, tool_calls }

Agent 主循环零改动:工具轮(content 为空 + tool_calls)和文本轮在流式里天然区分。前端渲染层也从"等完整结果"改成了"增量 append + 最终覆盖",两处都稳。

面试里这算一个可讲的点:设计 API 时,流式/非流式的返回结构一致性,决定了你的架构会不会被流式需求撕开一道口子。


坑 5:后端一切正常,前端"卡死"------Vue 响应式的引用陷阱

现象

用户反馈:点「总结本页」后一直转圈,但后端日志显示早就返回了完整结果

排查

后端日志:正文提取 48469 字符 → LLM 输出完整总结 → 已发回前端。后端 100% 成功

前端:流式 token 到了,thinking 状态也清了,但界面就是不动。

什么是 Vue 的"响应式"?

Vue 3 用 Proxy 实现响应式:你写的普通对象,Vue 会包一层"代理",只有通过这个代理去读写属性,Vue 才知道"数据变了,去刷新界面"。直接持有原始对象改,Vue 完全无感。

根因

js 复制代码
const streaming = { content: '' };      // 普通对象
messages.value.push(streaming);          // push 进 reactive 数组
// ... 之后直接改原始引用:
streaming.content += delta;              // ❌ 不走 Vue 的 proxy setter!

Vue 的响应式只对通过 proxy 读取/写入的属性生效。直接持有原始对象引用改内容,Vue 根本不知道,界面不刷新,而且没有任何报错。

解决

从 reactive 数组里取 proxy 引用再改:

js 复制代码
const idx = messages.value.length - 1;
messages.value[idx].content += delta;     // ✅ 通过 proxy 访问

排查套路(面试可讲):前后端分层定位------先看后端日志确认"数据有没有产出",再查前端渲染。很多"卡死"其实是"渲染没刷新",不是"请求没完成"。


结尾

5 个坑踩完,最大的收获不是代码,是几个认知:

  1. isolated world 才是插件的默认选择,MAIN world 是特例
  2. MV3 的"手势限制"不是 bug,是安全设计------它逼你想清楚消息链路里哪些操作必须在同步阶段完成
  3. Shadow DOM 不是"加个 shadow root 就完事" ,所有事件判断都要考虑边界穿越
  4. 流式需求是一把刀,用"返回结构一致性"把它收进 API 设计里,而不是拆架构
  5. 先看后端日志,再查前端代码------出 bug 时,分清责任方比猜更快

项目还在持续开发中(下一步做会话持久化和基于 LangGraph 的深度研究),踩坑还在继续。如果你也在做浏览器插件,欢迎交流。

相关推荐
渣波1 小时前
告别 LLM 幻觉:用 Harness 工程化思维打造生产级 AI 应用
前端·javascript
XuCoder1 小时前
面试官:MySQL 索引是什么?这道题答全的人真不多
后端
特立独行的猫a1 小时前
我用 Rust + Tauri 2 复刻了 N_m3u8DL-CLI:一个 m3u8 多线程下载器
开发语言·后端·rust·m3u8·视频下载器·m3u8dl-cli
月光有害1 小时前
理解 Spring 依赖注入:从构造器注入到集合与条件 Bean
java·后端·spring
对象存储与RustFS1 小时前
大文件传到 48 GiB 就断:S3 分片上传的参数怎么算,以及那些没人清的残片
后端·rust·开源
foggyprojects1 小时前
客户说“销售额”,系统怎样落到订单含税金额或开票含税销售额?
后端
breeze jiang1 小时前
React Todos 前端独立开发全解:用 vite-plugin-mock + axios 封装,再也不等后端接口
前端·react.js·状态模式
cyadyx1 小时前
【vue】Pinia相对Vuex
前端·javascript·vue.js
念何架构之路1 小时前
路由注册:RouterGroup(routergroup.go)
开发语言·后端·golang