返回博客列表
技术文章微信小程序全栈开发工程实践Supabase避坑指南

微信小程序全栈开发中 4 个经常被低估的深坑

2026年05月🇨🇳 中文

上一篇小程序决策清单讲的是交付前要想清楚的几个关键决策——登录模型怎么定、数据归属怎么画、后端选什么。那些是方向性的东西,方向错了后面全得返工。

但这篇要聊的东西不太一样。它们不是什么重大架构决策,更像那种你写着写着突然被绊一脚的小事——本地跑得好好的,一到真机上就炸。这些问题不会被一条决策链自然地推到你面前,但上线之后出事故,第一现场经常就在这里。

下面这四个坑,我都在真实项目里踩过,而且每一个都是在"本地看起来完全没问题,上线后才发现不对劲"的模式下暴雷的。

1. npm 兼容:别以为"构建 npm"点一下就行

微信开发者工具里有一个很友善的按钮:"工具 → 构建 npm"。很多人点完之后项目跑起来了,就以为 npm 已经和小程序完全打通了。

实际情况是,小程序的 npm 支持有非常具体的约束。

构建出来的东西,和你的 node_modules 不完全一致

开发者工具的构建过程会做两件事:把 node_modules 里的依赖拷贝到 miniprogram_npm 目录,同时做一些路径转换和依赖平铺。这个过程和 webpack / vite 的打包完全是两回事。

最常出问题的是这几种情况:

依赖内部引用了 Node.js 内置模块。 比如某些库在处理文件路径时用了 path 模块,或者在生成随机数时用了 crypto。小程序运行时没有这些模块,构建也不会帮你 polyfill,结果就是某个依赖树深处的子包在某条代码路径上突然抛异常——而报错堆栈往往不指向你写的任何一行代码。

依赖依赖了浏览器 API。 fetchFormDataBlobURLatobbtoaWebSocket(为标准格式)——这些东西小程序要么没有,要么提供的版本语义不一致。一个很常见的例子是,你引入了一个 HTTP 客户端或 Supabase 的官方 JS SDK,它在浏览器里通过 fetch 发包,到了小程序里就直接罢工。

包内部做了条件导出或者 browser / main 字段的切换。 小程序构建工具对 package.jsonexportsmodulebrowser 等字段的解析逻辑,和常规打包工具不完全一样。有时候你引了一个包,在 Web 项目里拿到的是一份用 ESM + fetch 的浏览器版本,在小程序里却拿到了一份依赖 fshttp 的 Node.js 版本。

这不是"小心一点"能解决的

npm 兼容问题的麻烦之处在于,它经常不是你自己写的代码出错,而是依赖树的深处某个不起眼的子依赖里带了不兼容的实现。你排查时看到的是一个莫名其妙的 TypeError 或者 undefined is not a function,但堆栈指向的是 miniprogram_npm 下一个你没见过的文件。

所以我对小程序 npm 的态度是两条:

  1. 引依赖之前先查它是否官方声明支持小程序。 官方支持意味着它处理了上述兼容问题,至少提供了不带 Node.js API 的构建产物。
  2. 如果一个能力底层只是 REST 协议或简单的算法逻辑,优先手写一层薄封装。 手写 80 行的 request.js 比引入一个 20KB 但依赖了 fetchFormData 的 HTTP 库更稳,因为你自己写的代码,每个报错你都知道它在哪儿。

举一个具体例子。Supabase 官方的 @supabase/supabase-js 在小程序里直接用会出问题,因为它内部依赖了 fetchlocalStorage 和浏览器级的 WebSocket。我在项目里用的是社区维护的 supabase-wechat-stable-v2,这个包做的事情很直接——把官方 SDK 底层的四个依赖全部替换成微信的等价实现:fetch 换成 wx.requestlocalStorage 换成 wx.storageWebSocket 换成 wx.connectSocketFormData 换成 wx.uploadFile。API 层面和官方 SDK 完全一致,所以 createClient.from().auth 这些用法都不需要改。

