
1. 为什么现在还要重新啃 Manifest.jsonMV3 的一场强制迁徙2023 年初开始Chrome Web Store 就关闭了 Manifest V2 插件的新提交通道到 2024 年更是全面停止了对 MV2 扩展的支持。这意味着什么如果你手上还有一套用browser_action、background.scripts写的老插件哪怕它运行得好好的也会在用户浏览器里被强制禁用取而代之的是一个灰色图标加上已停用的提示。很多开发者就是在这个节点上被迫开始读 Manifest.json 文档的我也不例外。当时我手上有三个线上插件要迁移翻遍官方文档和各类碎片文章发现了一个尴尬的事实Manifest V3 的字段说明散落在不同页面很少有文章能从头到尾把所有字段讲清楚更没有几篇能告诉你这个字段在 MV3 里已经改了、那个字段千万别照旧填。如果你正准备开发一个新的 Chrome 插件或者正在为老插件做迁移那么 Manifest.json 就是你绕不开的第一道门。它不只是一个声明文件它决定了你的插件能拿到什么权限、能跑哪些代码、能在什么页面里注入脚本、UI 长什么样。我在做迁移的过程中因为字段理解偏差踩了不少坑有些坑在官方文档里压根不会写。这篇文章我就按照自己实际操作过的顺序把 MV3 版本 Manifest.json 的字段从头到尾拆一遍重点标注那些和 MV2 差异巨大、特别容易翻车的地方。文章适合三种人第一次写插件的新手、从 MV2 迁移的老手、以及想在发布前自查一遍配置细节的开发者。在进入逐字段分析之前先想明白一件事MV3 的整体设计思路说白了就是收紧权限、消灭常驻后台、禁止远程代码。这三个原则几乎解释了 Manifest.json 里所有字段的增删改变化。你只有把这三个原则刻在脑子里再看字段列表时就不会觉得有些限制莫名其妙了。2. 三个看不见但决定一切的 MV3 设计原则2.1 常驻后台页被砍掉一切逻辑交给 Service WorkerMV2 时代我们习惯在后台配置一个background.html里面挂一个长期运行的 JavaScript 环境用来监听事件、维护状态、处理消息。这个页面只要浏览器开着就会运行哪怕什么都不做也在消耗内存。MV3 直接把这种模式废了换成了 Service Worker——一种用完即走的脚本环境插件被触发时才启动、空闲一段时间后自动休眠下次再被事件唤醒时重新执行。这个设计对 Manifest.json 最直接的影响就是background字段的写法完全不同了。MV2 里常见的是background: {scripts: [bg.js], persistent: true}到了 MV3 变成background: {service_worker: bg.js}。别小看这个变化persistent字段整个消失了因为 MV3 里不存在持久后台的概念而且service_worker只能指定一个文件不能再像 MV2 那样写一个数组。如果你想按模块拆分代码需要在background.service_worker.type里设置为module用 ES Module 的方式来组织代码。我刚迁移时在这个坑里卡了两天我把老代码拆成了background.js和utils.js两个文件按照 MV2 的惯性把它们写进数组结果 MV3 直接报错Service worker cannot be an array改成只保留一个入口文件后又发现我在background.js里用import导入utils.js的函数报错说import语句只能用在外层 module 环境。最后才发现要加type: module。这一个小字段官方文档里放在不起眼的角落但不知道它的话ES Module 写法就是跑不通。2.2 权限边界大幅收窄host_permissions 被单独拎出来MV2 里所有权限都堆在一个permissions数组里包括tabs、storage、http://*/这类站点权限。MV3 把访问哪些网站这种高危权限单独拆成了一个字段host_permissions和permissions分开放置。逻辑很清楚permissions管的是 Chrome 提供的 API 能力比如storage存储、alarms定时器、clipboardRead读剪贴板而host_permissions管的是你的脚本能跑在哪些域名上比如https://*.example.com/*。为什么要这么拆因为在 Chrome 的权限提示 UI 里用户可以清楚地看到这个插件能读取你在所有网站上的数据而不会把它和插件能使用存储 API混为一谈。如果你只声明了host_permissions而没有在permissions里声明对应 API那么你能往页面上注入脚本但未必能调用chrome.storage反过来你声明了storageAPI 权限但没声明任何host_permissions你的脚本就哪儿也去不了。这两个字段是配合关系不是替代关系。我在给一个网页标注工具做迁移时把permissions: [activeTab, scripting, storage, https://*/*]直接复制到了 MV3Chrome 倒是没报错但审核时被拒了理由是权限申请范围过大。后来我改成permissions: [activeTab, scripting, storage]加上host_permissions: [https://*/*]的组合。这里有个隐藏逻辑如果只用activeTab其实可以完全不用host_permissions因为这个权限会让你在用户主动点击插件图标时获得当前标签页的一次性访问权。能少声明就少声明审核更容易通过用户安装时的安全感也更强。2.3 远程代码全面禁止CSP 规则从字符串变成对象MV2 时代在content_security_policy里你可以写script-src self https://some-cdn.com;然后直接在插件页面里引入远程 CDN 的脚本。MV3 一刀切了推广线上 CSP 只允许self不允许任何远程源、不允许eval、不允许wasm-eval。这意味着你的插件代码、依赖库、所有逻辑都必须打包进插件本地的文件里想在运行时从服务器拉脚本没门。对应到 Manifest.json 里MV3 的content_security_policy字段变成了一种对象结构包含extension_pages和sandbox两个属性。extension_pages管的是插件自带的页面比如弹窗页、设置页、独立标签页这个值基本只能设为script-src self; object-src self;几乎没有操作空间sandbox才是留给你自由发挥的空间如果你需要做一些不安全的操作比如通过eval执行动态代码可以让这些代码在沙盒页面里跑。我之前写过一个小工具需要在弹窗页里动态拼一段模板字符串然后执行MV2 里靠eval就解决了。迁到 MV3 后发现整个弹窗页控制台全是 CSP 报错代码一行都跑不了。最后只能把动态执行的部分单独拆到一个沙盒 iframe 页面里然后在主页面和 iframe 之间用postMessage通信。这个改动上线后反而更稳定了因为沙盒页面里的错误不会影响主页面。如果你习惯了在插件里从远程服务器拉取最新脚本做热更新MV3 下这个方案基本是死路必须改为发版更新。3. 基础信息字段逐个拆name、version、icons 里的小监狱3.1 manifest_version、name、version格式不对连加载都失败先说最基础但最容易被忽略的。manifest_version在 MV3 下必须写整数3而且必须是数字类型不能写字符串3。这是 Chrome 在解析时直接强校验的写错的话插件在chrome://extensions页面会显示无法加载清单文件。name字段限制是 45 个字符description限制是 132 个字符。这两个都算好说真正坑人的是version。MV3 要求版本号最多 4 段数字每段只能是 1 到 2 位数字也就是说1.0、1.0.2、1.2.3.4都合法但1.0.0.0.0不合法1.10合法因为10是两位数1.100就出问题了——等等每段允许 1 到 2 位数字所以1.100不合法。很多开发者在这里翻车是因为他们用日期当版本号比如2024.12.31看起来没问题但如果月份或日期变成三位数呢而且 Chrome Web Store 的版本号必须是递增的你传了一个比线上更低的版本号上去直接给你拒回来。我自己的做法是维护一个简单的递增规则主版本号.功能版本号.修复版本号比如2.4.1。每次提交前先在本地跑一遍chrome --pack-extension打包验证确保版本号能被解析。如果你用自动化发布脚本记得在脚本里加一个版本号递增的检查逻辑不然 CI 里很容易因为手滑把版本号填低了导致发布失败。3.2 default_locale、key、icons平时不起眼缺了就出乱子default_locale这个字段是在你要做国际化i18n时才需要的。一旦你设置了它就必须在插件根目录下建一个_locales文件夹里面至少有一个和default_locale值对应的语言文件。比如default_locale: zh_CN时_locales/zh_CN/messages.json必须存在否则插件加载直接报错。这个字段的设计初衷是告诉 Chrome 你的默认语言是什么然后 Chrome 会优先加载用户浏览器语言对应的 messages 文件找不到再回退到默认语言。key字段可能是所有字段里最容易被误解的一个。它不是你在 Manifest.json 里手写的而是 Chrome 在你打包插件时生成的。它的作用是固定插件的 ID这样无论用户从商店安装还是你用--load-extension本地加载插件 ID 都是一样的。这对依赖固定 ID 的开发场景特别重要比如你通过externally_connectable让别的扩展和自己通信时ID 变了就全断了。如果你在开发环境里发现插件 ID 每次刷新都不一样那大概率是没设置key字段。怎么拿 key先在浏览器里加载一次未打包的扩展然后在chrome://extensions页面开启开发者模式查看扩展详情复制它的 ID再到扩展目录里用工具生成对应 Base64 的 key填进 manifest 后重新加载就能固定 ID 了。icons字段声明 16、32、48、128 四种尺寸的图标很多新手会把四个尺寸都设置成同一张图片这其实不太对。16 和 32 是顶栏工具栏用的图标太小的话会糊48 是扩展管理页大图标128 是商店列表页图标。最省事的方案是准备一张 128x128 的源图然后用工具导出四个尺寸。注意icons里的路径是相对 Manifest.json 所在目录的不要用../这种写法会解析失败。4. 核心功能字段全面拆解从 background 到 content_scripts 到 action4.1 background.service_worker注册规则与生命周期控制在 MV3 里background字段最常见的写法是这样background: { service_worker: service-worker.js, type: module }service_worker的值是相对于插件根目录的路径。type设为module时这个 worker 会以 ES Module 的身份执行你可以在里面用import语句引入其他模块这是目前最推荐的写法。但注意MV3 的 Service Worker 有几个和普通页面脚本完全不同的特性理解它们才能正确配置字段它是事件驱动的空闲约 30 秒就会被终止。所以你不能在 worker 里挂一个全局变量长期保存状态状态要存到chrome.storage或 IndexedDB。监听器必须在顶层同步注册。如果你在chrome.runtime.onInstalled.addListener的回调里再注册其他事件的监听worker 休眠后这些监听器可能丢失。在 worker 里访问 DOM 是不行的document、window这些对象都不存在。我早期犯过一个错我在 worker 里用了setInterval定时去轮询接口想着这跟 MV2 的后台页一样会一直跑。结果每次休眠唤醒后定时器就断了甚至接口请求都会因为 CORS 问题失败。后来我把定时任务改成使用chrome.alarmsAPI配合chrome.storage存储上一次的轮询时间戳才做到了永久任务。如果你原本在 MV2 里用setInterval做了很多周期任务迁移到 MV3 时务必要全部改成chrome.alarms。4.2 content_scripts注入时机与匹配规则的细节content_scripts字段在 MV2 和 MV3 里形式差不多但有几个细节需要注意。一个典型的配置content_scripts: [ { matches: [https://*.example.com/*], js: [content.js], css: [content.css], run_at: document_idle, all_frames: false, world: ISOLATED } ]matches是匹配规则写法是 Chrome 匹配模式match pattern比如https://*/*、http://localhost/*、all_urls。注意不要在matches里出现http://localhost:8080/*这种带端口的写法端口在匹配模式里是不支持的如果你要匹配本地开发地址得写成http://localhost/*然后靠include_globs或 JS 里自行判断端口。run_at有三个可选值document_startDOM 刚开始构建、document_endDOM 解析完、document_idle页面加载完默认值。如果你要在页面加载早期拦截某些请求就需要document_start如果只是往页面上加个按钮document_idle足够。这个字段直接影响脚本的执行时机改错了会导致找不到 DOM 元素。world是 MV3 新增的字段取值为ISOLATED默认或MAIN。ISOLATED表示脚本运行在独立的 JavaScript 环境中和页面本身的 JS 环境隔离——页面里的全局变量你访问不到你定义的变量也不会污染页面MAIN则表示脚本注入到页面自己的世界可以和页面脚本共享 DOM 和全局变量。需要注意的是MAIN模式下你的脚本等于和页面里其他脚本平起平坐页面脚本可能会恶意修改你的方法所以除非真需要操作页面自己的全局函数否则保持默认ISOLATED就好。4.3 action 与 commands工具栏按钮、弹窗和快捷键MV2 里的browser_action和page_action在 MV3 被统一成了action。因为很多插件其实不需要区分工具栏按钮一直可见和只在特定页面可见Chrome 干脆合并了统一默认可见然后用程序控制在特定页面禁用按钮。Manifest.json 里action字段的典型写法action: { default_popup: popup.html, default_title: 点击打开面板, default_icon: { 16: icons/icon16.png, 32: icons/icon32.png } }default_popup指定点击按钮后弹出的 HTML 页面路径这个页面有自己的独立窗口宽高受限最高 800x600。如果你不想用弹窗而是想在点击按钮后通过chrome.scripting.executeScript执行一段脚本那就不需要default_popup只需要在background里监听chrome.action.onClicked事件。注意一个坑一旦设置了default_popupchrome.action.onClicked事件就不会触发了。有时我在调试时发现点击按钮没反应第一反应是去看 onClicked 监听器结果发现是上次忘了删default_popup。两个机制是互斥的只能选一个。commands字段用来声明快捷键它不属于action但经常配合action使用。例如commands: { toggle-feature: { suggested_key: { default: CtrlShiftY, mac: CommandShiftY }, description: 开关某项功能 } }当你在 manifest 里声明了commands需要在一个页面里调用chrome.commands.onCommand.addListener来监听触发并执行对逻辑。系统快捷键有保留键位比如 CtrlShift某些特殊键如果冲突Chrome 会在chrome://extensions/shortcuts页面显示为可手动修改所以测试时如果没有生效先去看看是不是被其他软件或插件抢占了快捷键。4.4 options_page 与 options_ui设置页的两种形态设置页面有两种声明方式。options_page是老的写法设置页会作为独立的标签页打开相当于一个完整的网页这个页面里可以自由使用chrome.*API。options_ui是新写法可以配合open_in_tab: false让设置页在一个嵌入式面板中打开。两者都只能出现一个同时写进 manifest 的话 Chrome 会优先使用options_ui的设置。options_ui: { page: options.html, open_in_tab: true }如果用嵌入式设置页最好不要在options.html里使用window.close()这类操作因为页面不是在独立标签页中打开关闭逻辑可能不受控。另外有些插件希望让用户右键图标菜单里直接进设置页这个需要你在chrome.runtime.openOptionsPage()里手动触发即便没有写options_ui这个函数依然可用因为 Chrome 会自动兜底。5. 权限和资源声明MV3 最容易审核被拒的区域5.1 permissions 和 host_permissions怎么写才既不报错也不过度索权permissions里的 API 权限非常多开发中最常用的大概是这些storage使用chrome.storage.local或chrome.storage.session存取数据scripting使用chrome.scripting.executeScript/insertCSS动态注入脚本activeTab用户主动交互时获得当前标签页的临时访问权限tabs读取标签页的 url、title 等敏感信息不需要注入脚本时常常没必要申请alarms使用定时器clipboardRead/clipboardWrite读写剪贴板downloads主动触发下载notifications桌面通知别把permissions当成许愿池凡是你能想到的 API 全往上堆。Chrome Web Store 审核时会评估权限是否合理申请了一堆用不到的权限会被打回。而且权限多用户在安装时看到的警告信息也多很影响转化率。我见过有些插件只是做个网页高亮标注却申请了tabs和all_urls这些权限完全可以通过activeTab按需获取。host_permissions则用来声明脚本能运行在哪些站点上。注意如果你同时在content_scripts里写了matches那么host_permissions的作用不是重复声明匹配规则而是让你的插件在后台 Service Worker 和弹窗页面里也能进行跨域请求、使用chrome.tabs.query读取这些站点的标签数据等。也就是说content_scripts 匹配规则和 host_permissions 是两套体系后者决定插件自身的代码能访问哪些网络资源前者决定注入到页面里的脚本能出现在哪些页面。一个比较常见的组合是使用activeTabscripting来代替all_urls的权限申请。这样用户点击插件图标时你的脚本才被注入到当前页面不需要提前声明所有域名。这个方案在权限审查上极其友好缺点是用户第一次使用时不够自动需要多点一下图标。5.2 web_accessible_resources语义从可访问资源变成特定页面可用资源MV2 的web_accessible_resources就是一个简单的资源路径数组声明了之后任何网站上的脚本都可以通过chrome.runtime.getURL拿到这些资源的地址并访问。这在当时引发了很多滥用比如恶意网站可以通过访问你的插件资源来探测你是否安装了某个插件。MV3 把整个机制改了变成对象数组每个对象里必须包含resources和matches表示这些资源只允许在这些匹配的页面里被访问。web_accessible_resources: [ { resources: [injected.js, images/*.png], matches: [https://*.example.com/*] } ]这个字段通常和content_scripts中注入的脚本配合使用你的内容脚本想要动态加载插件里的某个资源就得把该资源声明为 web accessible否则页面端的 JavaScript 无法读取。还有一个新属性use_dynamic_url把它设为true时Chrome 每次启动插件会生成一个随机的资源路径前缀可以防止网站固定路径来探测插件。这个属性特别适合做隐私保护类插件值得用起来。5.3 content_security_policyMV3 里它基本没有发挥空间正如 2.3 节说的MV3 下content_security_policy已经变成content_security_policy: { extension_pages: script-src self; object-src self;, sandbox: sandbox allow-scripts; script-src self unsafe-eval }如果你没有特殊的沙盒需求可以完全不声明extension_pagesChrome 会使用默认策略。一旦你自己声明了就只能比默认策略更严格不能更宽松所以没必要去填一个和默认一样的值。如果你真的需要eval或者new Function来动态执行代码那就得把相关页面声明为sandbox。比如你在插件目录下建了一个sandbox.html然后在sandbox里给它指定 CSP就可以在这个页面里自由执行eval但这个页面不能直接调用chrome.*API只能通过postMessage和父页面通信。我在做模板渲染工具时就是这么干的渲染逻辑放在沙盒页面数据通过消息传给父页面对于合法用途来说这个是可行的但绝大多数情况你应该用正常的函数引用或Function.prototype的安全替代方案避免引入代码注入风险。5.4 optional_permissions 与 optional_host_permissions把权限申请延后到运行时这两个字段在 MV3 里同样存在作用是声明插件可能需要的权限但安装时不会提示等到用户实际操作到对应功能时通过chrome.permissions.request接口弹窗申请。这种模式可以显著降低首次安装的心理门槛。optional_permissions: [downloads], optional_host_permissions: [https://api.example.com/*]要注意optional_permissions里的权限不能和permissions里的重复用户一旦授予了可选权限之后chrome.permissions.remove可以再取消。如果你的插件核心功能需要某个权限尽量不要把它放到 optional 里否则用户拒绝授权你的主流程就跑不通了。合理用法是核心权限在安装时声明周边功能权限比如导出 PDF要用到downloads可以推迟到用户点击导出按钮时再申请。6. 那些容易被忽略的边缘字段与综合配置示例6.1 minimum_chrome_version、incognito、externally_connectableminimum_chrome_version指定你的插件最低能支持的 Chrome 版本。MV3 本身要求 Chrome 88 及以上但如果你用到了更新的 API比如chrome.scripting中较新的方法就要把最低版本调高。这个字段常常被忽略导致部分老版本浏览器用户反馈插件装上了但功能不生效我在自己插件里就把minimum_chrome_version设成了109因为这个版本之后 MV3 的稳定性才真正到位。incognito字段控制插件在无痕模式下的表现取值有spanning默认在有痕和无痕模式间共享、split每个模式单独运行一个后台、not_allowed无痕模式下完全禁用。如果你的插件需要缓存一些用户数据最好明确设为split避免隐私数据在无痕和有痕之间串台。externally_connectable字段用于声明哪些外部扩展或网站可以通过chrome.runtime.connect或chrome.runtime.sendMessage给你的插件发消息。配置时可以用ids指定扩展 ID也可以用matches指定网页域名。这个字段安全相关建议收敛到最小范围不写的话默认为不允许任何外部来源通信。6.2 一份可直接复制修改的最小完整 Manifest.json把前面讲的字段综合到一起给出一个我目前线上插件实际在用的配置骨架{ manifest_version: 3, name: 我的网页助手指南, version: 1.2.0, description: 一个用于网页标注与数据提取的示例插件, minimum_chrome_version: 109, icons: { 16: icons/icon16.png, 32: icons/icon32.png, 48: icons/icon48.png, 128: icons/icon128.png }, action: { default_popup: popup/popup.html, default_title: 打开助手 }, background: { service_worker: background/service-worker.js, type: module }, content_scripts: [ { matches: [https://*/*, http://*/*], js: [content/content.js], run_at: document_idle, world: ISOLATED } ], permissions: [storage, scripting, activeTab], host_permissions: [https://*/, http://*/], web_accessible_resources: [ { resources: [injected/injected.js], matches: [https://*/*, http://*/*], use_dynamic_url: true } ], options_ui: { page: options/options.html, open_in_tab: false }, commands: { toggle-annotation: { suggested_key: { default: AltShiftA }, description: 切换标注模式 } } }这份配置覆盖了绝大多数插件的核心需求。你要根据自己的功能删减不需要它就用不到不要照抄。6.3 加载与排错流程从本地加载到控制台报错定位写完 Manifest.json 后打开chrome://extensions开启右上角的开发者模式点击加载已解压的扩展程序选择插件目录。如果 Manifest.json 有 JSON 语法错误或字段校验不通过这里会直接给出报错信息具体到哪个字段有问题照着改就行。如果提示清单文件缺失或不可读先检查文件编码不要用带 BOM 的 UTF-8Chrome 对 BOM 的容忍度比较低。加载成功后去扩展详情页点击查看错误按钮能看到 Service Worker 和各个页面的报错日志。我在调试时遇到过一个诡异现象插件明明加载成功了但一点击图标就缩回去没任何反应。打开错误面板才发现是popup.html里引用的一个本地 JS 文件因为路径写错 404 了而弹窗页面一有 JS 错误就会自动关闭表现的就像点了没反应。这类问题排查时错误信息面板比什么都好使。7. 我实际踩过的坑和最终的排错结论说实话Manifest.json 的字段本身不算复杂真正麻烦的是 MV3 整体架构改变带来的连锁反应。有几个坑我特别想单独拎出来说因为它们在官方文档里不会写但几乎每个迁移者都会遇到。第一个坑是content_scripts里用了include_globs后发现脚本就是不在预期页面上运行。include_globs和exclude_globs是匹配模式的补充规则格式和 glob 相似比如*://*.example.com/*。但它们和matches的交集逻辑比较绕只有同时满足 matches 和 globs 规则才会注入。如果你习惯了只写 matches突然加了 globs反而会缩小注入范围。我的建议是能用 matches 解决的绝不写 globs否则调试时很容易怀疑人生。第二个坑是 MV3 下没法在插件内部使用XMLHttpRequest访问普通 HTTP 接口必须使用fetch。这个不是 Manifest.json 字段的问题但很多人把报错Service worker cannot use XMLHttpRequest当成 manifest 配置错误翻遍配置文件也找不到答案。实际上这是 Service Worker 环境的限制改用fetch即可。第三个坑最隐蔽某些 API 在 MV3 的permissions里写错了也不报错功能却静默失败。比如我用chrome.storage.sync时忘了在 permissions 里声明storage结果是同步功能完全不生效但浏览器控制台里不打印任何错误只有在你调用chrome.runtime.lastError检查时才会看到。所以写完 manifest 后建议逐个功能点走一遍并且通过chrome.runtime.lastError检查异步调用是否真的成功。最后一个排错结论如果插件在chrome://extensions页面加载时报Extension must be signed in to use this API或者一些莫名其妙的错误优先看你是不是同时开了 Chrome 的多用户配置、或者用了企业策略限制了扩展权限。这些情况下的报错信息往往指向 manifest 字段但根源其实在浏览器运行环境别在上面空耗时间。Manifest V3 的迁移阵痛是真实的但理解它背后的安全优先、资源友好思路之后你会发现每个字段限制都有它存在的理由。上面这些内容是我自己在迁移两个生产插件过程中边看文档边试错总结出来的希望你能避开我走过的弯路花更少的时间把 Manifest.json 一次写对。