零依赖、零构建、手机浏览器直接打开即用。从 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。简单的约定,往往比复杂的抽象更经得起时间。




