云函数调用量治理与一次 TDZ 线上崩溃:给小程序上线前的两道门禁

我做前端快十年,待过猫眼、新东方、完美世界,也自己创过业。现在在软通动力做美团小程序和鸿蒙外包的间隙,维护一款自己写的宝可梦问答小程序(原生 + 微信云开发,9 种玩法、28 个页面)。今天本来只是想省点云函数调用,最后查出一场线上崩溃。

一次多余的请求,把调用量抬到了 928 次/天

后台日志按云函数拆开看,数独玩法那个函数一天 928 次调用,全项目第一。这个数字很刺眼:每日一题的玩法,一个用户一天玩一局,怎么可能拉出接近一千次请求。

顺着前端调用链看下去,原因很直白。数独页要显示每格「还可选 N 只」,原来的实现是提交一次猜测后,前端再单独调一次云函数,走校正方法把整张棋盘重新拉一遍,只为拿一个计数字段。于是单局调用结构变成:

1 × get(出题) + 9 × guess(猜格) + 最多 8 × 校正 = 18 次

928 除以 18 大约是 51 局,量级对上了。

抽一个纯函数,让它跟着结果回来

我在云函数里抽出纯函数 countCells(board, usedNames),让出题的视图构造和判分分支共用同一份逻辑,判中时把计数字段一并返回:

// guess 正确分支
return {
  ok: true, correct: true,
  mon: { id: mon.id, name: mon.name, types: mon.types },
  cellCounts: countCells(board, usedNames),
};

改云函数前我先做兼容性判定,因为线上已有用户、小程序端要走审核,云函数能不能先单独部署很关键。结论是向前兼容:旧版前端不读 cellCounts,多返回字段无副作用;新版前端保留旧云函数兜底——if (r.cellCounts) patch.cellCounts = r.cellCounts; else if (!r.cellCounts) this.syncCellCounts();。新旧双向都能跑。

部署完,它立刻就崩了

上传后线上立刻报错,每次调用必失败:

ReferenceError: Cannot access 'board' before initialization
    at exports.main (/var/user/index.js:132:20)

这个报错值得拆开讲。if (action === "guess") 分支块里,第 132 行的重玩逻辑引用了 board,而 const board = buildBoard(dateStr) 声明在第 159 行。块级 const 会遮蔽外层第 81 行的 let board,使整个块自块顶起进入暂时性死区。

删掉块内的重复声明、统一复用外层变量即可。common 模式下外层就是同一个 buildBoard(dateStr),值相同;replay 模式下外层早已构建为 buildBoard(replayDate),语义正确。顺带还省了一次棋盘构建。

比 bug 本身更值得警惕的事

修完我去 git HEAD 核对了这一行——它本来就在。这说明这段重玩逻辑从提交那天起就带着这个错误,而上一个正式版本(1.0.6)发布时,pokedokuQuiz 这个云函数并没有同步部署。也就是说这段代码从未在真机执行过,直到我这次上传才第一次暴露。

它指向一类更麻烦的风险:小程序前端已经发布上线,对应的云函数还停在旧版本。用户看到的是新界面,跑的却是老逻辑。这类错配不会报错,只会让功能行为与预期不符。

两道门禁:运行时 harness 与静态作用域检查

node --check 通过、lint 干净,但它们只覆盖语法层。TDZ 是运行时语义错误,静态语法完全合法,只有执行到那一句才抛。

所以我现在改云函数必须过两道。

第一道是运行时 harness。用 Module._load 劫持 wx-server-sdk,stub 掉 getWXContext、database().collection().where().get()、doc().update()、add() 这些能力,加载真实题库 core.js,直接调用 main() 把全部分支跑一遍——出题、每日猜对猜错、重玩猜对猜错、重玩出题、取答案、放弃、结束后再查一次。修复后 10/10 通过。这里有个容易踩的点:重玩分支的正解必须按 replayDate 当天的棋盘计算,拿今天的答案去猜会被误判成错。

第二道是静态作用域检查:

npx --yes eslint@8 --no-eslintrc --env node,es2022 \
  --parser-options=ecmaVersion:2022 \
  --rule '{"no-use-before-define":["error",{"variables":true,"functions":false,"classes":false}]}' \
  cloudfunctions/*/index.js cloudfunctions/*/core.js

关键是先让工具自证有效:拿 HEAD 里那版有 bug 的代码试跑,规则精确报出 127:20 与 128:20,正是线上崩溃的两行;再用它扫当前全部云函数,输出为空。没有这一步,空输出说明不了任何问题。

验证不必碰数据库

另一个今天沉淀下来的手法:云开发控制台的云函数「云端测试」可以直接传参调用线上函数。传

{ "action": "guess", "mode": "replay", "replayDate": "2026-09-06", "cell": 0, "name": "妙蛙种子" }

返回判错结果——能走到判错这一步,说明执行路径确实读到了 board.rowConstraints,既证明 TDZ 已修,也证明云函数真的部署上去了,而不是命中旧缓存。随后用 {"action":"get","mode":"replay","replayDate":"2026-09-06"} 验证重构后的 countCells 在出题路径里仍返回完整的 9 个计数。

重玩分支不读写数据库(纯校验后返回),所以这条路径是最安全的线上验证方式,不会产生任何测试数据。

另一半工作:给 7 个玩法页接上本地缓存

数独之外,其余玩法页的 onShow 每次切页回来都会重拉整题。每日题当天固定,拉一次本该够用,所以我做了一个 dailyCache 本地缓存层。10 个玩法页里接了 7 个;另外两个没有 onShow(只在 onLoad 拉一次,本来就不重复请求),冲刺模式是限时玩法而非每日题——这三个不接,是有依据的不接,而不是漏了。

缓存层踩到三个坑:一是某玩法的揭示描述动作也消耗一次机会,那一次回写要把剩余次数一并更新,且在次数耗尽时必须先清缓存再拉取,否则会被旧缓存挡住看不到揭晓;二是重试入口必须先清缓存,否则坏数据会让重试立刻再失败一次,用户看到的现象是「点了没反应」;三是提示数组必须用服务端原始数组回写,本地那份已经被加了颜色和图标,存进缓存会重复装饰。另外两个页面的猜答方式不是原地推进而是带参数重拉(保留对比板),这类页面必须在重拉前清缓存,靠重新构建而不是原地回写。

收尾

今天最大的收获不是省下那几百次调用,而是意识到「部署」不该被当成收尾动作。云函数部署独立于小程序审核、立即生效,它才是最容易被放过的一环:小程序端有审核流程兜着,云函数没有,改完就能上,改错也能上。

明天要看日志里数独云函数的调用量有没有从 928 降到 300 上下。如果思路成立,再往其余玩法页推。至于「前端已上线、云函数没跟上」这种版本错配,我打算单独做一轮排查——这类问题不会报错,只会静默地让线上行为与代码不一致。