从零开始手撸一个二维码生成器

发布于:2025-07-09 ⋅ 阅读:(17) ⋅ 点赞:(0)

近期文章

每天扫码支付、加好友、连WiFi,你有没有想过这黑白方块背后到底藏着什么猫腻?作为前端工程师,咱们不只要会用qrcode.js这类第三方库,更要知道这玩意儿是怎么从一段字符串变成手机能认的图案的。

今天这篇文章,咱们就从0到1手撸一个二维码生成器,全程用原生JS实现,不依赖任何第三方库。放心,我会把每个技术点拆解得明明白白,保证你看完就能上手。准备好,咱们要开始钻牛角尖了!原文地址:从零开始手撸一个二维码生成器

一、二维码生成全景流程图

先上张全景图镇楼,咱们心里有个数,二维码是怎么一步步诞生的:

这七个步骤,就是把一段普通文本变成二维码图片的全过程。别看着复杂,咱们一个一个来啃,保证比你女朋友的心思还好懂。

二、核心技术拆解(附实战代码)

2.1 数据编码:把字符串变成二进制

咱们先思考个问题:二维码本质上就是一堆黑白方块,这些方块只认识0和1。所以第一步,就得把我们输入的文本(比如"hello world"或者网址)变成二进制的01串。

但这里有个门道:不同类型的数据,编码方式不一样,效率也差很多。二维码规定了四种编码模式:

  1. 数字模式:仅包含0-9,效率最高,3个数字用10位二进制表示
  2. 字母数字模式:包含0-9、A-Z(大写)、空格和一些符号,2个字符用11位表示
  3. 字节模式:可以表示任何ISO-8859-1字符,每个字符用8位表示
  4. 汉字模式:用于汉字,每个汉字用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 模式指示器与字符计数

确定了编码模式后,我们还需要告诉二维码扫描器两件事:

  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; // 模不可约多项式
    }
  }

网站公告

今日签到

点亮在社区的每一天
去签到