
之前做浏览器插件时一直在思考一个有点“不务正业”的问题如果打开任何一个网站页面右下角都有一个喜欢的角色陪伴浏览会不会让枯燥的查资料过程变得有趣很多说实话手动在每个网站里写一段脚本显然是行不通的但 Chrome 扩展的内容脚本Content Script可以做到。于是趁着周末搭了一个微型浏览器扩展让“普瑞赛斯”出现在我访问的每一个网页上。这种效果当然不是什么网络攻击而是一种前端的“视觉驻留”通过浏览器扩展向普通网页注入一段受控的、可关闭的 UI 组件。这个项目非常合适作为浏览器扩展入门案例因为它覆盖了 manifest 配置、资源隔离、动态 DOM 注入、本地存储通信、CSS 样式隔离等多个知识点。读完本文你会明白浏览器扩展是如何“钻进”普通网页的也会拥有一份可以随时改造成自己角色的悬浮助手 Demo。如果你之前完全没有写过扩展只要照着章节一步步来同样能在本地跑通。项目不依赖服务端不做任何网络请求适合个人学习、二次开发以及作为求职作品中的小型示例。1. 项目背景什么叫“让普瑞赛斯入侵互联网”1.1 这不是安全入侵是内容脚本注入“入侵整个互联网”听起来很危险但在本文的项目里它本质上是一个娱乐向的浏览器扩展。浏览器扩展通过 Chrome 提供的内容脚本机制可以在满足匹配规则的网页上下文中执行 JavaScript 和 CSS。内容脚本与网页 JavaScript 共享 DOM但运行在独立的“隔离世界”中无法直接访问页面里的全局变量也无法被页面脚本反向读取。所以“入侵”的真实含义是只要用户安装了扩展并打开了符合匹配规则的页面扩展就会向页面注入一个悬浮角色组件。组件不会篡改页面数据也不会读取表单信息只是纯粹地展示角色形象和台词。对学习者来说这种思路可以迁移到很多真实应用例如团队内部的运营浮窗、侧边栏工具、跨网页的快捷助手等。1.2 浏览器扩展的组成一个典型的 Manifest V3 浏览器扩展通常包含以下几个部分。manifest.json扩展的配置文件描述扩展名称、版本、权限、需要加载的脚本和页面。内容脚本运行在普通网页上下文中的脚本负责操作页面 DOM。扩展页面例如用户点击工具栏图标后弹出的 popup 页面通常用于控制扩展状态。背景脚本Manifest V3 中称为 Service Worker负责全局事件处理本示例不涉及。静态资源角色图片、音效、样式等需要通过特定的资源配置才能被网页上下文访问。理解这个组成之后我们的实现思路就很清晰了manifest 声明内容脚本会把presess.js注入到所有 http/https 网页presess.js在页面中创建悬浮组件并监听用户操作popup 页面通过chrome.storage保存开关状态所有页面共享这一状态。1.3 为什么选 Chrome Manifest V3Chrome 扩展自 2023 年左右开始全面推进 Manifest V3旧版 Manifest V2 的扩展逐步失去支持。新项目优先使用 Manifest V3 是更稳妥的方案。MV3 对权限管理更严格强制使用 Service Worker内容脚本的配置方式也更清晰。与此同时Manifest V3 的content_scripts配置方式与 V2 差别并不大很多既有示例代码稍作调整即可迁移。本文示例不依赖任何框架也不使用打包工具保证你拿到代码就能在本地运行。浏览器建议使用新版 Chrome 或 EdgeChromium 内核的浏览器在扩展机制上基本一致。需要提醒的是扩展运行在本地能影响的范围仅限于你自己安装扩展的浏览器并不会对他人产生任何影响。2. 环境准备与项目结构2.1 开发环境说明这个项目不需要安装任何第三方依赖对网络环境也没有要求。你只需要三样东西一个现代 Chromium 内核浏览器、一个文本编辑器、一张角色图片。操作系统Windows / macOS / Linux 均可。浏览器Chrome 稳定版或 Edge版本尽量保持较新。编辑器VS Code、WebStorm、Notepad 都可以。图片资源任意正方形角色立绘或头像建议 256x256 以上。如果手头暂时没有合适的“普瑞赛斯”图片可以先随便找一张本地 PNG 图片占位。示例中图片仅用于展示角色形象与实现 web_accessible_resources 资源配置不影响功能逻辑。2.2 扩展目录结构项目采用最简单的目录结构不引入构建工具这样每个文件的职责都一目了然。presess-invades-web/ ├── manifest.json ├── content/ │ ├── presess.js │ └── presess.css ├── popup/ │ ├── popup.html │ ├── popup.css │ └── popup.js └── assets/ └── avatar.png目录说明manifest.json扩展配置文件放在项目根目录。content/内容脚本和样式负责在网页上创建悬浮角色。popup/扩展弹窗页面提供“显示 / 隐藏”的开关按钮。assets/存放角色图片等静态资源。2.3 关于 manifest 版本与字段Manifest V3 的配置字段都比较直观但有几个字段需要特别理解。content_scripts是内容脚本的核心配置其中matches声明哪些网址会注入css声明注入样式js声明注入脚本run_at声明注入时机。注入时机一般选择document_idle表示 DOM 构建完成且页面基本空闲后再执行避免脚本过早运行找不到节点。web_accessible_resources用来声明允许网页上下文访问的扩展资源。因为内容脚本创建img时会使用类似chrome-extension://xxx/assets/avatar.png的地址如果没有声明普通网页环境下会直接拒绝加载。3. 核心原理内容脚本如何与网页共存3.1 Content Script 能做什么Content Script 是扩展与网页之间的桥。它可以读取和修改网页 DOM调整样式监听鼠标、键盘事件并且可以使用一部分扩展 API。由于它运行在隔离世界中所以网页脚本无法直接干扰扩展逻辑这也让浮层组件不容易被目标网站的不规范脚本意外破坏。在本项目中内容脚本的唯一职责是创建并管理一个div元素。这个div的id是固定的脚本运行时会先检查该元素是否已存在避免重复注入。每次用户打开新页面时每个标签页会独立执行一次内容脚本因此每个页面上都会出现一个独立的角色组件。3.2 页面注入的时机扩展加载完成后并不代表所有已经打开的旧页面都会立刻出现角色。content_scripts的注入时机发生在页面加载过程或加载完成后如果你在扩展加载之前就已经打开了某个网站需要刷新一次该页面才能看到效果。这是正常现象不是代码问题。另外Chrome 扩展默认不允许注入到 Chrome 内部页面例如chrome://settings、Chrome 网上应用店页面等。这是浏览器层面的安全限制无需尝试绕过。本文示例通过http://*/*和https://*/*匹配常规网页已经能覆盖绝大多数日常网站。3.3 样式隔离与潜在污染内容脚本注入的 CSS 会直接作用于页面因此存在两个方向的污染。页面自带样式可能影响注入元素。注入样式可能影响页面原有布局。为了避免这些问题CSS 应尽量使用足够独特的 ID 前缀例如presess-并且避免使用*通用选择器。更严格的方案是使用 Shadow DOM把组件完全封装在一个影子根中使外部样式无法侵入。本项目先使用普通元素实现原因有二一是代码直观方便初学者理解二是 Shadow DOM 会增加代码量后续可以按需优化。4. 实战把普瑞赛斯放进每个网页4.1 创建项目并编写 manifest.json先在本地创建一个名为presess-invades-web的目录然后在项目根目录新建文件manifest.json内容如下。{ manifest_version: 3, name: 普瑞赛斯悬浮助手, version: 1.0.0, description: 在网页右下角显示普瑞赛斯悬浮角色仅供个人学习使用。, permissions: [storage], action: { default_popup: popup/popup.html, default_title: 普瑞赛斯控制中心 }, content_scripts: [ { matches: [http://*/*, https://*/*], css: [content/presess.css], js: [content/presess.js], run_at: document_idle } ], web_accessible_resources: [ { resources: [assets/*.png], matches: [http://*/*, https://*/*] } ] }这里只申请了storage权限用来在多个页面之间同步“显示 / 隐藏”状态。项目没有使用tabs权限也没有使用任何远程接口属于权限非常克制的扩展。web_accessible_resources只放行assets目录下的 PNG 图片不暴露其他文件避免不必要的安全风险。4.2 实现悬浮角色的 CSS在content目录下新建presess.css编写悬浮组件的样式。#presess-widget { position: fixed; right: 24px; bottom: 24px; z-index: 2147483647; cursor: grab; user-select: none; font-family: PingFang SC, Microsoft YaHei, sans-serif; } #presess-widget.presess-dragging { cursor: grabbing; } .presess-bubble { position: absolute; left: -180px; bottom: 24px; width: 160px; padding: 12px 14px; background: rgba(30, 30, 30, 0.92); color: #fff; font-size: 13px; line-height: 1.6; border-radius: 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); opacity: 0; transform: translateY(8px); transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none; } #presess-widget.presess-show-bubble .presess-bubble { opacity: 1; transform: translateY(0); } .presess-avatar { width: 96px; height: 96px; border-radius: 16px; object-fit: cover; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); transition: transform 0.2s ease; } .presess-avatar:hover { transform: scale(1.04); }需要注意.presess-bubble使用了绝对定位因此它的参照对象是#presess-widget。初始位置向左延伸这样气泡不会挡住右侧滚动条。CSS 默认隐藏气泡只有添加了presess-show-bubble类时才显示。切换类名由 JavaScript 控制。4.3 编写内容脚本在content目录下新建presess.js。先看完整核心逻辑再逐段解释关键部分。const WIDGET_ID presess-widget; const AVATAR_URL chrome.runtime.getURL(assets/avatar.png); const PHRASES [ 博士我们又见面了。, 这个页面好像有点意思。, 别加班太晚注意休息。, 你在找什么资料我来陪你。, 这里的信息安全吗, 今天也要保持好心情。, 不要一直刷新页面啦。, 前方未知但我会陪着你。 ]; let widget null; let hideTimer null; let dragTimer null; init(); function init() { chrome.storage.local.get({ enabled: true }, ({ enabled }) { if (enabled) { createWidget(); } }); chrome.storage.onChanged.addListener((changes, areaName) { if (areaName ! local || !changes.enabled) { return; } const enabled changes.enabled.newValue; if (enabled) { if (!document.getElementById(WIDGET_ID)) { createWidget(); } } else { removeWidget(); } }); } function createWidget() { if (document.getElementById(WIDGET_ID)) { return; } widget document.createElement(div); widget.id WIDGET_ID; const bubble document.createElement(div); bubble.className presess-bubble; bubble.textContent PHRASES[0]; widget.appendChild(bubble); const avatar document.createElement(img); avatar.className presess-avatar; avatar.src AVATAR_URL; avatar.alt 普瑞赛斯; widget.appendChild(avatar); document.body.appendChild(widget); showBubble(); bindDrag(widget); bindClick(widget); bindTypingEvent(); startAutoBubble(); } function removeWidget() { if (widget) { widget.remove(); widget null; } if (hideTimer) { clearTimeout(hideTimer); hideTimer null; } } function showBubble(text) { if (!widget) { return; } const bubble widget.querySelector(.presess-bubble); if (!bubble) { return; } if (text) { bubble.textContent text; } widget.classList.add(presess-show-bubble); if (hideTimer) { clearTimeout(hideTimer); } hideTimer setTimeout(() { widget.classList.remove(presess-show-bubble); }, 3500); } function startAutoBubble() { setInterval(() { if (document.hidden) { return; } const phrase PHRASES[Math.floor(Math.random() * PHRASES.length)]; showBubble(phrase); }, 45000); }代码有几个关键细节。chrome.runtime.getURL(assets/avatar.png)是获取扩展真实资源地址的推荐方式不能直接写相对路径因为相对路径会相对于当前网页解析。chrome.storage.local.get的第一个参数可以传默认值对象这样首次安装时即使没有存储过enabled也会默认显示角色。chrome.storage.onChanged监听所有页面共享的存储变化popup 页面的修改会最终同步到每个已经打开且注入脚本的页面上。setInterval每隔 45 秒随机显示一句台词页面处于隐藏状态时跳过减少无意义渲染。4.4 实现拖拽移动悬浮组件不能固定死在右下角否则容易遮挡页面内容。拖拽逻辑通过mousedown、mousemove、mouseup三个事件实现。核心思路是按下时记录组件当前坐标和鼠标起点移动时计算偏移量并更新组件位置松开时判断是否发生了拖拽从而区分“点击”和“拖动”。function bindDrag(target) { let startX 0; let startY 0; let startLeft 0; let startTop 0; let rectWidth 0; let rectHeight 0; let moved false; target.addEventListener(mousedown, (event) { if (event.button ! 0) { return; } const rect target.getBoundingClientRect(); startX event.clientX; startY event.clientY; startLeft rect.left; startTop rect.top; rectWidth rect.width; rectHeight rect.height; moved false; target.classList.add(presess-dragging); }); document.addEventListener(mousemove, (event) { if (!target.classList.contains(presess-dragging)) { return; } const deltaX event.clientX - startX; const deltaY event.clientY - startY; if (Math.abs(deltaX) 5 || Math.abs(deltaY) 5) { moved true; } const maxLeft window.innerWidth - rectWidth; const maxTop window.innerHeight - rectHeight; const nextLeft Math.min(Math.max(0, startLeft deltaX), maxLeft); const nextTop Math.min(Math.max(0, startTop deltaY), maxTop); target.style.left ${nextLeft}px; target.style.top ${nextTop}px; }); document.addEventListener(mouseup, () { if (!target.classList.contains(presess-dragging)) { return; } target.classList.remove(presess-dragging); if (!moved) { const phrase PHRASES[Math.floor(Math.random() * PHRASES.length)]; showBubble(phrase); } }); }注意这里使用了全局document.addEventListener监听移动和松开事件而不是绑定在组件本身。因为鼠标移动速度很快时很容易移到组件范围之外只有全局监听才能保证拖拽过程不会丢失。4.5 键盘输入时的小彩蛋为了增加一点互动感可以监听键盘事件。当用户在输入框按下回车时角色会冒出一句提示。这个行为只读取event.key不会读取输入框内容因此不会触碰用户隐私。function bindTypingEvent() { document.addEventListener(keyup, (event) { if (event.key ! Enter event.key ! NumpadEnter) { return; } const target event.target; const isInput target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; if (isInput) { showBubble(回完消息记得也看看我呀。); } }); }这个彩蛋的触发条件非常克制只在输入框敲回车时出现不会影响页面原有的回车行为。5. 进阶弹出面板控制显示与隐藏5.1 popup 页面扩展图标通常位于浏览器工具栏右侧用户点击后可以弹出一个小窗口这个窗口就是popup页面。在本项目中popup 页面用来控制角色在当前浏览器的所有页面中是否显示。新建popup/popup.html内容如下。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / link relstylesheet hrefpopup.css / title普瑞赛斯控制中心/title /head body h1普瑞赛斯控制中心/h1 p idstatus当前状态加载中/p button idtoggleBtn隐藏角色/button script srcpopup.js/script /body /html为了让弹窗有更清晰的反馈简单增加一些样式。body { width: 220px; padding: 16px; font-family: PingFang SC, Microsoft YaHei, sans-serif; text-align: center; } h1 { font-size: 16px; margin: 0 0 12px; } #status { font-size: 13px; color: #666; margin: 0 0 12px; } button { width: 100%; padding: 8px 0; border: none; border-radius: 8px; background: #333; color: #fff; cursor: pointer; }popup 的样式只作用于弹窗本身不会对普通网页产生任何影响。5.2 popup 逻辑与状态同步新建popup/popup.js。const statusEl document.getElementById(status); const toggleBtn document.getElementById(toggleBtn); async function refresh() { const { enabled true } await chrome.storage.local.get(enabled); statusEl.textContent enabled ? 当前状态显示中 : 当前状态已隐藏; toggleBtn.textContent enabled ? 隐藏角色 : 显示角色; } toggleBtn.addEventListener(click, async () { const { enabled true } await chrome.storage.local.get(enabled); await chrome.storage.local.set({ enabled: !enabled }); refresh(); }); refresh();这里没有通过chrome.tabs.sendMessage直接通知当前页面而是通过chrome.storage.local.set修改全局状态。内容脚本中的chrome.storage.onChanged会收到变化通知从而动态创建或移除角色组件。这种做法的好处非常明显不需要申请tabs权限同时天然支持多标签页同步只要修改一次状态所有已打开的网页都会响应。5.3 为什么用 storage 而不是直接发消息如果用chrome.tabs.sendMessage需要先获取当前标签页的tab.id这意味着扩展可能需要权限而且当用户在某个标签页点击 popup 开关时其他已打开的标签页不会同步变化。用chrome.storage作为状态中心后内容脚本和 popup 页面变成了解耦的两个模块任意一方修改状态另一方都能感知到。这种思路与工程上的“中央状态”思想很相似非常适合扩展开发。后续如果希望加入背景页也可以继续围绕chrome.storage做状态管理结构依然清晰。6. 运行与验证6.1 加载未打包扩展在 Chrome 地址栏输入chrome://extensions打开扩展管理页面。确保右上角的“开发者模式”处于开启状态然后点击“加载已解压的扩展程序”选择项目根目录presess-invades-web。等待片刻扩展列表中会出现“普瑞赛斯悬浮助手”。加载成功后Chrome 工具栏会显示扩展图标。如果此时已经打开了其他网页需要刷新这些页面内容脚本才会被注入。如果打开的是扩展加载前就存在的页面刷新前不会看到角色这是正常行为。6.2 在不同网页验证打开任意普通网站例如某个技术文档站或资讯站。等页面加载完成后右下角会出现角色头像。头像旁边会先显示一句台词气泡几秒后自动隐藏。点击头像可以随机切换台词按住头像拖动可以移动组件位置在任意输入框内敲一下回车角色会给出互动提示。如果访问的是chrome://开头或扩展商店页面角色不会出现这是浏览器的安全限制不需要担心。6.3 调试内容脚本在需要排查问题时可以按F12打开开发者工具。页面脚本和内容脚本运行在同一个 DOM 里但 JavaScript 上下文不同需要手动切换执行环境。打开开发者工具的 Sources 面板在左侧文件列表里找presess.js右击选择“Add script to ignore list”之类的操作可以先不管。更方便的方式是直接在presess.js里加console.log然后在网页的开发者工具 Console 面板顶部切换 JavaScript Context选择当前扩展的上下文就能看到来自内容脚本的日志。如果完全没有看到日志优先检查扩展是否已加载、网站地址是否匹配content_scripts.matches、以及页面是否刷新过。7. 常见问题与排查思路下面汇总几个最容易出现的问题按“现象、原因、解决思路”整理成表格方便快速查阅。问题现象可能原因解决思路扩展加载后网页上没有任何角色页面在扩展加载前就已打开内容脚本尚未注入刷新目标网页重新加载扩展后再打开新页面角色头像显示为空白裂图图片资源没有声明为 web accessible在 manifest 中添加web_accessible_resources并匹配对应 URLpopup 开关没有反应popup 里修改了 storage但内容脚本没有监听变化确认内容脚本已监听chrome.storage.onChanged且没有在页面中手动删除组件角色被页面样式影响位置错乱内容脚本 CSS 没有使用足够独特的类名或页面本身对div有!important样式使用#presess-widget等高优先级选择器或者改用 Shadow DOM 隔离拖动角色时页面触发文本选中文字选中事件未禁用在组件样式上设置user-select: none网页输入框回车后角色没有反应监听事件绑定失败或上下文不同确认使用的是keyup并且事件监听器绑定在document上角色在多个页面状态不一致使用 message 直传导致其他标签页没有同步改用chrome.storage.local作为全局状态中心除了表格中的问题还有一个容易忽略的点内容脚本的 JavaScript 虽然能访问页面 DOM但它运行在隔离世界不能直接调用页面里的全局函数比如window.jQuery或某个框架的内部变量。如果以后想调用页面逻辑需要额外设计消息桥接而不是直接访问。8. 工程化与发布注意事项8.1 权限最小化原则不少扩展会把tabs、scripting、webNavigation等权限全部写进 manifest导致安装时的权限提示非常庞大也容易让用户产生疑虑。实际上很多实时注入功能根本不需要额外权限。本文示例只用storage权限就实现了核心功能这是权限最小化原则的典型示范。在实际开发中每增加一个权限都要问自己两个问题这个权限是否真的能用上有没有不申请权限的替代方案例如批量修改网页脚本需要scripting权限但只做静态注入时则不需要读取当前标签页 URL 需要tabs权限但如果只是修改存储状态则完全可以绕开。8.2 前端工程化改造方向项目当前是零构建的纯静态结构适合学习但不适合大规模迭代。如果后续要增加多角色、主题切换、配置面板等复杂功能建议引入一套轻量前端工程化方案。一种常见的改造思路是用 Vite 或 Webpack 把内容脚本和 popup 页面打包成独立文件再把产物放入最终扩展目录。构建过程中可以统一处理 CSS 前缀、图片压缩、多入口文件等问题。需要注意的是Manifest V3 的content_scripts.js不能打包成 ES Module 运行必须输出为单文件 CommonJS 或 IIFE 风格的脚本否则 Chrome 会拒绝加载。8.3 上架商店前的注意事项“入侵互联网”这种名字只适合放在个人项目里自娱自乐。如果要把扩展提交到 Chrome 网上应用店正式名称和描述都需要改得规范一些例如“网页悬浮角色助手”“普瑞赛斯小工具”。商店审核会关注功能声明是否与实际行为一致是否含有恶意广告是否在未经用户授权的情况下收集数据。本项目没有任何网络请求但如果在后续迭代中加入了埋点、统计、上报功能必须在上架说明里明确告知用户并在 manifest 中如实声明权限。任何时候都不要尝试在未授权页面上收集用户输入内容除非商业需求合法合规且用户知情同意。发布生产级别扩展时建议同时准备一个隐私政策页面说明扩展本地运行、数据存储位置和使用边界。9. 总结与下一步学习路线这个项目虽然只有十几个文件却完整地覆盖了浏览器扩展开发的骨架配置、注入、样式、状态同步、交互和调试。通过亲手实现“让普瑞赛斯出现在每一个网页”这个小目标你对内容脚本的运行机制、资源隔离、存储同步应该已经有了直观理解。下一步可以尝试的方向包括把角色从右下角浮窗改成可拖拽的新标签页天气组件在 popup 中加入自定义台词编辑功能让台词数据通过chrome.storage.local动态保存引入 Shadow DOM 彻底隔离样式也可以研究chrome.scripting.executeScript在用户点击按钮后临时向某些网页注入一段脚本。在实际项目中优先关注的是权限边界和代码稳定性。不要主动收集无关数据不要用setInterval重复创建组件也不要让浮层遮挡页面核心内容。只要把这些原则守住这种“有点浪漫的小入侵”就只是一个干净、安全又充满想象力的小玩具。想变成属于自己的角色助手就从修改浮层样式和台词开始吧。