纯前端二维码 / 条码生成器:从协议拼装到批量 ZIP 下载完整拆解

在线体验:geekformat.com/zh-CN/other/qrcode-gen

故事开头

做二维码页面时,需求几乎不会停在“输入一个网址,生成一张码图”。

真实场景通常是这样的:

  • 运营要一个活动报名二维码
  • 门店要一个顾客扫码直连的 WiFi 二维码
  • 销售要一个扫码存联系人的名片二维码
  • 仓库要一批商品条码,最好一次性导出

这时候你会发现,问题的重点根本不是“怎么画一个二维码”,而是:

  • 怎么把不同业务场景转换成正确的协议字符串
  • 怎么同时支持二维码和条码
  • 怎么把颜色、尺寸、Logo、样式这些配置接进同一套生成流程
  • 怎么把单张下载和批量 ZIP 导出一起做好

这篇文章不讲泛泛的二维码科普,而是直接结合这个页面的真实实现来拆:

  • 页面为什么要做成二维码 / 条码双模式
  • 动态表单和协议层是怎么组织的
  • 二维码生成后为什么还要再走一遍 Canvas
  • 批量生成和 ZIP 打包是怎么落地的

这页最终的结构很清楚:

  • 左侧是二维码 / 条码模式切换
  • 中间是不同内容类型的输入表单
  • 下方是颜色、尺寸、纠错级别、样式、Logo 等配置
  • 右侧是即时预览、下载、复制和批量导出
    在这里插入图片描述

技术栈一览

类别 技术 用途
框架 React + Next.js + TypeScript 页面结构、状态管理、类型约束
二维码生成 qrcode 把协议字符串转成基础二维码 DataURL
二次绘制 Canvas 2D 圆角、点状、Logo 叠加等样式加工
条码生成 Canvas 2D 按码制规则直接绘制条纹和文本
批量导出 jszip 多张二维码 / 条码打包下载
浏览器能力 Clipboard API / FileReader / Blob 复制图片、上传 Logo、下载文件

这页的选型很直接:

  • 二维码 交给成熟库 qrcode
  • 样式加工 交给 Canvas
  • 条码 单独绘制,不和二维码共用一套生成器
  • 批量打包 交给 jszip

它不是一个重渲染工具页,真正的难点在于 协议拼装、状态组织、批量流程和错误处理


一、整体架构:输入 → 协议 → 生成 → 预览 → 导出

flowchart TB A["用户选择模式<br/>二维码 / 条码"] --> B["填写动态表单"] B --> C{"内容类型"} C -->|二维码| D["拼装协议字符串<br/>url / wifi / vcard / event ..."] C -->|条码| E["校验码制与输入长度"] D --> F["generateQRDataURL"] F --> G{"是否需要样式加工"} G -->|否| H["直接预览"] G -->|是| I["drawStyledQR<br/>Canvas 二次绘制"] I --> H E --> J["generateBarcodeDataURL"] J --> H H --> K["下载 PNG"] H --> L["复制图片"] H --> M{"是否批量"} M -->|是| N["jszip 打包 ZIP"]

从代码实现上看,这页主要分成 5 层:

  1. 模式层:二维码 / 条码切换
  2. 输入层:根据内容类型渲染不同表单
  3. 协议层:把表单数据转换成真正可编码的字符串
  4. 生成层:二维码走 generateQRDataURL,条码走 generateBarcodeDataURL
  5. 导出层:下载单张、复制、批量 ZIP

这里最关键的一点是:真正决定结果对不对的,首先不是样式,而是协议字符串是不是拼对了。

如果把这页的最短主流程再压缩一层,其实就是下面这 6 步:

flowchart LR A["选择模式"] --> B["填写表单"] B --> C["拼装内容字符串"] C --> D["生成二维码 / 条码"] D --> E["即时预览"] E --> F["下载 / 复制 / ZIP"]

二、为什么这个页面不能只做一个输入框

很多二维码页面只有一个大输入框:

输入任意文本 -> 生成二维码

这种做法只能覆盖最基础的纯文本场景。

而这个页面把二维码内容拆成了 9 类:

  • url
  • text
  • email
  • phone
  • sms
  • wifi
  • vcard
  • geo
  • event

对应代码里的类型定义就是:

type QRContentType =
  | 'url'
  | 'text'
  | 'email'
  | 'phone'
  | 'sms'
  | 'wifi'
  | 'vcard'
  | 'geo'
  | 'event'

这样做的意义不是“表单更丰富”,而是把输入数据先结构化:

  • wifi 需要 ssid / password / encryption / hidden
  • vcard 需要 name / phone / email / company / title / address / url
  • event 需要 title / location / start / end / description

只有先把输入拆开,后面协议拼装、批量逻辑和错误提示才能写得稳。


三、协议层才是核心:同样是二维码,内容字符串完全不一样

页面里最关键的函数之一,是 generateQRDataURL 之前那段协议拼装逻辑:

