☰
小程序开发实战:用猜数字游戏拆解状态管理、缓存与交互打磨
2026/10/14 5:06:25 网站建设 项目流程

小程序练手项目翻来覆去就那么几个,猜数字小游戏绝对算得上“入门友好、坑却不少”的典型。界面简单,规则清晰,看起来半天就能写完,可真要把它做到在各种手机上都能顺畅玩、不出现邪门逻辑,你需要把小程序开发里最核心的那几根筋骨都过一遍:视图层渲染、业务逻辑拆分、生命周期管理、本地缓存、交互手感打磨。这篇文章我就用猜数字小游戏作为实例,完整拆一遍从需求到发布的自测过程。代码量不大,但每一步都有讲究,适合正在学小程序开发、想通过一个完整Demo把基础打牢的同学,也适合已经写过不少页面但没认真处理过状态和缓存的开发者。

这个项目没有后端、没有登录、没有云开发,纯前端就能完成。正因为简单,它才逼着你去思考“输入框拿到的到底是什么类型”“切换页面后进度要怎么保留”“连续点按钮会不会崩”这类在小程序里非常经典的问题。我按照自己做这个项目时的真实顺序来讲,先定规则,再搭页面,然后写逻辑,最后打磨体验和自测,你跟着走一遍就能得到一个可以直接跑通的成品。

1. 猜数字游戏的业务拆解:规则、数据与那些“想当然”的坑

1.1 规则先定清楚,代码才不会写乱

我做的版本规则是这样的:系统在1到100之间随机生成一个整数;玩家每次输入一个数字,点击“猜”提交;系统提示“偏大了”“偏小了”或“猜中了”;最多10次机会,用完还没猜中就失败,并显示正确答案;支持“再来一局”。

规则本身平平无奇,但每一条都会直接影响代码结构。“1到100”决定了随机数生成方式和输入校验边界;“最多10次”决定了游戏状态机里必须要有失败分支;“再来一局”要求所有关键数据都能一键复位。如果你一上来就写代码,规则还没想清楚,后面大概率会陷入“这里加个if,那里补个变量”的泥潭。

为什么我推荐用猜数字来练手,而不是直接去做商城、社区那种大项目?因为这个小游戏能纯粹地覆盖小程序框架的核心三板斧:WXML渲染、JS逻辑、setData状态同步。我见过不少初学者一上来就啃复杂的业务系统,被各种API、权限、分包绕晕,一个月下来连“数据驱动视图”这个最基本的概念都没吃透。猜数字这种级别,恰恰能把每个知识点都踩实。

1.2 第一个“想当然”:输入框拿到的根本不是数字

所有第一次做这个小程序的人,几乎都会写出类似下面的代码:

