Canvas:实现一个高颜值二维码生成器

78 阅读6分钟

最近做活动页,产品要求二维码要"好看点"。我看了下竞品的二维码,要么是黑白方块,要么就是贴个 Logo。想着能不能做得更精致点,顺便把二维码的原理研究透。

二维码不只是黑白方块

二维码的核心是 QR Code(Quick Response Code),1994 年 Denso Wave 发明。一个 QR Code 包含:

  • 定位标记(Position Markers):三个角的方块,让扫描器知道方向
  • 时序线(Timing Patterns):黑白相间的线,用于定位
  • 版本信息(Version Info):二维码大小(1-40 版本)
  • 格式信息(Format Info):纠错级别和掩码图案
  • 数据区(Data Area):实际存储的内容

最关键的是 Reed-Solomon 纠错码。即使二维码部分损坏,也能还原数据。这就是为什么二维码可以贴 Logo、甚至缺一角还能扫。

纠错级别:从 7% 到 30% 的容错

QR Code 有四个纠错级别:

级别容错率适用场景
L (Low)7%环境干净,无需 Logo
M (Medium)15%推荐默认值
Q (Quartile)25%适合加小 Logo
H (High)30%适合加大 Logo、印刷品

纠错率越高,二维码越"密",因为需要更多空间存储纠错码。例如,同样是存储 “Hello World”:

  • L 级:21×21 模块
  • H 级:25×25 模块

在实现中,我们用 qrcode 库生成数据:

import QRCode from 'qrcode'

const qrData = await QRCode.create(text, {
  errorCorrectionLevel: 'H'  // 要加 Logo,选 H 级
})

const moduleCount = qrData.modules.size  // 模块数量

Canvas 绘制:从方块到圆点

默认的二维码是黑白方块,但我们可以做得更精致。核心思路:

  1. 用库生成二维码数据(布尔矩阵)
  2. 用 Canvas 逐个绘制模块
  3. 定位标记单独处理(码眼样式)

基础绘制

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

// 计算每个模块的像素大小
const cellSize = Math.floor(size / moduleCount)
const actualSize = cellSize * moduleCount

canvas.width = actualSize
canvas.height = actualSize

// 填充背景
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, actualSize, actualSize)

// 绘制数据区
for (let row = 0; row < moduleCount; row++) {
  for (let col = 0; col < moduleCount; col++) {
    const isDark = qrData.modules.get(row, col)
    
    if (isDark) {
      const x = col * cellSize
      const y = row * cellSize
      
      // 判断是否是定位标记区域(三个角)
      const isPositionMarker = (
        (row < 8 && col < 8) ||           // 左上
        (row < 8 && col >= moduleCount - 8) ||  // 右上
        (row >= moduleCount - 8 && col < 8)     // 左下
      )
      
      if (!isPositionMarker) {
        // 数据区:绘制码点
        drawDot(ctx, x, y, cellSize, dotStyle, fgColor)
      }
    }
  }
}

码点样式

最有趣的是码点样式。我们实现了 5 种:

type DotStyle = 'square' | 'dots' | 'rounded' | 'diamond' | 'circle'

function drawDot(
  ctx: CanvasRenderingContext2D,
  x: number, y: number, size: number,
  style: DotStyle, color: string
) {
  ctx.fillStyle = color
  const padding = size * 0.1  // 留白
  const s = Math.max(1, size - padding * 2)

  switch (style) {
    case 'square':
      ctx.fillRect(x + padding, y + padding, s, s)
      break
      
    case 'dots':
      ctx.beginPath()
      ctx.arc(x + size / 2, y + size / 2, s / 2, 0, Math.PI * 2)
      ctx.fill()
      break
      
    case 'rounded':
      ctx.beginPath()
      ctx.roundRect(x + padding, y + padding, s, s, s * 0.3)
      ctx.fill()
      break
      
    case 'diamond':
      ctx.beginPath()
      ctx.moveTo(x + size / 2, y + padding)
      ctx.lineTo(x + size - padding, y + size / 2)
      ctx.lineTo(x + size / 2, y + size - padding)
      ctx.lineTo(x + padding, y + size / 2)
      ctx.closePath()
      ctx.fill()
      break
      
    case 'circle':
      ctx.beginPath()
      ctx.arc(x + size / 2, y + size / 2, s / 2.5, 0, Math.PI * 2)
      ctx.fill()
      break
  }
}

roundRect 是 Canvas 的新 API,Chrome 99+ 支持,可以画圆角矩形。兼容性处理可以用 Path2D 或手动画弧线。

码眼样式

定位标记(码眼)是二维码的"门面",我们单独绘制:

type EyeStyle = 'square' | 'rounded' | 'circle' | 'leaf'

function drawPositionMarkers(
  ctx: CanvasRenderingContext2D,
  moduleCount: number, cellSize: number,
  style: EyeStyle, fgColor: string, bgColor: string
) {
  const markerSize = cellSize * 7  // 定位标记占 7×7 模块
  const positions = [
    { row: 0, col: 0 },                      // 左上
    { row: 0, col: moduleCount - 7 },        // 右上
    { row: moduleCount - 7, col: 0 },        // 左下
  ]

  positions.forEach(pos => {
    const x = pos.col * cellSize
    const y = pos.row * cellSize
    
    ctx.fillStyle = fgColor
    
    switch (style) {
      case 'square':
        // 外框 7×7
        ctx.fillRect(x, y, markerSize, markerSize)
        // 内白框 5×5
        ctx.fillStyle = bgColor
        ctx.fillRect(x + cellSize, y + cellSize, cellSize * 5, cellSize * 5)
        // 中心黑块 3×3
        ctx.fillStyle = fgColor
        ctx.fillRect(x + cellSize * 2, y + cellSize * 2, cellSize * 3, cellSize * 3)
        break
        
      case 'circle':
        // 外圆
        ctx.beginPath()
        ctx.arc(x + markerSize / 2, y + markerSize / 2, markerSize / 2, 0, Math.PI * 2)
        ctx.fill()
        // 内白圆
        ctx.fillStyle = bgColor
        ctx.beginPath()
        ctx.arc(x + markerSize / 2, y + markerSize / 2, cellSize * 2.5, 0, Math.PI * 2)
        ctx.fill()
        // 中心黑点
        ctx.fillStyle = fgColor
        ctx.beginPath()
        ctx.arc(x + markerSize / 2, y + markerSize / 2, cellSize, 0, Math.PI * 2)
        ctx.fill()
        break
        
      // ... 其他样式
    }
  })
}

