最近不少朋友在问 Chrome 侧边栏开发的事情。我手头刚好有一个用sidePanelAPI 实现的扩展项目从原型到上架折腾了好几轮踩了不少文档里没写明白的坑。这篇文章就把整个过程拆开来讲侧边栏到底适合做什么、最小示例怎么写、怎么和网页交互、上线前要检查什么。内容主要以 Chrome 扩展 Manifest V3 为准代码可以直接抄原理也会解释清楚适合前端开发者、扩展开发者或者准备做浏览器端 AI 助手工具的同学参考。1. 侧边栏方案为什么值得做1.1 三种常见扩展 UI 的定位差异早期做 Chrome 扩展UI 层无非三个选择popup点击图标后的弹层、content script往页面里注入悬浮面板、或者干脆在后台页里维护一个新标签页。这三种方案其实都有明显的短板。Popup 最典型的问题就是“一碰就没”。你点击扩展图标弹出一个小窗口但只要焦点移到页面上它立刻就关闭了。用户想在里面翻看一段长文本、复制几条信息稍微操作慢一点就前功尽弃。如果想在 popup 里做一个需要持续输入的工具体验会非常差。Content script 注入悬浮面板是很多团队的第一选择毕竟能完全控制样式。但实际做起来有两个很烦的问题一是页面的 CSS 可能会污染你的面板样式反过来你的面板也可能影响原页面布局通常还得上 shadow DOM 做隔离二是页面上任何 z-index 很高的弹窗都可能盖住你的悬浮区域你永远不知道用户会在哪个网站遇到什么“叠罗汉”的情况。侧边栏是 Chrome 原生提供的停靠区域天然避开了上面两类问题。它更像一个独立的应用窗口但又在浏览器框架之内用户随时可以看到它在右侧也可以手动拖到左侧展开。相比 popup它不会因为鼠标失焦就关闭相比注入式面板它不占用页面空间也不会被页面元素遮挡。它适合做那些需要“长时间停靠 持续操作”的工具比如 AI 对话助手、即时翻译、SEO 分析面板、开发调试工具、收藏夹管理之类的。1.2 Chrome sidePanel API 能做什么、不能做什么Chrome 从 114 版本开始稳住了chrome.sidePanelAPIopen方法要到 116 才可用并且它强制要求 Manifest V3。如果项目还在用 V2就得先整体迁移这不是一个可选项。chrome.sidePanel的核心能力相当聚焦就四个方法API作用关键参数setOptions配置侧边栏的启用状态和页面路径tabId、enabled、pathgetOptions查询某个标签页的侧边栏配置tabIdsetPanelBehavior设置点击扩展图标时是否自动打开侧边栏openPanelOnActionClickopen以编程方式打开侧边栏需要用户手势触发tabId、windowId这里有两个很容易误解的点我多说一句。第一侧边栏没有控制窗口停靠位置的能力。用户右键点击侧边栏标题栏可以在左侧或右侧停靠之间切换但开发者无法在代码里指定“我的面板必须显示在右侧”。这个限制其实是合理的——停靠方向是浏览器层面的用户偏好如果每个扩展都能强制改变页面体验就乱套了。所以设计侧边栏 UI 时要默认它的宽度在 300 到 480 像素之间并且左右两侧都能正常工作。第二侧边栏页面本身是一个扩展页面拥有完整的浏览器 API 访问权限但它没有“当前标签页 DOM”的访问权限。想读取页面标题、URL用chrome.tabs就行想读取或修改页面内容必须配合 content script 或chrome.scripting.executeScript。这个架构区别很关键相当于侧边栏是一个独立的“控制器”页面数据要靠注入脚本去“搬运”。2. 最小可运行示例十分钟搭出骨架2.1 目录结构与 manifest 配置要点先看一个完整的最小项目长什么样sidebar-demo/ ├── manifest.json ├── background.js ├── sidepanel.html ├── sidepanel.js └── icons/ ├── icon16.png ├── icon48.png └── icon128.png没有 node_modules、没有构建工具就是一个最朴素的扩展工程。核心文件只有三个manifest.json声明扩展能力background.js是 service workersidepanel.html是自己注册的侧边栏页面。{ manifest_version: 3, name: Sidebar Demo, version: 1.0.0, description: 一个展示 Chrome 侧边栏开发的最小示例, minimum_chrome_version: 116, permissions: [sidePanel, activeTab, scripting], action: { default_title: 打开侧边栏 }, background: { service_worker: background.js }, side_panel: { default_path: sidepanel.html }, icons: { 16: icons/icon16.png, 48: icons/icon48.png, 128: icons/icon128.png } }side_panel.default_path就是侧边栏页面的入口写在 manifest 里是必须的。minimum_chrome_version建议直接写116因为一旦你在代码里用到chrome.sidePanel.open旧版本连 API 都不存在报错会非常难查。permissions里我放了三个sidePanel是必需的activeTab和scripting是为了后面做“读取页面内容”用的。很多新手一上来就申请host_permissions比如all_urls其实完全没必要activeTab在用户点击扩展图标或侧边栏被打开时会临时授权当前活动标签页这样既能拿到页面数据又不会让权限提示吓跑用户。2.2 用一句话让点击图标打开侧边栏background.js里只需要一段代码chrome.runtime.onInstalled.addListener(() { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); });setPanelBehavior({ openPanelOnActionClick: true })的效果是用户点击工具栏上的扩展图标时Chrome 自动打开这个扩展的侧边栏。注意这里有一个容易踩的坑——一旦开启openPanelOnActionClick点击图标这个动作就被绑定为“打开侧边栏”chrome.action.onClicked事件就不会再触发了。如果你希望在打开侧边栏之前先做点什么比如记录日志、初始化数据正确的做法是不开启openPanelOnActionClick而是在action.onClicked里自己写逻辑chrome.action.onClicked.addListener(async (tab) { // 先做自己的初始化逻辑 await chrome.storage.session.set({ lastOpenedAt: Date.now() }); // 再打开侧边栏Chrome 116 支持在用户手势回调中调用 open await chrome.sidePanel.open({ tabId: tab.id }); });chrome.sidePanel.open必须在用户手势的调用栈里执行否则会被浏览器直接拒绝。点击图标本身是一个合格的手势所以这个写法是安全的但要注意如果你从 timers、message 回调里直接调用open会看到类似“chrome.sidePanel.open()must be called during a user gesture”的报错后面排查章节我再展开讲。2.3 在侧边栏里读取当前标签页信息sidepanel.html先给一个最基础的展示页面包含标题、地址和一个刷新按钮!DOCTYPE html html langzh-CN head meta charsetUTF-8 title侧边栏示例/title style :root { color-scheme: light dark; } body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; box-sizing: border-box; } .page-card { border: 1px solid rgba(128, 128, 128, 0.3); border-radius: 8px; padding: 12px; margin-bottom: 12px; } .page-card p { margin: 6px 0; word-break: break-all; } button { width: 100%; padding: 8px; border: none; border-radius: 6px; background: #2563eb; color: #fff; font-size: 14px; cursor: pointer; } /style /head body h3当前标签页/h3 div classpage-card pstrong标题/strongspan idpageTitle加载中.../span/p pstrong地址/strongspan idpageUrl加载中.../span/p /div button idrefreshBtn刷新信息/button script srcsidepanel.js/script /body /html对应的sidepanel.jsconst pageTitle document.getElementById(pageTitle); const pageUrl document.getElementById(pageUrl); const refreshBtn document.getElementById(refreshBtn); async function updatePageInfo() { const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); if (tab) { pageTitle.textContent tab.title || 无标题; pageUrl.textContent tab.url || 无法访问; } else { pageTitle.textContent 未找到活动标签页; pageUrl.textContent ; } } refreshBtn.addEventListener(click, updatePageInfo); updatePageInfo();为什么用chrome.tabs.query({ active: true, currentWindow: true })因为侧边栏打开时它本身不是一个标签页活动标签页仍然是用户正在看的那个页面。currentWindow: true是为了防止用户开了多个 Chrome 窗口时取到别的窗口的活动标签页。这里有个细节如果用户把侧边栏“固定”了点侧边栏右上角的图钉按钮那么切换标签页时侧边栏会保持打开。此时页面上显示的“当前标签页信息”还是旧的所以最好监听tabs.onActivated事件来主动刷新内容这个我在第 3 节会给出完整代码。3. 做点更实用的侧边栏与页面双向交互3.1 读取页面选中的文本并回填到面板很多侧边栏工具的价值在于“感知当前页面”。比如 AI 助手侧边栏用户可能选中页面里的一段英文字符然后点侧边栏里的“翻译”按钮。要实现这个交互需要用到chrome.scripting.executeScript。它可以把一段函数注入到当前标签页执行然后把返回值传回侧边栏async function getSelectedText() { const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) return ; try { const results await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () { const selection window.getSelection(); return selection ? selection.toString() : ; } }); return results[0]?.result || ; } catch (err) { return ; } }这段代码里有两个隐蔽的坑。第一个坑在func参数。chrome.scripting.executeScript的func会被序列化后注入页面执行所以它不能直接引用外部变量你传入的函数必须是完全自包含的。想传参数的话要用args字段const results await chrome.scripting.executeScript({ target: { tabId: tab.id }, args: [keyword], func: (keyword) { return document.body.innerText.includes(keyword); } });第二个坑是执行权限问题。activeTab权限只在“用户与扩展交互”时才被授予比如点击 action、右键菜单等场景。侧边栏打开后如果用户什么都不点直接在页面上选中文字然后切到侧边栏点击按钮此刻activeTab往往已经失效了executeScript会报“Cannot access contents of the page”。两种解法一是申请host_permissions二是用tabs.onUpdated/tabs.onActivated监听页面切换事件在侧边栏打开期间保持一次“临时授权”。实测下来最稳妥的还是根据需要最小化权限让用户点击按钮后主动触发一次交互从而重新获得activeTab。3.2 按浏览器页面状态动态控制侧边栏不是每个页面都适合让侧边栏出现。比如你打开chrome://extensions这类浏览器内部页面扩展的权限会被限制侧边栏即使打开也什么都读不到。更合理的做法是主动判断在这种页面直接把侧边栏禁用掉。用chrome.sidePanel.setOptions可以实现按标签页动态开关chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) { if (changeInfo.status ! complete || !tab.url) return; const isRestricted tab.url.startsWith(chrome://) || tab.url.startsWith(https://chrome.google.com/webstore); chrome.sidePanel .setOptions({ tabId, enabled: !isRestricted, path: sidepanel.html }) .catch(() { // 标签页可能已经被关闭忽略即可 }); });这段代码的意图很清晰监听标签页导航完成事件每次 URL 变化都动态更新侧边栏配置。setOptions的第二个参数path是可选的不传就保持 manifest 里的默认路径传了的话可以让同一个扩展在不同页面显示不同内容的侧边栏。比如在视频页面显示“播放列表”在文章页面显示“摘要工具”这就是通过path字段实现的。顺带一提setOptions是按tabId粒度配置的。如果不传tabId就代表全局配置。这个全局与局部的优先级关系是tabId级别的配置会覆盖全局配置。所以在做“个别站点禁用侧边栏”功能时全局保持启用只针对特定tabId设置enabled: false即可。3.3 标签页切换时让侧边栏内容跟着变化默认情况下用户点击侧边栏之外的页面区域时侧边栏并不会自动刷新。如果你做的是一个“页面增强工具”必须监听标签页切换否则用户从 A 网页切到 B 网页侧边栏上还留着 A 页面的信息会造成明显误导。在侧边栏的页面脚本里也就是sidepanel.js可以直接监听浏览器事件chrome.tabs.onActivated.addListener(async ({ tabId }) { try { const tab await chrome.tabs.get(tabId); updateView(tab); } catch { // 标签页可能已被关闭 } }); chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) { if (changeInfo.status complete tab.active) { updateView(tab); } });注意chrome.tabs.onUpdated在侧边栏页面里监听是可行的因为扩展页面共享同一个扩展上下文。我在实际项目中是把“获取当前标签页信息”的方法抽成一个updateView(tab)函数然后所有入口都调用它。这样避免在多处重复维护获取标签页的逻辑。如果你的侧边栏需要和 service worker 保持实时通信更推荐用chrome.runtime.connect建立长连接。普通chrome.runtime.sendMessage是一次性请求适合“点一下问一下”的场景长连接则适合持续推送状态比如 service worker 里监测到下载进度、网络状态变化推送到侧边栏更新 UI。长连接的代码结构大概是// sidepanel.js const port chrome.runtime.connect({ name: sidebar }); port.onMessage.addListener((msg) { if (msg.type UPDATE_STATUS) { // 更新界面 } }); // background.js chrome.runtime.onConnect.addListener((port) { if (port.name ! sidebar) return; port.postMessage({ type: UPDATE_STATUS, payload: ready }); });长连接还有一个好处就是当 service worker 因为空闲被浏览器回收时扩展页面可以通过这个连接把它唤醒保证侧边栏的交互不掉线。4. 常见报错与排查实录开发侧边栏的过程中我遇到过的典型问题基本可以汇总成一张速查表现象原因解决方案点击图标没反应未声明sidePanel权限 / 未调用setPanelBehavior检查 manifest 权限确认background.js已注册open报错Chrome 版本低于 116或调用栈不在用户手势内升级浏览器改为在action.onClicked回调里调用页面内容读不到访问了chrome://等受限页面 /activeTab权限丢失对受限页面做降级处理在用户交互后重新获得授权多窗口状态错乱侧边栏按窗口独立管理用storage.session同步状态点击图标后不触发onClickedopenPanelOnActionClick抢占了 action二选一不要同时依赖样式和预期不一致侧边栏宽度不固定用户可调整停靠方向用响应式布局测试左侧停靠4.1 点击图标没有任何反应这是最基础也是最常见的问题。先检查三件事第一manifest.json的permissions里有没有sidePanel。少了这个权限所有chrome.sidePanel调用都会在运行时直接报Cannot read properties of undefined因为 API 压根没挂载。第二background.js有没有被执行。在chrome://extensions页面找到你的扩展点击“Service Worker”链接打开后看 console 有没有报错。如果setPanelBehavior被放在一个没被调用的分支里等于白写。第三是否重复设置了action.onClicked。一个真实案例我一开始先写了chrome.action.onClicked.addListener(...)在里面调sidePanel.open后面又加了一行setPanelBehavior({ openPanelOnActionClick: true })。结果点击图标后行为变得不可预测一会儿打开一会儿不打开。原因就是两个入口抢占了 action 的点击。最终的解法是二选一要么只依赖setPanelBehavior自动打开要么只依赖onClicked手动open不要同时写两套逻辑。4.2 open 方法“不生效”或直接报错如果你的 Chrome 版本是 114 或 115chrome.sidePanel.open根本不存在调用会得到一个TypeError。解决方法是把minimum_chrome_version设置到 116并在升级日志里明确告诉用户“请升级浏览器”。即使版本达标也还有一个很隐蔽的坑用户手势的判定。我试过在一条消息里监听网络请求当请求完成时调用chrome.sidePanel.open结果稳定失败报错信息大概意思是“必须在用户手势中调用”。这是浏览器的防滥用机制——不能让扩展在后台静默弹出侧边栏。如果你的业务确实需要“某个事件发生后打开侧边栏”最好的替代方案是做成通知条让用户点击通知后再打开侧边栏这样就把“用户点击”作为了合法手势。4.3 侧边栏在部分页面读取不到任何信息浏览器对内部页面和扩展商店有硬性限制。在chrome://开头、https://chrome.google.com/webstore的页面上chrome.tabs.query能拿到基本的 tab 信息但executeScript会被拒绝content script 也不会注入。如果侧边栏在这些页面里尝试读取 DOM就会返回空或者直接抛错。我的处理方式是在updateView里做一层兜底判断function isRestrictedPage(tabUrl) { return ( !tabUrl || tabUrl.startsWith(chrome://) || tabUrl.startsWith(https://chrome.google.com/webstore) ); }然后根据判断结果在侧边栏里展示一个友好提示比如“此页面不支持内容提取”而不是显示一片空白。这个降级处理是上线前必须做的否则用户会在无法理解的页面里看到一个像“坏掉”了的侧边栏。4.4 多个窗口的侧边栏状态对不上侧边栏是按窗口独立管理的。用户开两个 Chrome 窗口窗口 A 的侧边栏打开了窗口 B 可能没打开即便都打开了各自显示的内容也可能不一样。如果业务需要全局统一状态最简单的做法是用chrome.storage.session来保存共享数据。storage.session的特点是只在浏览器会话期间有效扩展重启后清空非常适合做临时状态同步。配合chrome.storage.onChanged监听可以让多个窗口的侧边栏保持同步// 写入状态 await chrome.storage.session.set({ currentTabId: tabId }); // 监听变化 chrome.storage.onChanged.addListener((changes, areaName) { if (areaName session changes.currentTabId) { // 更新当前窗口的 UI } });别用storage.local做这种高频临时状态因为它是持久化的浏览器关了再开旧状态还写在里面反而会污染新会话。5. 调试、发布与后续扩展建议5.1 开发期的高效调试技巧侧边栏的调试比普通扩展多一个步骤因为它的 UI 既不是 popup也不是新标签页。最常用的调试路径是在chrome://extensions页面找到扩展卡片点“Service Worker”打开后台的 DevTools这里能看到 service worker 的 console 日志。侧边栏页面本身要调试的话可以直接在侧边栏区域右键选择“检查”就能打开对应页面的 DevTools查看 DOM 和 CSS。还有一个我每次都要提醒自己的点manifest.json里改了配置比如新增权限需要去扩展管理页点一下刷新按钮让扩展重新加载而改sidepanel.html或sidepanel.js很多时候直接刷新侧边栏所在的标签页即可生效。如果你改了代码却没看到效果先确认是不是缓存问题。关于日志前期开发建议在关键分支里打console.log比如setOptions的 .catch 里一定要打点否则标签页相关的配置错误会被静默吞掉。比如chrome.sidePanel.setOptions({ tabId, enabled: false }).catch((e) { console.warn(setOptions failed, tabId, e); });5.2 发布前检查清单与上架注意事项Chrome 应用商店对扩展的审核这几年越来越严格侧边栏类扩展由于“长期占据浏览器界面”更要注重正当用途。我整理了一份自查清单权限最小化不要申请all_urls主机权限优先用activeTab只有在明确需要后台自动化操作的场景下才申请host_permissions。侧边栏用途说明在 Chrome 商店的“隐私权”部分如实填写数据用途尤其是涉及用户浏览历史、页面内容的必须写清楚“本地处理不会上传服务器”或“会上传至 XXX 服务用于 AI 识别”等。图标与截屏商店会要求提供至少一张侧边栏界面的截图建议真机截图不要把设计稿直接截图交上去。隐私与免责声明如果你的侧边栏接入了第三方大模型 API需要在隐私政策里明确说明数据会发送到哪个服务商否则很容易被审核打回。版本兼容说明在描述里写明“需要 Chrome 116 及以上版本”。审核被拒的情况大多与“权限滥用”有关。我见过一个案例某 AI 助手扩展申请了访问所有网站数据的权限但实际只在用户激活时才读取页面内容。后来把权限改成activeTabscripting一次性就过审了。侧边栏场景天然就是用户主动打开、主动点击的所以activeTab基本够用。5.3 从示例走向产品的三个扩展方向如果你已经跑通了上面的最小示例下一步可以根据自己的场景往三个方向扩展。第一个方向是“多面板切换”。一个扩展可以注册多个sidepanel.html通过setOptions({ path: ... })在不同页面或不同配置下切换。比如电商比价扩展在商品详情页显示“历史价格”在购物车页显示“运费对比”两块面板互不干扰。实现思路是在tabs.onUpdated里判断当前 URL 类型再动态改path。第二个方向是“与 content script 深度协作”。侧边栏本身无法访问页面 DOM但 content script 可以。你可以让 content script 监听页面上的用户行为比如划词、点击、表单输入再把消息通过chrome.runtime.sendMessage转给侧边栏实现“页面里选什么侧边栏就分析什么”的联动体验。需要注意消息方向content script 在页面上下文里侧边栏在扩展页面上下文里它们之间不直接通信必须经过 service worker 中转或用chrome.runtime.connect打通。第三个方向是“构建常驻工作台”。侧边栏虽然不能后台常驻但用户固定后它在浏览期间的体验接近常驻。适合做开发工具面板、笔记速记、待办清单、客服工作台这类应用。这类产品要特别注意状态保存用storage.session保留会话状态用storage.local保存用户设置刷新侧边栏才不至于丢数据。我在实际项目中体会最深的一点是侧边栏真正的价值不是“多一个弹窗”而是把工具从“点击一下用完就走”变成“浏览过程中始终在旁边待命”。这听起来简单但对交互设计的改变非常大。最后再分享一个小技巧在侧边栏页面里给整个body设置min-width: 320px再配合resize observer监听宽度变化这样无论用户把侧边栏拖宽还是拖窄界面布局都不会崩。这个小细节我第一次上线时漏掉了结果用户在窄侧边栏模式下看到的按钮全部挤成一团反馈截图一张比一张离谱。侧边栏开发本身不难难的是把这一小块常驻区域当成一个真正的产品去打磨。