但即便是用了适配包,也不是装完就完事了。这个包的依赖链里包含了 phoenix(Phoenix Channel 的 JS 实现),它的 CJS 文件里有微信 JS 解析器不支持的语法。如果你的项目不用 Realtime 功能,可以用一个 postinstall 脚本把 phoenix 的入口替换成空文件来绕过。

2. 文件上传:它不是一个特殊请求,而是一条独立子系统

小程序的文件上传和普通请求,在 API 层面就已经分家了。

普通请求走 wx.request,文件上传走 wx.uploadFile。这看起来好像只是换个函数名,但实际上它意味着你为 wx.request 搭建的整层封装——统一鉴权头注入、统一错误格式、统一重试逻辑、统一超时处理——在文件上传这里全都要重新做一遍。

wx.uploadFilewx.request 的真正差异

首先,wx.uploadFile 的鉴权头默认行为不一样。wx.requestheader 参数你传什么就是什么,但 wx.uploadFile 在某些情况下会自动设置 Content-Type: multipart/form-data,而且它的 header 合并逻辑在某些基础库版本里有坑——你传的 Authorization 头可能被覆盖。

其次,返回值的结构不一样。wx.requestsuccess 回调里 res.data 已经是解析后的对象(如果服务端返回 JSON),而 wx.uploadFileres.data 一定是字符串,你得自己 JSON.parse

再者,wx.uploadFile 有进度回调,wx.request 不支持上传进度。这意味着你的上传 UI 和普通请求 UI 天然需要不同的交互逻辑——进度条、上传中不可取消的按钮状态、失败后是重新开始还是续传。

文件上传真正的难点在生命周期

上传本身只是第一步。真正麻烦的是上传之后的事:

  • 临时路径 vs 持久路径。 用户手机上的文件是一个本地临时路径,wx.uploadFile 把它传给了你的服务端。传完之后,你的服务端是直接存进对象存储返回公开 URL,还是先做转码/压缩?返回的 URL 是永久有效还是有时效性?如果是签名 URL,过期之后前端怎么刷新?
  • 文件归属和权限。 这个文件是公开资源还是私有资源?如果是私有的,访问权限是跟用户绑定的(只有上传者能看),还是跟业务实体绑定的(属于某个项目的成员都能看)?这决定了你对象存储的路径结构和访问策略。
  • 业务数据和文件的原子性。 提交表单时,如果表单数据写库成功了但文件上传失败了怎么办?反过来,如果文件上传成功了但表单数据写库失败了怎么办?文件是否需要依赖业务记录的创建结果,还是可以先存到一个"暂存区"、等业务记录创建后再关联?
  • 删除和清理。 业务记录删除时,关联的文件要不要一起删?如果暂时不删(软删除),什么时候清理?这个清理逻辑是同步的还是异步的?

这些问题的答案没有通用标准,取决于你的业务。但有一点是确定的:如果等上线后再想,大概率会返工。 因为文件存储路径一旦定下来,后面迁移的成本非常高——你不仅要改代码,还要处理已经存下来的历史文件。

一个简单的路径设计可以帮助避免很多麻烦。比如把对象路径设计成 {env}/{business}/{user_id}/{timestamp}_{filename},这样权限边界、环境隔离和文件来源一眼就能看出来,以后也方便按用户或时间范围做批量清理。

3. 域名白名单:上线前最后一夜的常客

这是一个每个小程序开发者都知道、但几乎每个人都至少栽过一次的坑。

微信要求所有网络请求的域名必须在小程序后台的"开发 → 开发管理 → 开发设置 → 服务器域名"里配置。而且它把域名拆成了四个独立的白名单:

类型对应 API典型用途
request 合法域名wx.request所有 REST API
socket 合法域名wx.connectSocketWebSocket / Realtime
uploadFile 合法域名wx.uploadFile文件上传
downloadFile 合法域名wx.downloadFile文件下载

为什么它比你想象中更容易漏

因为你在开发阶段用到的一些域名,跟你在代码里"看得到"的可能不完全一致。

最典型的是 Supabase。你用 Supabase 时,代码里暴露出来的可能只有一个 REST API 域名 https://xxx.supabase.co。但当你开始使用 Auth、Storage、Realtime 这些能力时,它们背地里走的是不同的子域或路径。实际需要用到的域名至少包括:

