最近在做微信小程序登录页面时,我发现一个有意思的现象:很多人觉得登录页就是“放个按钮、调一下 wx.login、换到 token 就完事”,可真到了真机联调、上体验版、过审核的时候,一堆问题全冒出来了。登录页面看似简单,实际牵涉 wx.login 与 code2Session 的完整链路、用户信息授权方式的演进、自定义登录态的安全设计,还有隐私协议和合法域名的合规配置。这篇文章我会从小程序登录的整体方案讲起,把前端、后端、配置、常见报错全部串一遍,适合刚接触小程序开发、或者用 uni-app / Taro 等跨端框架做登录功能的朋友,也希望给已经能跑通但总被各种诡异问题卡住的人提供一些排查思路。
1. 登录方案设计:别把“微信登录”当成填账号密码
1.1 登录三件套:code、session_key、自定义登录态
小程序登录和我们平时做的账号密码登录完全是两套逻辑。Web 端登录通常是用户输入账号密码,后端比对数据库返回 token;微信小程序里没有密码框,它依赖微信客户端帮你确认“这个人确实是用这个微信号打开了小程序”。整个登录链路核心就三个东西:wx.login 返回的一次性 code、后端拿 code 找微信服务器换来的 session_key / openid,以及后端自己颁发的 token(也叫自定义登录态)。
我见过很多初学者直接把 code 当成 token 存起来用,这是不对的。code 是一次性的,有效期大约 5 分钟,而且用过一次就失效。真正的做法是:前端把 code 传给自己的后端,后端调用微信的 jscode2session 接口,拿到 openid 和 session_key 之后,在后端生成一个业务 token 返回给前端。前端后续请求都带这个 token,后端通过 token 识别用户身份。简单说,微信只负责证明“你是你”,而“你在我系统里是谁、能干什么”必须由后端自己维护。
1.2 静默登录与用户信息授权:两件事要拆开
很多产品把“微信登录”和“获取用户头像昵称”混在一起,用户一点登录按钮,就弹窗要头像昵称授权,这是老代码最常见的写法,也是现在最容易被拒审的写法。实际上这两件事完全可以拆开:静默登录用 wx.login 就能完成,整个过程用户无感知;获取头像昵称属于用户信息授权,必须由用户主动触发。
从 2022 年 10 月 25 日之后,微信调整了用户信息获取规则,getUserProfile 接口返回的头像昵称变成了灰色头像和“微信用户”这类默认值,个人小程序甚至无法再调用该接口。现在官方推荐的做法是:头像用 button 的 open-type="chooseAvatar",昵称用 input 的 type="nickname",让用户自己选头像、填昵称。这个改动影响很大,后面实操部分我会专门展开。设计登录页时,建议把“微信一键登录”和“完善头像昵称”分成两个步骤,既符合平台规则,体验也更顺。
1.3 为什么后端非参与不可
有些小项目图省事,想直接在小程序前端调 jscode2session,把 appid 和 secret 写死在代码里。这是绝对不能干的。appsecret 一旦暴露在小程序包中,任何人都有办法通过反编译拿到它,然后就能冒充你的小程序后端发起请求。微信官方也明确要求 jscode2session 必须由后端服务器调用,不能在小程序内直接请求。
另外,session_key 是微信会话密钥,用于解密手机号、运动数据等敏感信息,它只能留在后端,绝不能下发到前端。所以哪怕你的项目没有独立后端,也应该用微信云开发的云函数来封装登录逻辑。云函数运行在服务端,可以安全地保存 appid 和 secret,前端只调云函数传入 code 即可。用 Node 或 PHP、Java 做后端都行,核心原则只有一个:code 在前端产生,openid/session_key 只在后端出现,前端只拿自己业务系统的 token。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:wx.login 与 code2Session 的细节
2.1 wx.login 返回的 code 是什么,能做什么
wx.login 是小程序登录的起点。调用成功后会返回一个 code,这个 code 是微信客户端生成的一个临时凭证。它不包含任何用户身份信息,只是一个“兑换券”,靠它到微信服务器才能换到身份数据。之所以设计成临时凭证,是为了安全:就算 code 在网络传输中被截获,5 分钟内过期后也无法再次使用。
有个容易忽略的点:wx.login 不一定每次都会生成新 code。如果当前微信会话(session)仍然有效,它可能返回同一个 code。所以如果你发现后端拿到 code 后提示“code 已被使用”,前端不能简单地重新调一次 wx.login 就完事,要向用户明确提示重新触发登录,或者先调用 wx.checkSession 检查微信会话状态,必要时先清理本地登录态再重新登录。我在项目中一般会在 onLaunch 里先 checkSession,再决定要不要走完整登录流程。
2.2 code2Session 的调用细节和参数要求
前端拿到 code 后,要把它发给自己的后端。后端再拿着 code 去请求微信的官方接口,接口地址是:
text复制GET https://api.weixin.qq.com/sns/jscode2session
请求参数主要有四个:appid、secret、js_code(就是前端传来的 code)、grant_type=authorization_code。grant_type 固定写 authorization_code 就行。成功返回的 JSON 里会有 openid、session_key、unionid(如果小程序绑定过开放平台账号,则返回 unionid)。如果请求失败,会返回 errcode 和 errmsg,常见的有 40029(code 无效)、45011(频率限制)、40226(高风险用户)等。
这里要给后端同学提个醒:调用 jscode2session 的服务器必须能访问外网,某些内网部署环境直接请求 api.weixin.qq.com 是超时的。我就遇到过测试环境一切正常,生产环境一调这个接口就超时,最后查出来是生产服务器出口防火墙没放行微信官方域名。另外,这个接口没有官方 SDK 也能调,直接发 HTTP GET 请求就行,Node 里用 axios、Python 里用 requests 都可以。
2.3 openid、unionid、session_key 的安全边界
这三个字段的用途很多新手分不清。openid 是小程序维度下的用户唯一标识,同一个用户在你的小程序 A 和 B 里,openid 是不同的;unionid 是开放平台账号体系下的唯一标识,同一个微信用户在小程序、公众号、App 等不同平台下 unionid 是相同的,前提是这些平台绑在同一个开放平台账号下。session_key 是会话密钥,配合微信提供的解密算法,可以用来解密手机号等敏感信息。
安全边界必须划清楚:openid 可以在后端和数据库中使用,它是识别用户的关键;unionid 对做多端账号打通很重要;session_key 绝对禁止下发到前端,也不能写入日志。我见过有人图方便把 session_key 连同 token 一起返回前端,结果前端一旦被拿到 session_key,配合截获的加密数据就能解密出手机号,这是很严重的安全事故。如果后端需要解密手机号,session_key 只应在后端内存中短暂保存,用完即弃。
2.4 用户头像昵称的获取规则变化
头像昵称这块是改动最大的。以前做登录页,常见的写法是用户点击按钮后调用 wx.getUserProfile 弹窗获取头像和昵称;现在这套基本行不通了。微信官方最新规则是:头像昵称需要用户主动填写,不能通过接口直接读取。
具体落地方式:头像通过 <button open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar"> 让用户从微信头像或相册中选择;昵称通过 <input type="nickname"> 让用户填写,微信输入法会自动推荐用户的微信昵称。注意,chooseAvatar 返回的是临时文件路径,需要调用 wx.uploadFile 上传到自己的服务器换取正式 URL 才能持久化存储。很多人的登录页在新规则下拿不到头像昵称,就是因为还在用老接口,没改这两处组件。
3. 登录页面实操:从 UI 到后端 token 全链路
3.1 登录页 UI 与交互设计
登录页的 UI 不需要太花哨,但有几个关键点必须覆盖:品牌信息、微信登录主按钮、用户协议与隐私政策提示。从转化率角度看,小程序的登录页最好一句话说清“登录后能干什么”,比如“登录后同步你的订单与收藏”,避免用户对授权产生戒心。按钮要足够醒目,通常用微信绿(#07C160),文案直接用“微信一键登录”。
这里给出一份最简 WXML 结构:
xml复制<view class="login-page">
<view class="brand">
<image src="/images/logo.png" mode="aspectFit" />
<text>某某小程序的介绍语</text>
</view>
<button class="wechat-login-btn" loading="{{loading}}" disabled="{{loading}}" bindtap="handleLogin">
微信一键登录
</button>
<view class="agreement">
<checkbox checked="{{agree}}" bindtap="toggleAgree" />
<text>我已阅读并同意</text>
<text class="link" bindtap="goProtocol">《用户协议》</text>
<text class="link" bindtap="goPrivacy">《隐私政策》</text>
</view>
</view>
有一个细节值得强调:按钮的 loading 状态和 disabled 必须处理。wx.login 虽然通常很快,但在弱网环境下会出现用户连续点击、发送多次登录请求的情况。同一用户短时间内重复调 code2Session,容易触发微信频率限制(45011),所以按钮要加防重复点击逻辑。
3.2 前端登录逻辑与状态管理
前端登录逻辑分成三步:第一步,检查本地有无可用 token,有就直接进首页;第二步,调 wx.login 拿 code;第三步,把 code 发给后端换 token,存到 storage 中。考虑到服务端 token 可能过期,前端还要在请求拦截器里统一处理 401。
登录函数可以写成这样:
javascript复制function login() {
return new Promise((resolve, reject) => {
wx.login({
success: async (res) => {
if (!res.code) {
reject(new Error('登录失败:未获取到 code'))
return
}
try {
const { token } = await request.post('/api/auth/login', {
code: res.code
})
wx.setStorageSync('token', token)
resolve(token)
} catch (err) {
reject(err)
}
},
fail: (err) => reject(err)
})
})
}
request 方法里要统一带上 token:
javascript复制const request = {
post(url, data) {
return new Promise((resolve, reject) => {
wx.request({
url: BASE_URL + url,
method: 'POST',
data,
header: {
'Content-Type': 'application/json',
'Authorization': wx.getStorageSync('token') || ''
},
success: (res) => {
if (res.statusCode === 401) {
// token 失效,清掉本地登录态,重新静默登录
wx.removeStorageSync('token')
login().then(() => {
// 重新请求原接口
})
return
}
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
reject(res.data)
}
},
fail: reject
})
})
})
}
在 App 的 onLaunch 中,我会先执行一次静默登录。如果用户已经登录过,token 还没过期,这个动作几乎无感;如果过期了,就重新走 code2Session 换新 token。但注意,不要在 onLaunch 里同步阻塞业务页面的渲染,否则用户会感觉启动很慢。可以做成异步任务,页面先渲染,等 token 就绪后再刷新用户态。
3.3 后端登录接口最小实现
后端接收 code 后要做的事可以拆成四步:调微信接口换身份、查数据库或创建用户、生成 token、返回 token。
用 Node.js + Express 写一个最小实现:
javascript复制const axios = require('axios')
const jwt = require('jsonwebtoken')
async function loginHandler(req, res) {
const { code } = req.body
if (!code) {
return res.status(400).json({ message: 'code is required' })
}
// 1. code 换 openid
const wechatRes = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: {
appid: WECHAT_APPID,
secret: WECHAT_SECRET,
js_code: code,
grant_type: 'authorization_code'
}
})
if (wechatRes.data.errcode) {
console.error('wechat login error:', wechatRes.data)
return res.status(500).json({ message: 'wechat login failed' })
}
const { openid, session_key, unionid } = wechatRes.data
// 2. 查库,没有就创建用户
let user = await db.findUserByOpenid(openid)
if (!user) {
user = await db.createUser({ openid, unionid: unionid || null })
}
// 3. 生成业务 token
const token = jwt.sign(
{ userId: user.id, openid },
JWT_SECRET,
{ expiresIn: '7d' }
)
// 4. 返回给前端
res.json({ token, user: { id: user.id, nickname: user.nickname } })
}
有几个要点必须强调:
- 不要把 openid 直接变成 token 返回。token 必须由你签发,并且要设置过期时间。
- session_key 只留在后端使用,不要返回到前端。
- code 每次只能用一次,后端要处理重复使用 code 的情况,必要时加缓存记录已消费的 code。
- 微信接口返回的 openid 是敏感字段,在数据库里也应该加密存储,或者至少不要把完整 openid 打到日志里。
3.4 登录成功后的跳转与登录态恢复
登录成功的跳转逻辑看起来简单,但容易出错。如果用户是从“个人中心”被引导去登录的,登录成功之后应该返回个人中心,而不是粗暴地 reLaunch 到首页。我习惯在登录前记录当前页面路由,登录成功后用 wx.redirectTo 或 wx.navigateBack 返回原页面。
如果登录页是通过 wx.navigateTo 打开的,返回时直接用 wx.navigateBack 即可;如果是不小心在 app.json 里把登录页设成了首页,那登录成功就要用 reLaunch 跳到主页面。这里推荐的做法是:登录页不设为主页,主页面遇到未登录状态时再跳登录页。这样能避免“登录成功回不去”的尴尬。
另外,登录页的数据刷新也是个细节。返回上一页时,上一页的 onShow 会被触发,因此我通常会在上一页的 onShow 里重新读取本地 token 和用户信息,判断登录态是否变化。如果变化了,重新拉取用户数据。这个模式比登录成功后通过全局事件通知要简单可靠得多。
4. 高频报错排查与避坑实录
4.1 真机调试 net::ERR_CONNECTION_RESET 的排查路径
真机调试报 net::ERR_CONNECTION_RESET 是登录页面最常碰到的网络错误,几乎每次都让新人抓狂。出现这个错误,第一反应不要查代码,先去查域名配置。开发者工具里可以勾选“不校验合法域名”,所以接口在工具里能通;真机没有这个口子,所有 request 域名必须在小程序后台配置好 request 合法域名,而且必须是 HTTPS。
还有一个隐蔽点:如果后端接口是用 IP 地址 + 端口访问的,比如 http://192.168.1.100:8080,哪怕你把它配进合法域名也配不进去,因为合法域名要求备案过的 HTTPS 域名,IP 地址和端口号根本不在允许范围。开发调试阶段可以开“真机调试”配合工具里的域名校验关闭开关,但一旦脱离调试模式,就必须用正式 HTTPS 域名。
排查顺序建议是:
- 先确认报错的是 wx.request 还是其他网络请求。
- 小程序后台看看 request 合法域名有没有配,配置是否已发布生效。
- 用手机浏览器访问一下后端接口,确认服务器本身能通。
- 看后端日志,确认请求有没有到达服务器。
4.2 开发版能登录,体验版/正式版却不行
这个问题背后的原因通常是域名配置或服务器环境差异。开发版调试时很多人会顺手开启“不校验合法域名”,接口自然能通;体验版和正式版不会管这个开关,域名必须真实合法。
另一个常见原因是后端误把 appid 写死成了测试号。微信的 appid 是跟着小程序走的,如果你用测试号 appid 调 jscode2session,拿到的 openid 和正式小程序是完全隔离的。后端要对不同环境区分 appid 和 secret,不要把测试配置带到生产环境。我就在生产环境见过 appid 写的是测试号,结果所有用户都登陆不上,数据库里还莫名多了很多测试用户。
体验版还有一个特殊性:只有体验成员才能访问。如果登录页在体验版打开后直接白屏或报“页面不存在”,先确认微信号是不是已经被加为体验成员,这个看似低级的问题其实经常被忽略。
4.3 project.config.json 里 appid 一直没变的坑
打开微信开发者工具发现小程序 id 还是原来的,这个坑在复制项目、从 HBuilderX 导入项目时特别常见。开发者工具读取的 appid 优先级顺序是:project.config.json 里的 appid 字段,而不是你在工具界面里看到的那一栏。很多人只改了微信公众平台后台的 appid,根本没改本地配置文件。
在 project.config.json 里找到这一段:
json复制{
"appid": "wx1cb4398e1413dce7",
"projectname": "my-mini-program"
}
改成你自己的 appid 之后,关掉开发者工具重新打开,一般就能生效。HBuilderX 项目则不同,需要在 manifest.json 的“微信小程序配置”里修改 appid,HBuilderX 会把这个值同步到编译输出目录的 project.config.json 中。如果你改完 manifest 还是无效,检查一下是不是在 HBuilderX 里开了“运行时重新编译”之外的缓存模式。
4.4 content-type 无法置空、请求体类型问题
Content-Type 无法置空 这个问题在登录接口联调时很烦人。微信小程序的 wx.request 默认 Content-Type 是 application/json,如果后端接口恰好只接受 application/x-www-form-urlencoded,前端在 header 里手动设置:
javascript复制header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
有时候会发现设置后实际发出去的还是 application/json,或者直接被拦截报错。这是因为某些基础库版本对 Content-Type 有限制。排查方法:先用开发者工具的 Network 面板抓包,确认实际请求头是什么;后端如果是自己写的,直接改成接收 JSON 最省事;如果后端接口动不了,可以把参数手动拼接成 query string 放到 URL 后面,绕过 Content-Type 问题。
另外,如果调用 wx.uploadFile 上传头像时遇到 Content-Type 问题,注意 uploadFile 的 header 里不需要手动写 Content-Type,框架会自动生成带 boundary 的 multipart 格式,手动设置反而容易出错。
4.5 头像昵称获取失败的合规处理
现在很多登录页还报 getUserProfile 相关错误,是因为代码没更新。登录按钮不再适合直接弹 getUserProfile,应该改为“头像昵称填写”流程。头像采用:
xml复制<button open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
选择头像
</button>
<input type="nickname" placeholder="请输入昵称" bindinput="onNicknameInput" />
选择头像后,拿到的是一段临时路径,例如 http://tmp/xxxx.jpg,这个路径只在当前小程序会话内有效。如果要长期保存,必须调 wx.uploadFile 上传到自己的服务器,上传成功后用返回的永久 URL 存数据库。
还要注意隐私协议问题。自从微信隐私保护指引上线以来,涉及头像昵称、手机号等用户信息的组件调用会触发隐私弹窗。如果你在小程序后台没有配置隐私保护指引,或者前端没有调用 wx.requirePrivacyAuthorize 之类的接口处理授权,用户在登录页点击选择头像时可能直接没反应甚至报错。登录页越简单,隐私合规这个前置工作越不能漏。
4.6 登录开发常见问题速查表
| 问题现象 | 最可能原因 | 处理建议 |
|---|---|---|
| 真机 net::ERR_CONNECTION_RESET | 域名未配置或非 HTTPS | 配好 request 合法域名并发布 |
| 开发版能登、体验版不能 | 开了不校验域名或 appid 配置错误 | 关掉校验、核对环境 appid |
| 复制项目 appid 没变 | project.config.json 中的 appid 没改 | 修改项目配置并重启工具 |
| code2Session 返回 40029 | code 无效或已被使用 | 重新登录获取新 code |
| code2Session 返回 45011 | 接口频率限制 | 增加服务端缓存与限流 |
| 获取不到头像昵称 | 还在用 getUserProfile 老接口 | 改用 chooseAvatar + nickname |
| 接口返回 401 后无限重登 | 登录失败后缺少失败计数 | 增加重试次数上限 |
| 上传图片提示 Content-Type 错误 | 手动设置了 uploadFile 的 header | 删除 header 中的 Content-Type |
最后补几句我的实际体会
小程序登录页这块我前后做了好几个项目,踩得最深的一个坑来自“过度设计”。早期总想把登录、授权、拉取用户信息一次做完,结果微信规则一改,整套逻辑都得返工。后来把登录拆成“静默建立会话”和“用户主动完善资料”两步之后,微信规则再怎么调整,影响面都小得多。现在我的习惯是:登录页代码尽量精简,所有用户主动操作都放到登录之后做,再把 request 统一封装里的 401 处理做好,整个登录体系就能稳定跑很久。如果你正在做或者准备做小程序登录,建议先把基础链路设计成“code 换 token”的干净模型,再逐步扩展头像昵称、手机号这些附加能力,这样不管平台规则怎么变,改动成本都可控。
