如何自己写一个浏览器插件?

刷文章看到一句特别好的话,想存下来。复制,切到备忘录,粘贴,再切回来把链接也复制一遍......来回折腾好几下,有时候切回来页面还找不着了。

我后来干脆自己写了个小插件:网页里划词,旁边蹦个按钮,点一下就存好了。今天就把整个过程拆给你,顺便说说 Chrome 插件到底是个啥。


原文地址

墨渊书肆/如何自己写一个浏览器插件?


Chrome 插件是什么?

你平时用的 AdBlock、翻译插件、密码管理器,本质上都是 Chrome 扩展(Extension)

可以这么理解:

yaml 复制代码
Chrome  = 手机系统
插件    = 第三方 App
网页    = 某个 App 里的界面

网页里的 JS 只能在当前页面里折腾------改 DOM、发请求,都受同源策略管着。插件不一样,Chrome 单独给它开了一扇后门:可以跨网站注入脚本、读写存储、监听标签页切换,甚至拦截网络请求。

说白了,就是给浏览器本身加功能,不是给某个网站加功能。


和普通网页 JS 比,强在哪?

特点 网页 JS Chrome 插件
能跑几个网站 就当前这个 匹配到的都能跑
关页面还在不在 没了 Background 可以常驻
有没有独立界面 只能改页面 有自己的 Popup 小窗口
数据存哪 localStorage,跟网站绑死 chrome.storage,插件自己管

做「划词收藏」这种跨网站的小工具,用插件比油猴脚本正规,也比纯书签靠谱------数据跟着插件走,不跟某个域名走。


四大组件,先认个脸

一个 Chrome 插件通常由四块拼起来。不用全用上,但得知道各自干啥:

yaml 复制代码
manifest.json   → 身份证。名字、版本、权限、入口,全在这
Background      → 后台。监听浏览器事件,插件关着面板也在跑
Content Script  → 页面特工。注入到网页里,能摸 DOM
Popup           → 小面板。点工具栏图标弹出来的那个窗口

它们之间靠消息传递配合,大致是这样:

yaml 复制代码
用户点 Popup 里的按钮
    ↓
Popup 告诉 Background(或者直接找 Content Script)
    ↓
Content Script 在页面上动手

基础版先用三块:manifest + Content Script + Popup。Background 先不急------等加右键收藏的时候再让它上场。


我们要做什么?

一句话:选中文字 → 点收藏 → 打开图标看列表

具体长啥样:

yaml 复制代码
1. 任意网页划词,旁边蹦一个蓝色「收藏」按钮
2. 点一下,文字、来源标题、链接、时间全存进去
3. 点工具栏图标,弹个小面板,列出所有收藏
4. 能点链接跳回原文,也能删

平时看文章,划一句话旁边冒个按钮;点工具栏图标,面板弹出来,里面是之前存的列表。不花哨,但够用。

下面开始写代码。这个插件我放在 chrome-plugins 目录下------打算做一个插件集合,这是第一个,后面还会加。你跟着建个子文件夹 quote-saver 就行。


第一步:建文件夹

yaml 复制代码
chrome-plugins/
└── quote-saver/
    ├── manifest.json
    ├── background.js      # 后面加右键收藏时再建
    ├── content.js
    ├── popup.html
    ├── popup.js
    └── icons/
        └── icon128.png   # 可选,128×128 的 png

icon128.png 找张 128×128 的图放进去就行,没有也不影响功能,manifest 里不写 icon 字段照样跑,工具栏就是个默认的拼图块图标。background.js 是后面加右键收藏时才建的,一开始不用建。


第二步:manifest.json

每个插件都必须有这个文件,Chrome 靠它知道你是谁、要啥权限。