定位标记的结构是 7×7 模块,外框、内白、中心三层。圆形码眼需要用 arc 画圆,叶子形用 ellipse

渐变色:从单调到炫酷

纯色二维码太单调,渐变色更有设计感。Canvas 的 createLinearGradient 可以轻松实现:

// 创建渐变
const gradient = ctx.createLinearGradient(0, 0, actualSize, actualSize)
gradient.addColorStop(0, '#3b82f6')  // 起始色
gradient.addColorStop(1, '#06b6d4')  // 结束色

// 绘制时使用渐变
drawDot(ctx, x, y, cellSize, dotStyle, gradient)

但有个坑:渐变是基于整个画布的,不是单个码点。所以每个码点都会显示整个渐变的一部分,形成整体渐变效果。

Logo 叠加:容错的艺术

加 Logo 是二维码美化的常见需求,但有几个注意点:

1. 选择合适的纠错级别

Logo 会遮挡数据区,必须用高纠错级别:

  • Logo 小(< 15%):Q 级(25%)
  • Logo 大(15-20%):H 级(30%)

2. Logo 大小限制

Logo 不能太大,否则超出纠错能力。经验值:Logo 不超过二维码面积的 20%

const logoSize = actualSize * 0.2  // 最大 20%
const logoX = (actualSize - logoSize) / 2
const logoY = (actualSize - logoSize) / 2

// 白色保护区域(重要!)
ctx.fillStyle = bgColor
ctx.fillRect(logoX - 4, logoY - 4, logoSize + 8, logoSize + 8)

// 绘制 Logo
const logoImg = new Image()
logoImg.onload = () => {
  ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize)
}
logoImg.src = logoDataUrl

3. 白色保护区域

Logo 周围必须留白,否则与二维码数据区混淆。保护区域至少 4px。

性能优化:大尺寸二维码

当二维码尺寸达到 800×800 时,逐个绘制模块会卡顿。几个优化手段:

1. 离屏 Canvas

const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d')

// 在离屏 Canvas 上绘制
offCtx.fillStyle = fgColor
offCtx.fillRect(0, 0, cellSize, cellSize)

// 批量复制到主 Canvas
for (let row = 0; row < moduleCount; row++) {
  for (let col = 0; col < moduleCount; col++) {
    if (qrData.modules.get(row, col)) {
      ctx.drawImage(offscreen, col * cellSize, row * cellSize)
    }
  }
}

2. 预渲染码点

把每种样式的码点预渲染到小 Canvas,然后用 drawImage 复制:

// 预渲染圆点
const dotCanvas = document.createElement('canvas')
dotCanvas.width = cellSize
dotCanvas.height = cellSize
const dotCtx = dotCanvas.getContext('2d')

dotCtx.fillStyle = fgColor
dotCtx.beginPath()
dotCtx.arc(cellSize / 2, cellSize / 2, cellSize / 2, 0, Math.PI * 2)
dotCtx.fill()

// 使用时直接复制
ctx.drawImage(dotCanvas, x, y)

drawImage 比每次都 beginPath + arc 快得多。

实际踩坑

1. 模块数量不对

二维码版本决定了模块数量:version 1 = 21×21,每升一级加 4 个模块。公式:modules = 17 + version * 4

但库生成的 moduleCount 可能与预期不符,因为会根据内容长度自动选择版本。用 Math.floor(size / moduleCount) 计算实际像素大小。

2. Canvas 尺寸模糊

Canvas 的 CSS 尺寸和像素尺寸要分开设置,否则会模糊:

canvas.width = actualSize   // 像素尺寸
canvas.height = actualSize
canvas.style.width = `${actualSize}px`  // CSS 尺寸
canvas.style.height = `${actualSize}px`

或者用 devicePixelRatio 适配高清屏:

const dpr = window.devicePixelRatio || 1
canvas.width = actualSize * dpr
canvas.height = actualSize * dpr
ctx.scale(dpr, dpr)

3. Logo 跨域问题

如果 Logo 是网络图片,需要设置 crossOrigin

const logoImg = new Image()
logoImg.crossOrigin = 'anonymous'  // 必须在 src 之前设置
logoImg.src = logoUrl

否则 canvas.toDataURL() 会报错:Tainted canvases may not be exported

最终效果

基于以上思路,做了一个在线工具:二维码生成器

主要功能:

  • 12 种预设样式(渐变色、科技蓝、活力橙等)
  • 5 种码点形状(方形、圆点、圆角、菱形、圆形)
  • 4 种码眼形状(方形、圆角、圆形、叶子)
  • 4 级纠错(L/M/Q/H)
  • 支持上传 Logo
  • 尺寸 200-800px 可调

技术实现不复杂,但把细节做好需要花心思。二维码不只是黑白方块,它可以很精致。


相关工具:条形码生成器 | 图片压缩