onInput(e) { this.setData({ currentGuess: e.detail.value }); }, onGuess() { const guess = this.data.currentGuess; if (guess > this.data.target) { // 这里会出问题 } }

问题出在哪?e.detail.value永远是字符串,哪怕你在输入框里敲的是“50”,它拿到的也是字符串"50"。字符串和数字用>比较时,JavaScript 会尝试自动转换,所以"50" > 30这种写法表面看没问题。可一旦target在某个环节也被转成了字符串,或者输入的是"9",比较结果就会让你怀疑人生。更常见的情况是,你用==判断能通过,用===就死活不相等,团队规范里又往往要求严格相等。

我自己在项目里定了一条规矩:凡是来自输入框的值,一律先Number()转换,再进入逻辑层。后面所有比较都按数字走,绝不在中途用字符串跟数字混着比:

onGuess() { const raw = this.data.currentInput; const guess = Number(raw); if (Number.isNaN(guess)) { this.setData({ hint: '请输入一个有效数字' }); return; } // 这里开始,guess 才是真正的数字 }

这个坑几乎每个新手都会踩一次,踩过之后才理解为什么小程序教程里反复强调“类型转换”。其实不是JavaScript坑你,而是输入框这类表单组件的返回值设计上就是字符串,一切需要计算的地方都要显式处理。

1.3 哪些数据该放进 data,哪些不该

被问得最多的还有一个问题:哪些变量要放进data?setData会同步到视图层并触发渲染,代价不是零。猜数字涉及的数据有:目标数字、当前输入、历史记录、剩余次数、提示文案、游戏状态。

我的划分逻辑很简单:界面要展示的,放进data;只有逻辑自己用、不需要渲染的,放在页面实例上。比如目标数字target,界面从头到尾不展示,只通过“偏大/偏小”的反馈间接体现,那它就没必要进data。我用this._target这个私有字段来存,避免每次重开一局时都为它做一次无关的渲染。需要展示的字段才放data:currentInput、history、remainTimes、hint、gameState、answer。

在小项目里,这个区分看不出太大差别,但能帮你建立“数据和视图分离”的肌肉记忆。等以后做复杂项目,频繁更新一个不需要渲染的数据,白白消耗性能,排查起来还费劲。养成习惯之后,你写任何小程序页面都会下意识先去分一分:这个字段真的要上屏吗?

2. 页面骨架:输入框、历史记录与移动端数字键盘的取舍

2.1 布局别贪多,五块区域足够

猜数字的页面不需要花哨。我用上下结构分成几块:标题区、反馈提示区、输入操作区、历史记录区、再来一局按钮。比较重要的是,我把反馈提示区放在输入框上方,这样键盘弹起时,最新的提示依然在可视范围内。

<view class="container"> <view class="hint-area"> <text class="hint {{gameState === 'over' ? 'over' : ''}}">{{hint}}</text> <text wx:if="{{gameState === 'over'}}" class="answer">答案是:{{answer}}</text> </view> <view class="input-row"> <input class="guess-input" type="number" placeholder="输入1-100的数字" value="{{currentInput}}" bindinput="onInput" bindconfirm="onGuess" confirm-type="done" disabled="{{gameState !== 'playing'}}" /> <button class="guess-btn" size="mini" bindtap="onGuess" disabled="{{!canGuess}}">猜</button> </view> <scroll-view scroll-y class="history-list" wx:if="{{history.length}}"> <view class="history-item" wx:for="{{history}}" wx:key="index"> <text>第{{item.turn}}次:{{item.value}}</text> <text class="{{item.result === 'high' ? 'high' : ''}}">{{item.resultText}}</text> </view> </scroll-view> <button wx:if="{{gameState !== 'playing'}}" class="restart-btn" bindtap="restartGame">再来一局</button> </view>

type="number"会让移动端弹起数字键盘;bindconfirm对应软键盘右下角按钮的提交事件;confirm-type="done"在部分机型上会把按钮显示成“完成”,比默认的“换行”更符合提交语义。wx:for渲染历史记录时,wx:key="index"是最省事的写法。

2.2 数字键盘在不同系统上的真实表现

这里有个容易被忽略的差异:type="number"在 iOS 上仍然允许输入小数点和负号,在 Android 上则通常只弹纯数字键盘。同一个输入框,两端行为并不一致。为了统一,我在bindinput里做了一层强制过滤:

onInput(e) { let value = e.detail.value; value = value.replace(/[^\d]/g, ''); value = value.slice(0, 3); this.setData({ currentInput: value }); }

这段代码过滤掉所有非数字字符,同时限制长度不超过3位。因为游戏范围是1到100,3位数字足够,同时也能防止用户敲出“999999”这种明显越界的值。做过真机测试就会知道,type="number"并不保证“只能输入数字”,尤其是在部分Android输入法可以把全键盘调出来的情况下,过滤逻辑不能省。

2.3 为什么不用 picker 组件

有人可能觉得,范围固定1到100,直接用picker滑动选择不是更符合小程序组件规范吗?我试过以后放弃了。核心原因是:猜数字的乐趣在于“快速输入、快速反馈”,picker 需要先点开、再滑动、再确认,交互路径太长,反馈速度直接拖垮游戏节奏。另一个原因是 picker 的默认样式很难嵌进这个小布局里,想改到一个好看的状态,花费的时间比自定义输入框还多。所以最后选了“输入框+数字键盘”这个最朴素方案。做功能选型时,不是越“组件化”越好,而是越贴合交互目标越好。

2.4 历史记录渲染的几个小细节

历史列表用wx:for渲染,看起来简单,但有两个地方容易翻车。第一,往数组里加新记录时,setData必须传一个新数组,不能直接this.data.history.push(...)然后再赋值,这样不会触发渲染。第二,记录会越积越多,占满屏幕,我限制最多保留10条,超过就把旧的弹出去:

addHistory(guess, resultType, resultText) { const newItem = { turn: this._turn, value: guess, result: resultType, resultText }; const newHistory = [newItem].concat(this.data.history); this.setData({ history: newHistory.slice(0, 10) }); }

unshift到头部还是push到尾部,取决于你想让最新记录显示在最上面还是最下面。我选择让最新记录出现在顶部,因为玩家最关心的是“刚这次猜得怎么样”。

3. 核心逻辑:随机数边界、字符串陷阱与游戏状态机

3.1 随机数用 Math.floor 而不是 Math.round

生成1到100的随机整数,标准写法是:

function generateTarget() { return Math.floor(Math.random() * 100) + 1; }

为什么不用Math.round(Math.random() * 100)?因为Math.random()返回0到1之间的浮点数,Math.round会把结果映射成0到100一共101个整数,其中0和100的区间宽度只有其他数字的一半,出现概率明显更小。而Math.floor(Math.random() * 100)得到0到99的整数,再加1变成1到100,每个数字概率完全均匀。这个细节看似微小,但对一个猜数字游戏来说,均匀性就是公平性。

3.2 比较逻辑里要处理的边界条件

拿到数字后,不能上来就比大小。我习惯把所有判断收敛到一个方法里:

compare(guess) { if (guess < 1 || guess > 100) { return { type: 'invalid', text: '请输入1-100之间的数字' }; } if (guess === this._target) { return { type: 'win', text: '猜中了!' }; } if (guess > this._target) { return { type: 'high', text: '偏大了' }; } return { type: 'low', text: '偏小了' }; }

边界条件包括:超出范围的值不消耗次数;非法输入直接提示;重复猜测我选择不消耗次数,只提示“已经猜过了”。这里有个设计上的取舍:消耗次数的时机。我认为只有“合法的错误答案”才应该消耗一次机会,输入0、输入abc、输入重复值都不该让玩家白白损失次数。对应的代码就是在消耗次数前判断type是否为high或low。别小看这个取舍,它直接影响玩家对“公平”的感受。

3.3 用状态机取代多个布尔变量

很多新手写游戏逻辑时,喜欢在data里放isWin、isOver、isPlaying三个布尔值。三个布尔值互相组合会产生8种可能,其中至少一半是非法状态,代码判断起来一团乱麻。我推荐用一个枚举字段gameState来表示:

const STATE = { PLAYING: 'playing', WON: 'won', LOST: 'lost' };

所有渲染条件都围绕gameState展开,任何时候只有一个确定状态,逻辑清晰得多。比如第10次机会猜错时,我只做一件事:把gameState置为lost,同时把answer设置成this._target,让页面展示正确答案。输入框的disabled、按钮的disabled、是否显示“再来一局”,全部由gameState派生出来,而不是在多个事件回调里分散控制。

3.4 重开一局时让所有数据一起复位

“再来一局”最容易出的问题是漏清理某个字段。上一局的提示文案还在、历史记录没清、按钮状态错乱。我写了一个resetState()作为重开局唯一入口:

restartGame() { this._target = generateTarget(); this._turn = 0; this.setData({ currentInput: '', history: [], remainTimes: 10, hint: '开始猜吧,范围是1-100', answer: '', gameState: STATE.PLAYING }); }

以后不管是点“再来一局”,还是游戏结束后重新挑战,都调用这个入口。所有状态一次性复位,不会出现“哪里忘了改”的尴尬。

4. 生命周期与本地缓存:换台、杀进程后如何续上进度

4.1 不做缓存,游戏会变成一场“无限猜谜”

假设玩家猜了7次,切出去回了个消息,再回到小程序。如果没有缓存逻辑,页面重新onLoad会生成一个新谜底,前面的7次记录全部作废。更麻烦的是,用户如果反复进出,会发现自己面对的谜底每次都不一样——他根本没有机会通过充分的信息推理出正确答案,这已经不是体验问题,而是逻辑错误。

所以必须用wx.setStorageSync把当前局面持久化。我缓存的内容包括:target、已猜次数、历史记录、剩余次数、当前状态、提示文案。这样用户哪怕杀掉小程序进程重新打开,也能回到之前那一局。

4.2 onLoad 恢复、onHide 保存的配合方式

我的生命周期策略是:

  • onLoad:先读缓存,如果缓存里有未结束的游戏,直接恢复;否则开启新局;
  • onHide:把当前进度写入缓存;
  • onUnload:不清缓存,因为用户下次进入仍然要继续这一局。
onLoad() { this.restoreOrStart(); }, onHide() { this.saveProgress(); }, restoreOrStart() { const saved = wx.getStorageSync('guess_game_state'); if (saved && saved.gameState === STATE.PLAYING) { this._target = saved.target; this.setData({ currentInput: '', history: saved.history || [], remainTimes: saved.remainTimes, hint: saved.hint || '继续猜,请保持专注', gameState: saved.gameState }); } else { this.restartGame(); } }

注意,我只恢复“进行中”的游戏。如果缓存里的状态已经是won或lost,说明那一局已经结束,就没必要把玩家拽回旧画面,直接开新局更顺。

4.3 缓存写入的数据结构与版本意识

setStorageSync虽然是同步操作,有少量性能消耗,但这个项目数据量极小,完全无所谓。我在写saveProgress()时会一次性把整局数据打包写入,避免零散多次写入:

saveProgress() { wx.setStorageSync('guess_game_state', { target: this._target, history: this.data.history, remainTimes: this.data.remainTimes, hint: this.data.hint, gameState: this.data.gameState }); }

这里再分享一个习惯:给存储键加版本号,比如guess_game_v1。将来如果改了数据结构,比如新增了难度等级、增加了历史步数统计,就能平滑做迁移,而不是读到一堆结构对不上的旧数据导致崩溃。这种“缓存协议版本化”的思路,同样可以用在任何一个需要本地存储的小程序里。

5. 手感打磨:震动反馈、键盘收起与输入防抖/防误触

5.1 点击“猜”之后,先把键盘收起来

真机上最常见的体验困扰是:输入完数字,点“猜”按钮,键盘还开着,提示区又被键盘挡住,玩家根本看不到反馈。我的处理是在onGuess里先调wx.hideKeyboard(),再更新提示。同时配合confirm-type="done",让玩家可以直接点键盘右下角的“完成”提交,既省一步操作,又能收起键盘。

不过提醒一句,wx.hideKeyboard()在部分老版本基础库上不是立即生效,偶尔需要稍微延时再更新渲染。为了稳妥,我把提示区放在页面顶部,即使键盘没完全收下去,提示文字也基本可见。真机上验证过,这个布局能兜住绝大多数机型的表现差异。

5.2 震动反馈的正确用法与限制

wx.vibrateShort()能给真机带来轻微震动,猜中时用重震动,连续猜错时用wx.vibrateLong(),手感差别明显。但有三个限制你必须知道:一是必须在用户点击回调里调用,不能放在异步里,更不能在定时器里触发;二是开发者工具的模拟器上完全无效,必须真机调试;三是震动参数如type: 'heavy'只在新版基础库上支持,老版本不识别,会静默失败。

所以我把震动当作“锦上添花”,核心反馈仍然靠文字和颜色。不要为了震动而震动,否则只会变成一种打扰。

5.3 连点按钮与防抖处理

快速连点“猜”按钮,会连续触发多次onGuess。第一次提交后,游戏状态可能已经变了,后面几次理论上不该再执行。我用了两层防护:

onGuess() { if (this.data.gameState !== STATE.PLAYING) return; if (this._processing) return; this._processing = true; wx.hideKeyboard(); setTimeout(() => { this._processing = false; }, 300); // 后续业务处理 }

_processing是页面实例上的锁,不是data字段,因为这个锁不需要渲染。连续点十次按钮,也只会提交第一次。这样既避免了状态错乱,也让按钮的状态变化更稳定。

5.4 猜中与失败:少用弹窗,多用页面状态

wx.showModal在需要用户确认的场景里很好用,但在猜数字这种高频反馈的场景里,弹窗会打断节奏。我的做法是把“猜中了”“游戏结束”作为页面内的醒目文案,配合绿色或红色背景展示,然后显示“再来一局”按钮。玩家不需要额外点一下关闭弹窗,视觉反馈也更直接。只有游戏失败时,我考虑用一次轻量提示来展示正确答案,但同样保证点击按钮即可继续。

6. 发布前自测:真机差异、边界输入和体验版注意点

6.1 模拟器里看不到的差异

开发者工具的模拟器在键盘行为、震动、滚动、渲染细节上和真机差距很大。我自己测试时,会在iOS和Android各跑一遍。重点观察这几项:数字键盘的样式是否一致;负号、小数点、中文字符是否都能被过滤逻辑拦掉;键盘弹起时页面会不会被顶飞,提示区是否仍在可视范围内;震动接口是否生效;切后台再回来进度是否正常。

开发工具能帮你快速调试逻辑,但它永远替代不了真机验证。小程序的坑往往不在大流程上,偏偏就在这些细节里。

6.2 一张可以直接照做的自测用例清单

分享一组我在做这个项目时使用的用例:

  • 输入空字符串,点击“猜”;
  • 输入0、-5、3.14、abc、99999;
  • 输入与上一次相同的数字;
  • 输入边界值1和100;
  • 猜中后继续点击“猜”按钮;
  • 第10次猜错触发失败流程;
  • 玩到一半切后台,再回来;
  • 玩到一半杀掉小程序进程,重新打开;
  • 连续点击“再来一局”按钮多次;
  • 在带底部安全区的机型上检查按钮和列表是否被遮挡。

这些用例里,输入abc在数字键盘上很难触发,但部分Android输入法仍然可能调出全键盘,所以过滤逻辑不能省。边界值1和100要重点验证,随机数生成逻辑是否正确,在这两个值上最容易暴露。

6.3 体验版发布前的几个检查点

代码没问题不代表可以直接发布。我每次发体验版前都会检查:项目 AppID 是否选对;基础库版本是否过低,wx.vibrateShort、wx.hideKeyboard是否有最低版本要求;控制台有没有错误日志;wx.getStorageSync读到结构异常的数据时,恢复逻辑会不会崩溃;project.config.json里编译的基础库版本跟真机是否匹配。

这些配置项看着琐碎,但往往比代码本身更容易卡住新手。尤其当你在开发者工具里跑得好好的,一上真机就白屏或者按钮点不动,第一个查的就是控制台日志和基础库版本。

我在做这个猜数字小游戏时体会最深的一点是:小项目不等于简单项目。把输入字符串的类型问题、生命周期缓存、键盘交互、按钮防抖这些细节都处理好之后,代码量可能都不到300行,但完成度已经非常接近一个正式产品。这个过程比抄十套电商模板的收获大得多。如果你正在用小程序设计自己的第一个完整项目,不妨把猜数字从“能玩”一路打磨到“好用”,手感这个东西,只有亲手调过才知道值多少钱。后续想继续扩展,还可以加计时模式、本地排行榜、难度选择,但核心的“输入-反馈-状态”闭环已经足够撑起一个完整的小程序入门项目了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询