Chrome二维码插件开发实战:Manifest V3下生成与解码
2026/9/23 8:07:05 网站建设 项目流程

1. 为什么我要自己写一个二维码插件

浏览器地址栏里那一长串 URL,想从电脑传到手机上,最省事的办法就是扫个码。但 Chrome 原生并不提供这个能力,右键菜单里没有“生成二维码”这一项,地址栏也没有。于是大多数人会去应用商店装一个二维码插件,装完发现要么权限要得离谱,要么界面丑得没法看,要么生成出来的码扫不出来。

我前后用过七八款同类插件,踩的坑大致分三类:第一类是生成质量差,容错级别固定成最低档,稍微有点反光或者角度偏一点就识别失败;第二类是功能单一,只能生成不能解码,遇到别人发来一张二维码图片还得另找工具;第三类是权限过重,一个生成二维码的插件居然要读取所有网站的数据,这在安全上完全没法接受。

Chrome-QRCode 这个项目的出发点就是解决这三个问题:生成和解析双向能力、离线本地运算、最小权限。整个插件不依赖任何远程接口,二维码的编码和解码全部在浏览器本地完成,不联网也能用。代码量控制得很小,核心逻辑集中在一个内容脚本和一个弹出页面里,三分钟就能把结构看明白,适合想自己动手改插件的人拿来当模板。

这篇文章会从设计思路讲到具体实现,包括二维码编码的容错级别怎么选、解码时图像预处理怎么做、Manifest V3 下有哪些坑,以及我自己在调试过程中遇到的一堆问题。如果你只是想装一个用,看完第一节就能上手;如果你想自己改一个,后面几节可以直接抄。

2. 整体设计与技术选型拆解

2.1 功能边界怎么划

做插件最容易犯的错是功能贪多。我一开始想的是“生成 + 解码 + 历史记录 + 批量处理”,结果光历史记录就要引入存储层和 UI 列表,代码量翻三倍,维护成本陡增。后来砍到只剩两个核心动作:

  • 当前页面 URL 一键生成二维码,点击插件图标即可看到,支持下载成 PNG。
  • 上传或粘贴一张二维码图片进行解码,把里面的内容还原成文本。

这两个动作覆盖了 95% 的使用场景。历史记录、批量处理这些属于“锦上添花”,真需要的时候用系统截图和文件夹管理就够了,没必要塞进插件里。

提示:功能边界一旦确定,后面所有的技术选型都要围绕它做减法,任何“顺便加上”的念头都要警惕。

2.2 为什么选 Manifest V3 而不是 V2

Chrome 从 2023 年开始逐步停止对 Manifest V2 的支持,新提交的插件必须是 V3。V3 最大的变化是后台脚本从常驻的 background page 变成了按需唤醒的 service worker,同时远程代码被禁止执行。这对二维码插件其实是好事:

  • 二维码编解码本来就是纯本地计算,不需要常驻后台,service worker 按需唤醒完全够用。
  • 禁止远程代码意味着所有逻辑必须打包进插件,反而逼着你把依赖理清楚,不会出现“运行时偷偷拉一个 CDN 脚本”的情况。

代价是 service worker 有生命周期,不能在里面保存全局状态。我的做法是把状态全部放在弹出页面的内存里,弹出页面关闭即销毁,逻辑反而更干净。

2.3 二维码库的选型对比

生成和解码是两件事,用的库也不一样。我对比了几个主流方案:

方案生成解码体积是否纯 JS备注
qrcode.js支持不支持约 20KB老牌,API 简单
qrcode-generator支持不支持约 15KB无依赖,适合打包
jsQR不支持支持约 40KB解码能力强,社区活跃
ZXing-js支持支持约 200KB功能全但体积大

最后我选了qrcode-generator 负责生成 + jsQR 负责解码。理由很直接:两个库加起来 55KB 左右,都是纯 JS 无外部依赖,可以直接内联进插件包,不需要构建工具。ZXing-js 虽然一个库全包,但 200KB 的体积对一个“极简插件”来说太重了,而且它的生成 API 比 qrcode-generator 啰嗦不少。

2.4 权限最小化设计

Manifest 里我只声明了两个权限:

{ "permissions": ["activeTab", "downloads"], "host_permissions": [] }

activeTab让你在用户点击插件图标时临时获得当前标签页的访问权,用来读取 URL。downloads用来把生成的二维码保存成文件。注意host_permissions是空的,这意味着插件不会在任何网站上自动运行,也不会读取你的浏览数据。这一点在安装时用户能直观看到“此插件不需要读取和更改您在所访问网站上的所有数据”,信任度完全不一样。

