欢迎光临
我们一直在努力

用纯 HTML 写出一个古诗词闯关 App——一个独立开发者的踩坑实录

零依赖、零构建、手机浏览器直接打开即用。从 25 首到 300 首,从白屏一整晚到稳稳上线,我把过程里的真东西都写下来了。


一、项目缘起:一个想让女儿背诗的爸爸

这个 App 的起点特别朴素——我想让上小学的女儿每天背一首古诗,但翻了一圈手机,要么是广告满天飞的大 App,要么要下载安装包,要么内容错漏百出。

于是我给自己定了三条硬约束:

  • 打开网页就能用,不下载、不安装;
  • 零广告、零干扰,孩子专注在诗本身;
  • 有反馈、有动力,背诗这件事得有点"游戏感",不然小孩子坐不住。

目标用户很明确:学龄儿童 + 陪读的家长,做国学启蒙用。技术上则彻底做减法——不引入任何框架、不写构建脚本,一个 index.html 就是一个完整产品。后来它叫 gushici-web,纯静态、手机优先。


二、技术架构:纯静态 HTML + 原生 JS

整个应用就是两个文件:index.html(约 2200 行,CSS 内联在 <style>、JS 内联在 <script>)和一份外部诗词数据 data/tang_poetry_300.js。没有任何 npm 依赖,也不需要 webpack / vite。

1)手机优先的布局

用一个 .app 容器把内容锁在 max-width:480px 居中,模拟手机视口;<meta viewport> 关掉缩放,避免儿童误触放大。主题色全部用棕色系:主色 #8B4513、渐变副色 #A0522D、背景米色 #F5F0E8、正文深棕 #3a2a1a。

2)数据架构的演进:内联 → 外部文件

最初诗全部塞在一个内联数组 TANG_POETRY_INLINE 里,大约 25 首,够跑通原型。随着内容增多(要上"唐诗三百首"),再塞进单文件就不现实了,于是把数据抽成独立文件 data/tang_poetry_300.js,用一个全局变量 TANG_POETRY_300 暴露出来,并在 index.html 头部用 defer 加载,避免阻塞渲染:

<script src="data/tang_poetry_300.js" defer></script>

启动逻辑 initApp() 里做了"有就用外部、没有就回退内联"的兜底,保证任何情况下页面都能显示:

if (typeof TANG_POETRY_300 !== 'undefined') {
ALL_DATA["tang-poetry"] = TANG_POETRY_300;
console.log("✓ 已加载外部唐诗数据:" + TANG_POETRY_300.length + "首");
} else {
ALL_DATA["tang-poetry"] = TANG_POETRY_INLINE || [];
console.log("⚠ 使用内联唐诗数据:" + ALL_DATA["tang-poetry"].length + "首");
}

3)模块用配置驱动,而非写死

六个模块不是散落在代码各处的,而是统一收进 MODULES(首页卡片)和 MODULE_META(详情页元信息)两个数组/对象里,靠配置渲染。真实结构如下:

  • 📜 唐诗三百首 —— 300 首
  • 🎶 声律启蒙 —— 10 段
  • 📖 千字文 —— 32 段
  • 📝 三字经 —— 8 段
  • 📚 弟子规 —— 20 段
  • 🔤 百家姓 —— 10 段

4)多用户:localStorage 按 key 隔离

一部手机往往要服务好几个孩子,所以做了多用户系统,全部基于 localStorage,每个用户一个独立 key:

  • gushici_users:用户索引,结构是 { currentUserId, users:{}, userOrder:[] };
  • gushici_userdata_<userId>:每个用户的独立数据(积分、等级、徽章、学习记录);
  • gushici_current_user:当前登录用户 id。

关键函数有 initializeUsers / addUser / switchUser / deleteUser / loadUserState / saveUserState / migrateOldData 等,切换用户时先 saveUserState 保存旧的,再加载新的。这样 A 孩子的进度和 B 孩子互不干扰。


