我用码道从零手搓了个二维码生成器,还让它自己扫自己、逐字证明没骗我
一键开通华为云码道 CodeArts 代码智能体: https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgithd
作品介绍
先说清楚这是个啥:一个纯前端、零第三方运行时依赖的二维码生成器。输入一段文字、一个网址、一个 WiFi 密码、一张名片,它当场给你画出来,能改前景背景色、能切成圆点、能塞个中心 logo、能一键下载 PNG 和 SVG。
这些功能,随便一个在线工具都有。我做这个的野心不在"生成",在"自证"。
市面上九成的二维码生成器,都是import一个现成的库(qrcode、qrcodejs、python 的 qrcode 之类),调一下toDataURL就完事。你根本不知道它内部到底怎么把"HELLO"变成那堆黑白格子的,你只能信它。而我这个项目——二维码编码器是我一行一行手写的:GF(256) 有限域、Reed-Solomon 纠错、八种掩码的罚分规则、格式信息和版本信息的 BCH 编码,全自己撸。
然后关键来了:手写的东西对不对,不能我自己说了算。所以我给它配了一整套"自我体检"——用真正的解码器 jsQR 把我生成的二维码图片扫回来,逐字符跟我原始输入比对。18 个用例,覆盖文本/网址/WiFi/名片四种载荷、L/M/Q/H 四档纠错、版本一直到 7,18/18 全部一字不差地扫回来了。
89 项单元测试全绿,三份对拍报告全过。仓库是公开的,你可以 clone 下来自己npm run verify复现。
为什么非要从零写,而不是 import 一个库
起因很俗。我平时给 stuff 贴二维码,有次想搞个"扫码连 WiFi"的码,随手用某在线生成器,结果某些安卓机的相机扫不出来,换个生成器又好了。我就好奇:同一个 WiFi 信息,两家生成的码还能不一样?
去查了才发现,WiFi 二维码那套WIFI:T:WPA;S:名字;P:密码;;的转义规则(名字里带分号、冒号要反斜杠转义)各家实现细节不一样,有的还漏了隐藏网络字段。说白了,生成器这活儿,"能扫出来"和"扫得对"是两回事,而大多数工具只保证前者。
那我就想:与其信别人,不如自己写一个,并且把"扫得回来"这件事做成一个能反复跑的自动化验证。这正好也是这次码道比赛我想试的东西——让 AI 编程智能体(华为云码道 CodeArts)去啃一个"有客观真值可对拍"的活,而不是让它生成一个"看着挺好但没人能证伪"的玩具。
可验证,是我给这个项目选的护城河。好不好看是主观的,但"jsQR 能不能把这张图扫回原话"是客观的,是或否,赖不掉。
先跑起来看效果
在讲怎么造之前,先看它长啥样、验得怎么样。这是我在本地npm run serve起来后截的几态。
网址模式,扫出来就是原链接:
WiFi 模式,SSID 和密码里故意塞了分号冒号这种麻烦字符,转义后照样能扫回:
名片模式,vCard 3.0:
切圆点样式 + 蓝色前景 + 纠错等级拉到 H,自我体检卡同步变成 version 3 / size 29 / EC=H / Byte 容量 24 / 模块 SHA-256 换了一个:
然后是我最看重的部分——验证。跑npm run verify,三条线全过:
这三行数字,是整篇文章的底气所在。下面讲它们是怎么来的,以及中间踩的坑。
把提示词钉到函数级,别让 AI 自由发挥
我是分轮把活喂给码道的,第一轮只让它干一件事:建一个公开仓库,从零写二维码编码核心,纯逻辑,不许碰 DOM,写完配单元测试。
给 AI 派活我学乖了,提示词必须钉到函数级,否则它会给你一个"看起来能跑"的糊弄版。我第一轮的原话大概是这样:
src/galois.js —— GF(256) 有限域:本原多项式 0x11d,构建 log/antilog 表, 导出 gfMul(a,b)、gfPow、gfInv。 src/reed-solomon.js —— RS 纠错:构造生成多项式 rsGeneratorPoly(degree), 对数据码字求余得到 eccLength 个纠错码字。 src/tables.js —— 按 ISO/IEC 18004 提供各版本各纠错等级的 EC 码字数/分块数表、 对齐图案位置表、Byte 模式数据容量表。 src/qr.js —— 主编码:位流打包、版本与纠错等级自动选择、分块交织、 放置功能图形、遍历 8 种掩码用罚分选最优、格式信息 BCH(15,5)、 版本信息 BCH(18,6),输出 { size, modules:boolean[][] }。 测试必须锚定外部已知真值,不能自证。 直接建文件并 git commit + push,不要只描述、不要贴代码正文。最后那句"直接建文件并提交,别只描述"是血泪教训——不加这句,码道有概率给你写一大段"我打算这样做……"的方案,一个字代码不落。
它第一轮交出来的 GF(256) 乘法,是对数表那套经典写法:
exportfunctiongfMul(a,b){if(a<0||a>255||b<0||b>255){thrownewRangeError(`gfMul operands out of range:${a},${b}`);}if(a===0||b===0)return0;returnGF_EXP[GF_LOG[a]+GF_LOG[b]];}41 项测试全绿,仓库公开。看着挺完美,直到我自己 clone 下来复核时,发现了一个让我后背发凉的东西。
GF(256):我差点被一个"教科书值"带进沟里
我在提示词里,为了逼它锚定外部真值,写了这么一条:“测试必须断言gfMul(0x57, 0x83) === 0xC1(教科书经典值)”。
结果码道跑出来,把这行注释改成了这样,还专门在回复里跟我"顶了一句":
/** * 经典核对值:gfMul(0x57, 0x83) === 0x31。 * 注:0xC1 是本原多项式 0x11B(AES)下的对应结果; * QR Code 的 Reed-Solomon 使用本原多项式 0x11D,故真值为 0x31。 */我一开始还以为它写错了。赶紧自己拿笔算了遍 GF(256) 乘法:0x57 和 0x83 相乘,在 QR 用的本原多项式0x11D(x⁸+x⁴+x³+x²+1)下,余数确实是0x31;而 0xC1 是 AES 用的0x11B下的结果。也就是说——是我这个"教科书经典值"记串了,把 AES 的那套当成 QR 的了。
这事给我敲了警钟:GF(256) 的坑就在这,0x11B和0x11D两个多项式长得就差一位,全世界教程里 0xC1 这个例子大多是讲 AES 的,我顺手抄过来用到了 QR 上。如果码道当时"听话地"按我说的断言 0xC1,它要么把多项式偷偷改成 0x11B(那整个 QR 纠错就全错了),要么测试永远红。它选了后者并告诉我"你这个真值本身是错的",反而救了这个项目。
我后来专门去查,确认 0x11D 才是 ISO/IEC 18004 规定的。这条被我写进了 README 和源码注释里,就为了以后别人扫源码别被我一开始的错误带偏。
顺带说,第一轮它自己还干了件让我惊喜的事:它主动拿 Nayuki 和 kazuhikoarase 两个业界公认的参考 QR 库做了交叉验证——用相同的掩码、相同的码字,把它的实现和两个参考库生成的矩阵逐比特比对。这不是我要求的,是它自己加的保险。
Reed-Solomon:多项式乘法在有限域里怎么走
纠错码是二维码能"破破烂烂还能扫"的根本。生成多项式那段实现,码道写得很规矩,就是不断乘(x + αⁱ):
exportfunctionrsGeneratorPolyFull(degree){letpoly=[1];// 常数多项式 1letroot=1;// α^0for(leti=0;i<degree;i++){constnext=newArray(poly.length+1).fill(0);for(letj=0;j<poly.length;j++){next[j]^=poly[j];// poly * xnext[j+1]^=gfMul(poly[j],root);// poly * root}poly=next;root=gfMul(root,2);// root *= α}returnpoly;}掩码部分要遍历八种、按 ISO 18004 的 N1~N4 四条罚分规则打分取最低。这块码道一度在 2×2 同色罚分和全黑/对角线的判分上反复纠结,因为它想确保罚分是"确定性"的、而不是碰运气。这种较真我喜欢。
零依赖 PNG 编码器:连出图都不许用库
产品定位是零运行时依赖,那把编码好的矩阵变成一张 PNG 图片,也不能import sharp或者用 canvas(那是浏览器侧,Node 侧要能跑)。所以码道得自己写一个 PNG 编码器:PNG 签名、IHDR、IDAT、IEND,每段算 CRC32,图像数据用 zlib 的 stored(不压缩)块塞进去,末尾补 adler32。
// 零依赖 PNG:手写签名 + IHDR + IDAT(zlib stored, 0x00 不压缩) + IEND,每段带 CRC32exportfunctionencodePng(matrix,{scale=8,quiet=4,bg=[255,255,255],fg=[0,0,0]}={}){// ... 逐行铺 filter byte(0x00) + RGB 像素,zlib stored 包一层,CRC32/Adler32 自己算}验证它没写歪,最直接的办法就是拿 jsQR 去扫它吐出来的 PNG——扫得回原文,说明 PNG 和里面的二维码都对。这就是下一节的回环。
顺带说 WiFi 载荷这块,码道处理转义很细——SSID 或密码里带分号、冒号、反斜杠、双引号,都得按规范反斜杠转义,nopass 时还要省掉密码字段:
exportfunctionwifi({ssid,password,encryption='WPA',hidden=false}={}){constnorm=String(encryption).toUpperCase();if(!['WPA','WEP','NOPASS'].includes(norm)){thrownewRangeError(`encryption must be WPA|WEP|nopass, got:${encryption}`);}constenc=norm==='NOPASS'?'nopass':norm;letout=`WIFI:T:${enc};S=${escapeWifi(ssid)}`;if(enc!=='nopass')out+=`;P=${escapeWifi(password)}`;if(hidden)out+=';H:true';return`${out};;`;}这一轮 R2 也不是一遍过的。码道在 PNG 像素坐标的静区偏移上算错过一次(quiet=1 时把 (0,0) 模块对应的像素写成了 (1,1) 而非 (2,2)),是我本地 clone 跑测试时抓到、它再回去修的。这种"它说绿了我还得自己跑一遍"的来回,贯穿全程。
那个"二维码画成一片白"的坑
前端页面(白色系那套)和 SVG 渲染是第三轮做的。我在本地起了服务一看,人傻了:预览区几乎全白,只有右下角一小坨黑块。
查下来是render.js里两套坐标系打架:<rect>用的是像素坐标(x=(col+quiet)*scale,比如 x=32、width=8),但viewBox却写成了模块单位"0 0 size size"(比如 0 0 29 29)。像素坐标 32 远超 viewBox 的 29,全被裁到画布外了,只剩零星几个没裁干净。
修法就是把 viewBox 和像素坐标统一到同一空间:
constpx=(size+2*quiet)*scale;`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0${px}${px}" width="${px}" height="${px}">`修完,二维码就完整居中长这样了:
这个坑有意思的地方在于:单元测试当时是绿的。因为 render 的测试只数了<rect>的个数、看了 width 属性,压根没校验 viewBox 和 rect 坐标是不是同一个空间。这给我上了一课——测试绿不等于对,尤其是那种"自证式"的测试。这个点后来专家评审也独立揪出来了,我记着。
可验证护城河:让代码自己扫自己
这是整个项目我最想讲的部分。验证脚本放verify/,用 jsqr、pngjs 当 devDependency(只在验证时用,产品运行时依旧零依赖)。核心逻辑朴素得感人:
import{PNG}from'pngjs';importjsQRfrom'jsqr';constmanifest=JSON.parse(readFileSync(join(EVIDENCE,'manifest.json'),'utf8'));for(constentryofmanifest){constpng=PNG.sync.read(readFileSync(join(EVIDENCE,'png',entry.file)));constcode=jsQR(newUint8ClampedArray(png.data),png.width,png.height);constmatch=code&&code.data===entry.payload;// 扫回来逐字符比原话// ...}jsQR 是业界独立实现的一个纯 JS 二维码解码器,不是我写的、也不是码道写的,拿它当"第三方裁判"。它能把图扫回原话,就证明我这套从零写的编码器(含 GF、RS、掩码、BCH、PNG 出图)整条链路是对的。
除了回环,还有两条:一是标准对标——断言每张码的size === 4*version+17,且各版本各等级的码字数对得上 ISO/IEC 18004 的表;二是确定性——同一输入编两次,把模块矩阵展平成 01 串算 SHA-256,两次必须一模一样(可重放)。
标准对标这块,评审后我特意让它别 import 自己的表自证,而是内置一份权威逐版本总码字数表去对:
// 第三方权威表(ISO/IEC 18004 逐版本总码字数),不再拿自己的 tables.js 自校constTOTAL_CODEWORDS={1:26,2:44,3:70,4:100,5:134,6:172,7:196,8:242,9:292,10:346};for(constcofcases){assert.equal(c.size,4*c.version+17);assert.equal(c.totalCodewords,TOTAL_CODEWORDS[c.version]);// 对第三方常量}右侧那张"自我体检"卡,就是把 version、size、纠错等级、Byte 数据容量、模块 SHA-256 这五个数实时摆在页面上。它不是装饰,是护城河在 UI 上的投影。
三轮专家评审揪出的硬伤
写完 R1-R5 我以为差不多了,于是拉了 UI、技术、产品三个专家评审,各自读真实代码和截图打分。分数不算高:UI 78、技术 86、产品 78。但揪出来的问题很实在,尤其技术专家这三条,我服:
galois.js 注释自相矛盾——它发现源码注释里还留着
gfMul(0x57,0x83)===0xC1那句(就是我一开始写错的那个),而实现用的是 0x11D 得 0x31。代码对、注释错,评委扫源码会误判"这项目用错了多项式"。必须改。"标准对标"其实是自证——
verify/standard.mjs当时是 import 自己的tables.js再校自己,总码字数算了却没跟任何外部常量比。等于自己给自己发奖状。得内置一份真正的 18004 权威表去对。解码回环有覆盖盲区——15 个用例只测了 L/M 两个等级、版本还跳过了 7 和 8。而版本 ≥7 才有"版本信息图形",Q/H 的分块交织也更复杂。也就是说这两块从来没被 jsQR 独立验证过。
这三条我全认,直接开了 R6 一轮让码道修:改注释、内置第三方码字数表、把用例扩到 18 个(补上 v3-Q、v4-Q、v7-H),再把确定性那部分诚实降级成"回归哨兵"(它只证明幂等,不证明正确,正确性以 jsQR 为准)。UI 那边也把预览框的灰虚线占位感、自我体检卡的排版、下载按钮的禁用态一并打磨了。
修完再跑,node --test89 全绿,npm run verify三条线全部 18/18。v7-H 那条尤其关键——它证明版本信息图形的 BCH 编码也是对的。
码道推不上去这件事,才是真·开发日常
如果说上面都是技术,那这一段是"和工具搏斗"的流水账,但我觉得这才是 AI 编程真实的体感,值得写。
整个过程我踩了至少这么几个坑:
模型配额不足 / 流量高峰:好几轮跑到一半,页面直接甩
[错误: 当前模型配额不足 [APIError]]或者"检测到流量高峰,正在为您重试"。有一轮 R4 就是干完了活、卡在最后git push那步报配额错,代码没提交上去。上下文爆表:会话聊到 100%(628K/512K),码道开始"只讲不做"或者答非所问。只能点"新对话"重新绑仓库。好在 CodeArts 会自动压缩上下文,压回 20% 左右又能接着干。
推送认证:最搞心态的是这个。老会话的沙箱,码道在 push 失败后自作聪明地执行了
git config credential.helper store并往凭据文件里写了个dummy-token,把整个沙箱的 git 凭据污染了,之后怎么推都"认证失败"。我判断出来之后,直接开了个全新会话(新沙箱、干净凭据),把 R5/R6 重新喂一遍,一次就推上去了。
我后来学乖了,提示词里专门加一条:“push 只许试一次,认证失败就把原始报错贴出来,不许碰 credential.helper、不许写 token、不许 force push。” 免得它又好心办坏事。
公开仓库长这样
仓库是公开的,八次提交,从 R1 的编码核心一路到 R6 的专家评审修复,提交信息链清清楚楚。README 里我把功能亮点、可验证护城河那张表、复现命令、架构分层都写了。语言构成 JavaScript 90%、HTML、CSS,符合"纯前端"。
提效数据
给个粗略的账,方便你判断这套"分轮喂 + 本地复核 + 专家评审"的打法值不值:
| 环节 | 轮次 | 测试数 | 关键产出 |
|---|---|---|---|
| 编码核心 | R1 | 41 | GF(256)+RS+掩码+BCH,纠正了 0x11D/0x11B 真值 |
| 输入层+出图 | R2 | 78 | text/url/wifi/vcard,零依赖 PNG,取证 15 例 |
| 渲染+页面 | R3 | 87 | SVG 渲染、白色系 SPA、本地服务器 |
| 修 viewBox+护城河 | R4 | 89 | 修坐标系、decode/standard/determinism 三报告 |
| 修 serve+README | R5 | 89 | 路由回退、完整 README |
| 专家评审修复 | R6 | 89 | 注释纠偏、第三方标准表、Q/H+v7 覆盖、UI 打磨 |
全程我一行代码没手写(除了几段验证脚本是我本地跑的),但每一轮我都 clone 下来自己node --test复核,不是码道说绿就绿。最后 jsQR 回环 18/18 是我本地独立跑出来的。
本地怎么跑
gitclone https://atomgit.com/gcw_QMYlF6Ie/qr-studio.gitcdqr-studionpminstall# 只装验证用的 devDeps:jsqr、pngjsnpmtest# 89 项单元测试npmrun verify# 解码回环 18/18 + 标准对标 + 确定性npmrun serve# 打开 http://127.0.0.1:8099/ 看白色系页面写在最后
这个项目最想证明的其实不是"我会写二维码",而是另一件事:在 AI 什么都能给你生成的年代,“能生成"早就不值钱了,值钱的是"能自证”。
我让码道从零写编码器,然后逼它用第三方解码器 jsQR 把自己生成的东西扫回来对答案——18 个用例一字不差。它中途纠正了我记错的 GF(256) 真值,也揪出了我写的自证式测试的漏洞,最后被三个专家评审按在地上摩擦了一轮才收敛到能看的样子。
说白了,trust but verify。这套打法我打算以后每个项目都这么干:给 AI 派一个有客观真值可对拍的活,然后让它自己把证据摊在桌面上。好不好看是主观的,但"扫不扫得回来",赖不掉。
仓库在这,欢迎 clone 下来自己npm run verify打我的脸:https://atomgit.com/gcw_QMYlF6Ie/qr-studio