项目背景
最近在研究一个叫"3Q工具箱·真心话大冒险"的微信小程序项目,技术栈是微信原生框架 + TypeScript,纯单机、零网络、零后端。这个项目有几个工程上的亮点值得拆一下:13 个玩法+派对模式如何用一个轻量架构承载、个人主体如何在合规约束下做产品完整性、以及如何用工程化手段兜底合规要求。
本文从源码角度聊聊它的几个关键设计。
架构总览
miniprogram/ ├── app.ts # 入口,导航度量只在 onLaunch 算一次 ├── pages/ # 主包 9 页 │ ├── home/ # 首页(玩法分组+派对 CTA) │ ├── party-config/ # 派对配置 │ ├── party/ # 派对进行中 │ ├── party-result/ # 派对结算 │ ├── players/ # 玩家名单 │ ├── records/ # 惩罚记录 │ ├── bank/ # 题库 │ ├── settings/ # 设置 │ └── about/ # 关于 ├── packageDice/ # 分包:骰子专区 ├── packageLuck/ # 分包:运气类 ├── packageCard/ # 分包:卡牌文字类 └── utils/ ├── games-meta.ts # 玩法注册表(单一数据源) ├── party.ts # 派对调度纯函数 ├── store.ts # 手写极简 store ├── storage.ts # 本机存储+版本迁移 └── types.ts # 类型定义主包 9 页 + 3 个分包共 13 个玩法页。preloadRule 在进入对应分组时预加载分包。
关键设计一:一份元数据驱动多处
这是整个项目最值得学的地方。
utils/games-meta.ts里定义了一份 GAME_META 数组,每个玩法一条记录,包含 key、name、subtitle、group、color、badge、path、title、minPlayers、party、needsAi 等字段。
这一份数据同时驱动:
- 首页分组渲染:
groupedGames(aiOpponent)函数返回按组分类的卡片数据,首页 wxml 直接 wx:for 渲染 - 派对模式玩法池:
partyGames()函数过滤 party=true 的玩法 - 人机开关文案切换:
resolveAi(game, aiOpponent)函数根据开关返回带 aiSubtitle/aiBadge 的形态 - 页面标题 SEO:title 字段对应各页 index.json 的 navigationBarTitleText
玩法落地时只需要填上 path 字段,首页和派对池自动出现,不用改首页逻辑。这种"单一数据源"的设计在玩法数量增长时优势明显——加一个玩法只改一处,不会出现首页有入口但派对池没有的漂移。
exportfunctionresolveAi<TextendsGameMeta>(game:T,aiOpponent:boolean):T{if(!game.needsAi||!aiOpponent)returngamereturn{...game,subtitle:game.aiSubtitle||game.subtitle,badge:game.aiBadge||game.badge,minPlayers:game.aiMinPlayers===undefined?game.minPlayers:game.aiMinPlayers,}}更妙的是,首页分组时把奇数个玩法的最后一张标记为 wide,由 CSS 跨两列占满,不论将来每组有几个玩法都不会出现空缺。
关键设计二:派对调度器是纯函数
utils/party.ts里的调度逻辑是纯函数,输入(已用玩法、池子、轮次)→ 输出(下一个玩法)。
核心约束是"同一玩法在连续 3 轮内不重复",用滑动窗口实现:
- 维护最近 3 轮的玩法 key 列表
- 从池子里过滤掉这 3 个
- 池子不足 3 个时降级为"不与上一轮重复"
- 都不满足时才允许重复
这个逻辑不依赖小程序运行时,可以单独写 Jest 单测。项目里有 13 个测试文件覆盖各玩法的纯函数。
派对模式不改 13 个玩法页——这是低耦合的关键。玩法页完全无感知派对模式的存在。派对模式通过 roundStartedAt 时间戳筛"本轮新增的惩罚记录",玩法页该怎么写怎么写,派对结算时按时间戳聚合即可。
关键设计三:人机开关约束与 precheck
个人主体做小程序有一个隐形陷阱:人机对战的文案一致性。
需求文档里明确写了三条约束:
- 电脑是否参与只由
settings.aiOpponent决定,玩法内部不得有第二个开关 - 玩法注册表里用 needsAi 标记,并同时给出开关打开时的副标题/角标/最少人数
- 开关关闭时该玩法必须有一个可玩的真人形态
这三条不是靠人肉 review,而是靠scripts/precheck.mjs在发布前自动校验。precheck 会扫描所有 needsAi=true 的玩法,检查是否有 aiSubtitle、aiBadge、aiMinPlayers,并验证首页文案逻辑调用了 resolveAi。
这种"把合规判断写成检查脚本"的做法非常值得借鉴。人肉 review 会漏,CI 不会。
关键设计四:手写极简 store
项目没有引 MobX,自己写了一个极简的订阅/退订模式。对于这种状态不复杂的应用,引入状态管理库反而是负担。
store 的核心是:setState 时遍历订阅者回调,组件 onLoad 时订阅、onUnload 时退订。没有 selector、没有衍生状态、没有中间件——够用就行。
这个判断是合理的。很多人一上来就 MobX/Redux,其实小程序原生的 setData + 一个极简 store 能解决 90% 的问题。
关键设计五:隐私合规的工程化
打开about/index.wxml,明确写着"不收集、不上传、不共享"。这不是营销话术,而是工程约束:
- 不调用 wx.login(不获取 openid)
- 不获取头像昵称(不写 open-data 组件)
- 不申请位置、相机、相册读取、手机号
- 唯一权限是 scope.writePhotosAlbum(保存战绩图,拒绝可降级为长按保存)
- 所有数据走 wx.setStorageSync,纯本机
更关键的是,这套约束在工程上有兜底:precheck.mjs 会扫描源码,如果发现调用了 wx.login 或获取了敏感权限,直接拦截发布。
关键设计六:导航度量只在 onLaunch 算一次
小程序的 getMenuButtonBoundingClientRect 和 statusBarHeight 是很多开发者反复在每页 onLoad 里算的东西,其实没必要。这个项目在 app.ts 的 onLaunch 里算一次存 globalData,各页直接读 globalData 即可。
// app.tsApp({onLaunch(){constsys=wx.getWindowInfo()constmenu=wx.getMenuButtonBoundingClientRect()this.globalData.navTop=menu.topthis.globalData.navHeight=menu.heightthis.globalData.statusBarHeight=sys.statusBarHeight},globalData:{}})小细节,但能避免每页重复计算和潜在的机型适配 bug。
关键设计七:玩法分主持人式与交接式
这个设计解决了一台手机多人玩时的核心痛点:私密信息怎么不被下家看到。
- 主持人式(公开玩法):顶部统一用"轮到 XX"提示条,出结果后必须主持人点"做到了/没做到"才推进,惩罚默认记给当前轮到的人。
- 交接式(含私密信息如大话骰、数字炸弹):换人时插入全屏交接遮罩,必须本人亲手点开才继续。私密内容用不透明遮罩,遮罩期间不渲染秘密节点。
数字炸弹的秘密不属于某个人,用半透明遮罩,只承担"明确换人"的作用。这种"按信息私密性分流交互"的细节,是产品想清楚了的标志。
一些工程取舍的讨论
不是没有可挑剔的地方:
- 个人主体不能开通微信支付,所以变现路径受限。这个天花板是结构性的。项目代码层预留了广告插口(ad-slot 组件),AD.enabled 为 false 时整节点不渲染,达标后改配置开关即可上线。## 关于合规的几点工程化处理
这个项目在合规上做了几件值得学的事:
- 类目选"工具"不选"游戏"。游戏类目需《网络文化经营许可证》+ 版号,个人主体拿不到;且平台明确"一旦选择游戏类目,不可再变为其他类目"。产品叙事落在"聚会决策与随机工具"。
- 零酒精词汇。参考项目里饮酒语义是主线,本项目整体改造为"惩罚动作"体系(做鬼脸、学动物叫、发朋友圈),酒精只作为用户自建内容存在。
- 进阶内容分级开关。默认全年龄题库,进阶档需手动开启且有二次确认,不含性暗示、身体接触、露骨、酒精内容。
- UGC 不传播。自定义题库仅存本地、不上传、不共享、不可跨用户传播,从机制上规避 UGC 传播风险。
- 诱导分享红线。任何功能不以分享为解锁条件,不做分享得次数/积分。
- 隐私承诺工程化。不收集、不上传、不共享用户数据,所有数据仅存本机,并在发布前用 precheck 脚本扫描敏感 API 调用,从机制上兜底合规要求。## 总结
3Q工具箱·真心话大冒险在工程上做对了几件事:
- 单一数据源(games-meta)驱动多处,玩法扩展只改一处
- 派对调度纯函数化,低耦合不改玩法页
- precheck 脚本兜底合规约束,不靠人肉 review
- 手写极简 store,不引状态管理库
- 隐私承诺工程化,precheck 扫描敏感 API 调用
- 主持人式/交接式分流,解决私密信息传递问题
对于做微信原生小程序的开发者,这个项目的架构和工程化思路值得参考。特别是"把合规判断写成检查脚本"和"一份元数据驱动多处"这两个设计,在很多场景下都能用上。