二维码看起来像一张图片,本质上却是一条编译流水线:文本 → 比特流 → 有限域多项式除法 → 矩阵排布 → 掩码优选。本站的二维码生成器没有引入任何第三方库,用 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 用的是 x8+x4+x3+x2+1x^8 + x^4 + x^3 + x^2 + 1,也就是 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 就是“乘 α\alpha 再取模”。α=2\alpha = 2 是这个域的本原元,2 的幂能遍历全部 255 个非零元素,于是一次循环同时建好了指数表和对数表。
  • EXP 开 512 而不是 255,是为了消掉取模:LOG[a] + LOG[b] 最大 254+254 = 508,多存一份副本后 % 255 就被一次数组访问替代了。a === 0 的判断不能省——LOG[0] 在数学上不存在。

5. Reed–Solomon:生成多项式与长除法

思路是把 k 个数据码字当成多项式系数,乘上 xtx^t(左移 t 位),再除以生成多项式

g(x)=(xα0)(xα1)(xαt1)g(x) = (x - \alpha^0)(x - \alpha^1)\cdots(x - \alpha^{t-1})

余数的 t 个系数就是纠错码字。这样构造出的完整码字多项式在 α0αt1\alpha^0 \ldots \alpha^{t-1} 这 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 乘上 (x+αi)(x + \alpha^i)(GF(2) 里加减同号,所以减法直接写成加法)。这样 7、10、13、…、28 这十几种不同长度的生成多项式一个都不用预存,几百字节的常量表就此消失。

除法部分是教科书式的移位-异或长除法,rem 当滑动窗口用:取出最高位、与当前数据字节异或得到商系数 factor,再把 factor × gen 累加回窗口。factor === 0 时整轮都是异或 0,直接跳过——这是最常见也最有效的一处优化。

纠错能力到底是多少

t 个纠错码字最多纠正 t/2\lfloor t/2 \rfloor码字错误。注意单位是码字而不是比特:一个码字 8 位,里面错 1 位和错 8 位的代价完全一样。这是符号纠错的特性,也正好匹配现实中的污损形态——脏的是一小片区域,不是零散的单个像素。

规范还从纠错码字里额外预留了少量“防误判码字”(misdecode protection),所以官方标称的 7% / 15% / 25% / 30% 会略低于 t/2nt/2n。以 v1-L 为例:7 个纠错码字里有 3 个用于防误判,实际纠错能力是 (73)/2=2(7-3)/2 = 2 个码字,2/267%2/26 \approx 7\%——这就是“L 级约 7%“的出处。v1-H 则是 (171)/2=8(17-1)/2 = 88/2630%8/26 \approx 30\%

级别 恢复能力(标称) 适用场景
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 个码字……“轮流取,数据全部排完再同样轮排纠错码字。

为什么非要这么做:物理污损是连续的——一道划痕、一滴水、一个手指印。如果矩阵里相邻的码字同属一块,一道划痕就能让该块的错误数超过 t/2\lfloor t/2 \rfloor,那一块彻底不可恢复,整张码报废。交织之后,连续损伤被均摊到所有块上,每块只丢一两个码字,全部可救。

拿 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 = x10+x8+x5+x4+x2+x+1x^{10}+x^8+x^5+x^4+x^2+x+1(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 出现 1011101000000001011101 每处 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,作用仅仅是把那些位置标记成功能模块。

代价方面:四条规则都是 O(n2)O(n^2),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% 在你的浏览器本地完成,没有任何数据会发送给外部服务器,安全、私密,且支持离线使用。