之前在群里碰到个挺典型的提问:产品说“前端做个扫一扫功能,把二维码扫出来就行”。开发一听,第一反应是打开摄像头、接二维码识别库。但真放到微信生态里,事情完全不是这么回事——在微信内置浏览器里,你既不能随便调用摄像头权限,也没法保证普通二维码识别库的精度。真正的“微信扫一扫”,指的是通过微信官方JS-SDK提供的wx.scanQRCode接口,把微信原生扫一扫界面直接调起来。
这篇文章就把这个流程从头到尾拆一遍:从账号资质、域名配置,到前后端签名链路的每一步运算,再到实际调用和常见坑。不管你是第一次接这个能力,还是之前被签名问题折磨过,按这个思路走一遍基本能通。
1. 网页里的“扫一扫”和微信自带扫一扫是两码事
1.1 为什么不能直接调摄像头做扫码
先纠正一个常见误区:H5页面里“扫码”不等于“打开摄像头识别”。
浏览器确实提供了getUserMedia接口,配合jsQR、zxing-js这类二维码识别库,理论上可以在网页里实现扫码。但你真在微信里试一把就会发现问题很多:
- 微信内置浏览器(iOS是WKWebView,Android是X5内核)对
getUserMedia的支持和权限策略不统一,有些版本直接拿不到摄像头流。 - 识别库对光线、遮挡、二维码畸变敏感,暗光环境下识别率惨不忍睹。
- 摄像头权限弹窗是浏览器层面的,不是微信原生的,用户很容易产生疑虑,体验非常割裂。
- 即便识别出二维码,还要自己处理结果格式(文本、URL、Wi-Fi、联系人等),规则写不完整就翻车。
也就是说,纯前端方案的体验、兼容性、识别率三重短板,在生产环境里很难让人放心。做演示Demo玩玩可以,真上线做业务,大概率被测试和用户打回来。
1.2 微信扫一扫的本质:JS-SDK的能力之一
微信提供的扫一扫接口,本质不是“拍照识别”,而是直接调用微信原生扫一扫界面。也就是说,用户点按钮后,屏幕上出现的是微信App自带的那个扫码框,识别的速度、畸变校正、暗光补光都是微信自身的能力,这一层的体验和稳定性接近满分。
网页端要做的事情只有一个:通过JS-SDK把原生扫一扫叫起来,然后在回调里拿结果。
从接口归属上看,wx.scanQRCode属于微信JS-SDK的“设备信息/能力接口”那一类,和wx.getLocation、wx.chooseImage类似,都是前端页面借助微信客户端能力来完成的。前置条件也一样,必须先经过wx.config完成签名校验,让微信确认“这个网页有权限调用这些原生能力”。
所以整条链路的重点其实分两块:怎么让微信信任你的页面(签名)和怎么正确调用并处理结果(前端逻辑)。很多人只盯着第二个问题,结果卡死在第一个问题上一整天,非常典型。
2. 上线前的硬性条件:公众号认证与域名配置
2.1 账号资质:不是随便拿个公众号就能调
这是最容易被忽略、但最先卡人的环节。
JS-SDK的扫一扫能力,前提是你的公众号已经通过微信认证。按实际经验,最好直接使用已完成微信认证的服务号,别在认证订阅号上赌运气。我在项目里遇到过认证订阅号后台能配上JS安全域名,但真机调用时wx.config校验失败、扫码弹不起来的情况,换到服务号环境后一切正常。虽然微信官方文档的表述比较模糊,但开发阶段如果碰到这种“配置全对就是不行”的诡异问题,先怀疑账号类型是值得的。
如果只是开发联调,可以用微信公众平台的“接口测试号”,申请速度很快,功能也基本都有,适合先跑通流程。但测试号不能用于生产环境,上线前还是要换正式账号,并且重新走一遍签名配置。
另外,如果你是在企业微信环境里做扫码,还需要区分是自建应用还是第三方应用。自建应用走wx.config,第三方应用通常需要走wx.agentConfig,两者签名流程和配置入口不一样,别拿公众平台的配置经验直接套。
2.2 JS接口安全域名:配置了不代表配置对了
拿到有权限的公众号之后,下一步是登录微信公众平台,在“设置与开发 - 公众号设置 - 功能设置”中找到“JS接口安全域名”,填入你实际部署页面的域名。
几个细节非常容易踩坑:
- 填域名时不要带协议(
https://不行),也不要带端口号,就是裸域名,比如example.com或shop.example.com。 - 每个域名需要下载官方校验文件
MP_verify_xxxx.txt,放到该域名的根目录下,并确保通过HTTP/HTTPS都能直接访问到。校验文件上传后不要急着删,后续微信会不定时复查。 - 填完保存后,微信说有缓存,但实际上通常很快生效。如果测试时还是签名失败,先确认是不是在缓存期内(一般几分钟到几十分钟都有),大改域名后最好稍等片刻再测。
这里有个容易混淆的地方:“JS接口安全域名”和“网页授权域名”是两个完全不同的配置。JS域名管的是wx.config签名和原生能力调用,网页授权域名管的是OAuth2静默授权、拿用户openId。扫码功能本身只需要配好JS接口安全域名,但如果业务还需要拿用户身份,两个都要配,别漏。
2.3 IP白名单:后端服务这边也要配
很多人配完前端域名就以为完事了,结果后端调接口时报40164,提示“此IP地址不在白名单中”。原因很简单:获取access_token的接口,要求调用方服务器出口IP在公众号后台的“IP白名单”里。
具体位置在公众平台“设置与开发 - 安全中心 - IP白名单”。有几类环境容易出问题:
- 本地开发时,本机访问外网用的公网IP和公司出口IP可能不同,要都加进去。
- 服务器走Nginx代理时,填的应该是最终访问微信接口的那台机器的出口IP,不是Nginx所在内网IP。
- 有些云服务器出口IP和负载均衡的IP还不一致,以实际日志里
40164报错提示的IP为准。
IP白名单不是即时生效,一般添加后几分钟内生效,测试时不要一报错就立刻重试,可能白名单还没同步。
3. 后端签名链路:wx.config背后的四步运算
3.1 签名链路总览
前端页面在调用wx.scanQRCode之前,必须先执行wx.config,传入四个关键参数:
| 参数 | 含义 |
|---|---|
appId | 公众号的唯一标识 |
timestamp | 生成签名的时间戳(秒) |
nonceStr | 随机字符串,防重放 |
signature | 通过jsapi_ticket、timestamp、nonceStr、url计算出的签名 |
这四个参数不是前端自己随便造的,必须由后端生成。完整的获取链路是:
appId + appSecret -> access_token -> jsapi_ticket -> 拼接签名串 -> sha1 -> signature每一步都有缓存策略和有效期,理解这条链路,后面排查问题才有方向。
3.2 access_token与jsapi_ticket的获取与缓存
第一步,后端用GET请求微信接口换取access_token:
https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET返回的JSON里包含access_token和有效期expires_in(默认7200秒)。这里有个硬性注意点:appSecret只能放在后端,前端任何地方都不能出现这个值。同时,access_token接口的每日调用次数有限制,生产环境必须缓存,不能每次页面刷新都调一次。
第二步,拿access_token换jsapi_ticket:
https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=ACCESS_TOKEN&type=jsapijsapi_ticket的有效期也是7200秒,同样需要缓存。网上有很多文章只强调access_token要缓存,但实测下来jsapi_ticket不缓存的话,高频调用下更早触发频率限制,因为每次签名都要用到它。
缓存的工程实现上,建议用Redis存这两个值,key分开存,过期时间设置在7000秒左右(留200秒余量,避免恰好到期时有请求打过来)。高并发场景下要注意缓存穿透问题,获取时加个分布式锁,同一时刻只允许一个请求去微信拉取,其他请求等锁后取缓存。这不是过度设计,我见过一个中高流量页面因为没加锁,缓存过期瞬间上百个请求同时打到微信接口,直接把当天额度刷掉一大半。
3.3 签名串构造规则与后端实现
拿到jsapi_ticket之后,签名就算正式开始了。
需要参与签名的字段是下面四个:
noncestr: 随机字符串,不能跟之前重复jsapi_ticket: 就是从上面接口拿到的那一串timestamp: 当前时间戳(秒)url:当前网页的完整URL,注意要去掉#及其后面的部分,但?和query参数必须保留
接下来按规则构造签名串:
- 把这四个字段按字段名的ASCII码从小到大排序。
- 按
key=value&key=value的URL键值对格式拼接成一个字符串。 - 对这个字符串做SHA1哈希,得到40位的
signature。
听起来不复杂,但实际操作中url是最大的坑。前端传给后端的URL,必须和浏览器里location.href去掉#后的字符串逐字符一致。
下面给一个Node.js后端的参考实现,包含缓存和签名逻辑:
const crypto = require('crypto'); const redis = require('./redis-client'); // 假设已有redis客户端 const APPID = 'your-app-id'; const APPSECRET = 'your-app-secret'; async function getAccessToken() { const cacheKey = 'wechat:access_token'; const cached = await redis.get(cacheKey); if (cached) return cached; const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}`; const res = await fetch(url); const data = await res.json(); if (data.errcode) { throw new Error(`getAccessToken failed: ${data.errcode} ${data.errmsg}`); } await redis.set(cacheKey, data.access_token, 'EX', 7000); return data.access_token; } async function getJsapiTicket() { const cacheKey = 'wechat:jsapi_ticket'; const cached = await redis.get(cacheKey); if (cached) return cached; const accessToken = await getAccessToken(); const url = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${accessToken}&type=jsapi`; const res = await fetch(url); const data = await res.json(); if (data.errcode !== 0) { throw new Error(`getJsapiTicket failed: ${data.errcode} ${data.errmsg}`); } await redis.set(cacheKey, data.ticket, 'EX', 7000); return data.ticket; } // 这个url由前端传上来,必须是当前页面location.href.split('#')[0] function createSignature(ticket, noncestr, timestamp, url) { const params = { jsapi_ticket: ticket, noncestr, timestamp, url, }; // 1. 字段名ASCII排序 // 2. 拼接成 key=value&key=value const string1 = Object.keys(params) .sort() .map((key) => `${key}=${params[key]}`) .join('&'); // 3. sha1签名 return crypto.createHash('sha1').update(string1).digest('hex'); } exports.getJsConfig = async function (req, res) { const url = req.query.url; if (!url || !url.startsWith('http')) { return res.status(400).json({ code: 400, message: 'invalid url' }); } const ticket = await getJsapiTicket(); const timestamp = Math.floor(Date.now() / 1000); const nonceStr = Math.random().toString(36).substring(2, 15); const signature = createSignature(ticket, nonceStr, timestamp, url); res.json({ code: 0, data: { appId: APPID, timestamp, nonceStr, signature, }, }); };后端生成好之后,暴露一个接口给前端,由前端把当前页面url传进来。这里强调一下,不要在后端自己拼URL,因为经过反向代理、改写地址、大小写变化之后,后端拿到的东西和浏览器实际地址很可能不一致,签名十有八九会失败。直接用前端上报的location.href.split('#')[0]最稳妥。
如果签名调试半天不知道对不对,可以先在浏览器里打开微信官方提供的“JS-SDK签名校验工具”页面,把你生成的签名参数填进去校验一遍,如果工具校验通过,说明签名算法没问题,问题多半出在URL匹配或配置上。
4. 前端调用scanQRCode:完整代码与兼容写法
4.1 JS文件引入与wx.config注入
页面里需要引入微信官方JS-SDK文件:
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>这个JS文件要放在页面里尽早加载,但如果页面较复杂,也可以选择在需要时动态加载,只要保证调用wx.scanQRCode时wx对象已经存在就行。
页面加载完成后,请求后端签名接口拿到appId、timestamp、nonceStr、signature,然后配置:
wx.config({ debug: false, // 上线前必须关闭,否则会弹出大量校验信息 appId: config.appId, timestamp: config.timestamp, nonceStr: config.nonceStr, signature: config.signature, jsApiList: ['scanQRCode'], // 这里声明需要用到的接口,不要多写 });配置成功后,微信会触发wx.ready回调;配置失败会触发wx.error回调,回调里带错误信息。调试阶段强烈建议把debug: true打开,能看到“config:ok”或具体的报错原因,一目了然。但上线前一定记得关掉,否则用户每次打开页面都会弹出一堆签名校验成功的提示,体验直接废掉。
4.2 调起扫码与结果处理
当用户点击页面上的“扫一扫”按钮时,调用wx.scanQRCode:
document.getElementById('scanBtn').addEventListener('click', function () { wx.scanQRCode({ needResult: 1, // 1表示需要返回扫码结果,0表示不返回 scanType: ['qrCode', 'barCode'], // 可以只保留二维码,也可以支持条形码 success: function (res) { const result = res.resultStr; if (result) { // 拿到结果后做业务处理 handleScanResult(result.trim()); } else { // needResult为0时这里拿不到结果 alert('未获取到扫码结果'); } }, cancel: function () { // 用户中途取消扫码 console.log('用户取消了扫码'); }, fail: function (err) { // 调用失败,比如没有权限或未正确注入 console.error('scanQRCode fail', err); alert('扫一扫调用失败,请确认是否在微信内打开'); }, }); });needResult这个参数要特别说清楚:设为1时,扫码成功后微信会把结果字符串通过success回调的res.resultStr返回给页面;设为0时,微信扫码完成后不会把结果回传给前端。项目里如果只是想让用户“扫一下码”然后自己处理,比如扫门店二维码加关注,可以设0;但绝大多数业务场景(扫设备码、扫订单码、扫商品码)都需要needResult: 1。
success回调里拿到的res.resultStr可能是URL、纯文本、数字或JSON字符串,取决于二维码内容。做业务处理时,最好先判断类型再走分支,比如以http开头走跳转逻辑,纯数字走设备绑定逻辑,避免一刀切处理。
4.3 复杂场景:SPA路由变化后的二次签名
如果你用的是Vue、React这类SPA框架,并且路由模式是HTML5 History模式,这里有一个非常隐蔽的坑:签名是和URL绑定的。
wx.config虽然只执行一次,但SPA页面在路由跳转后,URL已经变了,而微信校验的是签名时对应的那个URL。也就是说,A页面签的名在B页面就无效了。解决办法是监听路由变化,在跳转后重新请求后端签名接口,带上新URL再wx.config一次。
如果是Hash模式的路由(URL里有#),情况会简单一些,因为签名时不包含#后面的部分,签名串里的url始终是同一个。但这也意味着你不能在签名后共享同一个wx.config去调用需要不同URL的权限判断,hash变化时微信通常不会重新校验,但实际项目里还是建议在页面级别的操作前后主动校验一下。
另外一个容易忽略的点:如果在iframe里嵌入了页面,微信的JS-SDK签名校验是以iframe内页面的实际URL为准的,不是外层页面URL。跨域iframe下,签名配置会非常麻烦,建议能不用就不用。
4.4 非微信浏览器环境下的降级处理
wx.scanQRCode只在微信内置浏览器(或企业微信内置浏览器)里可用,用户在普通浏览器(Chrome、Safari)里打开页面时,wx对象不存在或wx.scanQRCode没有定义。前端在调用前最好做一次环境判断:
function isWeChatBrowser() { const ua = navigator.userAgent.toLowerCase(); return ua.indexOf('micromessenger') !== -1; }不是微信环境时,可以引导用户“请在微信中打开”,或者降级为使用getUserMedia加二维码识别库的方案(如果你的业务确实需要支持外部浏览器)。但要注意,降级方案的识别率和权限问题都远不如原生扫码,生产环境慎重。
5. 实战踩坑记录:从签名失败到扫码框不弹出
5.1 invalid signature的排查链路
wx.error里报invalid signature(或config:invalid signature)是整个流程中出现频率最高的错误。别慌,按下面链路一步步查,基本能定位:
- 先确认URL完全一致。在
wx.error回调里打印location.href,在后端日志里打印收到并参与签名的url,逐字符对比。重点看:有没有多一个/、大小写、协议(https还是http)、有没有带上#后的内容、有没有被前端encodeURIComponent编过。实测中,前端传来encodeURIComponent(location.href)导致后端解码后与签名时URL不一致的情况,几乎每周都能遇到一次。 - 确认signature不是拿旧的jsapi_ticket生成的。
jsapi_ticket缓存过期后,如果后端没有重新拉取还继续用旧值,签名必然失败。排查时可以在后端打印生成签名时用的jsapi_ticket前20位,跟微信公共接口刚拉到的ticket前20位对比。 - 确认appId与ticket是否匹配。如果项目里有多个公众号,后端拿A公众号的ticket去给B公众号的页面签名,结果一定是
invalid signature,这种错误不仔细看日志根本发现不了。 - 测试号与正式号混用。开发环境用测试号、生产用正式号,如果前端页面某个地方把正式号的
appId和测试号后端生成的signature拼在一起,也会挂。
结合微信官方的签名校验工具,基本能把签名算法问题排除掉。剩下的就是环境配置和URL匹配问题。
5.2 安卓和iOS的差异
微信扫一扫在安卓和iOS上的表现不是完全一致的,以下几个点实测遇到过:
- iOS对URL大小写敏感,后端做URL比对时如果做了
toLowerCase(),iOS上很容易导致签名失败。iOS的location.href有时候会保留路径大小写,而安卓某些内核会自动把小写化,签名时要以前端实际location.href为准,不要做任何改写。 - 某些安卓手机在扫码成功后,紧跟一个
location.href跳转会丢失回调。也就是说在success里拿结果后立刻跳转,页面上还没来得及处理业务逻辑就卸载了。稳妥做法是先把resultStr保存到全局变量或sessionStorage,再在setTimeout里跳转,或者先做业务请求,成功后再跳转。 - 结果字符串可能带空格或不可见字符。二维码生成工具千奇百怪,有些会在内容前后加换行符或BOM头,直接拿去请求后端接口容易莫名报错。建议拿到结果统一
trim(),必要时再按\n分割处理。
5.3 扫码框弹不出的另类原因
如果签名校验已经通过(wx.ready正常触发),但点按钮后扫码框就是不弹,排查方向要转向下面几个点:
- 页面不是微信内置浏览器打开。这个常见于开发调试时,在PC浏览器里联调扫码,肯定弹不出来的。真机必须用微信扫一扫打开页面。
jsApiList里没声明scanQRCode。有人只写wx.config配置基本参数,但jsApiList是空的,结果调用时微信直接报“no permission”。scanQRCode必须出现在这个数组里。- 调用的按钮事件绑定时机不对。如果页面是异步渲染的,事件绑定在DOM元素创建之前,点击时函数根本没绑上,看起来就像“扫码框没弹”。这个问题跟微信SDK无关,但很容易被误判。
- 页面存在多层
iframe嵌套。微信对iframe中的JS-SDK调用支持非常有限,特别是跨域iframe,扫码框可能直接不出现。解决办法是把扫码入口放到顶层页面,或者用postMessage把扫码指令传给顶层页面执行。
如果你已经上了生产环境,debug: false看不到日志,建议在扫码按钮回调里临时挂一个vConsole,或者把wx.error的内容上报到自己的监控系统。扫码链路出问题时,有日志和没日志,排查时间能差出好几倍。
写在最后的几点个人经验
项目做多了,我自己总结了一套比较稳的接入节奏:先用测试号跑通全链路,确认签名和扫码都没问题;再切正式号,重新配置JS安全域名和IP白名单;上线前关掉debug,在wx.error和scanQRCode.fail里做好埋点。这套顺序看起来很基础,但能帮你把“配置问题”和“代码问题”分开定位,省掉大量无效排查时间。
另外有个小技巧:签名用的url,不要前端传什么就信什么,后端可以加一层校验,对格式、协议、域名做透传前的白名单匹配。这样即使有人恶意传一个不在你业务域名下的URL,也能直接拦截,别让签名接口变成给别人页面授权的“公共工具”。
还有,扫码拿到的结果,如果是一个URL,不要在前端直接location.href跳转。原因很简单:二维码是公开的,内容可以被任何人伪造,直接跳转存在被诱导到钓鱼页面的风险。稳妥做法是先把结果提交到后端,由后端做安全校验(是否是业务域名、是否在允许列表内)后再决定是否跳转。扫码是入口,安全校验永远不能在入口处省掉。