ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React H5 二维码生成与 base64 导出:canvas 适配与踩坑实录

React H5 二维码生成与 base64 导出:canvas 适配与踩坑实录

最近在做一个移动端H5活动页,需要在 React 应用里生成一个二维码,并且要能把它转成 base64 链接,用来展示、保存或者分享。需求听起来很简单:页面上有个二维码,用户点一下保存,图片就存到手机相册。但真动手才发现,这里面的门道其实不少——尤其是"转成 base64 链接"这个动作,背后牵扯到 canvas 绘图、设备像素比、画布污染、移动端兼容性等一系列问题。这篇文章就把我从零到一把这个功能跑通的全过程记录下来,顺带把踩过的坑都列出来,给后面遇到类似需求的朋友一个参考。

我把这个功能拆成了几个关键阶段:先选型生成二维码的方式,再处理 canvas 清晰度问题,接着解决画布污染和导出格式,最后落到移动端真机的保存兼容性上。整个过程在 React 和 H5 环境里反复改了好几轮,最终沉淀出一套相对稳定、可复用的方案,下面按顺序聊。

1. 项目概述与核心思路

1.1 这个需求解决什么问题

这类需求常见于活动营销页、邀请函、电子票、签到系统。页面上有一个二维码,内容可能是某个活动链接或者用户专属标识,用户希望能把二维码连同背景图一起保存到相册,再转发给朋友。这里就有两个关键动作:一是"生成二维码",二是"转成 base64 链接"。

为什么要转 base64,而不是直接把二维码存成文件或引用一个网络图片地址?原因有几层:

  • base64 字符串可以直接放进<img src>、<canvas>甚至接口返回的 JSON 里,不需要额外发起网络请求。
  • 移动端 H5 无法直接通过 JS 触发下载一个远程图片并保存到相册,但可以通过分析 base64 数据或者把 base64 转成 Blob 后再尝试调用系统能力。
  • 需要做"二维码 + 背景图 + 文案"合成海报时,必须先把二维码绘制到 canvas 上,然后导出,而 canvas 最通用的导出结果就是 base64。
  • base64 不受同源策略限制,方便在各种 WebView、小程序 WebView、浏览器里传递。

所以,从这个角度说,"转成 base64"不是临时起意的做法,而是移动端 H5 场景下绕不开的中间形态。

1.2 技术选型:为什么用 canvas,而不是纯 div 或 img

二维码本质是一堆黑白方块,用 div 拼格子当然也能实现,但在移动端会带来几个问题:

  • 生成 33x33 或更高密度的矩阵时,会产生大量 DOM 节点,渲染性能差,页面滚动卡顿。
  • 很难做到静区(quiet zone)的控制,二维码边缘留白不够或者不均匀,导致手机扫不出来。
  • 无法直接导出图片,也不方便和背景图、头像之类的内容合成为一个新的画布。

反过来,使用 canvas 绘制二维码,天然是位图,一帧就能画出来,导出也方便。但它的坑在于 canvas 的"逻辑尺寸"和"屏幕物理尺寸"不一致时,画出来的二维码会模糊,这个后面细讲。

另一种常见方案是直接用现成的二维码库生成 dataURL,再赋给<img>标签。这种方式在"只看不存"的场景下完全够用,但一旦你要在二维码周围合成背景图、头像、文案,对位置和尺寸有精细控制时,还是要把二维码绘到自己的 canvas 画布上,最后统一导出。所以我把整个方案定成:生成二维码(组件或库)→ 绘制到独立 canvas 或合成 canvas → 统一转 base64。

2. 初始实现:React 中快速生成 canvas 二维码并导出 base64

2.1 基于 qrcode.react 组件的最短路径

如果你只是想在页面上放一个二维码,点按钮把它转成 base64 链接,最短的路径是使用 qrcode.react 组件。它支持renderAs="canvas",也就是直接渲染出一个 canvas 节点。

