【MV3 实战】我用 3 个月写了一个 Chrome 插件 - 完整踩坑记录
写在前面:本文整理我做 Image Harvest 这个 Chrome 插件过程中踩过的坑,主要包括 Manifest V3 迁移、Shadow DOM 深度提取、客户端感知哈希、Side Panel API 适配等。希望对正在或准备写 Chrome 插件的同学有帮助。
一、Manifest V3 vs V2 的那些破事
V3 出来好几年了,但中文 MV3 实战资料还是偏少。我踩过的几个迁移痛点:
1.1 service worker 替代 background page
V2 时代的 background page 是常驻的,可以放全局变量、长连接。V3 改成 service worker,默认 30 秒不活跃就被杀。
这意味着:
- ❌ 任何挂在
globalThis上的状态都不可靠 - ❌ 任何
setInterval/setTimeout都可能被中断 - ✅ 必须用
chrome.storage持久化所有需要跨调用的状态
我的做法是把所有"会话级"状态放到 chrome.storage.session(浏览器关闭时清):
// service worker 里
async function getCachedExtraction(tabId) {
const cache = await chrome.storage.session.get(`extraction_${tabId}`);
return cache[`extraction_${tabId}`];
}
async function setCachedExtraction(tabId, results) {
await chrome.storage.session.set({
[`extraction_${tabId}`]: {
timestamp: Date.now(),
results
}
});
}
打开侧边栏时先查缓存,有就秒开,没有再扫:
async function loadPanel(tabId) {
const cached = await getCachedExtraction(tabId);
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
renderResults(cached.results);
return;
}
showLoading();
const fresh = await runExtraction(tabId);
renderResults(fresh);
}
1.2 远程代码禁用
V3 不能加载远程 JS 了。所有逻辑必须打包进扩展本体。这对我来说反而是好事——一开始就知道不能"动态更新功能",倒逼把所有功能内置。
1.3 declarativeNetRequest 替代 webRequest
如果你以前用 webRequest 做请求拦截,V3 要改用 declarativeNetRequest 的声明式规则。我的图片下载场景没用到这个,但设计师在做去广告类插件时这是大坑。
二、深度图片提取:从 img 到 Shadow DOM
document.querySelectorAll('img') 在 2026 年的网页上只能抓到大约 50% 的图片。剩下的藏在 5 个地方:
<picture>和srcset- CSS
background-image - 懒加载(
loading="lazy"/ IntersectionObserver / 框架特定) - 同源
<iframe> - 开放 Shadow DOM
完整的提取函数:
function extractAll(doc) {
const urls = new Set();
// 1. <img> 和 <picture>
doc.querySelectorAll('img').forEach(img => {
if (img.currentSrc) urls.add(img.currentSrc);
if (img.src) urls.add(img.src);
if (img.srcset) {
parseSrcset(img.srcset).forEach(url => urls.add(url));
}
});
// 2. CSS background
doc.querySelectorAll('*').forEach(el => {
const bg = getComputedStyle(el).backgroundImage;
if (bg && bg !== 'none') {
const matches = bg.match(/url\(["']?(.*?)["']?\)/g) || [];
matches.forEach(m => {
const url = m.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
if (url && !url.startsWith('data:')) urls.add(url);
});
}
});
// 3. 递归开放 Shadow DOM
doc.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
extractAll(el.shadowRoot).forEach(url => urls.add(url));
}
});
// 4. 同源 iframe
doc.querySelectorAll('iframe').forEach(iframe => {
try {
if (iframe.contentDocument) {
extractAll(iframe.contentDocument).forEach(url => urls.add(url));
}
} catch (e) {
// 跨域 iframe,静默跳过
}
});
return urls;
}
function parseSrcset(srcset) {
return srcset.split(',').map(s => s.trim().split(/\s+/)[0]);
}
几个非显然的细节:
getComputedStyle很贵。在 5000 节点的 Pinterest 页面上要 200ms。生产代码里我用requestIdleCallback分批跑。document.querySelectorAll('*')反而不贵——单次遍历很快,慢的是对每个元素做的事。- 闭合 Shadow Root 是设计上不可达的,没有 hack 能绕过。我的策略是 UI 上明确告诉用户:"本页使用了闭合 Shadow DOM,部分图片不可达。"
三、客户端感知哈希实现相似图检测
提取完后经常有同一张图的多个分辨率版本。需要去重。像素级比对没用(缩放后像素不同),需要 感知哈希。
我用的是 dHash(差值哈希),比 pHash 简单:
async function dHash(imageUrl) {
const img = await loadImage(imageUrl);
const canvas = new OffscreenCanvas(9, 8);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, 9, 8);
const { data } = ctx.getImageData(0, 0, 9, 8);
let hash = 0n;
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const left = grayscale(data, row * 9 + col);
const right = grayscale(data, row * 9 + col + 1);
hash = (hash << 1n) | (left > right ? 1n : 0n);
}
}
return hash;
}
function grayscale(data, pixelIndex) {
const i = pixelIndex * 4;
return (data[i] + data[i + 1] + data[i + 2]) / 3;
}
function hammingDistance(a, b) {
let xor = a ^ b;
let dist = 0;
while (xor) {
dist += Number(xor & 1n);
xor >>= 1n;
}
return dist;
}
性能数据:单图 ~50ms。两张图 hamming 距离 < 8 判定相似。
关键:必须放到 Web Worker 里跑,否则 200 张图扫描会冻住 UI。OffscreenCanvas 在 Worker 里可用:
// 主线程
const worker = new Worker('hash-worker.js');
worker.postMessage({ urls: extractedImages });
worker.onmessage = (e) => {
updateUIWithSimilarityCluster(e.data);
};
// hash-worker.js
self.onmessage = async (e) => {
const { urls } = e.data;
const hashes = [];
for (const url of urls) {
const hash = await dHash(url);
hashes.push({ url, hash });
// 流式回报,UI 不用等全部跑完
if (hashes.length % 10 === 0) {
self.postMessage({ progress: hashes.length, total: urls.length });
}
}
self.postMessage({ done: true, clusters: clusterByHash(hashes) });
};
四、Side Panel API 与 Popup 双形态共存
Chrome 的 Side Panel API 文档少,但功能挺好。我同时支持 Side Panel 和 Popup,难点不在 API 本身,而在 让同一组件在 280px(Side Panel 最小宽度)和 360px(Popup 标准宽度)下都好看。
三个救命方案:
4.1 Container queries 而非 Media queries
.image-card {
container-type: inline-size;
}
@container (max-width: 200px) {
.image-card .meta { display: none; }
}
@container (max-width: 280px) {
.image-card { aspect-ratio: 1; }
}
组件根据自己容器的宽度响应,不管页面整体宽度。
4.2 三套密度预设
不要试图让一套 UI 在所有宽度都好看。我提供 compact / comfortable / spacious 三种密度,用户自己选。
4.3 Side Panel 和 Popup 共用同一渲染层
// 同一个 view 函数,传不同 config
function renderImageGrid(images, config) {
// config.width / config.density / config.theme
// ...
}
// popup.html 入口
renderImageGrid(images, { width: 360, density: 'comfortable', theme: 'auto' });
// sidepanel.html 入口
renderImageGrid(images, { width: 'fluid', density: 'compact', theme: 'auto' });
零代码重复,行为自动一致。
五、上架 Chrome Web Store 的几个坑
把上架流程也写一下,给后来者省时间:
- 图标尺寸:必须同时提供 16/32/48/128 px 四种 PNG,少一个就被打回。我第一次就因为漏了 32px 被拒,浪费 2 天审核周期。建议直接画 1024×1024 母版,用脚本批量缩放。
- 描述长度限制:商店描述 132 字符(标题)、最多 16384 字符(详细描述)。中文按字符数算,不是字节,写起来比英文宽裕。
- 隐私政策必须有:哪怕你的插件零数据收集也要写一个隐私政策页面挂在自己的域名上。我用一句话版本就够了:「Image Harvest 不收集、不传输、不存储任何用户数据。所有处理 100% 在本地完成。」
- 审核周期:通常 1-3 个工作日。第一次提交慢一些(5-7 天)。提交后不要改 manifest 版本号,否则审核重新计时。
- 被拒处理:拒绝邮件里会列具体原因。我遇到过一次"权限超出必要范围"——本来申请了 webRequest,但实际没用到,删掉重提就过了。不要在拒绝邮件下面跟 Google 客服扯,直接改了重提最快。
- 截图准备:商店主页要 5 张截图,1280×800 或 640×400。这是用户决定要不要装的关键,比代码本身更值得花时间。
完整项目
如果你对成品感兴趣,已经上架 Chrome Web Store:
- 安装:https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo
- 官网:***********************************
- 留评(如果觉得文章对你有帮助):https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo/reviews
代码暂未开源,但欢迎技术问题交流。如果文章对你有帮助,求一个赞 + 收藏 🙏
利益相关:本文作者是 Image Harvest 开发者。