switch (contentType) {
  case 'url':
    content = data.url || ''
    break
  case 'text':
    content = data.text || ''
    break
  case 'email':
    content = `mailto:${data.email || ''}?subject=${encodeURIComponent(data.subject || '')}&body=${encodeURIComponent(data.body || '')}`
    break
  case 'phone':
    content = `tel:${data.phone || ''}`
    break
  case 'sms':
    content = `sms:${data.phone || ''}?body=${encodeURIComponent(data.message || '')}`
    break
  case 'wifi':
    content = `WIFI:T:${data.encryption || 'WPA'};S:${data.ssid || ''};P:${data.password || ''};${data.hidden === 'true' ? 'H:true;' : ''};`
    break
}

这段代码说明了一件事:
二维码不是“把用户看到的文本原样塞进去”就行,而是要拼成目标 App 或系统能识别的协议。

1. WiFi 不是普通文本

如果用户只是输入:

TP-Link_5G / 12345678

扫码器大概率只会把它当成文本显示。

而页面真正生成的是:

WIFI:T:WPA;S:TP-Link_5G;P:12345678;H:false;

这才是系统能识别的 WiFi 连接协议。
在这里插入图片描述

2. 名片不是 JSON,而是 vCard

名片类型最终拼成的是:

BEGIN:VCARD
VERSION:3.0
FN:张三
TEL:13800138000
EMAIL:zhangsan@example.com
ORG:GeekFormat
END:VCARD

这样扫码之后,很多系统会直接进入“保存联系人”的流程。

3. 日程不是纯文本,而是事件协议

事件类型最终拼的是:

BEGIN:VEVENT
SUMMARY:产品评审会
LOCATION:会议室 A
DTSTART:20260608T100000Z
DTEND:20260608T113000Z
DESCRIPTION:讨论二维码改版
END:VEVENT

所以这页最核心的价值,不是“生成二维码图片”,而是把高频业务场景正确映射成规范字符串


四、二维码生成只是第一步:样式层还要再走一遍 Canvas

页面里真正生成二维码的函数是 generateQRDataURL

return QRCode.toDataURL(content, {
  width: size,
  margin: 2,
  color: {
    dark: fgColor,
    light: bgColor
  },
  errorCorrectionLevel: errorLevel
})

如果只是黑白标准二维码,到这里已经够了。

但页面实际还支持这些配置:

  • 前景色 / 背景色
  • 尺寸
  • 纠错级别
  • 方形 / 圆角 / 点状
  • 中心 Logo

这时就不能只停在 qrcode 的默认输出,而是要把生成出来的二维码再画进 Canvas 里继续处理。对应的就是 drawStyledQR

1. 圆角 / 点状:本质是二次裁剪

这两种样式不是重新算二维码矩阵,而是对基础二维码图片做遮罩裁剪。

2. Logo:先垫白底,再贴图

页面处理 Logo 的逻辑是:

const logoS = Math.floor(size * (logoSize / 100))
const x = (size - logoS) / 2
const y = (size - logoS) / 2
ctx.fillStyle = bgColor
ctx.fillRect(x - 4, y - 4, logoS + 8, logoS + 8)
ctx.drawImage(logoImg, x, y, logoS, logoS)

这里有两个很实际的点:

  • Logo 不是直接贴上去,而是先垫一层白底
  • Logo 尺寸不是写死,而是按百分比可调

另外,页面里 Logo 大小滑杆只有在上传 Logo 后才会出现,这也是一个很合理的交互细节。

3. 纠错级别决定了样式容忍度

页面提供了 4 档纠错级别:

  • L (7%)
  • M (15%)
  • Q (25%)
  • H (30%)

如果只是普通分享码,用 M 基本够了;
如果中间放 Logo,通常更适合提高到 QH

从实现流程上看,二维码样式层的处理链路其实很简单:

flowchart LR A["协议字符串"] --> B["qrcode 生成基础 DataURL"] B --> C{"是否需要样式加工"} C -->|否| D["直接预览 / 下载"] C -->|是| E["Canvas 二次绘制"] E --> F["圆角 / 点状 / Logo"] F --> D

五、为什么这页还要支持条码

这页不是单纯的二维码生成器,而是 二维码 / 条码双模式

这个设计很贴近真实业务:

  • 海报、菜单、名片、活动页更常用二维码
  • 商品标签、物流包装、库存编码更常用条码

页面支持的条码格式有 6 种:

  • CODE128
  • EAN13
  • EAN8
  • UPC
  • CODE39
  • ITF14

其中条码不是交给外部条码库生成,而是直接在 generateBarcodeDataURL 里用 Canvas 绘制。

更关键的是,这页在生成前先做了格式校验:

switch (format) {
  case 'EAN13':
    return /^\\d{13}$/.test(data)
  case 'EAN8':
    return /^\\d{8}$/.test(data)
  case 'UPC':
    return /^\\d{12}$/.test(data)
  case 'ITF14':
    return /^\\d{14}$/.test(data)
  default:
    return data.length > 0
}

也就是说:

  • EAN13 必须是 13 位数字
  • EAN8 必须是 8 位数字
  • UPC 必须是 12 位数字
  • ITF14 必须是 14 位数字

