二维码看起来像一张图片,本质上却是一条编译流水线:文本 → 比特流 → 有限域多项式除法 → 矩阵排布 → 掩码优选。本站的二维码生成器没有引入任何第三方库,用 415 行 TypeScript 从零实现了 ISO/IEC 18004 的核心路径。这篇文章按流水线顺序把它拆开。
1. 全景:一次编码要过 7 道工序
"https://qcsunny.org"
↓ ① 选版本 按字节数挑能装下的最小版本(1–10)
↓ ② 比特流 模式指示符 + 长度 + 数据 + 终止符 + 0xEC/0x11 填充
↓ ③ 分块 按版本与纠错级别切成 1–8 个数据块
↓ ④ Reed–Solomon 每块在 GF(256) 上做多项式长除法,余数即纠错码字
↓ ⑤ 交织 数据块列优先打散,纠错块紧随其后
↓ ⑥ 排布 画功能图形,剩余空位按 Z 字形填比特
↓ ⑦ 掩码 8 个候选各算一次 ISO 惩罚分,取最低者
21×21 ~ 57×57 的 0/1 矩阵
值得先记住一个数字:同一版本的“总码字数”是固定的,纠错级别只决定这些码字里有多少给数据、多少给纠错。版本 1 恒为 26 个码字,版本 10 恒为 346 个。所以选 H 级不是“加了冗余”,而是“拿数据容量换冗余”。
2. 选版本:容量公式里的 −12 是什么
function byteCapacity(version: number, ecc: Ecc): number {
const bits = dataCodewords(version, ecc) * 8 - (version < 10 ? 12 : 20);
return Math.floor(bits / 8);
}
减掉的 12 位是头部开销:4 位模式指示符(字节模式是 0b0100)+ 8 位长度字段。从版本 10 起长度字段扩展到 16 位,所以减 20。
| 版本 | 矩阵 | 总码字 | L 数据/纠错 | L 字节容量 | H 数据/纠错 | H 字节容量 |
|---|---|---|---|---|---|---|
| 1 | 21×21 | 26 | 19 / 7 | 17 | 9 / 17 | 7 |
| 10 | 57×57 | 346 | 274 / 72 | 271 | 122 / 224 | 119 |
这几个数字可以直接和官方容量表对账:v1-L 是 17 字节、v1-M 14、v1-Q 11、v1-H 7,v10-L 271、v10-H 119,全部吻合。手写实现最怕抄错常量表,而这个公式给了一个免费的自校验手段。
因为走的是 UTF-8 字节模式,一个汉字占 3 字节,所以 v10-L 的 271 字节大约只能装 90 个汉字。
3. 比特流:终止符、字节对齐与 0xEC/0x11
appendBits(0b0100, 4); // 字节模式
appendBits(bytes.length, version < 10 ? 8 : 16); // 长度
for (const b of bytes) appendBits(b, 8); // 数据
const capacityBits = dataCodewords(version, ecc) * 8;
appendBits(0, Math.min(4, capacityBits - bits.length)); // 终止符(不足 4 位就少写)
while (bits.length % 8 !== 0) bits.push(0); // 补到字节边界
const padBytes = [0xec, 0x11];
let padIdx = 0;
while (bits.length < capacityBits) {
appendBits(padBytes[padIdx % 2]!, 8); // 交替填充
padIdx++;
}
三处细节值得注意:
- 终止符是
Math.min(4, 剩余空间),而不是死写 4 位。数据刚好填满容量时一位都不能多写,这是手写实现最容易漏的边界。 - 填充字节是 0xEC 和 0x11 交替,即
11101100/00010001。规范挑这两个值不是随意的:交替排布后不会产生长串同色,从源头上减少后面掩码评分要处理的连续块。 - 数据必须填满整个容量。二维码没有“部分使用”的概念,矩阵里每个数据位都得有值。
4. GF(256):为什么纠错必须在有限域里做
Reed–Solomon 需要一个“加、减、乘、除都封闭,且每个非零元都有逆元”的代数结构。整数不行(除法出分数、乘法会溢出),实数也不行(浮点误差会毁掉精确恢复)。答案是有限域 GF(2⁸):把一个字节看成 GF(2) 上的 8 次以下多项式,运算后模一个 8 次不可约多项式。QR 用的是 ,也就是 0x11D。
在这个域里,加法就是 XOR(无进位加法),所以代码里到处是 ^= 而不是 +=。乘法则用对数表换成加法:
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]!;
})();
function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return EXP[LOG[a]! + LOG[b]!]!;
}
这 12 行里藏着两个技巧:
x <<= 1; if (x & 0x100) x ^= 0x11d就是“乘 再取模”。 是这个域的本原元,2 的幂能遍历全部 255 个非零元素,于是一次循环同时建好了指数表和对数表。EXP开 512 而不是 255,是为了消掉取模:LOG[a] + LOG[b]最大 254+254 = 508,多存一份副本后% 255就被一次数组访问替代了。a === 0的判断不能省——LOG[0]在数学上不存在。
5. Reed–Solomon:生成多项式与长除法
思路是把 k 个数据码字当成多项式系数,乘上 (左移 t 位),再除以生成多项式
余数的 t 个系数就是纠错码字。这样构造出的完整码字多项式在 这 t 个点上取值全为 0;解码端把收到的数据代进这些点,得到的就是校验子,非零即说明有错,进一步还能定位错在哪。
function rsRemainder(data: number[], degree: number): number[] {
let gen: number[] = [1];
for (let i = 0; i < degree; i++) {
const next: number[] = new Array(gen.length + 1).fill(0);
for (let j = 0; j < gen.length; j++) {
next[j]! ^= gen[j]!; // × x
next[j + 1]! ^= gfMul(gen[j]!, EXP[i]!); // × α^i
}
gen = next;
}
const rem: number[] = new Array(degree).fill(0);
for (const b of data) {
const factor = b ^ rem.shift()!;
rem.push(0);
if (factor !== 0) {
for (let i = 0; i < degree; i++) rem[i]! ^= gfMul(gen[i + 1]!, factor);
}
}
return rem;
}
生成多项式是增量构造的:每轮把 gen 乘上 (GF(2) 里加减同号,所以减法直接写成加法)。这样 7、10、13、…、28 这十几种不同长度的生成多项式一个都不用预存,几百字节的常量表就此消失。
除法部分是教科书式的移位-异或长除法,rem 当滑动窗口用:取出最高位、与当前数据字节异或得到商系数 factor,再把 factor × gen 累加回窗口。factor === 0 时整轮都是异或 0,直接跳过——这是最常见也最有效的一处优化。
纠错能力到底是多少
t 个纠错码字最多纠正 个码字错误。注意单位是码字而不是比特:一个码字 8 位,里面错 1 位和错 8 位的代价完全一样。这是符号纠错的特性,也正好匹配现实中的污损形态——脏的是一小片区域,不是零散的单个像素。
规范还从纠错码字里额外预留了少量“防误判码字”(misdecode protection),所以官方标称的 7% / 15% / 25% / 30% 会略低于 。以 v1-L 为例:7 个纠错码字里有 3 个用于防误判,实际纠错能力是 个码字,——这就是“L 级约 7%“的出处。v1-H 则是 ,。
| 级别 | 恢复能力(标称) | 适用场景 |
|---|---|---|
| L | ~7% | 屏幕显示、干净环境、想把码做到最小 |
| M | ~15% | 日常默认,绝大多数场景 |
| Q | ~25% | 名片、包装、可能被摩擦的印刷品 |
| H | ~30% | 贴纸、户外、中间要压 Logo 的码 |
6. 分块与交织:为什么码字要打散
高版本会把数据切成多块,每块独立算 RS,然后交织输出:
for (let i = 0; i < maxDataLen; i++) {
for (const block of dataBlocks) if (i < block.length) interleaved.push(block[i]!);
}
for (let i = 0; i < ecPerBlock; i++) {
for (const block of ecBlocks) interleaved.push(block[i]!);
}
也就是“各块的第 0 个码字、各块的第 1 个码字……“轮流取,数据全部排完再同样轮排纠错码字。
为什么非要这么做:物理污损是连续的——一道划痕、一滴水、一个手指印。如果矩阵里相邻的码字同属一块,一道划痕就能让该块的错误数超过 ,那一块彻底不可恢复,整张码报废。交织之后,连续损伤被均摊到所有块上,每块只丢一两个码字,全部可救。
拿 v10-Q 算一下:分块规格是 6 块 19 字节 + 2 块 20 字节,共 8 块,每块 24 个纠错码字。单块能纠 12 个码字,8 块交织后,理论上一片连续打掉 96 个码字的污损仍可完全恢复——占 346 个总码字的 27%。这正是 CD、DVD、DVB 乃至 RAID 都在用的“块纠错 + 交织”组合。
7. 功能图形:一个 max 表达式画完定位图形
const drawFinder = (row: number, col: number): void => {
for (let dr = -1; dr <= 7; dr++) {
for (let dc = -1; dc <= 7; dc++) {
const r = row + dr, c = col + dc;
if (r < 0 || r >= size || c < 0 || c >= size) continue;
const dist = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
setFn(r, c, dist !== 2 && dist !== 4);
}
}
};
dist 是到中心的切比雪夫距离(棋盘距离)。按距离分层:0–1 黑(3×3 实心方块)、2 白、3 黑(外环)、4 白(分隔带)。一个表达式同时画完了 7×7 定位图形和它周围的白边,还顺手处理了三个角落越界的情况。
同样的手法用在 5×5 校正图形上:Math.max(Math.abs(dr), Math.abs(dc)) !== 1。时序图形是第 6 行与第 6 列的奇偶交替,i % 2 === 0——校正图形的中心坐标都是偶数,两者天然对齐,不需要额外协调。
格式信息:BCH(15,5) 与那个 0x5412
const data = (ECC_LEVEL_BITS[ecc] << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const formatBits = ((data << 10) | rem) ^ 0x5412;
5 位信息(2 位纠错级别 + 3 位掩码号)扩成 15 位 BCH 码,生成多项式 0x537 = 。(rem >>> 9) * 0x537 是无分支写法:最高位是 1 就乘出 0x537 去异或,是 0 就乘出 0,省掉一个 if。
最后异或 0x5412 这一步很关键。如果不加掩码,M 级 + 掩码 0 的格式信息全是 0,会在定位图形旁边形成一整片纯白,反而干扰识别。这 15 位在矩阵里还存了两份(左上角一份,右上角 + 左下角拼一份)——格式信息一坏整张码就无从解读,所以规范给了它全码最高的冗余:两份副本 + BCH 本身可纠 3 位错。
版本信息走同一套逻辑,BCH(18,6)、生成多项式 0x1F25、不加掩码,只在版本 ≥ 7 时出现。另外 (size-8, 8) 处有一个恒黑的固定模块。
8. 数据排布:从右下角开始的蛇形
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5; // 跳过垂直时序列
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const col = right - j;
const upward = ((right + 1) & 2) === 0;
const row = upward ? size - 1 - vert : vert;
const idx = row * size + col;
if (!isFunction[idx] && bitIdx < totalBits) {
modules[idx] = (interleaved[bitIdx >>> 3]! >>> (7 - (bitIdx & 7))) & 1;
bitIdx++;
}
}
}
}
比特按两列一组填,组内右先左后,组间上下折返。三个细节:
if (right === 6) right = 5处理第 6 列——那是垂直时序图形,整列都是功能模块,跳过它同时也让后续列号重新对齐。((right + 1) & 2) === 0用列号本身推出方向,比维护一个direction变量少一个状态。- 排布过程完全不判断“这是什么图形”,只查
isFunction位图。功能图形绘制时顺手打上标记,排布时无脑跳过,两个阶段彻底解耦。
9. 掩码:生成 8 张再挑一张
掩码的目的不是加密,而是打散图案。原始数据很可能产生大片连续同色区域,或者恰好出现和定位图形一样的 1011101 序列,让扫描器找错基准点。规范的办法很直接:8 个固定掩码全试一遍,按四条惩罚规则打分,取分数最低的。
| 掩码 | 条件(i = 行,j = 列,成立则反色) |
|---|---|
| 0 | (i + j) mod 2 = 0 |
| 1 | i mod 2 = 0 |
| 2 | j mod 3 = 0 |
| 3 | (i + j) mod 3 = 0 |
| 4 | (⌊i/2⌋ + ⌊j/3⌋) mod 2 = 0 |
| 5 | (i·j) mod 2 + (i·j) mod 3 = 0 |
| 6 | ((i·j) mod 2 + (i·j) mod 3) mod 2 = 0 |
| 7 | ((i + j) mod 2 + (i·j) mod 3) mod 2 = 0 |
四条 ISO 惩罚规则
| 规则 | 判定 | 扣分 |
|---|---|---|
| 1 | 某行或列上连续 5 个同色 | 3 分,之后每多 1 个 +1 |
| 2 | 任意 2×2 全同色 | 每处 3 分 |
| 3 | 出现 10111010000 或 00001011101 |
每处 40 分 |
| 4 | 黑模块占比偏离 50% | 每偏离 5% 扣 10 分 |
规则 3 的 40 分远高于其他规则,因为 1011101 正是定位图形的横截面,两侧再跟 4 个白模块就足以骗过扫描器的基准点识别——这是真正会导致误读的图案,必须重罚。规则 4 追求黑白均衡,让相机的自动曝光更容易找到二值化阈值。
惰性求值:不复制 8 份矩阵
评分最容易写臃肿的地方是“生成 8 个候选矩阵”。这份实现不复制任何数据,而是在读取时按需算:
const dark = (row: number, col: number): boolean => {
const v = modules[row * size + col] === 1;
return isFunction[row * size + col] ? v : v !== maskBit(mask, row, col);
};
功能模块原样返回,数据模块才与掩码位异或。四条规则全部通过 dark() 访问矩阵,于是 8 轮评分只读不写、零内存分配。全部算完才把胜出的掩码真正异或进 modules,并用真正的掩码号重画一次格式信息——第一次画格式信息时用的是占位值 0,作用仅仅是把那些位置标记成功能模块。
代价方面:四条规则都是 ,8 个候选在版本 10(57×57)下约 10 万次模块读取,实测在毫秒量级,肉眼无感。
10. 这个实现做了什么、没做什么
做了:字节模式、版本 1–10、四档纠错级别、完整的 GF(256) Reed–Solomon、分块交织、全部功能图形、BCH 格式信息与版本信息、8 种掩码 + 完整 ISO 惩罚评分。因为掩码选择逻辑是完整的,输出应当与商业库逐模块一致——这也是验证正确性最省事的办法:同一段文本喂给任意成熟生成器,比对 0/1 矩阵即可。
没做:
- 只有字节模式。纯数字用数字模式只需 3⅓ 位/位,纯大写字母 + 数字用字符数字模式只需 5.5 位/字符,而这里统一按 8 位/字节算。所以编码长串数字时会比理论最优多占一档版本。
- 上限版本 10(57×57,L 级 271 字节 ≈ 90 个汉字)。再往上要补版本 11–40 的分块表和更多校正图形坐标。
- 无 ECI 头,UTF-8 靠读码器自动识别(主流手机相机都能处理)。
- 无结构化追加(多码拼接一条长消息)、无 Micro QR。
11. 为什么不 npm install 一个现成的
二维码编码是一个纯函数:文本进、矩阵出,没有网络、没有 I/O、没有状态。这类算法的规范二十年没变过,自己实现一次,就再也不会因为上游包被 unpublish、被投毒、或者 CDN 挂掉而失效。415 行、零依赖,压缩后只有几 KB,比拉一个覆盖全部 40 个版本和 4 种模式的通用库更小。
更重要的是隐私边界。二维码里经常是收款地址、Wi-Fi 密码、内网链接、一次性口令——这些内容一个字节都不该离开设备。本站的生成器从编码到出图全在浏览器内完成:矩阵由这份编码器算出,图像由 Canvas 2D 画出,PNG 由 canvas.toDataURL() 就地生成,全程没有任何网络请求。
顺便记一个实用经验:二维码的边长大约取扫描距离的十分之一就能稳定识别。10 厘米的码在 1 米外可扫,海报上要在 5 米外扫就得做到 50 厘米。另外码的四周必须留出至少 4 个模块宽的静默区(本站生成器固定留 4),紧贴边框的码经常扫不出来,原因往往就在这里。
12. 在线试试
- 二维码生成器:本文拆解的这份实现,可切换 L/M/Q/H 纠错级别,实时显示版本号、矩阵尺寸与已编码字节数,一键下载 PNG;
- URL 解析器:编码前先检查查询参数与转义是否正确,避免生成一个扫出来打不开的链接;
- Base64 编解码:处理需要塞进二维码的二进制载荷;
- UUID 生成器:需要往码里放唯一标识时,用
crypto.getRandomValues()生成 v4/v7。
所有工具 100% 在你的浏览器本地完成,没有任何数据会发送给外部服务器,安全、私密,且支持离线使用。