我做了个儿童哲学课堂的小程序,微信原生,没后端、没联网。
上线后老师提了个要求:同一个孩子每次进来,抽到的话题别重复。
我心想这还不简单,随机抽题不就完了。改完第一版让老师试,她点开,聊了一个,退出,再进来——
这道题我刚才不是聊过了吗?
回去一看,去重逻辑是写了,但只管了"话题"这一层,题目没管,聊满一轮之后怎么办也没管。
还有个更扎心的地方:这个 bug 我自己手动测不出来。因为我自己用的是新身份,历史上根本没记录,程序每次都判定"这话题没聊过",自然测不出问题。得用真实孩子的账号,用够了次数,才会暴露。
所以"不重复"这件事,至少得拆成三层来管。少一层,用户立刻就能发现。
我最后把逻辑收进了两个函数。topicsOf负责列出去重后的话题清单,drawTopic 负责挑。
整体的决策流是这样的:
进入 drawTopic(grade, excludeTopics, excludeIds) │ ├─ 第 1 层 话题级:这个话题聊过没有? │ └─ 全部聊过 → 兜底:pool = list.slice(0),清空重开一轮 │ (没有这一句会永远抽不到题) │ ├─ 第 2 层 题目级:优先挑"话题内所有题都没做过"的 │ └─ 全都做过了 → 兜底:退回整池随机 │ └─ 第 3 层 轮次边界:记录进 talkedTopics,供下一轮排除
三层各有各的兜底分支,少任何一层都会在某个边界情况下翻车。
function topicsOf(grade) { var list = BANKS[grade] || []; var out = []; for (var i = 0; i < list.length; i++) { if (out.indexOf(list[i].t) < 0) out.push(list[i].t); } return out; }
我的题库结构是这样的:每个年级 12 个话题,每个话题 1道主问题 + 2 道延伸题,一共 36 道。
所以即使话题没聊过,同一个话题内部还有 3 道题可以挑。这就是第 2 层存在的意义:
// fresh = 这个话题下所有问题都还没做过的 var fresh = []; for (var j = 0; j < pool.length; j++) { var qs = questionsOf(pool[j]); var allNew = true; for (var k = 0; k < qs.length; k++) { if (exQ.indexOf(pool[j].id + '|' + qs[k]) >= 0 || exQ.indexOf(qs[k]) >= 0) { allNew = false; break; } } if (allNew) fresh.push(pool[j]); } var cand = fresh.length ? fresh : pool; // 全都做过了就退回整池
这是最容易漏的一层。如果不写这一句,等孩子把某个年级的 12 个话题全聊完,程序就会去一个空池子里抽题,表现是永远抽不到题。
我自己一开始没意识到这个坑,因为低年级孩子根本聊不满 12 个——只有高年级、或者长期使用的孩子才会撞上。这种 bug 在测试阶段几乎抓不到,只能等真实用户用够了才暴露。
if (!pool.length) pool = list.slice(0); // 聊完一轮 → 自动重新开始
要记住"谁聊过什么",就得有个查找依据。我用的是:
function topicKey(p) { return [p.school, p.nick, p.grade].join('|'); }
也就是 学校 | 昵称 | 年级。这个设计有个好处:同一个孩子换设备、重装、清缓存都不影响,只要他填同样的三个信息,历史就还在。而且不同孩子、不同年级之间天然隔离,不会互相污染。
存储结构是一个对象,每个键下挂两个数组:
var KEY_TOPICS = 'wr_topics'; // { '学校|昵称|年级': { talked:[], qids:[] } }
我一开始在登录页的初始化里写了一句:
S.talkedTopics = []; // ❌ 错
看起来只是"初始化",但它每次登录都会把历史清空。结果就是:孩子第一次抽到新题很兴奋,退出重进又回到原题。
修法就一行——初始化时必须读回:
S.talkedTopics = STORE.talkedTopics(profile);
教训:凡是"重置型"的初始化代码,先问一句"这个变量是不是跨会话需要保留的"。需要保留的,就该从存储读,而不是赋空值。
这个坑值得单独讲,因为它花了我最久,而且表面上看不出任何错误。
对话主线是设计成这样的 4 段:
探索(6 问)→ 结论时刻 → 余问时刻 → 收尾
但实际跑起来,结论时刻被整段跳过了,界面上的进度点和 AI 说的话对不上。
我以为状态机写错了,去检查 phase 的跳转逻辑,越查越觉得没问题——因为它单独看是对的。
问题出在顺序上。原来的写法大概是:
1. advance() 先算出下一个阶段,立刻 setData 更新 phase 2. 生成 AI 回应 ← 这里读到的却还是旧 phase
也就是说,界面已经显示进入"结论时刻"了,但 AI 这一轮说的话还是按"探索"阶段生成的。气泡内容和进度点对不上,下一轮再算阶段时就跳过去了。
把它拆成"先算 → 再说 → 最后落库"三个明确的动作,每一步职责单一:
nextPhase: function () { // ① 先算出本轮该处于什么阶段 var fi = this.data.followIdx + 1; var cur = this.data.phase; if (cur === 'explore') return fi >= GUIDE_QUESTIONS ? 'conclude' : 'explore'; if (cur === 'conclude') return fi > GUIDE_QUESTIONS + 1 ? 'wonder' : 'conclude'; if (cur === 'wonder') { var last = this.turns[this.turns.length - 1]; return (last && AI.hasWonder(last.text)) ? 'wonder' : 'done'; } return 'done'; }, answer: function (text) { var phase = this.nextPhase(); // ① 先算 var r = AI.reply({ answer: text, phase: phase }); // ② 按这个阶段说话 this.pushAi(r.text, r.strategy); this.commit(phase, fi); // ③ 说完再落库 }, commit: function (phase, fi) { ... } // 只负责写数据,不负责决策
一句话总结:状态不能边算边改,要先算清楚、说完话、再落库。凡是"计算下一步"和"执行当前步"写在一起,就容易出现这种"显示和内容对不上"的错位。
重构 Page 对象里的方法时,我犯过一个错:把 answer 改成了三个方法,但旧的 answer 没删。
Page({ answer: function (text) { /* 旧版本 */ }, // ...中间加了别的代码... nextPhase: function () {...}, answer: function (text) { /* 新版本 */ }, // 覆盖了旧的,JS 不报错 commit: function (phase, fi) {...} });
JavaScript 允许对象字面量里有同名属性,后者默默覆盖前者,不报任何错。运行时表现是"新代码没生效",但你翻代码时两段都在,会怀疑人生。
修法很土但有效:改完数一遍方法名出现次数。
grep -c "answer:" pages/chat/chat.js // 应该是 1
既然 bug 都是"看起来对其实错"的类型,我干脆写了 5 套自动检查脚本(无头运行,不打开界面),像给小学生判卷一样判自己的代码。目前全绿 326 项。
跑门禁时我遇到 14 项失败,直觉是"代码又坏了",准备改代码。逐条看下来,11 项是我自己的断言写错了,只有 3 项是真缺陷。
比如我断言"出现『不对』二字就是批判孩子",于是 FAIL 了。可实际句子是:
先别管它对不对,我想说的是……
这明显是接住孩子的念头、不急着评判,是好设计,不是 bug。我改的是断言。
而另外 3 项是真的:5 类价值观没识别出来、重复字压缩把孩子的叠词压坏了("为什么为什么"被压成"为什")。这些改了代码。
我把当时的判据整理成了一张表。左边是门禁报错的现象,右边是我最后的判定。
# | 门禁报错现象 | 真实原因 | 判定 | 处理 |
|---|---|---|---|---|
1 | 回应含 " 不对 " ,判定为批判孩子 | 原句是 " 先别管它对不对 " ,是在接住念头 | 断言错 | 改断言 |
2 | 句库缺 " 引导追问 " 关键词 | 引导问题已改成动态生成,不再是固定句式 | 断言过时 | 改断言 |
3 | 追问策略数应为 11 | 实际就是 11 ,是断言里写死了 12 | 断言错 | 改断言 |
4 | 追问模板未以问号收尾 | 逐条查了,真有一条模板用了句号 | 代码错 | 改模板 |
5 | " 这个题真难 " 触发了价值观 | " 难 " 不是价值观触发词,只是抱怨 | 断言错 | 改断言 |
6 | " 公园 " 被当成错别字纠正 | 词表里 " 公 "→" 工 " 的规则误伤了正常词 | 代码错 | 加保护位 |
7 | 价值观漏判: " 抄别人的答案 " | 正则只覆盖书面语 | 代码错 | 补正则 |
8 | 价值观漏判: " 把他赶走 " | 同上,儿童口语未覆盖 | 代码错 | 补正则 |
9 | 重复字压缩把 " 为什么为什么 " 压成 " 为什 " | 重复单元取错 | 代码错 | 改算法 |
10 | 重复字压缩把 " 天天 " 压成 " 天 " | 合法叠词未进白名单 | 代码错 | 加白名单 |
11 | 总分 58 未达 70 分上榜线 | 断言用旧样本间接验证,样本已涨分 | 断言错 | 改断言 |
从这张表能看出两件事:
第一,"断言错"占了 11/14。门禁脚本本身也是代码,也会错。如果你把它当成"绝对权威",就会陷入无限改代码的死循环。
第二,真正的代码缺陷有共性——都是"覆盖不全"或"边界处理错"。"抄别人的答案"和"把他赶走"漏判是同一类问题:写规则时只想着书面表达,没想着真实用户会怎么说。
看到 FAIL 先逐条判断"断言过时 or 代码错",别看到 FAIL 就改代码。
具体操作上,我用了一个很土但有效的判据:把报错的那句话在脑子里过一遍,问"这句话在一个正常用户嘴里会被说出来吗?"
会→ 大概率是断言错;不会 → 才是代码错。
比如"这个题真难"——正常孩子绝对会说这句话,所以它不该触发价值观追问,是断言错了。而"抄别人的答案"也是正常孩子会说的,所以是正则漏了,是代码错。
这条经验可能比任何一条技术细节都值钱。
有一轮批量改文案时,我用脚本一次性替换 108 处字符串,每处都报"命中成功"。但跑完门禁发现少了 7 处——工具报告成功,文件实际没变。
从那以后我养成了习惯:批量改完必须抽查几个关键锚点,比如
grep -r "连起来想" . # 应该是 0 命中
不放心就再跑一遍全量替换,脚本按"精确整串 old→new"匹配,重复运行是幂等的。
所以我现在不信任何"已完成 N 处修改"的报告,只信我自己 grep 出来的结果。
可能有朋友好奇:326 项检查是怎么跑起来的?其实没有测试框架,就是纯 Node 脚本,手写 ok() 数对错。
核心就三行:
let pass = 0, fail = 0, fails = []; function ok(name, cond, extra) { if (cond) pass++; else { fail++; fails.push(name + (extra ? ' → ' + extra : '')); } }
难的是怎么让小程序代码能在 Node 里跑起来。小程序没有浏览器环境,wx、Page、getApp 全都不存在。解法是造一个假环境,把这些 API 用 vm 沙箱注入进去:
const vm = require('vm'); function makeEnv(seedStore) { const wxObj = { __store: seedStore || {}, getStorageSync(k) { return this.__store[k] === undefined ? '' : JSON.parse(JSON.stringify(this.__store[k])); }, setStorageSync(k, v) { this.__store[k] = JSON.parse(JSON.stringify(v)); }, showModal(o) { if (o.success) o.success({ confirm: false, cancel: true }); } // ...按需补toast / navigateTo 等 }; const sandbox = { console, wx: wxObj, getApp() { return app; }, Page(o) { sandbox.__page = o; }, setTimeout, clearTimeout, Date, Math, JSON, Object, Array, String, Number, RegExp, require(p) { return sandbox.__require(p); } }; return sandbox; }
这里有个必须注意的坑:getStorageSync 首次必须返回空字符串,不能返回 undefined。因为小程序里读一个从没写过的 key,返回的就是空字符串;你要是返回 undefined,代码里 if (!cached) 和 cached.length 的行为就全变了,测出来的东西不作数。
同理,setStorageSync 存进去要深拷贝,否则测试里改一个对象会污染到"上一次读取"的值:
setStorageSync(k, v) { this.__store[k] = JSON.parse(JSON.stringify(v)); },
Page({...}) 只是注册,不会自动实例化。测试里要自己造一个带 setData 的实例:
function mount(cfg) { const inst = Object.assign({}, cfg); inst.data = JSON.parse(JSON.stringify(cfg.data || {})); inst.setData = function (o) { Object.assign(this.data, o); }; return inst; }
setData 写成同步的 Object.assign 就够了——真实小程序是异步的,但测试里我们只关心"状态有没有变对",不需要模拟渲染。
我一开始写断言时直接去读假存储的内部结构:
// ❌ 错:绑死了实现细节 wxObj.__store[KEY].records.length === 3
这样写有个坏处:只要你改了存储的内部组织方式(比如把数组换成对象),测试就全红了,但代码其实没问题。
正确做法是调它自己暴露的接口:
// ✅ 对:只依赖公开方法 STORE.all().length === 3
all() 返回什么、怎么存的,是它自己的事。你的断言只关心"能读出来几条"。测试应该验证行为,而不是验证实现。
小程序里有 1600ms 的延迟动画。我在测试里等 2100ms:
const wait = ms => new Promise(r => setTimeout(r, ms)); await wait(2100); // finishRound 的定时器必须跑完
等不够就读不到刚写入的成绩,会误报 FAIL。这个坑我踩了两次才学会:不是代码有问题,是你的等待时间不够。
把结果写进文件,别指望 stdout:
fs.writeFileSync('_mp_check.txt', `PASS ${pass} / FAIL ${fail}\n` + fails.join('\n')); process.exit(fail ? 1 : 0);
因为脚本一调 process.exit,管道里的 stdout 就被截断了,终端上常常只能看到第一行"已就绪"。写文件最稳。
我把检查拆成 5 个文件,各管一段:
脚本 | 干什么 | 项数 |
|---|---|---|
_input_check.cjs | 输入处理:错别字纠正、价值观追问 | 138 |
_ai_check.cjs | 对话质量:追问不重复、语气、价值观识别 | 60 |
_mp_check.cjs | 小程序全流程(沙箱真跑) | 61 |
_topic_flow.cjs | 完整对话流程 | 34 |
_topic_check.cjs | 话题结构静态检查 | 33 |
合计 326 项。拆开的好处是跑得快、失败好定位。全塞一个文件里,跑一次要一分多钟,失败还得翻日志找是哪块。
拆开的好处是跑得快、失败好定位。全塞一个文件里,跑一次要一分多钟,失败还得翻日志找是哪块。
说实话,这 326 项里有一部分是"改了需求就得改断言"的重复劳动。但比起"改完代码不知道对不对,每次都要手动点一遍小程序",这个成本划算太多了。
一、做不出来的事,界面上别装。
需求是"家长能看到孩子聊的内容"。但微信本地存储是不跨设备的,纯本地做不出实时同屏——家长手机上打开,孩子平板上聊,两个设备各存各的。
我一开始想用房间号糊过去,后来想明白了:房间号在这套纯本地方案里,真实作用只能是"约定同一场对话",不是"同屏"。所以我把文案改了,直接告诉用户这是干什么用的。真要同屏得接云开发,那是另一个项目。
装得出来的东西骗得了一时,骗不过第二周。
二、需求会翻案,别急着删东西。
登录页那个"其他学校(自己填写)"的入口,老师一开始说不要,说只留指定的几所。我照做了,把相关代码全清了。
后来拿到完整的需求清单,才发现这项要保留。
教训是:删东西之前先问一句"这个会不会被加回来"。收到新清单,别推倒重来,拿现有的逐条对一遍,只改有差异的地方。
三、AI 写代码,便宜的是速度,贵的是验证。
写代码确实快了很多,但我后来算过一笔账:真正花时间的地方全是"怎么确认它是对的"。
所以我才去写那 5 套门禁。一开始觉得是自我折腾,写完发现每次改完代码跑一遍,心里踏实。改代码的心理负担小了很多,这个投入是值的。
就是有点烦——需求一变,断言就得跟着改,改完还得重跑。
待补充:效果段 |
|---|
以下事实需要作者确认后补入正文,在确认之前不要发布。 请填真实情况(不代为编造): · 上线后有 ____ 个孩子实际在用 · ____ 天内没有出现"题目重复"的反馈 · 之前手动测测不出的问题,是怎么被发现的(谁反馈的?用了多久?) · 有没有某个孩子/某次对话的具体例子,能说明改动前后的差别 |
下面这些是可以直接抄走的。
1. "不重复"这类需求,至少拆成话题级 / 内容级 / 轮次边界三层,每层都要有兜底分支。少一层就会在边界情况下翻车。 2. 记忆键用"身份三元组"(学校|昵称|年级),天然隔离不同用户,重装不丢。 3. 状态机:先算 → 再说 → 再落库,三者分开写。凡是"算下一步"和"做当前步"混在一起写,就会出现"界面显示和实际内容对不上"的错位。
如果你也在做类似的项目,我整理了这份对照:
环节 | 容易踩的坑 | 怎么防 |
|---|---|---|
需求理解 | 把 " 不要重复 " 当成一层,实际要三层 | 追问边界:上限是多少?超了怎么办? |
初始化 | 登录时 xxx = [] 把跨会话数据清空 | 凡 " 重置型 " 初始化,先问 " 这变量要跨会话保留吗 " |
状态机 | 边算下一阶段边改状态, AI 说的话和进度点错位 | 拆成 nextPhase / answer / commit 三个方法 |
重构 | 对象字面量里方法重名,旧方法被静默覆盖 | 改完 grep -c " 方法名 :" 确认只剩 1 个 |
批改 | 工具报告 " 已替换 108 处 " 但实际有几处没落盘 | 批量改完 grep 关键锚点抽查 |
存储 | 测试里直接读假存储的内部结构 | 断言只调公开方法(如 STORE.all() ) |
异步测试 | 定时器等不够,读不到刚写的成绩 | await wait(ms) 比定时器长一点 |
门禁 | 看到 FAIL 就改代码 | 先判断 " 断言错 or 代码错 " |
交付 | 收尾内容放在某些标签里,转换时静默丢失 | 转换后解包校验关键文本是否在场 |
诚实性 | 做不到的功能在 UI 上假装做得到 | 做不到就写明,别骗用户 |
最实用的不是某个 AI 工具,而是给自己写自动检查脚本。当项目有 300 多个断言在盯着你的时候,改代码的心理负担会小很多。
而且这个投入是一次性的:门禁写完之后,每次改代码都免费复用。
关于这个项目 |
|---|
本文提到的项目是我在工作坊里做的一个儿童哲学课堂小程序,无后端、无联网,所有数据都存在本地。如果你想看某一部分的具体实现,欢迎在评论区留言,我补充上来。 |
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。