Manifest V3 迁移陷阱 — 来自 17 个 Chrome 扩展的经验教训
Source: Dev.to
Google 的 Manifest V3 迁移截止日期已经过去。 在将 17 Chrome extensions 从 MV2 迁移到 MV3 之后,我汇总了所有的陷阱、变通方法和经验教训。
如果你仍在迁移——或正在构建新扩展——本指南将为你节省数周的调试时间。
1. Service‑worker 生命周期 & 丢失的全局状态
问题 – MV3 用 service worker 替代了持久化后台页面。service worker 在约 30 秒无活动后会被终止,因此存放在全局变量中的任何状态都会丢失。
导致错误的原因 – 我的订阅检查代码把用户的付费状态存放在变量中。service worker 重启后,变量为 undefined,付费用户会看到免费层的限制。
解决方案 – 切勿在全局变量中存储状态。所有数据都使用 chrome.storage。
// BAD: Lost when service worker restarts
let userIsPaid = false;
// GOOD: Persisted across restarts
async function isPaid(): Promise {
const { subscriptionCache } = await chrome.storage.local.get('subscriptionCache');
return subscriptionCache?.paid ?? false;
}
额外陷阱 – chrome.storage.session 确实存在,但默认只能在 service worker 中访问。
如果需要在弹出页/内容脚本中使用,需要从 service worker 调用:
chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' });
2. chrome.webRequest → declarativeNetRequest
问题 – chrome.webRequest.onBeforeRequest 的阻塞功能已不再存在。需要修改或阻止请求的扩展必须使用 declarativeNetRequest。
出现的情况 – FocusGuard 使用 webRequest 来重定向被阻止的站点。整个阻塞机制停止工作。
解决方案 – 使用动态规则迁移到 declarativeNetRequest:
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 1,
priority: 1,
action: {
type: 'redirect',
redirect: { extensionPath: '/blocked.html' }
},
condition: {
urlFilter: '*://*.twitter.com/*',
resourceTypes: ['main_frame']
}
}],
removeRuleIds: [1] // optional: clean up old rules
});
注意 – 动态规则每个扩展最多 5,000 条。如果需要阻止成千上万的 URL,请改用 rule_resources 方法并使用静态规则集。
3. chrome.alarms 最小周期
问题 – chrome.alarms.create 在生产环境强制最小周期为 1 分钟(开发环境为 30 秒)。
导致的错误 – 我的订阅刷新使用了 30‑秒的轮询间隔。生产环境中它悄悄升级为 60 秒,导致数据陈旧。
解决方案 – 围绕 1‑分钟的最小限制进行设计。若需亚分钟精度,可在 Service Worker 中使用 setTimeout —— 但要记住工作线程可能会被终止。对于关键计时,接受 1‑分钟的粒度。
4. 当 Service Worker 休眠时的消息传递
问题 – 当 Service Worker 不活跃时,内容脚本中的 chrome.runtime.sendMessage 可能会静默失败或抛出错误。
导致的错误 – 内容脚本请求后台获取订阅状态。如果 Service Worker 正在休眠,Promise 会永远挂起。
解决方案 – 始终添加超时和后备方案:
async function getSubscription(): Promise {
// 1️⃣ Check cache first
const cache = await chrome.storage.local.get('subscriptionCache');
if (cache.subscriptionCache?.timestamp > Date.now() - 300_000) {
return cache.subscriptionCache;
}
// 2️⃣ Ask background with timeout
return new Promise((resolve) => {
const timeout = setTimeout(() => resolve(cache.subscriptionCache || DEFAULT), 3000);
try {
chrome.runtime.sendMessage({ action: 'getSubscription' }, (res) => {
clearTimeout(timeout);
if (chrome.runtime.lastError || !res) {
resolve(cache.subscriptionCache || DEFAULT);
return;
}
resolve(res);
});
} catch {
clearTimeout(timeout);
resolve(cache.subscriptionCache || DEFAULT);
}
});
}
5. chrome.downloads.download 现在需要用户手势
问题 – chrome.downloads.download() 现在在某些上下文中需要用户手势。来自后台脚本的程序化下载可能会失败。
导致的错误 – DataPick 的导出功能通过内容脚本向后台触发下载。在 MV2 中可用,但在 MV3 中会悄然失败。
解决方案 – 任选其一:
- 直接在内容脚本中触发下载,使用 Blob URL 并通过锚点点击实现,或
- 确保后台下载在对用户操作的直接响应消息中执行。
6. chrome.tabs.executeScript → chrome.scripting.executeScript
问题 – chrome.tabs.executeScript 已被 chrome.scripting.executeScript 取代,API 形式也不同。
旧写法
chrome.tabs.executeScript(tabId, { code: 'document.title' });
新写法
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.title,
});
console.log(result.result); // 页面标题
注意 – func 参数必须是 可序列化的函数。它不能引用外部作用域中的变量。请通过 args 参数传递数据。
7. 更严格的 MV3 审核流程
问题 – MV3 扩展面临更严格的审核。Google 现在会标记拥有广泛权限(<all_urls>、tabs 等)以及体积大的扩展。
导致的错误 – 我的两个扩展因同时请求 activeTab + <all_urls> 而被拒绝,因这被视为冗余。
解决方案
- 请求 最小权限。
- 在可能的情况下使用
activeTab替代主机权限。 - 在 CWS 开发者后台提供 权限说明。
- 保持包体积小(积极进行 tree‑shaking)。
8. 迁移后检查清单
在完成 17 次迁移后,这是我的常用检查清单:
- 用
chrome.storage替换所有全局状态 - 将
webRequest迁移到declarativeNetRequest - 用
chrome.scripting.executeScript替换chrome.tabs.executeScript - 为 所有
runtime.sendMessage调用添加超时/回退 - 使用服务工作线程重启进行测试(
chrome://serviceworker-internals) - 确认闹钟在 1 分钟最小间隔下正常工作
- 审查并最小化权限
- 在服务工作线程休眠后测试 content‑script ↔ background 通信
- 确认下载在没有持久后台页面的情况下正常工作
TL;DR
MV3 是一种根本不同的编程模型。服务工作线程的生命周期改变了一切。从第一天起就为 无状态 进行设计,并将服务工作线程视为 短暂的助手,而不是持久的后台页面。遵循上述检查清单,你就能避免最常见的陷阱。
由 S‑Hub 构建 — 17 个 Chrome 扩展,全部运行在 Manifest V3 上。
- Procshot — 自动捕获浏览器操作步骤
- DataPick — 从任意网页提取数据
- FocusGuard — 阻止分心网站