众所周知,目前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 对象(防止被检测/被调用)。
于是问题链条是这样的:
- content-script 在 MAIN world 里跑,它眼里的
window就是页面的window; - 掘金把
window.chrome删了,所以chrome在页面里是undefined; - 我的脚本一上来就执行
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为什么"自带" :sender是onMessage回调的第二个参数,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 个坑踩完,最大的收获不是代码,是几个认知:
- isolated world 才是插件的默认选择,MAIN world 是特例
- MV3 的"手势限制"不是 bug,是安全设计------它逼你想清楚消息链路里哪些操作必须在同步阶段完成
- Shadow DOM 不是"加个 shadow root 就完事" ,所有事件判断都要考虑边界穿越
- 流式需求是一把刀,用"返回结构一致性"把它收进 API 设计里,而不是拆架构
- 先看后端日志,再查前端代码------出 bug 时,分清责任方比猜更快
项目还在持续开发中(下一步做会话持久化和基于 LangGraph 的深度研究),踩坑还在继续。如果你也在做浏览器插件,欢迎交流。