3. 核心细节解析与实操要点

3.1 二维码容错级别到底怎么选

二维码有四个容错级别:L(7%)、M(15%)、Q(25%)、H(30%)。数字越大,二维码被遮挡或污损后还能被识别的比例越高,但同样内容需要的模块数也越多,码会变得更密。

很多人默认用 L,觉得码看起来清爽。但实际使用中,二维码经常被印在名片、贴在设备上、显示在反光的屏幕上,L 级别稍微脏一点就扫不出来。我的默认选择是M 级别,这是容错和密度的平衡点。如果是需要打印或者长期张贴的场景,建议直接上 Q。

具体到 qrcode-generator 的调用:

const qr = qrcode(0, 'M'); // 0 表示自动选择版本,M 表示容错级别 qr.addData(url); qr.make(); const svg = qr.createSvgTag({ cellSize: 4, margin: 2 });

第一个参数传 0 让库自动根据内容长度选择最小的版本号,避免手动算错。cellSize控制每个模块的像素大小,margin是四周的留白。留白很重要,二维码规范要求至少 4 个模块的静区,留白不够会导致识别率下降。

注意:margin不要设成 0,哪怕 UI 上看起来紧凑好看,实际扫码时边缘模块和背景混在一起,识别率会明显下降。

3.2 解码前的图像预处理

jsQR 的输入是 ImageData,也就是一个包含 RGBA 像素的数组。直接把用户上传的图片丢进去,识别率往往不理想,因为:

  • 图片可能太大,jsQR 处理高分辨率图会慢。
  • 图片可能带透明通道,背景透明时对比度不够。
  • 图片可能倾斜或者有噪点。

我的预处理流程是这样的:

  1. 限制尺寸:把图片等比缩放到最长边不超过 1000px。二维码本身信息密度有限,超过这个尺寸对识别没有帮助,只会拖慢速度。
  2. 铺白底:如果图片有透明通道,先画一层白色背景再画图片,避免透明区域被当成黑色。
  3. 转灰度:jsQR 内部会做二值化,但提前转灰度能减少它的计算量。
function preprocess(img) { const maxSide = 1000; const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); return ctx.getImageData(0, 0, w, h); }

这段代码里fillRect那一步是关键,很多人漏掉,结果透明背景的二维码死活解不出来。

3.3 弹出页面的布局取舍

弹出页面(popup)的宽度在 Chrome 里最大是 800px,但实际使用中超过 400px 就会显得很宽。我定的是 360px,刚好能放下一个 256px 的二维码加两行按钮。

布局上我用了最朴素的上下结构:上面是二维码显示区,下面是操作按钮。没有用任何 UI 框架,纯 CSS 手写,总共不到 80 行。这样做的好处是加载快,弹出页面打开时不会有任何闪烁。

一个细节:二维码生成后要等图片加载完再显示,否则会出现一瞬间的空白。我的做法是生成 SVG 字符串后直接innerHTML塞进去,SVG 是矢量图,渲染是同步的,不存在加载延迟。

3.4 下载功能的实现细节

下载二维码用chrome.downloads.downloadAPI,但这里有个坑:这个 API 在 service worker 里调用需要传url,而我们的二维码是 SVG 字符串,没有 URL。解决办法是转成 data URL:

const svgBlob = new Blob([svgString], { type: 'image/svg+xml' }); const url = URL.createObjectURL(svgBlob); chrome.downloads.download({ url: url, filename: 'qrcode.svg', saveAs: true });

saveAs: true让用户自己选保存位置,避免默认下载到下载文件夹后找不到。另外记得在下载完成后URL.revokeObjectURL(url)释放内存,虽然弹出页面关闭后浏览器会自动回收,但养成习惯没坏处。

4. 完整实操流程与关键环节实现

4.1 项目目录结构

整个插件的文件结构如下,没有构建步骤,改完直接刷新就能用:

chrome-qrcode/ ├── manifest.json ├── popup.html ├── popup.css ├── popup.js ├── lib/ │ ├── qrcode-generator.js │ └── jsQR.js └── icons/ ├── 16.png ├── 48.png └── 128.png

lib目录放两个第三方库,直接下载未压缩版本放进去。不压缩是为了方便调试,如果在意体积可以用 terser 压一下,但 55KB 的差距对插件来说可以忽略。