# REST API
https://xxx.supabase.co

# Auth (通常同域, 但某些配置下可能不同)
https://xxx.supabase.co/auth/v1

# Realtime (WebSocket)
wss://xxx.supabase.co/realtime/v1

# Storage (上传)
https://xxx.supabase.co/storage/v1/object

# Storage (下载)
https://xxx.supabase.co/storage/v1/object/public

所有这些都要分别落到四类合法域名里去。而且还要注意:开发环境、体验版和正式版的域名配置是分开的。很多人在开发阶段用了测试环境域名、上线前忘了换成正式环境域名,或者反过来——上线后发现还有测试环境域名残留。

怎么避免

我现在的做法是在项目早期就建一张域名清单表,不等到上线前再翻代码找域名:

域名用途requestsocketuploaddownload
https://xxx.supabase.coREST API✔️---
wss://xxx.supabase.coRealtime-✔️--
https://xxx.supabase.coStorage - Upload--✔️-
https://xxx.supabase.coStorage - Download---✔️
https://api.weixin.qq.com微信 API✔️---

这张表有两个作用:一是配白名单时不会漏,二是你看到某个域名出现在多个类型的列里时,能意识到它可能需要人工验证——比如 Supabase Storage 的域名在上传和下载时是否都正确匹配了对应的合法域名配置。

4. 线上监控:问题出在产线上,不是出在你电脑上

小程序上线之后,你失去的不仅是方便的 console.log,还有整个调试能力。用户说"刚刚提交失败了",你能问的只有"什么时候";用户说"页面卡住了",你能说的只有"你再试一次呢"。

这听起来是常识,但真正做小程序项目时仍然有大量项目在没有任何线上观察手段的情况下就跑起来了。

至少要留三条信息

我接手过的小程序项目里,线上问题排查最痛苦的不是"问题难修",而是"出了什么问题都不知道"。所以后来我对任何项目都有一个最低要求:哪怕不接完整监控平台,也要在代码里留三条信息:

请求错误:接口 URL、状态码、错误消息、请求发起时间、当前用户标识。这些信息能帮你判断是服务端报错还是网络中断,是全局性的还是单用户的。

前端异常:页面路径、用户操作停留的最后一个可追踪点、错误堆栈。App.onErrorApp.onUnhandledRejection 是小程序全局捕获异常的入口,哪怕只在这里做一个最简单的上报,也能把"页面卡住了"这种模糊反馈变成可排查的线索。

关键业务事件:登录成功/失败、核心提交操作、支付发起与结果、文件上传完成。这些不是错误,但它们是诊断问题时最重要的上下文——你知道了用户在"出问题"之前做了什么。

一个轻量的起步方式

不需要一上来就接整套可观测平台。起步阶段,一个最简单的上报端点就够了:

function reportEvent(type, data) {
  wx.request({
    url: 'https://your-api.com/log',
    method: 'POST',
    data: {
      type,
      data,
      timestamp: Date.now(),
      openid: getStoredOpenid(),
    },
  })
}

App({
  onError(error) {
    reportEvent('js-error', { message: error, stack: '' })
  },
  onUnhandledRejection({ reason }) {
    reportEvent('unhandled-rejection', { reason })
  },
})

这段代码本身很简单,但它代表的意识很重要:线上问题不应该只存在于用户的聊天记录里。 哪怕只是一个最简陋的上报,它也是可诊断的开端。

如果你用的是 Supabase,连自建上报端点都可以省——把日志直接写进一个 logs 表,利用 Supabase 的 Dashboard 就能做简单的查询和过滤。

一个很容易被忽略的点

小程序的 wx.request 有并发限制(同时最多 10 个),而且在前台切后台时请求会被挂起。这意味着你的监控上报本身不能太重——如果每次出错你都发一个 5KB 的堆栈过去,本身可能因为网络抖动或并发限制导致上报失败。

一个实用的策略是:上报只发最小信息集,完整堆栈和上下文在服务端需要时再通过用户反馈或主动拉取来补充。