这一步非常重要,因为条码和二维码不一样,码制和输入不匹配时,不应该继续往下走。


六、批量生成才是这页最像“生产工具”的部分

如果这页只能一张一张生成,那它解决的只是轻场景。

真正把它和普通在线小工具拉开差距的,是批量模式。

页面目前支持这几种批量:

  • 二维码 url
  • 二维码 text
  • 条码数据

规则很清楚:

  • 每行一条
  • 空行忽略
  • 先生成首张预览
  • 所有结果进入 batchItems

对应的基础函数就是:

function parseBatchLines(raw: string): string[] {
  return raw
    .split(/\\r?\\n/)
    .map((s) => s.trim())
    .filter(Boolean)
}

然后生成阶段会逐条循环处理,最后通过 jszip 打包:

const JSZip = (await import('jszip')).default
const zip = new JSZip()
const folder = zip.folder('qrcodes')

这一层还有几个做得很实用的细节:

1. 批量条码支持失败跳过

如果某几行条码数据不合法,页面不会整批报废,而是:

  • 跳过失败项
  • 保留成功项
  • 把失败行号提示出来

2. 文件名做了清洗

如果原始内容里带有这些字符:

/ \\ ? % * : | " < >

会先替换掉,避免 ZIP 内文件名出问题。

3. 批量预览不是把所有图都无脑塞满页面

页面的策略是:

  • 右侧优先展示批量缩略图
  • 下载区提供“全部 ZIP”
  • 同时保留“首张 PNG”和“复制首张”

这套设计比只给一个“导出全部”按钮更顺手。


七、这页里几个很容易被忽略的实现细节

1. 不是所有二维码类型都开放批量

现在二维码批量只对 urltext 开放,这是一个很合理的边界。

因为像这些类型:

  • wifi
  • vcard
  • event

都不是“每行一段文本”就能舒服表达的。强行做成批量,输入体验反而会变差。

2. Logo 上传走的是本地 DataURL

页面用的是:

const reader = new FileReader()
reader.onload = () => {
  setQrConfig(prev => ({ ...prev, logo: reader.result as string }))
}
reader.readAsDataURL(file)

这样后面的 Canvas 处理可以直接消费结果,不需要单独上传图片。

3. 生成逻辑做了轻微防抖

useEffect(() => {
  const timer = setTimeout(() => {
    generateCode()
  }, 300)
  return () => clearTimeout(timer)
}, [generateCode])

这 300ms 看着小,但非常有必要。
否则用户每输入一个字,都会立即触发一轮生成和重绘。

4. 复制不是复制文本,而是复制图片

页面走的是 Clipboard 图片写入:

await navigator.clipboard.write([
  new ClipboardItem({ [blob.type]: blob })
])

这比“复制链接”更接近日常使用场景,尤其是海报、文档和设计稿场景。


八、真实项目里最容易踩的 6 个坑

1. 二维码能扫,不代表扫出来就是对的

最常见的问题不是“图片坏了”,而是协议没拼对

比如:

  • WiFi 没用 WIFI:
  • 邮件没用 mailto:
  • 电话没用 tel:
  • 名片没按 vCard 格式拼

2. Logo 不是越大越好

Logo 放太大,会明显影响扫码率。

经验上更稳的做法是:

  • Logo 占比控制在 15% ~ 25%
  • 同时把纠错级别提高到 Q / H

3. 条码不是“随便输点数字”

不同码制有严格限制:

  • EAN13 必须 13 位数字
  • EAN8 必须 8 位数字
  • UPC 必须 12 位数字
  • ITF14 必须 14 位数字

4. 批量下载时,文件名一定要清洗

否则真实数据里一旦带 /:? 之类字符,ZIP 文件很容易出问题。

5. 样式和可扫性是此消彼长的

圆角、点状、Logo 都会吃掉一定的扫码冗余。
样式做得越重,越要靠纠错级别兜底。

6. 批量预览不能把页面塞爆

几百张图全部直接渲染到 DOM 里,会让页面又重又乱。
这页现在“首张预览 + 批量结果 + ZIP 下载”的组合是更稳的方案。


九、这页真正解决的问题,不是“生成一个二维码”

如果一个页面只能做:

  • 贴一个网址
  • 生成一张黑白二维码
  • 下载一张 PNG

那它最多只能算一个最基础的 demo。

而更接近真实业务的实现,至少要同时解决这些问题:

  • 二维码 / 条码双模式
  • 多种内容类型
  • 协议化拼装
  • 样式定制
  • Logo
  • 批量生成
  • ZIP 打包下载
  • 输入校验和错误提示

这页真正补齐的,不是“把内容变成一张码图”,而是把业务里的码生成流程补完整。

如果你也在做类似页面,最值得优先抄走的不是某一个库,而是这条链路:

动态表单 → 协议字符串 → 基础生成 → Canvas 二次样式 → 批量导出

这条链路一旦搭顺,后面不管你是补更多二维码协议,还是继续扩展条码格式,都会顺很多。

posted @ 2026-06-09 09:22  疯狂SQL  阅读(21)  评论(0)    收藏  举报