4.2 manifest.json 完整配置

{ "manifest_version": 3, "name": "Chrome-QRCode", "version": "1.0.0", "description": "一键生成当前页面二维码,支持图片解码,纯本地运算。", "permissions": ["activeTab", "downloads"], "action": { "default_popup": "popup.html", "default_icon": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" } }, "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" } }

注意action里没有default_title,因为图标本身已经够直观了。host_permissions完全省略,这是权限最小化的体现。

4.3 生成二维码的核心逻辑

popup.js 里生成部分的完整流程:

async function generateQR() { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const url = tab.url; if (!url || url.startsWith('chrome://')) { showError('当前页面不支持生成二维码'); return; } const qr = qrcode(0, 'M'); qr.addData(url); qr.make(); const svg = qr.createSvgTag({ cellSize: 4, margin: 2 }); document.getElementById('qrcode').innerHTML = svg; currentUrl = url; }

这里有个必须处理的边界:chrome://开头的页面(比如设置页、扩展管理页)不允许扩展读取 URL,tab.url会是 undefined 或者空字符串。如果不判断,用户在这些页面上点插件会看到一片空白,体验很差。我的做法是显示一句明确的提示,告诉用户换个页面再试。

4.4 解码功能的完整实现

解码部分要处理两种输入:用户上传的图片文件,以及用户直接粘贴的图片。粘贴的处理稍微复杂一点,因为剪贴板里的图片是 Blob 格式:

document.addEventListener('paste', async (e) => { const items = e.clipboardData.items; for (const item of items) { if (item.type.startsWith('image/')) { const blob = item.getAsFile(); const img = await blobToImage(blob); decodeImage(img); } } }); async function decodeImage(img) { const imageData = preprocess(img); const result = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); if (result) { showDecodedText(result.data); } else { showError('未能识别出二维码,请尝试更清晰的图片'); } }

inversionAttempts: 'attemptBoth'这个参数值得说一下。默认情况下 jsQR 只尝试识别“深色模块在浅色背景上”的二维码,但有些设计感强的二维码是反色的(浅色模块在深色背景上),加上这个参数后两种都会尝试,识别率明显提升。代价是计算量翻倍,但对于单张图片来说完全可以接受。

4.5 参数计算:二维码版本与内容长度的关系

二维码有 40 个版本,版本越高能存的内容越多,模块也越密。以 M 容错级别为例,几个常见版本的能力:

版本模块数数字容量字母容量字节容量
121x21342014
537x37248152106
1057x57652395271
2097x9716631013692
40177x177305718521273

一个典型的 URL 长度在 50 到 200 字节之间,对应版本 5 到 10。qrcode-generator 传 0 会自动选,不需要手动算。但如果你要生成的是长文本(比如一段 JSON),就要注意版本 40 的字节容量上限是 1273,超过这个长度必须换方案,比如先压缩再编码,或者改用短链接。

提示:二维码不是越大越好。版本 40 的码在手机屏幕上显示时,每个模块可能只有 1 到 2 个像素,摄像头根本分辨不出来。实际使用中建议控制在版本 15 以内,也就是字节容量 500 左右。

5. 常见问题与排查技巧实录

5.1 生成正常但扫码扫不出来

这是最常见的问题,原因通常有三个:

第一,留白不够。前面提过,静区至少 4 个模块。如果你在 CSS 里给二维码容器设了overflow: hidden或者负 margin,可能把留白裁掉了。检查方法是把生成的 SVG 单独保存下来,用图片查看器打开,看四周是否有足够的白色边距。

第二,对比度不足。有些主题下二维码容器背景是深色,二维码本身是黑色模块,两者混在一起。解决办法是给二维码容器强制白色背景:

#qrcode { background: #fff; padding: 8px; display: inline-block; }

第三,缩放导致模块模糊。SVG 是矢量图,理论上缩放不失真,但如果容器宽度不是模块数的整数倍,浏览器渲染时会对模块做亚像素插值,导致边缘模糊。解决办法是让cellSize乘以模块数等于容器宽度,或者干脆用image-rendering: pixelated强制最近邻插值。

5.2 解码时提示“未能识别”

解码失败的原因比生成失败更多,我整理了一个排查顺序:

现象可能原因排查方法
图片明显是二维码但解不出透明背景检查预处理是否铺了白底
图片倾斜严重未做透视校正jsQR 对倾斜有一定容忍,超过 30 度建议先手动裁剪
图片分辨率过高处理超时限制最长边 1000px
反色二维码未开启双向尝试设置 inversionAttempts: 'attemptBoth'
二维码有 logo 遮挡容错级别不够生成时用 H 级别,遮挡面积不超过 30%

5.3 弹出页面打开慢

如果弹出页面打开时有明显延迟,通常是两个原因:一是把两个库都放在了 popup.html 的<head>里同步加载,二是初始化时做了不必要的计算。

我的优化做法是把库的加载放在popup.js顶部,用defer属性让 HTML 先渲染:

<script src="lib/qrcode-generator.js" defer></script> <script src="lib/jsQR.js" defer></script> <script src="popup.js" defer></script>

另外,二维码生成不要放在DOMContentLoaded里同步执行,而是等用户真正点击“生成”按钮时再算。弹出页面打开时只显示一个占位符,用户点击后才生成,感知上反而更快。

5.4 扩展加载后报错“无法读取 URL”

这个错误几乎都出现在chrome://页面或者新标签页上。Chrome 的新标签页 URL 是chrome://newtab/,同样不允许扩展读取。处理方式就是前面代码里的判断,遇到这类页面直接提示用户。

还有一个容易忽略的场景:如果用户把插件固定到了工具栏,但在无痕窗口里使用,activeTab权限默认是不生效的。需要在扩展管理页里手动开启“在无痕模式下启用”,这个没法通过代码绕过,只能在文档里说明。

5.5 下载的 SVG 在某些软件里打不开

SVG 是文本格式,用记事本打开就能看到内容。如果某些图片查看器打不开,通常是两个原因:一是 SVG 里用了currentColor之类的 CSS 变量,脱离浏览器环境后无法解析;二是 SVG 没有声明xmlns命名空间。

qrcode-generator 生成的 SVG 默认是带xmlns的,但如果你手动拼接字符串,一定要加上:

const svg = `<svg xmlns="http://www.w3.org/2000/svg" ...>`;

保险起见,下载时也可以同时提供 PNG 格式。PNG 的生成方式是把 SVG 画到 canvas 上再导出:

const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; canvas.getContext('2d').drawImage(img, 0, 0); canvas.toBlob(blob => { /* 下载 blob */ }, 'image/png'); }; img.src = 'data:image/svg+xml;base64,' + btoa(svgString);

