最近做活动页,产品要求二维码要"好看点"。我看了下竞品的二维码,要么是黑白方块,要么就是贴个 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 绘制:从方块到圆点
默认的二维码是黑白方块,但我们可以做得更精致。核心思路:
- 用库生成二维码数据(布尔矩阵)
- 用 Canvas 逐个绘制模块
- 定位标记单独处理(码眼样式)
基础绘制
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 可调
技术实现不复杂,但把细节做好需要花心思。二维码不只是黑白方块,它可以很精致。