
在实际前端小项目里最容易出现的问题并不是某个框架不会用而是“需求边界没定清楚就开始写代码”。项目标题“真假公主|仿甜甜樱”看起来像一个休闲小游戏但它背后涉及随机出题、用户判断、得分统计、状态管理、界面反馈和本地存储掉线重开等多个可拆解的技术点。这篇文章就围绕这个项目名带你从零实现一个“真假公主”小游戏系统展示一名公主角色和一段身份描述玩家需要判断这位公主是“真”还是“假”连续作答并累计得分。这个小游戏适合刚学完 Vue 或 React 基础、想用一个小项目串联起组件通信、状态管理、渲染逻辑和本地存储的前端学习者。同时它也可以作为练习测试驱动开发和接口模拟的入门案例。项目名里的“仿甜甜樱”指的是借鉴日系少女题材常见的粉嫩配色、圆润卡片和轻量动效来设计界面目标是让原本枯燥的逻辑判断练习有更好的视觉反馈。下面进入实现细节。1. 先拆解“真假公主”的需求边界和核心玩法1.1 玩法规则要先用自然语言说清楚在没有需求文档的情况下写代码前必须先把游戏规则写成人话。这样做的好处是后面写状态逻辑时可以逐条对照避免“做着做着发现规则变了”。这个项目的规则可以定义成这样题库里有若干名公主角色每个角色包含 ID、姓名、头像、身份描述和“是否真的是公主”的布尔标记。游戏开始时系统从题库中随机抽取一题展示角色信息和描述。玩家有两个操作按钮“真公主”和“假公主”。点击后系统立即判断玩家选择与角色自身标记是否一致并给出正确或错误的反馈。每轮固定 10 题答对加 10 分答错不扣分但记录错误次数。全部答题结束后展示总得分、正确率和错误题号列表。游戏过程中最高分要保存到浏览器本地存储下次刷新后仍然存在。这里的关键点是把“真假公主”中的“真假”落实到数据结构上。如果一个角色的isReal为true那么身份描述就应该是可信的如果为false则描述与画像存在矛盾。玩家要做的是通过描述来判断角色是否符合“公主”这一身份。1.2 技术选型为什么用 Vite Vue 3 而不是直接写 HTML“真假公主”这类小游戏完全可以用原生 JavaScript 加 HTML 实现但那样会暴露出两个问题第一界面状态一多手动操作 DOM 会导致逻辑分散第二下一轮题目切换、得分更新、错误记录这些状态变化很难维护。这里选择 Vite Vue 3主要理由是Vue 3 的组合式 API 可以让“游戏状态”集中在一个独立的模块里而不是散落在各个组件中。Vite 启动速度快修改代码后热更新体验好适合边写边看效果。Vue 的单文件组件结构适合把“公主卡片”“结果弹窗”“得分栏”拆成独立的可复用组件。如果你更熟悉 React技术选型可以换成 Vite React但本文代码基于 Vue 3 来说明实现思路。核心不在框架而在状态流转和组件划分。1.3 功能清单与目录结构规划动手写代码前先规划出功能清单这样后面不会漏掉模块模块功能优先级题库数据维护角色信息和真假标记高出题逻辑随机抽题、避免重复、支持重开高判断逻辑玩家点击、判断对错、更新分数高界面反馈正确/错误样式、下一题切换高本地存储保存历史最高分中结果页展示得分、正确率、错误题号中样式粉色系卡片、圆角布局、响应式中对应的建议目录结构如下true-false-princess/ ├── index.html ├── package.json ├── vite.config.js ├── src/ │ ├── main.js │ ├── App.vue │ ├── data/ │ │ └── princesses.js │ ├── composables/ │ │ └── useGame.js │ └── components/ │ ├── PrincessCard.vue │ ├── OptionButton.vue │ └── ResultPanel.vue在这个结构里data/princesses.js只负责数据composables/useGame.js只负责游戏状态和判断逻辑组件只负责渲染和派发事件。这样分层之后后面加题库、加计时器、接接口都不会把代码搅乱。2. 环境准备先把开发工具链和项目骨架搭好2.1 Node.js 版本和包管理器检查Vite 5 系列要求 Node.js 18 或以上版本。学习环境建议先确认版本再创建项目否则可能出现依赖安装后无法启动的问题。在终端执行node -v npm -v如果node -v输出的版本低于 18需要先升级 Node.js。如果机器里已经装了 nvm可以直接切换版本nvm install 20 nvm use 20实际项目中Node.js 版本可能由公司统一规定。这里只说最低要求落地前需要确认自己的开发环境和部署环境是否满足这个版本要求。2.2 用 Vite 创建 Vue 3 项目创建项目的命令很简单打开终端执行npm create vitelatest true-false-princess -- --template vue执行完会生成一个 Vue 3 基础项目。进入项目目录并安装依赖cd true-false-princess npm install安装完成后先启动一次空页面确认脚手架本身没有问题npm run dev浏览器访问终端输出的本地地址比如http://localhost:5173能看到 Vite Vue 的默认欢迎页。这里有一个常见误区很多人急着马上写业务代码跳过“空项目启动验证”。其实这一步非常重要它把“脚手架问题”和“业务代码问题”隔离开。如果空页面都打不开先不要写任何业务代码优先检查 Node 版本、依赖安装和端口占用。2.3 安装必要依赖并初始化基础文件这个项目不需要 UI 组件库也不需要路由所以依赖很少。为了后面写测试方便可以安装 Vitestnpm install -D vitest然后在package.json的scripts里增加测试命令{ scripts: { dev: vite, build: vite build, preview: vite preview, test: vitest } }此时项目骨架已经完整。接下来先清理默认文件把src/App.vue改成一个空壳再继续填充数据层和逻辑层。3. 实现核心玩法出题、选角、判断与反馈3.1 设计“公主”数据结构和题库数据数据结构是这个小项目的地基。每个角色必须保证字段固定否则后面的判断逻辑会越写越乱。推荐的数据结构如下export const princesses [ { id: 1, name: 雪莉, avatar: https://example.com/shirley.png, description: 她从小生活在王宫最喜欢在花园里喂白鸽。, isReal: true }, { id: 2, name: 艾拉, avatar: https://example.com/ella.png, description: 她自称来自邻国却不知道本国国花是什么。, isReal: false }, { id: 3, name: 露娜, avatar: https://example.com/luna.png, description: 她随身带着一枚王室印章并且能准确说出宫廷礼仪。, isReal: true } ];在真实项目中avatar会指向静态资源路径或 CDN 地址。学习环境下可以先用占位图或网络图片但要注意图片资源是否可访问。如果图片无法加载可以先使用本地项目src/assets里的图片或者直接用纯色背景加文字头像。需要解释的是description字段。它并不仅仅是一段展示文本而是玩家判断“真假”的核心依据。真实公主的描述会包含可信细节假公主的描述会有明显漏洞。这样设计能把“真假”落到具体可推理的信息上而不是让玩家随机猜测。题库数量建议至少 10 个角色否则一轮 10 题时会出现重复。如果你只有 5 个角色就需要允许重复出题并在出题逻辑里做随机抽取保证同一轮尽量少重复。3.2 随机出题与乱序确保每次游戏不重复出题逻辑的职责是从题库里随机抽取一题返回给界面并记录当前题号。可以使用下面的工具函数export function getRandomQuestion(questionList, excludeIds []) { const available questionList.filter( (item) !excludeIds.includes(item.id) ); if (available.length 0) { const fallbackIndex Math.floor(Math.random() * questionList.length); return questionList[fallbackIndex]; } const randomIndex Math.floor(Math.random() * available.length); return available[randomIndex]; }这个函数接收题库和已经出过的题目 ID 列表优先从没有出过的题目中抽取。如果题库已经被抽完则做一次兜底从全部题库中随机抽取。这里要注意随机函数使用Math.floor(Math.random() * length)不要写成Math.round(Math.random() * length)否则会出现越界或概率不均的问题。测试随机逻辑时不需要验证“每次都不重复”而是验证传入排除列表后返回的题确实不在排除列表中。另一种做法是先把题库打乱再依次取出。这样更简单export function shuffleArray(list) { const arr [...list]; for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; }游戏每次开始时可以用shuffleArray把题库打乱然后按顺序出题。打乱算法采用的是 Fisher-Yates 洗牌算法比简单sort(() Math.random() - 0.5)更均匀。3.3 用组合式函数集中管理游戏状态如果把游戏状态写在App.vue里会导致组件越来越臃肿。推荐的做法是把所有游戏逻辑封装到useGame这个组合式函数中。实现代码如下import { ref, computed } from vue; import { princesses } from ../data/princesses; import { shuffleArray } from ../utils/shuffle; export function useGame() { const questions ref([]); const currentIndex ref(0); const score ref(0); const wrongRecords ref([]); const gameOver ref(false); const feedback ref(null); const totalQuestions 10; const currentQuestion computed(() questions.value[currentIndex.value] || null ); function startGame() { const shuffled shuffleArray(princesses); questions.value shuffled.slice(0, totalQuestions).map((item, index) ({ ...item, round: index 1 })); currentIndex.value 0; score.value 0; wrongRecords.value []; gameOver.value false; feedback.value null; } function answer(choice) { const question questions.value[currentIndex.value]; if (!question || feedback.value) return; const isCorrect choice question.isReal; if (isCorrect) { score.value 10; feedback.value correct; } else { wrongRecords.value.push(question.round); feedback.value wrong; } setTimeout(() { if (currentIndex.value totalQuestions - 1) { gameOver.value true; } else { currentIndex.value 1; } feedback.value null; }, 800); } const correctRate computed(() { const answered wrongRecords.value.length score.value / 10; if (answered 0) return 0; return Math.round((score.value / 10 / answered) * 100); }); return { questions, currentIndex, score, wrongRecords, gameOver, feedback, currentQuestion, startGame, answer, correctRate }; }这段代码有几个关键点需要解释。第一feedback用来控制按钮点击后的视觉反馈。在反馈期间再次点击按钮会被直接忽略防止连点导致跳题。第二setTimeout的设计并不是最佳实践因为如果组件被销毁定时器仍然会执行。在真实项目中更稳妥的做法是使用状态加watch或者把定时器在onUnmounted里清理掉。这里给出的版本是为了让代码保持简洁便于理解核心流程。第三correctRate的计算需要小心。如果所有题都答对wrongRecords.length为 0score.value / 10等于 10正确率就是 100%。如果只答对 5 题也就是答了 10 题后wrongRecords.length为 5score.value / 10为 5正确率就是 50%。这个公式假设每题 10 分且每轮 10 题。如果改了分制需要同步调整。3.4 分数和最高分怎么保存到本地存储最高分建议单独封装一个方法而不是在组件里直接操作localStorage。const STORAGE_KEY true-false-princess-high-score; export function getHighScore() { const value localStorage.getItem(STORAGE_KEY); return value ? Number(value) : 0; } export function saveHighScore(score) { const current getHighScore(); if (score current) { localStorage.setItem(STORAGE_KEY, String(score)); return true; } return false; }在useGame中引入这两个方法在gameOver变为true之后调用一次import { getHighScore, saveHighScore } from ../utils/storage; const highScore ref(getHighScore()); watch(gameOver, (over) { if (over) { const isNewRecord saveHighScore(score.value); highScore.value getHighScore(); if (isNewRecord) { console.log(新纪录); } } });需要特别注意的是localStorage只能在浏览器环境中使用。如果你以后把游戏改成 SSR 或组件测试环境就会出现localStorage is not defined的报错。所以不要把存储调用直接写进组合式函数的顶层而是通过工具函数隔离。4. 界面与交互让页面具备“甜甜樱”风格的观感4.1 布局与卡牌设计界面应该满足两个目标第一信息层级清楚第二视觉风格统一。这里把“仿甜甜樱”理解为粉嫩色系、圆角卡片、柔和阴影和轻量动效。整体布局可以采用上下结构顶部是得分栏和最高分中间是公主卡片底部是操作按钮。这样玩家视线从分数到题目再到按钮不需要额外寻找。在App.vue中可以把页面拆成几个区域script setup import { onMounted } from vue; import { useGame } from ./composables/useGame; import PrincessCard from ./components/PrincessCard.vue; import OptionButton from ./components/OptionButton.vue; import ResultPanel from ./components/ResultPanel.vue; const game useGame(); onMounted(() { game.startGame(); }); /script template main classgame-page header classscore-bar span当前得分{{ game.score }}/span span最高分{{ game.highScore }}/span span第 {{ game.currentIndex 1 }} / 10 题/span /header PrincessCard v-ifgame.currentQuestion :questiongame.currentQuestion / div classaction-bar OptionButton label真公主 colorprimary clickgame.answer(true) / OptionButton label假公主 colordanger clickgame.answer(false) / /div ResultPanel v-ifgame.gameOver :scoregame.score :correct-rategame.correctRate :wrong-recordsgame.wrongRecords restartgame.startGame() / /main /template这里把highScore补回来需要从useGame中返回highScore。上面组合式函数代码里已经引入但返回列表中漏了实际使用时要补上。PrincessCard组件的职责是展示题目信息script setup defineProps({ question: { type: Object, required: true } }); /script template section classprincess-card div classprincess-avatar img v-ifquestion.avatar :srcquestion.avatar :altquestion.name / span v-else{{ question.name.charAt(0) }}/span /div h2{{ question.name }}/h2 p{{ question.description }}/p /section /template头像图片如果加载不到使用姓名首字符兜底这样不会出现空白卡片。4.2 加入计时和分数动画反馈动画可以通过 CSS 类切换来实现。在App.vue中监听game.feedbacksection classprincess-card :class{ feedback-correct: game.feedback correct, feedback-wrong: game.feedback wrong } 配合 CSS 可以做一个简单的缩放和变色.princess-card { transition: transform 0.3s ease, box-shadow 0.3s ease; } .feedback-correct { box-shadow: 0 0 20px rgba(76, 175, 80, 0.5); transform: scale(1.02); } .feedback-wrong { box-shadow: 0 0 20px rgba(244, 67, 54, 0.5); transform: scale(0.98); }这里不建议在 JavaScript 里操作 DOM 来改样式用数据驱动的 class 切换更符合 Vue 的开发方式。计时功能可以后续扩展。如果需要每道题限时可以在useGame中加入timer和leftTime状态并在每道题开始时创建setInterval。限时逻辑会引出新的问题比如定时器清理、超时自动跳题、暂停处理等。如果要加建议放在基础版本完成之后再迭代。4.3 响应式适配与常用样式变量“甜甜樱”风格并不复杂但要有统一的设计变量。在src/styles/variables.css中可以定义:root { --color-primary: #f8bbd0; --color-primary-dark: #f06292; --color-bg: #fff5f8; --color-card: #ffffff; --color-text: #5a3a42; --color-success: #66bb6a; --color-danger: #ef5350; --radius-lg: 20px; --radius-md: 12px; --shadow-card: 0 8px 24px rgba(240, 98, 146, 0.15); }然后在组件里引用这些变量避免每处都写死色值。响应式适配的核心是卡片宽度和按钮大小。可以用min()和clamp()来设置.game-page { max-width: 640px; margin: 0 auto; padding: 24px; } .princess-card { width: 100%; max-width: 480px; margin: 0 auto; } .action-bar { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; } .action-bar button { padding: 12px 28px; font-size: 16px; border: none; border-radius: var(--radius-md); cursor: pointer; }在手机端按钮应该至少达到 44px 的点击高度否则容易误触。这里padding加上字体大小基本能保证可用性。5. 运行验证与常见问题排查5.1 用 Vitest 验证核心判断逻辑界面可以手动验证但判断逻辑建议用自动化测试固定下来。下面是一个简单的测试示例保存在src/composables/useGame.spec.jsimport { describe, it, expect, beforeEach } from vitest; import { useGame } from ./useGame; describe(useGame, () { let game; beforeEach(() { game useGame(); game.startGame(); }); it(startGame 后共有 10 道题, () { expect(game.questions.length).toBe(10); }); it(答对后分数增加 10, () { const question game.currentQuestion; if (question.isReal) { game.answer(true); } else { game.answer(false); } expect(game.score).toBe(10); }); });这个测试能验证出题数量和初始分数逻辑。要注意的是useGame中的setTimeout在测试中会保留定时器可能导致测试环境挂起。更好的做法是在测试中把setTimeout改成同步处理或者让answer方法支持传入一个回调。实际项目中测试游戏逻辑时往往会发现状态耦合问题。如果你写测试遇到困难先不要急着 mock 框架而是检查是不是函数职责太杂。例如把“判断对错”和“延迟跳下一题”分开会更容易测试。5.2 手动测试路径与预期结果即使有自动化测试也要做一轮手动测试覆盖完整流程。建议按以下列表逐项验证测试步骤操作预期结果首次进入打开页面显示第一题分数为 0答题按钮可用连续答对每题都点击正确选项分数按 10 分累加无错误记录连续答错每题都点击错误选项分数为 0错误记录更新答完 10 题完成所有题目出现结果面板正确率正确重开一局点击再来一局题目顺序随机分数清零结果面板关闭刷新页面刷新浏览器最高分保持为历史最高分手动测试时要注意观察浏览器控制台是否报错。如果控制台有红色报错先记录报错信息再回找对应代码。5.3 常见报错与解决方案这里总结这个项目最常见的五类问题可以直接对照排查。问题现象常见原因检查方式处理建议页面启动后空白useGame的返回字段拼写错误打开控制台查看 Vue 警告检查模板中引用的字段是否在返回值中存在点击按钮无反应没有调用startGame或currentQuestion为 null在answer中打断点在onMounted中启动新游戏分数不对每题分数不一致或isReal使用错误打印当前题目统一每题 10 分确保按钮事件传递布尔值与isReal对应最高分刷新后丢失localStorage被禁用或存储字段不一致在浏览器 Application 面板查看 Key统一存储 key并在工具函数中做容错题目顺序不随机使用sort(() Math.random() - 0.5)多次重开观察顺序改用 Fisher-Yates 洗牌算法先排查输入再排查逻辑最后排查存储。这条顺序适用于绝大多数前端小项目。6. 最佳实践与小游戏扩展方向6.1 学习环境与生产环境的差异学习环境里题库数据直接写在princesses.js中没有问题因为数据量小、更新频率低。但如果你要把它做成正式运营的小游戏生产环境还需要考虑几件事题库不应该硬编码在前端放在后端接口或静态 JSON 文件里方便更新和热修复。正确率、得分规则、每轮题数应该支持配置而不是写死在代码里。最高分只存在本地会有被修改的风险。要做多人排行榜需要后端和用户身份体系。图片资源要部署到 CDN并做好加载失败兜底。启动时要考虑接口超时、数据为空、接口报错等异常情况不能只处理正常流程。学习环境可以快速跑通但生产环境要额外关注日志、监控、权限和回滚方案。这些不是一开始就要实现的但需要知道边界在哪里。6.2 怎么扩展题库、联网排行榜与账号体系扩展题库最简单的方式是把princesses.js数据源替换成请求函数export async function fetchPrincesses() { const response await fetch(/api/princesses); const data await response.json(); return data; }然后在startGame中先请求数据再洗牌。请求过程中需要增加loading状态避免用户看到空白页面。联网排行榜的前提是用户身份。没有账号体系时可以用设备 ID 或本地生成的 UUID 作为用户标识。每个玩家提交成绩时把 UUID、昵称、得分发送到服务端。排行榜接口返回 Top 100 列表。这里需要明确的取舍本地最高分可以帮助玩家离线体验联网排行榜则可以提供竞争感和长期留存。两者并不冲突可以同时存在。但引入后端后代码量会明显增加建议在基础玩法稳定后再做。6.3 对前端初学者的核心建议这个项目最值得反复练习的能力是“把规则翻译成状态”。例如“一题只能答一次”翻译成feedback为null时才允许执行answer“游戏结束”翻译成currentIndex totalQuestions - 1。这种能力比语法本身更重要。同时代码审查时可以按这份清单自查数据层是否有统一的字段命名。是否存在直接修改props的行为。定时器是否在组件卸载时清理。随机函数是否存在越界或概率不均。存储操作是否经过工具函数隔离。有没有在模板里写复杂表达式导致渲染逻辑难以测试。手动测试是否覆盖了答对、答错、重开、刷新四种情况。最后给一个练习建议不要止步于把这个小游戏跑通可以把规则改一版例如加入“限时 10 秒答题”“连续答对加更多分”“根据正确率解锁不同角色”等。每一次改动都会逼你重新梳理状态流转这就是前端项目成长最快的方式。