三、游戏化设计:积分 / 等级 / 徽章 / 成就

背诗要"上瘾",靠的是即时反馈。我把激励拆成四层:积分、等级、徽章、连续打卡,全部集中在一个 state 对象里管理,并随用户状态持久化。

积分与等级

每完成一关给积分(闯关结果页会显示"获得 15 积分")。等级用一张阈值表 LEVELS 驱动,从童生一路考到状元,共八阶:

  • 童生 0
  • 秀才 100
  • 举人 300
  • 贡士 600
  • 进士 1000
  • 编修 1500
  • 翰林 2000
  • 状元 3000

取等级的逻辑非常直白——遍历表、取"满足积分门槛"的最高那档:

function getLevel(points){
let l = LEVELS[0];
for (const lv of LEVELS){ if(points >= lv.need) l = lv; }
return l.name;
}

徽章(成就系统)

徽章由 BADGE_DEFS 定义,触发条件对应真实行为:

  • 🏅 初露锋芒:完成首次闯关
  • 📚 学富五车:累计学习 50 首
  • ✨ 过目不忘:单关获得 3 星
  • 🔥 持之以恒:连续学习 7 天
  • 👑 状元及第:达到状元等级

个人页顶部是棕色渐变头图,显示当前等级(如"童生")与积分(“0 积分”),下方是"🏅 我的徽章"网格。再加上 streakDays(连续学习天数)、learnedIds(已学列表)、stats(闯关数 / 最佳分 / 总星数),一套轻量但完整的成长体系就跑起来了。

顺手做的朗读功能

借着浏览器原生的 Web Speech API,加了"朗读诗词"。checkTTSSupport() 先探测能力,speakPoem() 把诗句拼成文本丢给 speechSynthesis,语速放慢到 0.8 更适合孩子:

function checkTTSSupport(){
isTTSSupported = !!(window.speechSynthesis && window.SpeechSynthesisUtterance);
return isTTSSupported;
}
// …
const text = item.content.join('。') + '。';
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'zh-CN'; utter.rate = 0.8; utter.pitch = 1;
window.speechSynthesis.speak(utter);

按钮还能在"朗读 / 停止"间切换(靠 ttsSpeaking 标志位 + onend/onerror 复位),不朗读时就当纯阅读器用。


四、踩坑实录:三个让我印象最深的 Bug

真正长本事的不是写新功能,而是修那些"看着离谱、查起来要命"的 Bug。下面三个都是真实发生、真实修掉的。

坑 1:null 引用——用户数据不存在时直接崩

老用户数据迁移函数 migrateOldData(userId) 一开始这样写:

function migrateOldData(userId){
let userData = getUserData(userId); // 旧用户可能返回 null
userData.points = parseInt(localStorage.getItem('gushici_points') || '0');
// … 对 null 赋值 → TypeError
}

当 getUserData 返回 null 时,紧接着 userData.points = … 直接抛错。同样的问题也出现在 loadUserState(userId):当"用户不存在"的分支缺少 else,代码会"穿透"到下方去读一个根本不存在的 userData,结果一片混乱。

修复方式:migrateOldData 里加 if (!userData) 先建好默认对象;loadUserState 补上 else { …清理无效 currentUserId…; return; } 分支(代码里还留着注释"重要:添加 return 避免继续执行")。从此用户不存在就安全降级,不再穿透。

坑 2:首页白屏——就因为一个逗号

这是最折磨人的一次。现象是:打开页面,全屏"正在加载应用…"永远不消失,首页卡片一个都不显示。

排查了一整晚,最后发现罪魁祸首是内联数据数组的闭合处——本该写成 ] 收尾,却手滑写成了 ],(多了一个逗号):

// 错误写法:数组没闭合,多了一个逗号
const TANG_POETRY_INLINE = [ ...25 首诗…,
]; // ← 实际写成了 ],