json 复制代码
{
  "manifest_version": 3,
  "name": "划词收藏",
  "version": "1.0.0",
  "description": "选中网页文字,一键收藏",
  "permissions": ["storage"],
  "action": {
    "default_popup": "popup.html",
    "default_title": "我的收藏",
    "default_icon": {
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "128": "icons/icon128.png"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

几个字段记一下:

  • manifest_version: 3 ------ 现在新建插件都得用 MV3,旧版 MV2 不让上了
  • permissions: ["storage"] ------ 只申请存数据的权限,别贪多,装插件的人会在意这个
  • action.default_popup ------ 点图标弹出 popup.html
  • action.default_icon / icons ------ 工具栏和扩展管理页的图标,不写就用默认的灰色拼图块
  • content_scripts ------ 告诉 Chrome 在哪些页面注入 content.jsmatches 里的 <all_urls> 就是字面意思:所有网页都注入。document_idle 是等 DOM 解析完了再跑,不抢页面加载的活儿

第三步:content.js ------ 划词和收藏按钮

Content Script 跑在网页里面,能操作 DOM,但跟页面自己的 JS 变量是隔开的------你在 content.js 里定义的变量,页面 JS 访问不到,反过来也一样。正好,我们挂个悬浮按钮,不会跟网站自己的代码搅在一起。

js 复制代码
let saveBtn = null;

document.addEventListener('mouseup', () => {
  const text = window.getSelection().toString().trim();

  if (text.length < 2) {
    removeBtn();
    return;
  }

  showBtn(text);
});

function showBtn(text) {
  removeBtn();

  const range = window.getSelection().getRangeAt(0);
  const rect = range.getBoundingClientRect();

  // 防止按钮跑到屏幕外面
  const btnLeft = Math.min(rect.right + 8, window.innerWidth - 80);
  const btnTop = Math.min(rect.bottom + 8, window.innerHeight - 40);

  saveBtn = document.createElement('button');
  saveBtn.textContent = '收藏';
  saveBtn.style.cssText = `
    position: fixed;
    left: ${btnLeft}px;
    top: ${btnTop}px;
    z-index: 2147483647;
    padding: 4px 10px;
    font-size: 13px;
    border: none;
    border-radius: 4px;
    background: #1a73e8;
    color: #fff;
    cursor: pointer;
    box-shadow: 0 2px 6px rgba(0,0,0,.2);
  `;

  // 不加这行的话,点按钮会先把选中取消掉
  saveBtn.addEventListener('mousedown', (e) => e.preventDefault());

  saveBtn.addEventListener('click', async () => {
    await saveQuote(text);
    removeBtn();
    showToast('已收藏');
  });

  document.body.appendChild(saveBtn);
}

function removeBtn() {
  if (saveBtn) {
    saveBtn.remove();
    saveBtn = null;
  }
}

async function saveQuote(text) {
  const { quotes = [] } = await chrome.storage.local.get('quotes');

  quotes.unshift({
    id: Date.now(),
    text,
    title: document.title,
    url: location.href,
    time: new Date().toLocaleString()
  });

  await chrome.storage.local.set({ quotes: quotes.slice(0, 100) });
}

function showToast(msg) {
  const toast = document.createElement('div');
  toast.textContent = msg;
  toast.style.cssText = `
    position: fixed; top: 20px; right: 20px; z-index: 2147483647;
    background: #333; color: #fff; padding: 8px 16px;
    border-radius: 4px; font-size: 14px;
  `;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 1500);
}

// 页面滚动了,按钮位置会飘,干脆去掉
document.addEventListener('scroll', removeBtn, true);

监听 mouseup 看有没有选中文字,有的话在选区旁边画个按钮。点一下,文字、标题、链接、时间一起塞进 chrome.storage.local。最多存 100 条------应该够用,真不够再改。


第四步:popup.html ------ 收藏列表

点工具栏图标弹出来的小窗口,就是个普通 HTML 页面,样式随便写。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { width: 360px; max-height: 480px; font-family: system-ui, sans-serif; font-size: 13px; }
    header { padding: 12px 14px; border-bottom: 1px solid #eee; font-weight: 600; }
    #list { overflow-y: auto; max-height: 420px; }
    .item { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; }
    .item:hover { background: #fafafa; }
    .text { color: #222; line-height: 1.5; margin-bottom: 6px;
            display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
    .meta { color: #888; font-size: 12px; }
    .meta a { color: #1a73e8; text-decoration: none; }
    .del { float: right; color: #c00; cursor: pointer; border: none; background: none; font-size: 12px; }
    .empty { padding: 40px; text-align: center; color: #999; }
  </style>
</head>
<body>
  <header>我的收藏</header>
  <div id="list"></div>
  <script src="popup.js"></script>
</body>
</html>

第五步:popup.js ------ 读列表、删条目

js 复制代码
const listEl = document.getElementById('list');

async function render() {
  const { quotes = [] } = await chrome.storage.local.get('quotes');

  if (quotes.length === 0) {
    listEl.innerHTML = '<div class="empty">还没有收藏<br>去网页里划词试试</div>';
    return;
  }

  listEl.innerHTML = quotes.map(q => `
    <div class="item" data-id="${q.id}">
      <div class="text">${escapeHtml(q.text)}</div>
      <div class="meta">
        <a href="${q.url.startsWith('http') ? q.url : '#'}" target="_blank">${escapeHtml(q.title)}</a>
        · ${q.time}
        <button class="del" data-id="${q.id}">删除</button>
      </div>
    </div>
  `).join('');
}

function escapeHtml(str) {
  const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  return String(str).replace(/[&<>"']/g, c => map[c]);
}

listEl.addEventListener('click', async (e) => {
  if (!e.target.classList.contains('del')) return;

  const id = Number(e.target.dataset.id);
  const { quotes = [] } = await chrome.storage.local.get('quotes');
  await chrome.storage.local.set({ quotes: quotes.filter(q => q.id !== id) });
  render();
});

render();

Content Script 往里写,Popup 往外读,用的是同一个 chrome.storage.local,key 都是 'quotes'。两边对上就行。

对了,这里用的是 local,数据只存在当前电脑上。想换电脑也能看到收藏的话,把 local 改成 sync 就行------但 sync 只有 100KB 配额,收藏多了容易爆,文本量大的话还是 local 稳。


第六步:装进 Chrome

代码写完了,不用发布,本地就能跑:

  1. 地址栏输入 chrome://extensions/,回车(Edge 用 edge://extensions/,Brave 用 brave://extensions/,都一样)
  2. 右上角「开发者模式」打开
  3. 「加载未打包的扩展程序」→ 选 chrome-plugins/quote-saver 文件夹
  4. 工具栏出现图标,搞定

找篇文章试试:划词 → 点「收藏」 → 再点图标看列表。删一条看看列表会不会更新。都正常的话,第一个插件就算做完了。


调试时我踩过的坑

改代码之后记得两处刷新:扩展管理页点刷新按钮,已经打开的网页也要 F5。Content Script 是页面加载时注入的,不刷新网页它不会变。

yaml 复制代码
看 Popup 的 log    → 右键图标,「检查弹出内容」
看 Content Script  → 普通网页 F12,Sources 里找 content.js

有个报错特别常见:

text 复制代码
Extension context invalidated

扩展刚 reload 过,老页面里的 Content Script 已经废了,刷新网页就好。我第一次看到还以为是代码写错了,折腾半天。

还有 storage 写了但 Popup 看不到------八成是 key 名没对上,或者 Popup 打开时没调 render()


顺手再加两个功能

基础版跑通了,但用着用着有两个事不太得劲:一是有些网站自己的 JS 会吞掉 mouseup 事件,划词按钮压根弹不出来;二是想换浏览器,收藏的数据没地方导出。

干脆补上。

加个右键收藏

前面说 Background 先不急,现在它上场了。右键菜单这事儿必须 Background 来注册,Content Script 干不了。

manifest 加两行:

json 复制代码
{
  "permissions": ["storage", "contextMenus"],
  "background": {
    "service_worker": "background.js"
  }
}

contextMenus 是右键菜单的权限,background.service_worker 告诉 Chrome 后台跑哪个文件。MV3 里 Background 叫 Service Worker------名字跟网页里的 Service Worker 撞了,但不是一回事,别搞混。

完整 manifest 现在长这样:

json 复制代码
{
  "manifest_version": 3,
  "name": "划词收藏",
  "version": "1.1.0",
  "description": "选中网页文字,一键收藏",
  "permissions": ["storage", "contextMenus"],
  "action": {
    "default_popup": "popup.html",
    "default_title": "我的收藏",
    "default_icon": {
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "128": "icons/icon128.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

新建 background.js

js 复制代码
// 安装时创建右键菜单
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'save-quote',
    title: '收藏这段话',
    contexts: ['selection']
  });
});

// 右键菜单点击
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== 'save-quote') return;

  const text = info.selectionText.trim();
  if (!text) return;

  const { quotes = [] } = await chrome.storage.local.get('quotes');

  quotes.unshift({
    id: Date.now(),
    text,
    title: tab.title,
    url: tab.url,
    time: new Date().toLocaleString()
  });

  await chrome.storage.local.set({ quotes: quotes.slice(0, 100) });

  // 告诉当前页面的 content script 弹个提示
  // 有些页面(chrome:// 开头的、PDF 查看器)没注入 content script,sendMessage 的 Promise 会 reject
  // 不能用 try-catch 抓------Promise 是异步的,try-catch 只抓同步错误。得用 .catch() 兜底
  chrome.tabs.sendMessage(tab.id, { action: 'saved' }).catch(() => {
    // 存是存进去了,只是没法弹提示,不影响
  });
});

逻辑很直白:插件装的时候注册一个右键菜单,只在选中文字时出现。点了之后,info.selectionText 是选中的文字,tab.titletab.url 是当前页面的标题和链接------Background 直接就能拿到,不用 Content Script 去取。

最后一句 chrome.tabs.sendMessage 是给当前页面的 Content Script 发消息,让它弹个「已收藏」。但有些页面(chrome:// 开头的、PDF 查看器)没注入 Content Script,消息发过去没人收。MV3 里 sendMessage 返回的是 Promise,没人收就会 reject------但 Promise 的 rejection 是异步的,try-catch 抓不到,得用 .catch() 兜底。数据已经存进去了,只是没法弹提示而已,不影响。

Content Script 那边加几行就行,content.js 末尾补上:

js 复制代码
// 右键收藏后,background 会发消息过来,弹个提示
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.action === 'saved') {
    showToast('已收藏');
  }
});

这就是前面说的「消息传递」------Background 干完活,发个消息让 Content Script 在页面上弹提示。showToast 之前写好了,直接复用。

reload 插件,选中文字右键看看------「收藏这段话」出来了。点一下,右上角弹「已收藏」,跟划词按钮一样的效果。

导出 JSON

这个不用动 manifest,只改 popup。面板头部加个「导出 JSON」按钮,点一下下载所有收藏。

popup.html 的 header 改一下,加个按钮:

html 复制代码
<header>
  <span>我的收藏</span>
  <button id="export">导出 JSON</button>
</header>

CSS 补几行,让按钮靠右:

css 复制代码
header { display: flex; justify-content: space-between; align-items: center; }
#export { font-size: 12px; font-weight: 400; color: #1a73e8;
          border: 1px solid #1a73e8; border-radius: 4px; padding: 3px 10px;
          cursor: pointer; background: none; }
#export:hover { background: #f0f6ff; }

popup.js 加导出逻辑:

js 复制代码
document.getElementById('export').addEventListener('click', async () => {
  const { quotes = [] } = await chrome.storage.local.get('quotes');
  if (quotes.length === 0) return;

  const blob = new Blob([JSON.stringify(quotes, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `quotes-${new Date().toISOString().slice(0, 10)}.json`;
  a.click();
  URL.revokeObjectURL(url);
});

老套路:读 storage,JSON.stringify 成字符串,塞进 Blob,用 <a download> 触发下载。文件名带当天日期,导出多次也不会覆盖。


回头看一眼四大组件

现在功能齐了,再回头看四大组件,每个都派上用场了:

组件 对应文件 干了啥
manifest manifest.json 声明权限、注册 Content Script、Popup 和 Background
Content Script content.js 划词画按钮、存数据、接收右键消息弹提示
Popup popup.html + popup.js 展示列表、删除、导出 JSON
Background background.js 注册右键菜单、右键收藏存数据

两条数据路径:

yaml 复制代码
划词收藏:划词 → content.js 写入 storage → 点图标 → popup.js 读 storage 渲染
右键收藏:右键 → background.js 写入 storage → 发消息 → content.js 弹提示

想发到 Chrome 商店的话

本地加载只能自己用,想让别人也装,就得发到 Chrome Web Store。流程不复杂,但有几个坑。

先打包。 不用压缩成 zip,Chrome 扩展管理页有现成的:

yaml 复制代码
chrome://extensions/ → 「打包扩展程序」
程序包目录     → 选你的 quote-saver 文件夹
私钥文件       → 第一次留空,Chrome 会自动生成 .pem

点一下,同目录下会生成 quote-saver.crx(插件包)和 quote-saver.pem(私钥)。.pem 千万收好------以后更新插件要用同一个私钥签名,丢了就只能新建一个扩展 ID,等于换了一个新插件。

然后上架。Chrome Web Store Developer Dashboard,登录 Google 账号:

  1. 5 美元 一次性注册费(信用卡或 Google Pay,国内卡不一定能过)
  2. 「添加新内容」→ 上传 zip 包(注意:商店要的是 zip,不是 crx------把 quote-saver 文件夹直接压缩成 zip 就行)
  3. 填商店信息:截图、描述、分类、隐私政策
  4. 提交审核

审核快的话一两天,慢的话一两周。常见被打回的原因:permissions 申请太多(比如你只用 storage 却申请了 tabs)、描述太敷衍、截图不是实际界面。

有一说一,如果你只是自己用或者分享给几个朋友,完全没必要上架。把 quote-saver 文件夹打个 zip 发给别人,对方解压后「加载已解压的扩展程序」就完事了------少花 5 美元,还不用等审核。


写在最后

一个 manifest.json 告诉 Chrome 你是谁,一个 content.js 钻进网页干活,一个 popup 做界面给人看,一个 background.js 在后台接右键菜单的活。四块齐了,一个不算简陋的插件就出来了。

代码都在 chrome-plugins/quote-saver 里,直接复制就能跑。改改样式、加个搜索功能,就是自己的工具了------这种「自己造个小东西用」的感觉,说实话还挺上瘾的。

相关推荐
亿元程序员2 小时前
为什么Cocos都4.0了还有人用2.x?
前端
MomentYY2 小时前
AI 到底是“懂”,还是在“猜”?
前端·人工智能·ai编程
鹏毓网络科技2 小时前
Cursor Rules 文件配置实战:3 个隐藏参数让我每月少写 40% 样板代码
前端·github
没烦恼3012 小时前
无痕模式下 HTTP\-First 拦截引发的“页面刷新”误判
前端
文心快码BaiduComate2 小时前
从个人提效到组织提效:Comate辅助构建自我进化的AI研发系统
前端·程序员
hunterandroid3 小时前
Compose 状态管理:remember、rememberSaveable 与状态提升
前端
星栈3 小时前
Dioxus 接数据库最容易写歪的 3 个地方:sqlx + SQLite 怎么接才顺
前端·rust·前端框架
晴虹3 小时前
vue3-scroll-more:横向滚动条-元素或页签过多滚动显示处理的组件
前端·vue.js
代码搬运媛3 小时前
Claude 全栈开发专用 Rules 配置
前端