这需求我太熟悉了。做小程序电商、线下点餐、活动票务的同学基本都会碰到同一个产品需求用户在小程序里下了单但付款方式要从“小程序内直接调起支付”变成“生成一个二维码让用户自己拿去扫”。最常见的场景就是线下扫码购、面对面收款、活动签到付费甚至有些人会想在小程序里生成一个聚合收款码发给客户扫完付款。我最初接手这个需求时也踩了不少坑尤其是二维码在微信小程序里的生成方式和普通H5完全不同不能直接套用 qrcode.js 的 DOM 方案。这篇就把我做小程序付款转二维码付款的完整思路、代码实现和踩坑记录整理出来给同样遇到这个需求的朋友一份可以直接上手的参考。1. 先搞明白付款转二维码到底解决的是什么问题1.1 两种典型业务场景别一上来就写代码我接到这个需求之后第一件事不是开IDE而是找产品确认场景。因为“付款转二维码”这句话在不同业务里落地形态差得非常远。第一种是“订单生成二维码别人扫了替他付款”的转赠/代付场景。用户自己选好商品、生成订单但不想或者不能自己付款系统把一个订单标识编码成二维码他把二维码发给朋友朋友微信扫一扫跳转到付款确认页完成支付。这种场景核心是把“订单信息”转成二维码。第二种是“收银台被扫”场景。用户到店消费完在商家的小程序里核对账单后屏幕展示一个二维码用户用自己的微信扫码弹出的其实是“向商家付款”的确认页或者商家关联的收款账户扫码的人完成付款。这种场景核心是把“收款身份/支付凭据”转成二维码。还有一种比较边缘但很常见的情况——你的小程序本身没有微信支付资质或者只是个人开发者的工具型小程序不能直接调起支付。你要做的其实是把一笔订单标记成“待线下支付”然后生成一个二维码包含订单号、金额、收款方信息由另一个有支付能力的主体比如商户App、服务号H5去承接实际付款流程。这种情况下你生成二维码的目的就是“跨端传递支付上下文”。搞清楚场景再设计技术方案否则你写出来的二维码很可能是一个“看起来能扫、扫完没反应”的废码。1.2 为什么不能直接把小程序码当付款码用很多非技术人员会问小程序不是自带“小程序码”吗把那个码发给用户扫不就行了原理上可以但实际行不通。小程序码扫完之后进的是小程序页面它需要用户先登录、再找到那笔订单、再手动支付链路太长。而且如果你需要的是“指定订单、指定金额、一次性有效”的动态支付码小程序码根本做不到。小程序码只是标识小程序页面路径它不携带订单状态、金额限制、过期时间这些动态数据的语义。所以付款转二维码本质要做的是用二维码承载一个“统一支付链接”或者“支付参数串”这个链接可以是微信支付Native支付的 code_url也可以是你自己服务端生成的短链扫码后跳到支付确认页。做技术方案之前先把这个逻辑捋清楚后面才不会做偏。1.3 核心链路设计小程序、服务端、扫码端三方协作我在动手前画了一条完整链路这里不贴图用文字描述小程序端确认订单后请求服务端创建支付单服务端调用微信支付下单接口拿到支付链接或者支付参数服务端返回一个该订单唯一关联的支付token小程序拿到token后生成二维码扫码端使用微信扫一扫微信解析二维码跳转到支付链接支付完成后微信支付回调服务端小程序通过轮询或者微信支付结果通知查询订单状态刷新页面。这里最关键的一点是二维码里到底装什么。我建议不要直接把完整支付链接塞进二维码而是通过服务端把订单信息编码成短token然后拼成一条短链。原因有三个二维码容量有限链接太长会降低二维码密度导致扫码识别率下降完整支付链接可能包含签名参数直接暴露在二维码里有安全风险短链便于后端在扫码跳转时做统计、风控和订单状态校验。2. 小程序里生成二维码的技术选型为什么我最终选了 canvas 2d2.1 H5 的 qrcode.js 方案在微信小程序里为什么直接报废在普通网页里生成二维码最常用的做法是引入 qrcodejs新建一个 div再调用库把二维码画成 canvas 或者直接输出图片标签。但是在微信小程序里这套东西完全不适用。小程序没有 DOM 节点概念qrcodejs 依赖 window 和 document 来创建 canvas 元素在小程序环境里这两个对象都不存在运行到一半就会报错。另一个思路是用后端生成二维码图片返回给前端展示。这个方案我早期也用过稳定但有两个痛点一是每张二维码都要走一次网络请求弱网环境下图片加载慢影响用户付款体验二是后端生成图片需要考虑存储和过期清理加了一堆运维成本。如果你的列表页或弹窗里要同时展示很多二维码这个方案会非常吃力。所以最终方向锁定为小程序前端本地生成二维码。2.2 本地生成方案横向对比我在实测阶段对比过三类前端生成方案纯JS 移植库、第三方 UI 组件、原生 canvas 绘制。纯JS移植库里最知名的是 weapp-qrcode这个库专门为小程序环境做了适配去掉了对 DOM 的依赖直接把二维码绘制逻辑改写成小程序 canvas API。它的优点是轻量不需要额外 UI 框架也不依赖服务端。缺点是它最初适配的是旧版 canvas 接口新版 canvas 2D 接口需要一些额外的初始化处理这个我后面详细讲。第三方 UI 组件比如 tki-qrcode优点是封装完整插件市场直接安装就能用组件化调用代码量最少。缺点是你为了一个二维码功能引入一个没有长期维护的第三方组件一旦基础库升级可能被动背锅我试过其中一个组件在 iPhone 14 Pro 上画出来的二维码密度异常排查了半天才发现是组件内部还在用已经废弃的旧版 canvas API。原生 canvas 绘制方案最可控性能也最好但需要你自己实现二维码编码算法包括数据编码、纠错码生成、矩阵排列、掩码处理这一整套东西工作量非常大。除非你是想彻底掌握二维码原理或者有其他特殊需求否则我不建议从零造轮子。我的结论是用 weapp-qrcode 作为核心绘制库手动配合新版 canvas 2D 接口初始化。既能拿到本地生成的优势又不需要为团队引入维护风险大的第三方组件。2.3 新旧 canvas 接口的兼容问题这里必须单独说一截因为这个坑几乎让所有人翻车。小程序早期提供的 canvas 接口是 wx.createCanvasContext导出图片用 wx.canvasToTempFilePath这套接口已经在基础库 2.9.0 开始被官方标记为“不推荐使用”新项目建议使用 canvas 2D 接口。为何这个切换影响很大因为 weapp-qrcode 的老版本只支持旧接口而很多网上的教程也还停留在旧接口时代。如果你照葫芦画瓢把旧代码粘贴到新项目里懒加载模式下会出现画布空白、画完就消失等诡异问题。我在项目里统一做了封装底层使用新版 canvas 2D 接口通过 wx.createSelectorQuery 获取 canvas 节点然后调用 node.getContext(2d) 拿到 2D 渲染上下文再传给 weapp-qrcode 内部的绘制逻辑。后面给的代码里我会完整展示这套封装拿去就能直接用。3. 完整实操从支付参数到付款二维码落地3.1 服务端返回什么数据前端要拿什么先说服务端要做什么。小程序点击“生成付款二维码”后请求服务端接口服务端逻辑大概是校验用户身份和订单归属核对订单未支付且未过期调用微信支付下单接口申请预支付交易单拿到微信返回的 code_url 参数Native 支付的核心字段这个 code_url 就是扫码后直接拉起支付确认页的链接把 code_url 和你自己的订单 token 绑定返回给小程序。前端真正需要的其实就是一个字符串要么是完整的 code_url要么是你自己拼接的支付跳转短链。我比较推荐后者服务端把订单号、金额、收款方ID 编码成一个 token然后生成 https://你的域名/pay/{token} 这样的短链返回给前端。二维码内容就算被别人拿到他也只能在这个短链里做有限操作核心支付数据不会直接暴露。还有一个容易被忽略的点服务端要给每笔订单设置二维码过期时间。微信支付 Native 支付的 code_url 有效期一般是2小时如果你自己拼短链也要在服务端维护过期时间过期后扫码跳转提示“订单已失效”。前端拿到这个有效期后要在二维码下方展示倒计时时间到了自动销毁二维码并刷新。3.2 前端核心代码实现可直接复制改造下面这段是我封装好的二维码生成组件核心逻辑适配新版 canvas 2D 接口使用时传入一个 DOM id 和二维码内容字符串即可。// utils/qrcode.js import QRCode from ./weapp-qrcode; function drawQrcode(option) { const { canvasId, text, size, success } option; // 新版canvas通过SelectorQuery获取节点 const query wx.createSelectorQuery(); query.select(# canvasId).fields({ node: true, size: true }) .exec((res) { const canvas res[0].node; const ctx canvas.getContext(2d); const dpr wx.getSystemInfoSync().pixelRatio; // 设置canvas的实际渲染尺寸避免在高清屏上出现模糊 canvas.width size * dpr; canvas.height size * dpr; ctx.scale(dpr, dpr); // 调用weapp-qrcode绘制二维码 QRCode({ ctx, text, width: size, height: size, correctLevel: QRCode.CorrectLevel.M, // 可以加logo模块我这里先跳过 callback(qrcode) { if (success) success(qrcode); } }); }); } export default drawQrcode;画布组件侧写法注意新版 canvas 需要设置 type2d 和 id 属性不能用旧版的 canvas-id。样式尺寸直接用 css 控制但内部绘制尺寸要用上面的代码乘以 dpr 做适配否则会模糊。view classqrcode-box canvas type2d idpayQrcode stylewidth: 400rpx; height: 400rpx;/canvas /view小程序部分调用import drawQrcode from ../../utils/qrcode; Page({ onLoad(query) { const orderNo query.orderNo; this.generatePayQrcode(orderNo); }, generatePayQrcode(orderNo) { wx.showLoading({ title: 生成中 }); wx.request({ url: https://api.example.com/pay/qrcode, data: { orderNo }, success: (res) { const { payUrl, expireAt } res.data; wx.hideLoading(); // 画二维码 drawQrcode({ canvasId: payQrcode, text: payUrl, size: 160, // 绘制区域逻辑像素 success: () { this.startCountdown(expireAt); this.startPolling(orderNo); } }); } }); } });这里有个细节size 参数我传的是 160这是 canvas 内部的逻辑尺寸和400rpx样式尺寸是两回事。内部逻辑尺寸决定了二维码矩阵的渲染精度过小会导致二维码太密扫不出来过大浪费性能实测常规下单场景 160-200 足够。3.3 二维码弹窗展示的交互细节支付二维码一般不会直接放在页面上而是通过弹窗展示用户完成支付后自动关闭。弹窗组件我用的是自定义半透明遮罩加居中卡片这有一个好处弹窗里放 canvas 画布不会有层级问题。千万别用 wx.showModal 做二维码弹窗它不支持自定义内容也不要直接在 scroll-view 里放 canvas 画长页canvas 天然是原生组件层级最高滚动过程中会出现悬浮和撕裂问题。虽然新版 canvas 2D 不再是原生组件它走同层渲染但在 iOS 的 web-view 里仍然会遇到兼容问题。弹窗显示之后要在底部放两个按钮一个是“保存二维码/分享”一个是“刷新二维码”。保存功能我后文专门讲实现。刷新按钮是为了配合过期逻辑用户超时未支付可以主动换一个新码。3.4 订单状态同步轮询的正确写法二维码展示出来后小程序端需要持续监听支付结果。做移动端支付最稳妥的方案就是后端收到微信支付异步通知后小程序再通过轮询订单状态接口拿到结果。轮询不能用 setInterval因为 setInterval 即使在上一次请求还没返回时也会继续触发定时器出现请求堆积。我比较推荐用 setTimeout 递归startPolling(orderNo) { if (this._polling) return; this._polling true; const poll () { wx.request({ url: https://api.example.com/order/status, data: { orderNo }, success: (res) { const { status } res.data; if (status PAID) { this._polling false; wx.showToast({ title: 支付成功 }); setTimeout(() { this.closeQrcodeModal(); wx.redirectTo({ url: /pages/order/detail?orderNo orderNo }); }, 1000); return; } if (status CLOSED) { this._polling false; wx.showModal({ title: 提示, content: 订单已关闭 }); return; } // 未支付继续轮询间隔2秒 setTimeout(poll, 2000); }, fail: () { // 网络错误继续轮询间隔放长 setTimeout(poll, 4000); } }); }; poll(); }轮询结束后一定记得在页面 onUnload 和 onHide 里清理标志位否则从支付结果回调返回页面时会重复开启新的轮询。4. 踩坑复盘每个坑都是钱买来的经验4.1 canvas 画出来是空白或者是模糊的马赛克这个现象在新旧接口混用时代非常常见。我排查这类问题有一个固定套路先看开发者工具是否有报错确保 weapp-qrcode 引入路径正确检查 canvas 节点是否成功获取在 exec 回调里打印 canvas 对象是否存在然后确认 canvas 的 width/height 是否被正确设置为大于0的值最后检查页面的 canvas 是否渲染完成。如果 canvas 放在弹窗里而弹窗初始状态是 display:noneSelectorQuery 获取节点时可能拿到的是空节点。弹窗里的 canvas 必须在弹窗显示后再绘制不能在 wx:if 控制显示的同时立刻去拿节点。我的处理方式是弹窗显示动画结束后触发一个事件再在这个事件回调里调用生成二维码函数。模糊问题更直接就是没有设置 canvas.width 为 css 尺寸乘 dpr。在 iPhone 上 dpr 是3如果不乘3画出来的二维码在物理屏幕上就只有逻辑尺寸三分之一的大小看起来就是糊的。4.2 二维码生成出来了但总是扫不出来大部分情况是二维码的纠错级别设置太低或者尺寸太小。weapp-qrcode 支持 L、M、Q、H 四个级别级别越高容错能力越强但图案越密集。我实测在手机扫码场景M 级别最均衡。尺寸方面canvas 内部绘制尺寸低于 120 像素时图案密集度会明显上升用微信扫一扫在弱光或者纸张有纹理的情况下识别率很低。还有一个高频问题二维码内容里包含中文字符或者特殊字符。二维码对UTF-8编码的中文支持没问题但某些老二维码生成库默认使用 ISO-8859-1 编码中文就会变成乱码扫码后跳转链接直接 break。所以我建议二维码内容固定为 ASCII 字符集的短链从源头规避。4.3 用户长按保存二维码发现保存的是空白图这是最容易让测试同学崩溃的一个问题。小程序里的 canvas 不是普通图片用户长按弹出来的菜单里没有“保存图片”选项只能通过代码调用 wx.canvasToTempFilePath 把画布导出成临时图片再交给用户保存或者转发。这里有一个隐患新版 canvas 2D 接口下wx.canvasToTempFilePath 需要传 canvas 对象而不是 canvasId。老接口传 canvasId 就能用新接口传了 canvasId 反而导出空白。正确做法是导出前先通过 SelectorQuery 拿到 canvas 节点对象再传给 canvasToTempFilePath 的 canvas 参数。exportQrcodeImage() { const query wx.createSelectorQuery(); query.select(#payQrcode).fields({ node: true }) .exec((res) { wx.canvasToTempFilePath({ canvas: res[0].node, success: (r) { wx.saveImageToPhotosAlbum({ filePath: r.tempFilePath, success: () wx.showToast({ title: 保存成功 }) }); } }); }); }4.4 轮询导致服务端接口被刷爆上线第一天我就发现下单量涨上去之后服务器的支付状态查询接口 QPS 飙升。排查发现是多个页面同时开启了轮询同一个用户开了两个订单页每个页面都在每2秒请求一次订单状态接口稀疏到极值时叠加了支付回调统计服务端压力剧增。解决思路有三层页面级控制同一个页面在 onShow 时检查 _polling 标志位正在轮询就不重复发起订单级节流服务端对同一订单的查询接口做 3 秒缓存前端频繁查也只回一个响应环境变量控制小程序切后台时用 wx.onHide 暂停轮询回到前台再恢复毕竟用户切到微信扫码后如果一直轮询订单状态接口会白白跑很多无效请求。4.5 已支付成功后二维码仍然能扫出来这是业务上的严重漏洞。有些用户支付成功后没有立即关闭弹窗或者支付成功后返回订单页这个时候二维码还在如果被旁边的朋友扫走微信会提示该订单已支付无法重复支付。虽然是安全兜底但体验很差。我在发二维码时会在服务端记录一个“已支付”状态前端轮询到支付成功后不要立刻销毁二维码而是先把二维码内容变成一个“核销完成”的占位图或者直接调用支付结果页遮挡。我这里直接在弹窗里加了遮罩层显示“已完成支付请勿重复扫码”。4.6 不同手机屏幕上的显示问题canvas 画布尺寸我一开始写死 400rpx但部分安卓机型上 400rpx 对应的像素尺寸小于 canvas 内部逻辑尺寸导致二维码被裁剪。这个问题是在做兼容测试时发现的。后来我统一用 CSS 的 flex 布局控制画布外层容器宽度让 canvas 的 css 尺寸跟随容器自适应而不是写死 rpx 值。CSS 画布宽度设为容器宽度的百分比内部尺寸通过 getBoundingClientRect 动态获取。drawQrcode({ canvasId: payQrcode, text: payUrl, size: Math.floor(containerWidth * dpr), ... });5. 扩展二维码付款的更多玩法5.1 动态刷新二维码如果业务要求每笔订单金额会变或者同一个码只能使用一次可以做成动态二维码每次点击刷新按钮服务端重新生成一个支付 token旧 token 立即失效。核心是让服务端支持 token 的撤销接口前端刷新时先调用撤销接口再生成新的支付码。5.2 带 Logo 的专属收款码很多商家希望二维码中间放自己的店铺 Logo。weapp-qrcode 画完二维码之后可以在 canvas 中心再绘制一个图片或文字。注意 logo 区域大小不能超过二维码整个矩阵的 1/4否则会遮挡纠错区域导致扫码失败。我一般取总尺寸的 1/5 作为 logo 宽度。5.3 二维码内容支持多种支付渠道如果扫码用户可能没有微信支付只支持支付宝可以考虑二维码内容链接到一个 H5 中转页在这个页面解析浏览器 UA 后自动跳转到对应的支付渠道。这种方案在技术上有点绕需要处理好不同浏览器的支付唤起限制但业务兼容性确实强很多。这个方向我还在尝试等稳定了再单独写一篇分享。回到付款转二维码这件事本身我最大的感受是需求听起来简单真正落地时细节比想象多得多。好在核心方案是成熟的你只需要把 canvas 2D 的适配、二维码内容规范、轮询逻辑这三点做扎实这个功能就能跑得又稳又顺。如果照着这篇做完还有卡壳的地方大概率是 canvas 新老接口或者弹窗渲染时序的问题可以按我前面给的排查顺序从前往后捋一遍应该能找到问题。