ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于TypeScript的微信小程序打牌记账源码:类型安全实战

基于TypeScript的微信小程序打牌记账源码:类型安全实战 简介面向计算机科学、信息安全、数据科学与大数据技术、人工智能、通信、物联网等专业在校生及教师的TypeScript打牌记账微信小程序课程大作业源码既可用于课程作业与期末大作业也适合毕设演示和初期项目立项参考。项目围绕打牌记账场景涵盖房间入口、历史记录、账目统计等界面代码经过功能验证运行稳定模块划分清晰支持基于现有逻辑进行二次开发或功能扩展。压缩包共含50个文件主体为17个TS源码、11个JSON配置、11张PNG界面截图、5个WXSS样式与4个WXML页面结构另含JavaScript入口与Markdown介绍文档整体308KB目录结构清晰便于快速定位和学习。已有113人学习下载代码中提供TypeScript类型定义、工具函数与组件目录有助于快速上手小程序工程化写法随包附带的操作截图和介绍文档也可作为排错与二次开发时的参考。1. 打牌记账小程序真正难的不是页面而是“账怎么算不错”打牌记账微信小程序和普通流水账小程序最大的区别是每一局都要记录多人的分差最后还要回答“这一场谁该补谁多少”。这个账一旦当场对不上后果比普通记账翻车严重得多。而 JavaScript 的松散类型恰恰最容易在深夜打牌的场景里埋雷——金额串了、字段拼错、缓存读出来结构不对全是运行时才暴露。这正是“基于 TypeScript 开发的打牌记账微信小程序源码”这类课程作业的价值用 TypeScript 把房、局、玩家、分差的数据结构在编译期锁死再配合小程序原生页面跑通输入、存储、汇总、展示一整条链路。适合想把工程化意识写进课程作业的同学抄结构也适合第一次接触微信小程序 TypeScript 模板的人当参考骨架。2. 牌局记账的核心不是增删改查而是把“一局牌”建模成不会算错的分差结构2.1 从需求反推数据模型先理清“房、局、玩家、结算”四个实体做记账类小程序最常见的错误是一上来就画页面画完发现数据模型撑不住业务规则。打牌记账的规则其实很固定一次聚会是一个“房”一个房里有多个人参与每个人打多轮每一轮产生一个分差最后把分差汇总。先把这四个实体用 TypeScript 接口固定下来后面的页面和计算才不会跑偏。// 某个玩家在一局里的得分记录 export interface PlayerRoundScore { playerId: string; // 玩家唯一标识创建时生成不要用姓名做主键 playerName: string; // 冗余姓名列表页直接展示避免反复查表 score: number; // 本局分差单位分整数别直接存“元” } // 一局牌的数据结构 export interface GameRound { roundId: string; roomId: string; // 属于哪个房 roundNo: number; // 第几局排序和“撤销上一局”都靠它 scores: PlayerRoundScore[]; createAt: number; // 时间戳用来和手工记录对账 } // 一次聚会的完整数据 export interface Room { roomId: string; roomName: string; players: PlayerRoundScore[]; // 参与者快照开局后成员变动不影响历史局 rounds: GameRound[]; rule: DiffScoreRule; } // 计分规则用继承避免每个房间重复定义基础字段 export interface ScoreRule { ruleType: diff | fixed; // 按分差结算还是按固定台费结算 decimalPlaces: 0 | 1 | 2; // 展示用的小数位存储仍然按整数分 } export interface DiffScoreRule extends ScoreRule { ruleType: diff; settleMode: total | avg; // 总差额制还是人均之后取整 }代码逻辑说明房在创建时就生成 players 和 rounds 两个空数组每局一次性记录整桌所有人的分差而不是逐条插入这样校验“一局分差合计为 0”就非常容易。roundNo 是自增序号不依赖时间戳因为时间戳在倒回修改时会乱。interface 继承在这里是最典型的用法基础规则和差异化规则分开后续扩展“按台费结算”时不需要改原有字段——这也是面试里常被问到的 “TypeScript interface 怎么继承” 的实际落点。2.2 结算规则藏在金额类型里为什么用“分”不用“元”很多人写记账小程序直接用toFixed(2)存字符串界面显示很好看一算合计就开始飘。打牌分差往往都是几十、几百的整数但一旦出现“人均”“按比例分摊”这类规则浮点误差就会在累计几轮后被放大。这个源码里我坚持核心计算全走“整数分”只在边界做转换。// 输入框拿到的永远是 string先转整数分统一计算口径 export function yuanToFen(value: string | number): number { const v typeof value string ? parseFloat(value) : value; if (Number.isNaN(v)) return 0; // 乘 100 后取整避开 0.1 * 100 这类浮点尾差 return Math.round((v Number.EPSILON) * 100); } export function fenToYuan(fen: number): string { return (fen / 100).toFixed(2); } // 汇总一个房里所有人的分差 export function calcRoomBalance(room: Room): Mapstring, number { const balance new Mapstring, number(); for (const round of room.rounds) { for (const s of round.scores) { const prev balance.get(s.playerId) ?? 0; balance.set(s.playerId, prev s.score); } } return balance; }参数说明yuanToFen的入参类型是string | number因为表单输入事件e.detail.value在 WXML 里永远是 string不要在事件处理函数里直接转 number 再参与运算Number.EPSILON是给浮点尾差打补丁不是玄学是 IEEE 754 的常规处理方式。calcRoomBalance返回 Map 而不是普通对象因为玩家 ID 可能包含数字下划线Map 对 key 没有任何限制遍历顺序也更可靠。一旦汇总结果里所有玩家分差之和不为 0页面应当弹提示而不是悄无声息地存进去。2.3 把类型约束当成运行时校验的廉价替代品TypeScript 的类型检查只在编译期生效但这里有个课程作业最容易忽略的点小程序的数据来源是 Storage、接口和用户输入全是运行时数据。类型再严格也拦不住一份“曾经合法、后来被旧版本写坏”的缓存。所以在封装读取函数时光用as Room断言不够必须写类型守卫函数在运行时把不符合结构的脏数据挡在外面。// 类型守卫判断一个 unknown 是不是 Room export function isRoom(value: unknown): value is Room { if (!value || typeof value ! object) return false; const room value as Recordstring, unknown; return ( typeof room.roomId string typeof room.roomName string Array.isArray(room.players) Array.isArray(room.rounds) ); } export function loadRoomFromStorage(key: string): Room | null { const raw wx.getStorageSync(key); if (!raw) return null; // 不要直接 as Room先守卫再收窄 if (!isRoom(raw)) { console.warn(缓存结构不合法已清除, key); wx.removeStorageSync(key); return null; } return raw; }逻辑说明value is Room是 TypeScript 的类型谓词函数返回 true 后调用方在这个分支里才能把unknown当Room用。这里还体现了typescript里一个常见的反直觉点类型守卫不是“防止脏数据”而是“防止脏数据以合法身份继续流进界面”。去掉守卫后页面会渲染出 undefined然后以Cannot read property length of undefined的形式在真机上崩溃加上守卫后至少能定位到“缓存坏了”而不是“页面坏了”。3. 原生小程序接入 TypeScript从模板目录到 types 文件夹的声明文件3.1 确认项目骨架TS 模板的关键文件与常见误用微信开发者工具新建项目时如果选择 TypeScript 模板生成的结构和 JS 模板差别不大多出来的主要是几个配置文件和一个 typings 目录。很多同学拿到课程作业源码后第一反应是删掉这些“多余文件”结果类型检查全线变红。以下这些文件一个都不能少。路径作用常见误用project.config.json声明项目类型、miniprogramRoot、编译选项从 JS 项目改 TS 后忘记开 ES6 转 ES5tsconfig.json控制 TypeScript 编译范围、严格度、类型入口include 路径写错导致 global.d.ts 不进编译typings/index.d.ts小程序全局类型声明入口手写签名不完整API 参数全变成 anyminiprogram-api-typings官方 wx API 类型包npm 安装手动 declare 覆盖了原生类型造成冲突package.json记录 npm 依赖和构建脚本没把 miniprogram-api-typings 写进 dependencies这里最值得强调的是miniprogram-api-typings。这个包把wx.getStorageSync、wx.showToast等全部内置 API 的类型声明补齐了没有它你在 TypeScript 里调用任何wx方法都会报“找不到名称 wx”。常见做法是把它装成开发依赖然后在 tsconfig 里显式引入而不是自己写一堆declare const wx: any。3.2 手工把 JS 小程序改造成 TS 的三个步骤如果拿到的是纯 JS 源码想改成 TypeScript 并不需要推倒重来。常见的手工改造路径分三步先补依赖和配置再逐步把app.js、pages/**/*.js改成.ts最后处理编译报错。不建议一次全改小程序页面文件多一次全量改动会让报错列表淹没真实问题。{ compilerOptions: { strict: true, target: ES2018, module: CommonJS, moduleResolution: node, typeRoots: [./typings], types: [miniprogram-api-typings], noImplicitAny: true, skipLibCheck: true }, include: [miniprogram/**/*.ts, typings/**/*.d.ts] }参数说明typeRoots告诉编译器去 typings 目录找声明文件types限制只加载指定的包避免把 node_modules 里无关的全局声明都拉进来skipLibCheck: true是血泪经验——微信官方底层类型里有些声明比较旧不跳过会在每次编译时爆出一堆和业务无关的第三方类型错误。include里同时包含小程序源码目录和 typings 目录缺了后者你写在 .d.ts 里的全局类型根本不会进编译。配置完成后在开发者工具里执行一次“编译”。TypeScript 的报错会直接出现在控制台按顺序修。遇到any报错时与其用as any押掉不如反推一下那个值到底从哪来、应该是什么类型——课程作业阶段养成这个习惯比多写十个页面都有价值。3.3 types 文件夹里的声明文件怎么用全局声明 vs 按需 import“typescript types 文件夹的声明文件如何使用”是搜索热度很高的一个问题课程作业里最容易踩的坑是把 .d.ts 当成普通 ts 文件往里写实现代码。声明文件的职责是“描述形状”不是“提供逻辑”。// typings/global.d.ts declare namespace Bookkeeping { // 声明一个全局工具函数页面里直接 Bookkeeping.parseWaitRecord(raw) 调用 function parseRoom(raw: unknown): Room | null; } // 声明一个调试开关配合条件编译让 console.info 只在开发时输出 declare const BOOKKEEPING_DEBUG: boolean; // 常见做法把页面间传递的参数也声明成全局变量 declare var activeRoomId: string;这段声明文件在项目里生效的前提是 tsconfig 的 include 包含了typings/**/*.d.ts。页面里使用时不需要 import VVV 就能直接引用Bookkeeping.parseRoom因为 namespace 是全局声明但要注意一点判断哪些类型放全局、哪些按需 import衡量标准是使用频率。像Room、GameRound这种整个项目都在引用的类型放全局声明能省掉一大串import而RoomListPageData这种只被一个页面使用的类型就没有全局的必要。还有一个容易被忽略的边界.d.ts 文件不会参与编译输出也不会被打包进小程序代码包。所以你写在里面的函数体如果手滑写了不会在运行时存在这也是为什么声明文件里只写类型签名和declare不写console.log。4. 从输分面板到记录列表打牌记账功能落地的三个关键点与五个必踩的坑4.1 输分面板让“一轮输入只按三下”的交互设计打牌记账的输入场景有一个很现实的需求手速要快不能让人家等。如果每次记一局都要弹数字键盘先输玩家名再输金额一轮下来要按十几次用户用三天就会放弃。这个源码里比较稳妥的方案是预设常用分值按钮 玩家头像点选一局默认只点两下。// 快速记一局入参是当前房间和本局所有人分差 export function appendRound(room: Room, scores: PlayerRoundScore[]): Room { const total scores.reduce((sum, s) sum s.score, 0); if (total ! 0) { wx.showToast({ title: 本局合计 ${total}应为 0, icon: none }); return room; } const round: GameRound { roundId: ${Date.now()}_${Math.floor(Math.random() * 10000)}, roomId: room.roomId, roundNo: room.rounds.length 1, scores: scores.map(s ({ ...s })), // 拷贝避免外部引用篡改 createAt: Date.now() }; // 不可变更新返回新 Room不直接 push return { ...room, rounds: [...room.rounds, round] }; }逻辑说明appendRound是纯函数不在原 room 上 push而是返回新对象。这样页面里可以保留旧 room 作为“撤销快照”实现“撤销上一局”时不用走数据库回滚。scores.map(s ({ ...s }))是浅拷贝这里够用因为PlayerRoundScore里没有嵌套对象。合计校验放在写入前比在 UI 层校验更可靠——任何页面调用这个函数都会被拦一次。参数说明roundId用时间戳加随机数拼不是为了做加密只是避免同毫秒内连续创建两个 round 时 key 冲突roundNo用room.rounds.length 1而不是取最后一条的 roundNo 加一因为删除历史局后最后一条序号可能不是最大。4.2 持久化把 Storage 封装成带命名空间和类型守卫的存取函数小程序的 Storage 接口本身很简单坑都在“类型”上。wx.setStorageSync存储的时候不检查类型读取的时候返回 any——编译期完全不管。更隐蔽的问题是 key 冲突课程作业里多个页面如果都想存“当前房间”各写各的 key真机缓存就会串。这里建议做一个带前缀的存取封装所有模块共用一套入口。const PREFIX bk_; export function saveRoom(room: Room) { // key 带房间 ID避免多个房间互相覆盖 const key ${PREFIX}room_${room.roomId}; wx.setStorageSync(key, JSON.stringify(room)); } export function removeRoom(roomId: string) { wx.removeStorageSync(${PREFIX}room_${roomId}); } export function listRoomKeys(): string[] { const keys wx.getStorageInfoSync().keys; return keys.filter(k k.startsWith(PREFIX room_)); }参数说明JSON.stringify(room)这一步很有必要虽然 Storage 能直接存对象但存 JSON 字符串配合 2.3 里的isRoom守卫才能在读取端做结构校验直接存对象的话读写两端都只能靠as断言。listRoomKeys返回的是 key 数组而不是数据是为了首页列表可以先用 key 做轻量渲染点击进详情后再真正解析——这也是“微信小程序页面列表加载更多”场景下的推荐姿势先渲染列表头数据按需加载。提示小程序 Storage 单 key 上限约 1MB总上限约 10MB。打牌记账一个房几十局通常放得下如果一房几百局就该考虑换云开发数据库而不是继续往 Storage 里堆。4.3 记录列表的加载更多分页参数、触底判断与离开页面保存现场房间详情页要展示全部局记录如果一次性渲染所有 round页面会越来越卡。原生小程序里做加载更多核心是onReachBottom生命周期 分页参数。这里说一个课程作业里很容易漏掉的点页面 onReachBottom 触发的条件是页面滚动到底部如果你的自定义导航栏没有适配顶部安全区滚动高度算错触底事件就永远不触发。export function paginateRounds(rounds: GameRound[], page: number, pageSize 10): GameRound[] { const start (page - 1) * pageSize; return rounds.slice(start, start pageSize); }逻辑说明paginateRounds不直接修改 data而是返回当前页应该渲染的那一截。页面里用一个pageNo变量记录当前加载到第几页onReachBottom里pageNo 1然后setData({ list: [...list, ...paginateRounds(...)] })。这里用展开运算符不是啰嗦是为了让 WXML 层收到新数组时能触发 diff 更新直接 push 后 setData 同一个引用界面不会刷新。顶部导航栏高度的问题建议直接写进工具函数。常见做法是用wx.getMenuButtonBoundingClientRect()拿胶囊按钮位置再用wx.getWindowInfo()拿窗口尺寸去算自定义导航栏的高度不要写死 44px——不同机型的差异让你在真机上调试时怀疑人生。另外用户打到一半切去微信回消息再回来发现草稿丢了这是记账场景最高频吐槽。解决方案很朴素在onHide和onUnload里都把当前正在编辑的局保存成草稿onShow时恢复。监听用户离开小程序不要只依赖onUnload因为微信切后台不会销毁页面。4.4 避坑给 TypeScript 微信小程序新手的 5 条踩坑记录现象 1编译全过真机一进房间页就报Cannot read property length of undefined。 原因Storage 读出来的对象字段缺失类型守卫没做完整校验代码里又直接用room.rounds.length。 解决把 2.3 节isRoom的字段校验做全至少确认rounds是数组后再访问length不要在上游用as Room硬吞。现象 2一局内分差合计明明应该是 0算出来总是差几分。 原因界面用元为单位显示输入框拿到的字符串被Number(value)之后产生浮点尾差累计多次后误差暴露。 解决输入框录入后立刻走yuanToFen转成整数分所有计算用整数只在 WXML 渲染层用fenToYuan转回带小数的字符串。现象 3setData传了一个 number界面上显示NaN。 原因传统输入事件e.detail.value返回的是 string直接参与四则运算后赋值给score非数字字符被转成了 NaN。 解决写一个toScoreValue函数统一做 parse 和兜底任何从 WXML 拿到的值都先经过它再进入数据层顺带把PlayerRoundScore.score的注释写上“单位分整数”提醒后来人。现象 4在 Page 方法里用this拿 data经常是 undefined。 原因微信小程序的 Page 对象方法里的this绑定是动态的在回调函数、定时器、异步请求里用this会丢。 解决页面加载时就const page this存一下或者在onLoad里用箭头函数方式绑定不要在 Promise 里直接this.setData。现象 5类型报错到处飘红线看 wx API 也报错项目根本跑不起来。 原因没装miniprogram-api-typings也没在 tsconfig 里配置types。 解决按 3.1 节表格里的方式配好依赖和配置文件如果用了 npm 且开发者工具的“构建 npm”没开还要先在工具里点一次“构建 npm”否则 node_modules 里的声明文件不会进编译。5. 提交前给源码做一次“可演示体检”清理冗余、缩小包体与三角对账课程作业源码和工程化项目的验收标准不太一样老师或面试官会直接打开开发者工具编译点开你的页面看交互是否顺畅代码包是否超限。因此把功能做出来只是第一步提交前还要过一道“可演示体检”。第一是清仓。课程作业从开始到交往往往改过很多版pages/下可能残留着没注册的页面目录images/堆了一堆调试截图这些都会算进主包体积。小程序主包上限 2MB超了直接传不上去。检查project.config.json的packOptions.ignore把没用的目录配进去同时把app.json里没用的页面注册删掉因为 pages 数组里每一个页面都会被编译。第二是编译设置。开发者工具的“本地设置”里ES6 转 ES5必须打开上传代码时自动压缩样式文件也要打开。课程作业经常用到的Promise、async/await在不做 ES6 转 ES5 的机型上会直接挂开发者工具里正常不代表真机正常。压缩选项开关可以在project.config.json的setting里看到提交前看一眼。第三是三角对账。这里的“三角”不是指三个玩家而是“代码算的结果、页面显示的结果、手工算的结果”三方比对。不要只在页面里点两下就认为逻辑正确尤其结算逻辑改过一次就会引入新 bug。// 三角对账冒烟造一个 3 人 3 局的样本房断言每个玩家总分 function assertBalance(room: Room, expect: Mapstring, number): boolean { const balance calcRoomBalance(room); for (const [playerId, value] of expect) { if (balance.get(playerId) ! value) { console.error(对账失败${playerId} 期望 ${value} 实际 ${balance.get(playerId)}); return false; } } return true; }这段断言脚本可以放在开发者工具的“普通编译”模式下通过wx.setStorageSync注入样本数据后自动执行。它的核心价值是把“手工点一遍查错”变成“每次改动后立刻能跑”后续所有章节每一次改结算逻辑都值得花一分钟跑一遍三角对账。我自己最尴尬的一次演示经历就是在现场把一局分差记错页面显示的总额和手工算的差 20 分又找不到是哪个环节出的问题。后来养成习惯任何输出到页面的聚合数值都在提交前跑一遍断言对账任何从 Storage 读出来的数据都过一道类型守卫。这才把“当场翻车”的概率压到最低。这套源码骨架不一定是你最后提交的版本但用它做底子往里面加“撤销上局”“按玩法分组”“导出账单”这些功能时你会发现凡是类型约束到位的地方改动都很有底气凡是之前贪快用 any 糊弄过去的地方改两行就炸一片。希望帮到你。本文还有配套的精品资源点击获取
返回列表