文章目录
近期文章:
- AI时代如何拓展Web前端开发的边界
- 告别繁琐!用 Excalidraw 绘制手绘风格的技术图,让你的文档更有“温度”
- 从零到一通过Web技术开发一个五子棋
- 【前端练手必备】从零到一,教你用JS写出风靡全球的“贪吃蛇”!
- Google Search Console 做SEO分析之“已发现未编入” 与 “已抓取未编入” 有什么区别?
- 如何通过 noindex 阻止网页被搜索引擎编入索引?
- 建站SEO优化之站点地图sitemap
- 个人建站做SEO网站外链这一点需要注意,做错了可能受到Google惩罚
- 一文搞懂SEO优化之站点robots.txt
每天扫码支付、加好友、连WiFi,你有没有想过这黑白方块背后到底藏着什么猫腻?作为前端工程师,咱们不只要会用
qrcode.js
这类第三方库,更要知道这玩意儿是怎么从一段字符串变成手机能认的图案的。
今天这篇文章,咱们就从0到1手撸一个二维码生成器,全程用原生JS实现,不依赖任何第三方库。放心,我会把每个技术点拆解得明明白白,保证你看完就能上手。准备好,咱们要开始钻牛角尖了!原文地址:从零开始手撸一个二维码生成器
一、二维码生成全景流程图
先上张全景图镇楼,咱们心里有个数,二维码是怎么一步步诞生的:
这七个步骤,就是把一段普通文本变成二维码图片的全过程。别看着复杂,咱们一个一个来啃,保证比你女朋友的心思还好懂。
二、核心技术拆解(附实战代码)
2.1 数据编码:把字符串变成二进制
咱们先思考个问题:二维码本质上就是一堆黑白方块,这些方块只认识0和1。所以第一步,就得把我们输入的文本(比如"hello world"或者网址)变成二进制的01串。
但这里有个门道:不同类型的数据,编码方式不一样,效率也差很多。二维码规定了四种编码模式:
- 数字模式:仅包含0-9,效率最高,3个数字用10位二进制表示
- 字母数字模式:包含0-9、A-Z(大写)、空格和一些符号,2个字符用11位表示
- 字节模式:可以表示任何ISO-8859-1字符,每个字符用8位表示
- 汉字模式:用于汉字,每个汉字用13位表示(注意:不是所有二维码扫描器都支持)
所以,智能的编码方式应该是:根据输入内容自动选择最合适的编码模式,并且在内容混杂时能自动切换模式。
2.1.1 编码模式自动识别
咱们先来实现一个函数,判断一段文本应该用什么编码模式:
/**
* 判断文本应该使用的最佳编码模式
* @param {string} text 输入文本
* @returns {string} 编码模式标识 ('numeric', 'alphanumeric', 'byte', 'kanji')
*/
function detectBestEncodingMode(text) {
// 检查是否全是数字
const numericRegex = /^[0-9]*$/;
if (numericRegex.test(text)) {
return 'numeric';
}
// 检查是否是字母数字模式(支持的字符集)
const alphanumericChars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
const alphanumericRegex = new RegExp(`^[${
alphanumericChars}]*$`);
if (alphanumericRegex.test(text)) {
return 'alphanumeric';
}
// 检查是否是纯ASCII字符(字节模式)
const asciiRegex = /^[\x00-\x7F]*$/;
if (asciiRegex.test(text)) {
return 'byte';
}
// 默认为字节模式(实际应用中可能需要判断汉字模式)
return 'byte';
}
这个函数会按照"数字→字母数字→字节"的优先级来选择编码模式,因为越是靠前的模式,编码效率越高。比如同样是"123456",用数字模式编码只需要20位(每3个数字10位),而用字节模式则需要48位(每个数字8位)。
2.1.2 模式指示器与字符计数
确定了编码模式后,我们还需要告诉二维码扫描器两件事:
- 我们用的是什么编码模式
- 这个编码模式下有多少个字符
二维码标准为每种模式分配了一个"模式指示器":
- 数字模式:0001(4位)
- 字母数字模式:0010(4位)
- 字节模式:0100(4位)
- 汉字模式:1000(4位)
字符计数的位数则根据二维码的"版本"和编码模式而不同。二维码的版本从1到40,版本1是21x21的矩阵,每增加一个版本,矩阵 size 增加4个模块(宽高各+4),到版本40就是177x177的矩阵。
不同版本支持的字符计数位数:
版本范围 | 数字模式 | 字母数字模式 | 字节模式 | 汉字模式 |
---|---|---|---|---|
1-9 | 10位 | 9位 | 8位 | 8位 |
10-26 | 12位 | 11位 | 16位 | 10位 |
27-40 | 14位 | 13位 | 16位 | 12位 |
所以,我们需要一个函数来获取字符计数字段的长度:
/**
* 获取字符计数字段的位数
* @param {number} version 二维码版本(1-40)
* @param {string} mode 编码模式
* @returns {number} 字符计数字段的位数
*/
function getCharCountIndicatorLength(version, mode) {
if (version >= 1 && version <= 9) {
const lengths = {
'numeric': 10, 'alphanumeric': 9, 'byte': 8, 'kanji': 8 };
return lengths[mode];
} else if (version >= 10 && version <= 26) {
const lengths = {
'numeric': 12, 'alphanumeric': 11, 'byte': 16, 'kanji': 10 };
return lengths[mode];
} else {
// 27-40
const lengths = {
'numeric': 14, 'alphanumeric': 13, 'byte': 16, 'kanji': 12 };
return lengths[mode];
}
}
2.1.3 三种编码模式的具体实现
现在,咱们来实现三种主要编码模式的核心算法:
数字模式编码:
数字模式的编码规则很简单:每3个数字一组,转成10位二进制。如果剩下1个数字,转成4位;剩下2个数字,转成7位。
/**
* 数字模式编码
* @param {string} data 仅包含数字的字符串
* @returns {string} 编码后的二进制字符串
*/
function encodeNumeric(data) {
let bits = '';
// 每3个数字一组处理
for (let i = 0; i < data.length; i += 3) {
// 取3个数字,不足3个则取剩下的
const group = data.substr(i, 3);
// 转成数字
const num = parseInt(group, 10);
let bitLength;
if (group.length === 1) {
bitLength = 4; // 1个数字 -> 4位
} else if (group.length === 2) {
bitLength = 7; // 2个数字 -> 7位
} else {
bitLength = 10; // 3个数字 -> 10位
}
// 转成二进制并填充前导零至指定长度
let binary = num.toString(2);
while (binary.length < bitLength) {
binary = '0' + binary;
}
bits += binary;
}
return bits;
}
字母数字模式编码:
字母数字模式包含45个字符,每个字符都有对应的索引值(见下表)。编码时每2个字符一组,第一个字符的索引乘以45再加上第二个字符的索引,结果转成11位二进制。如果剩下1个字符,直接转成6位二进制。
字母数字字符集及索引:
0:0, 1:1, …, 9:9, 10:A, 11:B, …, 35:Z, 36:空格, 37:$, 38:%, 39:*, 40:+, 41:-, 42:., 43:/, 44::
/**
* 字母数字模式编码
* @param {string} data 字母数字字符串
* @returns {string} 编码后的二进制字符串
*/
function encodeAlphanumeric(data) {
// 字符到索引的映射表
const charMap = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
let bits = '';
// 每2个字符一组处理
for (let i = 0; i < data.length; i += 2) {
// 取当前字符和下一个字符(如果存在)
const c1 = data[i];
const c2 = i + 1 < data.length ? data[i + 1] : null;
if (c2) {
// 两个字符:索引 = c1索引 * 45 + c2索引,转成11位二进制
const index1 = charMap.indexOf(c1);
const index2 = charMap.indexOf(c2);
const combined = index1 * 45 + index2;
let binary = combined.toString(2);
// 确保是11位,不足则补前导零
while (binary.length < 11) {
binary = '0' + binary;
}
bits += binary;
} else {
// 单个字符:直接转成6位二进制
const index = charMap.indexOf(c1);
let binary = index.toString(2);
while (binary.length < 6) {
binary = '0' + binary;
}
bits += binary;
}
}
return bits;
}
字节模式编码:
字节模式相对简单,就是把每个字符转成其ISO-8859-1编码值(0-255),再转成8位二进制。对于中文等非ISO-8859-1字符,通常会先转成UTF-8字节序列,再进行编码。
/**
* 字节模式编码
* @param {string} data 字符串
* @returns {string} 编码后的二进制字符串
*/
function encodeByte(data) {
let bits = '';
// 将字符串转换为UTF-8字节数组
const encoder = new TextEncoder('utf-8');
const bytes = encoder.encode(data);
// 每个字节转成8位二进制
for (const byte of bytes) {
let binary = byte.toString(2);
// 确保是8位,不足则补前导零
while (binary.length < 8) {
binary = '0' + binary;
}
bits += binary;
}
return bits;
}
2.1.4 数据编码总函数
现在我们可以把上面的函数整合起来,实现一个完整的数据编码流程:
/**
* 数据编码主函数
* @param {string} text 输入文本
* @param {number} version 二维码版本
* @returns {string} 完整的编码后二进制字符串
*/
function encodeData(text, version) {
// 1. 检测最佳编码模式
const mode = detectBestEncodingMode(text);
console.log(`检测到最佳编码模式: ${
mode}`);
// 2. 获取模式指示器
const modeIndicators = {
'numeric': '0001',
'alphanumeric': '0010',
'byte': '0100',
'kanji': '1000'
};
const modeIndicator = modeIndicators[mode];
// 3. 计算字符计数指示符
const charCount = text.length;
const charCountLength = getCharCountIndicatorLength(version, mode);
let charCountBinary = charCount.toString(2);
// 补前导零至指定长度
while (charCountBinary.length < charCountLength) {
charCountBinary = '0' + charCountBinary;
}
// 4. 根据模式编码数据
let dataBits = '';
switch (mode) {
case 'numeric':
dataBits = encodeNumeric(text);
break;
case 'alphanumeric':
dataBits = encodeAlphanumeric(text);
break;
case 'byte':
dataBits = encodeByte(text);
break;
case 'kanji':
// 汉字模式实现略,原理类似
dataBits = '';
break;
}
// 5. 连接所有部分:模式指示器 + 字符计数 + 数据位
let encodedData = modeIndicator + charCountBinary + dataBits;
// 6. 添加终止符:最多4个0,直到数据长度达到当前版本的码字总数的8倍
const totalCodewords = getTotalCodewords(version); // 这个函数我们后面会实现
const requiredBits = totalCodewords * 8;
// 计算需要添加的终止符长度
let terminatorLength = Math.min(4, requiredBits - encodedData.length);
encodedData += '0'.repeat(terminatorLength);
// 7. 如果还没达到需要的长度,用补齐符填充(8位一组的11101100和00010001)
if (encodedData.length % 8 !== 0) {
const padLength = 8 - (encodedData.length % 8);
encodedData += '0'.repeat(padLength);
}
// 8. 如果仍然不够,用填充码字填充
const padCodewords = ['11101100', '00010001'];
let padIndex = 0;
while (encodedData.length < requiredBits) {
encodedData += padCodewords[padIndex];
padIndex = (padIndex + 1) % 2; // 交替使用两个填充码字
}
return encodedData;
}
到这里,我们已经把原始文本转换成了二维码能理解的二进制数据流。这一步就像是把我们想说的话翻译成二维码的"母语",是整个生成过程的基础。
2.2 纠错引擎:Reed-Solomon编码实战
现在我们有了编码后的数据,但只有数据还不够。二维码之所以靠谱,很大程度上是因为它强大的纠错能力——就算被挡住一部分、有点污损,照样能扫出来。
这个纠错能力是怎么来的?靠的就是 Reed-Solomon码(里德-所罗门码),一种强大的纠错编码技术。CD、DVD、蓝光光盘、卫星通信中都用它来纠错。
2.2.1 Reed-Solomon码的基本原理
Reed-Solomon码(简称RS码)是一种前向纠错码,它的核心思想是:在原始数据后面添加一些校验码(纠错码),使得即使部分数据丢失或出错,也能通过这些校验码恢复出原始数据。
二维码中使用的是 RS(n, k) 码,其中:
- n 是码字总数(数据码 + 纠错码)
- k 是数据码数量
- 纠错码数量 = n - k
- 最多能纠正 t = (n - k)/2 个错误码字
二维码定义了四个纠错级别:
- L级(低):7%的码字可以被纠正
- M级(中):15%的码字可以被纠正
- Q级(较高):25%的码字可以被纠正
- H级(高):30%的码字可以被纠正
不同版本、不同纠错级别的二维码,其数据码和纠错码的数量都有明确规定。比如版本1、L级纠错的二维码有16个数据码,7个纠错码,构成RS(23, 16)码,最多能纠正3个错误码字。
2.2.2 伽罗瓦域(GF(2^8))运算
RS码的所有运算都在伽罗瓦域(有限域)中进行,二维码使用的是GF(2^8)域(包含256个元素)。这部分是整个二维码生成中最复杂的数学部分,咱们得慢慢啃。
首先,我们需要构建GF(28)域的元素表和运算表。在GF(28)中,每个元素可以表示为一个8位二进制数(0-255)。加法就是按位异或(XOR),乘法则比较复杂,需要使用不可约多项式。二维码标准规定使用的不可约多项式是:x⁸ + x⁴ + x³ + x² + 1(十六进制0x11D)。
伽罗瓦域乘法实现:
/**
* GF(2^8)域乘法
* @param {number} a 乘数a (0-255)
* @param {number} b 乘数b (0-255)
* @returns {number} 乘积 (0-255)
*/
function gfMultiply(a, b) {
if (a === 0 || b === 0) return 0;
let result = 0;
// 不可约多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D
const irreducible = 0x11D;
for (let i = 0; i < 8; i++) {
// 如果b的第i位是1
if ((b & (1 << i)) !== 0) {
// 左移i位相当于乘以α^i
let aShifted = a << i;
// 模不可约多项式,确保结果是8位
for (let j = 7 + i; j >= 8; j--) {
if ((aShifted & (1 << j)) !== 0) {
aShifted ^= irreducible << (j - 8);
}
}
result ^= aShifted;
}
}
// 取低8位作为结果
return result & 0xFF;
}
为了提高效率,实际应用中我们会预先生成一个乘法表,而不是每次都实时计算:
// 预生成GF(2^8)乘法表
const gfMulTable = new Array(256);
for (let i = 0; i < 256; i++) {
gfMulTable[i] = new Array(256);
for (let j = 0; j < 256; j++) {
gfMulTable[i][j] = gfMultiply(i, j);
}
}
2.2.3 生成多项式
RS码的纠错码是通过将数据多项式除以一个生成多项式得到的余式。生成多项式的形式为:
G(x) = (x - α^0)(x - α^1)…(x - α^(2t-1))
其中t是可纠正的错误数,α是GF(2^8)域的本原元。二维码中使用α = 2(即多项式x)。
对于纠错码数量为2t的RS码,生成多项式有2t个根。我们需要预先生成这些生成多项式的系数。
/**
* 生成Reed-Solomon生成多项式
* @param {number} degree 多项式次数(纠错码数量)
* @returns {number[]} 生成多项式系数数组
*/
function generateGeneratorPoly(degree) {
// 初始多项式为1 (x^0)
let poly = [1];
// 生成 (x - α^0)(x - α^1)...(x - α^(degree-1))
for (let i = 0; i < degree; i++) {
// 当前因子: (x - α^i),在GF(2^8)中表示为 [1, α^i]
const factor = [1, alphaPower(i)]; // alphaPower函数见下文
// 多项式乘法:poly = poly * factor
const newPoly = new Array(poly.length + factor.length - 1).fill(0);
for (let j = 0; j < poly.length; j++) {
for (let k = 0; k < factor.length; k++) {
newPoly[j + k] ^= gfMulTable[poly[j]][factor[k]];
}
}
poly = newPoly;
}
return poly;
}
/**
* 计算α^exp mod 不可约多项式
* @param {number} exp 指数
* @returns {number} α^exp在GF(2^8)中的值
*/
function alphaPower(exp) {
if (exp === 0) return 1;
let result = 1; // α^0 = 1
for (let i = 0; i < exp; i++) {
result = gfMulTable[result][2]; // α^(i+1) = α^i * α,而α=2
if (result >= 256) {
result ^= 0x11D; // 模不可约多项式
}
}