这一个逗号导致整段 <script> 解析失败(报 SyntaxError: Unexpected string)。连锁反应是:里面所有函数都没被定义,initApp() / startAppWhenReady() 根本没执行,加载遮罩自然永远不隐藏。改回 ];,并把模块数据用赋值重新挂回 ALL_DATA 后,世界清净了。

教训:语法错误是"一损俱损"——一个字符能让整个脚本归零。之后我专门加了加载失败的兜底(见坑 3),再也不让用户在白屏里干等。

坑 3:把"加载提示"本身做成一道安全网

为了让任何异常都有反馈,我在页面最上层放了一个全屏 #loading-indicator:米色背景、旋转动画的 .loading-spinner、带脉冲效果的"正在加载应用…“文字,副标题是"古诗词学习 · 游戏化闯关”。

  • 成功时:opacity 淡出(transition: opacity 0.4s),400ms 后 display:none,干净交还给首页;
  • 失败时:initApp() 用 try/catch 包住,出错就把遮罩内容换成"⚠️ 应用加载失败" + 转义后的错误信息 + 一个"刷新页面"按钮(location.reload());
  • 兜底调试:再挂一个全局 window.onerror,手机上出错时顶部贴一条红色错误条,方便没有控制台的环境也能看到问题。

try {
initializeUsers();
checkTTSSupport();
// …加载数据、渲染首页…
loadingEl.style.opacity = '0'; // 成功:淡出
setTimeout(()=> loadingEl.style.display = 'none', 400);
} catch (e) {
loadingEl.innerHTML =
'<div class="loading-error">⚠️ 应用加载失败</div>' +
'<div>' + escapeHtml(e.message) + '</div>' +
'<button onclick="location.reload()">刷新页面</button>';
}


五、部署上线:GitHub Pages + develop 分支

因为是纯静态资源,部署简单到没有"构建"这一步。

  • 代码托管在 GitHub 仓库 honyanzhao/guoxue;
  • 站点走 GitHub Pages,发布分支固定为 develop;
  • 更新内容时,通过 GitHub API 直接把 index.html 和 data/tang_poetry_300.js 推上去,不必在本地走完整的 git 提交流程;
  • 没有后端、没有数据库,所有数据在用户浏览器本地(localStorage),天生适合静态托管。

一次"改完即发布"的循环基本是:本地改两行 → 手机浏览器打开真机验证 → API 上传到 develop → Pages 自动刷新。从改代码到线上生效,往往不超过五分钟。


六、心得收获:小步快跑与端到端验证

做完这个 App,回过头看,比"学会了某几个 API"更重要的是几条工程习惯。

1)一个文件就是一个产品,边界要清晰

纯静态单文件的好处是没有隐性依赖:你打开它,它就能跑;它跑不起来,问题一定在它本身。这种"可完整端到端验证"的特质,让调试成本极低。

2)小步快跑:先跑通,再膨胀

数据演进路径很典型:内联 25 首跑通原型 → 扩到 300 首(tp001–tp300)→ 抽成外部 JS 文件。没有一上来就追求"唐诗三百首完整版",而是先用最小可用集验证交互,再逐步加料。每一步都建立在"上一步已经能跑"的基础上。

3)端到端验证,比任何单元测试都救命

那个"逗号导致白屏"的事故,本质上就是少了一次真机打开。后来我定下规矩:再小的改动,也要在手机浏览器上真正打开看一眼。对前端来说,“能在本机 dev 跑"不等于"用户打开能用”。

4)给用户留好退路

加载失败要有提示、有重试,而不是永远转圈;用户数据不存在要安全降级,而不是整页崩溃。把"异常路径"当一等公民来写,体验才立得住。

5)用 key 隔离状态,简单且可靠

多用户没用什么花哨方案,就是 gushici_userdata_<userId> 一人一个 key。简单的约定,往往比复杂的抽象更经得起时间。

赞(0)
未经允许不得转载:171主机测评 » 用纯 HTML 写出一个古诗词闯关 App——一个独立开发者的踩坑实录
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址