1. 为什么我要自己写一个二维码插件浏览器地址栏里那一长串 URL想从电脑传到手机上最省事的办法就是扫个码。但 Chrome 原生并不提供这个能力右键菜单里没有“生成二维码”这一项地址栏也没有。于是大多数人会去应用商店装一个二维码插件装完发现要么权限要得离谱要么界面丑得没法看要么生成出来的码扫不出来。我前后用过七八款同类插件踩的坑大致分三类第一类是生成质量差容错级别固定成最低档稍微有点反光或者角度偏一点就识别失败第二类是功能单一只能生成不能解码遇到别人发来一张二维码图片还得另找工具第三类是权限过重一个生成二维码的插件居然要读取所有网站的数据这在安全上完全没法接受。Chrome-QRCode 这个项目的出发点就是解决这三个问题生成和解析双向能力、离线本地运算、最小权限。整个插件不依赖任何远程接口二维码的编码和解码全部在浏览器本地完成不联网也能用。代码量控制得很小核心逻辑集中在一个内容脚本和一个弹出页面里三分钟就能把结构看明白适合想自己动手改插件的人拿来当模板。这篇文章会从设计思路讲到具体实现包括二维码编码的容错级别怎么选、解码时图像预处理怎么做、Manifest V3 下有哪些坑以及我自己在调试过程中遇到的一堆问题。如果你只是想装一个用看完第一节就能上手如果你想自己改一个后面几节可以直接抄。2. 整体设计与技术选型拆解2.1 功能边界怎么划做插件最容易犯的错是功能贪多。我一开始想的是“生成 解码 历史记录 批量处理”结果光历史记录就要引入存储层和 UI 列表代码量翻三倍维护成本陡增。后来砍到只剩两个核心动作当前页面 URL 一键生成二维码点击插件图标即可看到支持下载成 PNG。上传或粘贴一张二维码图片进行解码把里面的内容还原成文本。这两个动作覆盖了 95% 的使用场景。历史记录、批量处理这些属于“锦上添花”真需要的时候用系统截图和文件夹管理就够了没必要塞进插件里。提示功能边界一旦确定后面所有的技术选型都要围绕它做减法任何“顺便加上”的念头都要警惕。2.2 为什么选 Manifest V3 而不是 V2Chrome 从 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 二维码容错级别到底怎么选二维码有四个容错级别L7%、M15%、Q25%、H30%。数字越大二维码被遮挡或污损后还能被识别的比例越高但同样内容需要的模块数也越多码会变得更密。很多人默认用 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 处理高分辨率图会慢。图片可能带透明通道背景透明时对比度不够。图片可能倾斜或者有噪点。我的预处理流程是这样的限制尺寸把图片等比缩放到最长边不超过 1000px。二维码本身信息密度有限超过这个尺寸对识别没有帮助只会拖慢速度。铺白底如果图片有透明通道先画一层白色背景再画图片避免透明区域被当成黑色。转灰度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 URLconst svgBlob new Blob([svgString], { type: image/svgxml }); 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.pnglib目录放两个第三方库直接下载未压缩版本放进去。不压缩是为了方便调试如果在意体积可以用 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://开头的页面比如设置页、扩展管理页不允许扩展读取 URLtab.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 容错级别为例几个常见版本的能力版本模块数数字容量字母容量字节容量121x21342014537x372481521061057x576523952712097x971663101369240177x177305718521273一个典型的 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 srclib/qrcode-generator.js defer/script script srclib/jsQR.js defer/script script srcpopup.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 xmlnshttp://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/svgxml;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即可。