import { useRef, useEffect } from 'react'; import QRCode from 'qrcode.react'; function Demo() { const wrapRef = useRef(null); const handleExport = () => { const canvas = wrapRef.current.querySelector('canvas'); if (!canvas) return; const base64 = canvas.toDataURL('image/png'); console.log('二维码base64:', base64); }; useEffect(() => { // 需要等组件渲染完再操作 canvas setTimeout(() => { const canvas = wrapRef.current?.querySelector('canvas'); console.log('canvas mounted:', !!canvas); }, 0); }, []); return ( <div ref={wrapRef}> <QRCode value="https://example.com/activity?uid=123" size={256} renderAs="canvas" /> </div> ); }

这里有两个容易忽略的细节:

  1. ref 不能直接挂在 QRCode 组件上,因为这不是函数组件(实际上 qrcode.react 内部会渲染真正的 canvas 元素),ref 指向的是包装实例。最稳妥的做法是包一层 div,再用querySelector去找 canvas。
  2. toDataURL必须在 canvas 绘制完成后调用。如果 useEffect 里立刻执行,理论上 canvas 已经渲染出来了,但在某些 React 渲染时序下可能还没挂载。我习惯先用setTimeout(0)或者requestAnimationFrame确保 DOM 和绘制都稳定。

这种方法适合"只要二维码本身"的场景。但它的缺陷也很明显:qrcode.react生成的 canvas 尺寸是组件内部决定的,想插入背景图、更换二维码位置、增加头像,几乎不可能。

2.2 基于 qrcode 库的 canvas 绘制方式

更灵活的方式是使用qrcode这个库(npm 包名就是qrcode),它提供toCanvas和toDataURL等方法。我们可以在一个自己创建的 canvas 上绘制二维码,也可以把生成的二维码先转成 dataURL,再当成图片画到合成画布上。

最直接的用法:

import QRCode from 'qrcode'; const canvas = document.getElementById('qr-canvas'); QRCode.toCanvas(canvas, 'https://example.com/activity?uid=123', { width: 256, margin: 2, errorCorrectionLevel: 'M', }, (err) => { if (err) { console.error(err); return; } const base64 = canvas.toDataURL('image/png'); console.log(base64); });

也可以不指定 canvas,直接用QRCode.toDataURL(url, options)生成一个 base64 字符串:

const dataUrl = await QRCode.toDataURL('https://example.com', { width: 256, margin: 2, color: { dark: '#000000', light: '#ffffff', }, });

这种方式做"简单展示"更快,甚至不需要碰 canvas。但为了后续海报合成,我建议核心走toCanvas或者先生成 dataURL 再绘制。

2.3 这两种方式的差别与适用场景

我把两种方式列了一个对比,方便按需选择:

方式优点缺点适用场景
qrcode.react(renderAs=canvas)声明式,代码简单,React 友好定制能力弱,改 canvas 尺寸、颜色不方便,导出时要靠 ref 查找元素页面直接展示二维码,点击导出单张二维码图片
qrcode 库(toCanvas)完全掌控 canvas 节点,可自定义绘制细节,支持回调需要自己管理 canvas 生命周期,不是 React 组件,需要手动挂载需要合成海报、添加背景、程序化控制绘制顺序
qrcode 库(toDataURL)直接得到字符串,不用碰 canvas拿不到中间绘制过程,无法在二维码外围叠其他内容只需要二维码图片 URL,或作为中间图片再次绘制

我实际项目里最终采用的是"混合策略":先用qrcode库的toDataURL生成一个二维码 base64,再把它放进Image对象,最后 drawImage 到合成海报的 canvas 上。这样二维码的质量由库保证,合成画布的布局由自己完全控制,两边不容易打架。

3. 移动端适配:让 canvas 画出的二维码既不糊又能导出

3.1 设备像素比(DPR)与 canvas 模糊原理

移动端的坑,第一个就是模糊。为什么在电脑上看着很清晰的二维码,放到手机上就发虚?这要从 canvas 的尺寸机制说起。

canvas 有两个尺寸:

  • canvas.width和canvas.height:画布的实际像素尺寸,决定 canvas 内部能容纳多少像素。
  • canvas.style.width和canvas.style.height:元素在页面布局中的 CSS 尺寸,决定元素占多大视觉空间。

在手机屏幕上,一个逻辑像素往往对应多个物理像素。比如 iPhone 12 的屏幕 DPR 是 3,也就是 CSS 上 1px 的宽度,实际上需要 3x3 个物理像素来呈现。如果我们只把 canvas 的 width 设置为 256,CSS 尺寸也是 256px,那么在 DPR=3 的屏幕上,浏览器会把 256 个物理像素拉伸到 768 个物理像素来显示,每一颗粒子被放大 3 倍,自然就糊了。

正确的处理方式是:让 canvas 的物理像素 = CSS 逻辑尺寸 × DPR,然后再用ctx.scale(dpr, dpr)把所有绘制操作的坐标系统一除以 dpr,这样绘制内容就能以清晰的物理像素呈现。

3.2 合理设置 canvas 尺寸与 DPR 缩放

如果用的是 qrcode 库直接画到 canvas,可以这样调整:

const canvas = document.getElementById('qr-canvas'); const dpr = window.devicePixelRatio || 1; const displaySize = 200; // CSS 显示尺寸 canvas.width = displaySize * dpr; canvas.height = displaySize * dpr; canvas.style.width = displaySize + 'px'; canvas.style.height = displaySize + 'px'; QRCode.toCanvas(canvas, 'https://example.com', { width: displaySize * dpr, // 库内部按照这个尺寸绘制 margin: 2, }).catch((err) => console.error(err));

这里其实有个坑:如果你想用ctx.scale(dpr, dpr),再让库绘制,顺序可能会乱。因为 qrcode 库内部会自己拿 canvas 的 2d context 并设置尺寸,如果你提前 scale,再调用 toCanvas,库不会感知这个 scale,画出来的内容会被二次缩放,反而乱套。

所以如果你用toCanvas画单张二维码,更简单的方法是直接把物理像素尺寸传给库,不再额外ctx.scale。真正需要ctx.scale的场景是自己手动合成海报时,因为我们的坐标设计稿往往是按逻辑像素(如 375 或 750)来写的,drawImage 和 fillText 的坐标希望按逻辑单位传递,这时候才用 scale 来一次性放大。

举个例子,假设你想生成一张宽 750、高 1200 的海报,显示器上按 DPR=2 输出:

const canvas = document.createElement('canvas'); const dpr = window.devicePixelRatio || 2; canvas.width = 750 * dpr; canvas.height = 1200 * dpr; canvas.style.width = '750px'; canvas.style.height = '1200px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); // 之后的 fillRect、drawImage 都按 750x1200 的逻辑尺寸来

这样写,逻辑层的坐标不用变,又保证了输出图片是 1500x2400 的高清图。

3.3 二维码静区(quiet zone)与可扫描性

二维码周围一定要留白,专业上叫 "quiet zone",通常要求至少等于二维码 4 个模块(module)的宽度。很多二维码生成库的margin参数可以控制,但要注意,margin 的单位是"模块",不是像素。比如 qrcode 库的margin: 2表示留白宽度是当前二维码模块大小的 2 倍。

我实际测试中发现,如果二维码是纯色背景、边缘就是画布边缘,没有额外留白,手机扫起来偶尔会失败,尤其是白光较强的环境下。所以合成海报时,我通常会在二维码图标四周围一个白色圆角矩形,宽度比二维码本身多出 16~20 个逻辑像素,等于人为手动加了一个静区。这也是为什么不能用 div 拼二维码的原因之一——静区控制不稳定。

4. 进阶:海报合成场景下 canvas 转 base64 的完整流程

4.1 把二维码绘制到背景图上的标准步骤

当功能升级为"生成含二维码的海报"时,就不仅仅是简单导出二维码了,而是要把二维码画到一个指定尺寸的 canvas 上,并与背景图、文案、头像等元素合成。

我在项目里总结了一套稳定的流程,大致这样:

async function generatePoster(config) { const dpr = window.devicePixelRatio || 2; const width = config.width || 375; const height = config.height || 600; // 1. 创建 canvas const canvas = document.createElement('canvas'); canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); // 2. 画背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); // 3. 加载背景图 const bgImg = await loadImage(config.bgImageUrl); ctx.drawImage(bgImg, 0, 0, width, height); // 4. 生成二维码 dataURL const qrDataUrl = await QRCode.toDataURL(config.qrText, { width: 200, margin: 2, color: { dark: '#1d1d1d', light: '#ffffff' }, }); // 5. 把二维码 dataURL 转成 Image const qrImg = await loadImage(qrDataUrl); // 6. 按位置绘制二维码 const qrX = (width - 200) / 2; const qrY = 250; ctx.drawImage(qrImg, qrX, qrY, 200, 200); // 7. 导出 base64 return canvas.toDataURL('image/png'); } function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); }

这里有三个关键点:

  1. 背景图加载完成后才绘制二维码,不能并行直接 drawImage,否则背景或二维码可能没加载完,画出来是空的。
  2. 二维码 dataURL 本身是一个 base64 图片,它不涉及跨域,所以直接 new Image 然后赋值 src 再 drawImage 是安全的,不会污染 canvas。
  3. 画背景之前先填充白色。如果你后面要导出为 JPEG,这一步尤为重要,否则透明区域会变成黑色。

4.2 处理远程图片跨域污染画布

如果你直接把一张来自 CDN 的背景图丢进 canvas 并调用 toDataURL,很可能会报SecurityError。原因是浏览器认为 canvas 被跨域内容"污染"了,出于安全考虑不允许导出。

解决办法是给<img>设置crossOrigin属性,并且服务端必须返回Access-Control-Allow-Origin响应头。注意:

const img = new Image(); img.crossOrigin = 'anonymous'; img.src = 'https://cdn.example.com/bg.jpg';

如果服务端不支持 CORS,那么即便设置了 crossOrigin,图片加载也会失败。这种情况下有两个备选方案:

  • 把远端图片先通过接口转成 base64 再传回前端,用自己的数据源就不存在跨域问题。
  • 使用 canvas 的getImageData前不能污染,如果只是展示不需要导出,可以重新创建 canvas,但不导出。

我这里遇到过一个典型场景:背景图是团队运营配置的海报设计稿,放在对象存储上,虽然支持 CORS,但在部分 Android WebView 里行为不一致,反复排查后最终让服务端加了一个图片代理接口,统一把图片二进制转成 base64 返回,问题彻底解决。

4.3 导出格式选择:PNG、JPEG,还是 Blob

canvas.toDataURL(type, quality)有两个参数,第一是图片格式,第二是质量系数(JPEG 用)。实际使用中要仔细选:

  • image/png:无损,二维码的边界锐利,扫描稳定性最高,但体积大。一张 750x1200 的 PNG 可能 500KB~1MB,若后续要传给后端或放到缓存里,URL 会又长又占内存。
  • image/jpeg:体积小很多,但压缩到质量 0.9 时,二维码的边缘可能出现轻微模糊,虽然一般不影响扫描,但如果是纯色二维码,中间白色区域还容易产生杂色。如果转 JPEG,一定要先填充白色背景,否则透明区域会变成黑色。
  • image/webp:部分浏览器支持,体积更小,但在老版 iOS 上支持不佳,不推荐。
  • canvas.toBlob(callback, type, quality):不是 base64,但可以通过URL.createObjectURL生成临时链接,直接用于<img>显示或下载,免去字符串拷贝。如果最终需要 base64,再用FileReader.readAsDataURL转换。

我的建议是:对外导出优先用 PNG,除非体积压力非常大。二维码属于读取容错率很敏感的图形,PNG 是最保险的。如果前端需要把图片传给后端,可以先用canvas.toBlob拿到 Blob,再通过 FormData 上传,避免把一个超长 base64 字符串塞进 JSON。

顺便提一句,canvas.toDataURL默认导出 PNG,质量参数对 PNG 无效,只有 JPEG 才需要传第二个参数。

4.4 导出图片保存到相册的移动端方案

拿到 base64 之后,真正"保存到相册"在移动端 H5 里并不统一。我这里总结了三条路:

  1. 浏览器内下载:用<a>标签,设置href为 base64 或 objectURL,download为文件名,并模拟点击。Android Chrome 通常可以直接下载,但 iOS Safari 对 base64 链接不太支持,经常点击无反应。
  2. 长按图片保存:把 base64 放在一个<img>标签里,提示用户长按图片,系统会弹出保存图片。这是移动端 H5 最通用的办法,几乎不用写代码,但交互不够直接。
  3. 调用微信 JSSDK:在微信内打开页面时,可以用微信 JSSDK 的updateAppMessageShareData或自定义菜单来分享海报,但需要前端主动调起 wx.config 配置签名,依赖后端接口。

我实际项目中,主要用第二种,也就是把生成的 base64 放在一个全屏遮罩里展示,提示用户长按保存。这种方式在 iOS 和 Android 的微信、浏览器里都稳定,不需要额外授权。

5. 常见问题与排查实录

5.1 toDataURL 报 SecurityError,画布被污染怎么办

如果你在使用canvas.toDataURL()时抛出了SecurityError: The operation is insecure,基本可以判定画布被跨域内容污染了。

排查步骤:

  1. 检查是否绘制了远程图片,如果有,给该图片预加载时设置crossOrigin = 'anonymous'。
  2. 查看服务端响应头是否包含Access-Control-Allow-Origin,如果缺少,需要让服务端配合处理。
  3. 排除再绘制二维码图片的情况。二维码 dataURL 是本地生成的 base64,不跨域,不受影响。
  4. 检查 WebView 环境是否禁用了 CORS,某些第三方 App 内置 WebView 对跨域资源限制更严格,这时只能走图片代理接口。

我开发时为了快速定位,会在绘制每一步后都调用一次canvas.getImageData(0, 0, 1, 1),如果某一步之后抛出异常,就说明是刚绘制的内容导致污染。这个方法对排除嫌疑很有用。

5.2 导出的 base64 是空白或全黑

这种情况通常有两类原因:

  • canvas 尺寸为 0。如果 canvas 元素在隐藏状态下创建,且设置了display: none,浏览器可能不会给它分配实际的绘图缓冲区,width和height虽然设置了,但绘制内容不会生效。解决办法是让 canvas 处于"视觉隐藏"但"能布局"的状态,比如用position: absolute; left: -9999px; top: 0;,而不是display: none。
  • 绘制顺序错误。比如背景图还没加载就执行 drawImage,或者 drawImage 的时候原图对象还没有naturalWidth,导致绘制空内容。我用loadImage包装成 Promise 后,就能从代码逻辑上规避。

另外,如果你导出了 JPEG 格式但忘记填充白色底,二维码区域是白色的,但透明区域会被填充为黑色,整个图片看起来就是黑色底,二维码几乎看不见。这是我在项目里踩过的一个低级坑,当时一看到黑色底整个人愣了三秒,才反应过来是透明通道的问题。

5.3 iOS 下 canvas 过大导致内存问题

iOS 对 canvas 的尺寸和内存有自己的限制。我在一台旧 iPhone 上测试一张 2048x2048 的海报,导出时 App 直接闪退,后来把 canvas 宽高降到 1200x1600 才稳定。

经验数值是:canvas 总像素不要超过 iPhone 最大纹理限制的一半。常见做法是控制在 1500x2100 左右,如果屏幕要求更高分辨率,可以输出 2 倍 DPR 但画布逻辑尺寸不要超过 750x1200。如果海报本身很长,宁可分段绘制,也不要造一个超大的 canvas。

另外,导出前如果内存已经紧张,可以手动释放一些正在使用的中间 Image 对象,比如把img.src = ''或者img = null,给 GC 一点机会。

5.4 qrcode.react 在新版 React 中的 ref 与 effect 执行时机

用 qrcode.react 时,如果你把 ref 直接挂到组件上,拿到的不是 canvas,而是组件实例或者 null。这在 React 18 里尤其容易踩,因为 StrictMode 下 effect 会执行两次,如果第一次就调用toDataURL可能拿不到 canvas。我建议统一用外层容器 ref +querySelector('canvas'),并且在真正用户点击导出时再去获取,不要在初始化时急着取 canvas 节点。

5.5 常见问题速查表

异常现象可能原因解决方案
导出报 SecurityError远程图片污染了 canvas给 image 加 crossOrigin,服务端加 CORS 头,或改用代理接口
二维码模糊canvas 物理像素小于逻辑像素按 DPR 设置 canvas.width/height,并用 ctx.scale 绘制
导出空白canvas 被 display:none 隐藏用绝对定位移出屏幕,而非隐藏
导出全黑底导出 JPEG 时透明区域变黑绘制前用 fillRect 填充白色背景
iOS 闪退canvas 尺寸过大、内存不足缩小画布尺寸,控制总像素
保存无反应系统不支持 base64展示 img 让用户长按保存
二维码扫不出来留白不够、颜色对比度低增加静区,使用深色前景浅色底
背景图加载不出来crossOrigin 设置导致图片请求失败检查服务端 CORS 或改用同源代理

6. 最后一点个人经验

整个功能做下来,我发现真正的难点不在于生成二维码,而在于 canvas 的尺寸、跨域和下载兼容性。用一句话概括经验:先确认导出格式,再控制 canvas 尺寸,最后处理资源加载时序,这个顺序不能乱。

以我自己的项目为例,最初直接在所有资源加载完成后同步画布,结果 iOS 上偶尔空白;后来全部改成 async/await,确保背景图、二维码 dataURL、头像全部加载完成后再 drawImage,问题就消失了。另外,如果只是给用户展示二维码,其实可以偷懒直接用 qrcode.react 渲染 DOM 方式,不需要碰 canvas,但一旦涉及海报、保存、分享,就必须走 canvas 这一套流程。

如果你也在做类似的东西,建议先用一个公共函数把所有绘制逻辑包起来,比如generatePosterBase64(config),方便后续在 H5、App WebView、甚至小程序 web-view 里复用。自己封装一次,后面再遇到类似需求就能节省不少时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进