注意btoa不能直接处理包含中文的 SVG 字符串,如果二维码内容里有中文,需要先做 UTF-8 编码再转 base64,否则会抛异常。

6. 我踩过的几个坑和最终取舍

第一个坑是过早引入构建工具。我一开始用 webpack 打包,配置了 babel 和 terser,结果改一行代码要等三秒编译,调试体验极差。后来全部改成原生 ES 模块,浏览器直接加载,改完刷新就行。对于这种几百行代码的小插件,构建工具带来的收益远小于它增加的复杂度。

第二个坑是试图支持所有二维码格式。二维码之外还有 Data Matrix、Aztec、PDF417 等格式,我一度想全部支持,后来发现 jsQR 只支持二维码,要支持其他格式得换 ZXing-js,体积翻四倍。最终决定只做二维码,因为 99% 的场景就是二维码,其他格式属于长尾需求。

第三个坑是在 service worker 里做图像处理。Manifest V3 的 service worker 里没有 DOM,没有 canvas,没法做图像预处理。我一开始把解码逻辑放在 service worker 里,结果document.createElement('canvas')直接报错。后来把解码全部移到弹出页面里,service worker 只负责响应事件,问题解决。

第四个坑是忽略了 CSP 限制。Manifest V3 默认的内容安全策略禁止eval和内联脚本。qrcode-generator 的老版本里用了eval,加载时会直接报错。解决办法是换用新版本,或者用Function构造器替代。我选的是换版本,因为改第三方库的源码后续维护成本太高。

提示:每次 Chrome 大版本更新后,建议重新测一遍插件的所有功能。Manifest V3 的规范还在演进,一些 API 的行为可能微调,早发现早适配。

最后分享一个调试技巧:在chrome://extensions/页面开启开发者模式后,点击插件的“检查视图”可以打开弹出页面的 DevTools。但弹出页面一关闭 DevTools 就断了,调试很不方便。我的做法是临时把default_popup改成default_page,让插件在一个独立标签页里打开,这样 DevTools 可以一直开着,改完代码刷新页面就行,效率高很多。调试完再改回default_popup即可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询