一个能真机上背单词、带英/美发音、进度云端同步的移动端 H5。从数据建模到上线踩坑,全部记录。
一、为什么做这个
市面上的背单词 App 要么太贵,要么广告满天飞,要么数据不透明。作为一个程序员,最直接的想法就是:自己写一个。
我的需求很简单:
- 手机浏览器打开就能用(H5,不用装 App)
- 单词书能自己维护,数据可控
- 学完一个词自动记住进度,下次接着背
- 点击小喇叭能听到英式/美式发音
- 界面要好看,不能是上个时代的土味 UI
于是就有了这个项目:一个 后台管理系统 + H5 学习端 的一体化应用。本文讲 H5 学习端是怎么做的。
技术栈
| 层 | 选型 |
|---|---|
| 框架 | Next.js 16.3.3(App Router) |
| 语言/UI | React 19 + TypeScript + Tailwind CSS v4 |
| 组件库 | shadcn/ui(base-nova 主题,基于 Base UI) |
| ORM | Drizzle ORM 0.45 |
| 数据库 | Supabase(PostgreSQL,直连 5432) |
| 鉴权 | scrypt 密码哈希 + httpOnly Cookie 会话 |
二、数据怎么设计:一张 JSON 就能装下整个单词
单词的「知识点」特别多:词性、中文释义、英/美音标、英/美发音、例句、短语、同根词、近义词、记忆方法......如果全部拆成关系表,会非常痛苦。
我采用了业界常见的做法:词书(books)+ 单词(words)两张表,单词的丰富内容全部塞进一个 content JSON 字段。
bash
books 单词书(目录)
├── id uuid
├── book_id 可读标识,如 PEPXiaoXue3_1 / CET6_2
├── title 书名(小学三年级 / 六级词汇)
├── word_count 单词总数
└── cover_url 封面图
words 单词(内容)
├── id bigint
├── wordRank 在书内的序号(第 1 个、第 2 个......)
├── headWord 单词本身
└── content json ------ 释义/音标/发音/例句/短语/近义词......全在这里
words.content 的 JSON 结构长这样(来自有道词库,清洗后入库):
json
{
"word": {
"wordId": "CET6_2_123",
"wordHead": "trade",
"content": {
"trans": [{ "pos": "n.", "tranCn": "贸易;交易", "tranOther": "trade" }],
"ukphone": "treɪd",
"usphone": "treɪd",
"ukspeech": "trade&type=1",
"usspeech": "trade&type=2",
"sentence": {
"sentences": [
{ "sContent": "They trade goods with each other.", "sCn": "他们互相交换商品。" }
]
},
"relWord": { "rels": [{ "pos": "n.", "words": [{ "hwd": "trading", "tran": "贸易" }] }] },
"syno": { "synos": [{ "pos": "vt.", "hwds": [{ "w": "exchange" }], "tran": "交换" }] },
"remMethod": { "val": "tra(交易)+ de → 交易" }
}
}
}
关键点:书和词的关联没有外键 。words.content->'word'->>'wordId' 长成 {book_id}_{rank},所以按 LIKE 'PEPXiaoXue3_1\_%' 前缀匹配就能把某一本书的全部单词捞出来。这个设计让词库的导入变得非常灵活------任何格式的第三方词库,只要清洗成这个 JSON 结构就能用。
三、学一个单词,前端页面长什么样
H5 端我按「首页 → 学习页 → 我的」三个页面来组织,底部两个 Tab(首页 / 我的)。
3.1 首页:问候 + 最近背 + 最近学 + 全部单词书
首页是 Server Component(app/h5/page.tsx),直接调用数据层函数取数,不需要额外 API:
tsx
export default async function H5HomePage() {
const session = await getH5Session();
const books = await getWordBooks();
const recent = session ? await getProgressListWithBooks(session.id, 3) : [];
const recentWords = session ? await getRecentWords(session.id, 10) : [];
return <HomeView session={session} books={books} recent={recent} recentWords={recentWords} />;
}
页面上依次是:
- 渐变问候 hero:按当前时间问候(早上好/下午好/晚上好),显示「在学 N 本」「累计 N 个单词」
- 最近背单词:横向滚动的小卡片,展示刚背过的单词 + 中文释义,点击跳详情
- 最近学习:正在学的单词书卡片,带进度条
- 全部单词书:两列卡片网格,每张卡显示封面、书名、词数、学习进度
未登录用户点单词书 → 跳登录弹窗;已登录 → 直接进学习页。
3.2 学习页:详情内联 + 吸底操作栏
学习页是客户端组件,最核心的交互是:
- 顶部:退出按钮 + 书名 + 「已学完」徽标
- 进度条 :
第 N / M 个 · P%,渐变进度条实时更新 - 单词详情:直接内联展示(不用跳转卡片页),渐变词卡 + 英/美发音小喇叭 + 释义/例句/短语/同根/近义/记忆方法
- 底部吸底操作栏 :
上一个(描边按钮)/下一个(渐变主按钮),点了「下一个」就上报进度
tsx
async function handleNext() {
const res = await fetch(`/api/h5/me/progress/${encodeURIComponent(book.bookId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastWordRank: word.wordRank }),
});
if (res.status === 401) {
router.push(`/h5/me?book=${book.bookId}&openLogin=1`);
return;
}
if (isLast) { setFinished(true); return; }
setIndex(i => i + 1);
}
进度上报的语义是:学到第 N 个,last_word_rank = N,下次从 N+1 开始。存的是「学到的位置」而不是「已掌握的单词数」,这样续背特别自然。
3.3 我的:账号 + 学习进度列表
我的页展示当前账号(头像、昵称、邮箱)、退出登录按钮,以及每本书的学习进度列表。未登录点「去登录」弹出登录/注册弹窗(登录注册共用,注册时角色固定为 learner)。
四、几个值得讲的技术点
4.1 有道发音:库里存的不是完整 URL
词库清洗后,ukspeech/usspeech 字段存的是 trade&type=1 这样的发音标识 (type=1 英音、type=2 美音),不是完整链接。真正播放时要拼上有道 dictvoice 的前缀:
ts
const DICTVOICE_BASE = "https://dict.youdao.com/dictvoice?audio=";
export function getUkSpeechUrl(c) {
const t = c?.word?.content?.ukspeech;
return t ? DICTVOICE_BASE + t : null;
}
前端播放用的是纯客户端组件 + HTMLAudioElement:
tsx
async function handlePlay() {
const audio = new Audio(url);
audio.onended = () => setPlaying(false);
audio.onerror = () => { setPlaying(false); setError(true); };
await audio.play();
}
跨域音频只要播放 、不读流,new Audio(url).play() 不需要 CORS 配合,浏览器直接放。这里我踩过一个小坑:audio.play() 返回的是一个 Promise,在某些浏览器(比如 iOS Safari)必须处理它的 rejection,否则会报 Unhandled Promise Rejection。所以我包了 try/catch,失败时把图标切成 ✕ 提示用户。
4.2 JSON 安全取值:所有字段都可能不存在
因为 content 是外来词库数据,每个字段都可能缺失 。我用了一组纯函数来安全取值,缺失返回 null,渲染层再决定整块隐藏:
ts
function inner(c) {
return c?.word?.content ?? null;
}
export function getTranCn(c) { // 卡片只用第一个中文释义
return inner(c)?.trans?.[0]?.tranCn ?? null;
}
export function getSentences(c) { // 例句
return inner(c)?.sentence?.sentences ?? null;
}
export function getRelWords(c) { // 同根词
return inner(c)?.relWord?.rels ?? null;
}
export function getRemMethod(c) { // 记忆方法
return inner(c)?.remMethod?.val ?? null;
}
对应的详情页组件里,每一块都是 xxx?.length ? <Section>...</Section> : null------字段没有就整块隐藏,绝不渲染空壳。
4.3 LIKE 前缀匹配:_ 是个坑
前面说书和词靠 content->'word'->>'wordId' LIKE 'PEPXiaoXue3_1\_%' 关联。但 book_id 里的下划线 _ 恰好是 LIKE 的通配符(匹配任意单个字符),不转义会把别的书也匹配进来。必须手动转义:
ts
const escaped = bookId.replace(/[\\%_]/g, (m) => "\\" + m);
const rows = await db
.select({ ... })
.from(words)
.where(sql`${words.content}->'word'->>'wordId' LIKE ${escaped + "_%"}`)
.orderBy(words.wordRank);
如果某天 book_id 里还出现 %,同样要转义------正则里一起处理了。
4.4 UPSERT 进度:唯一键 + onConflictDoUpdate
每个用户 × 每本书一行进度,用 user_id + book_id 做唯一索引。保存进度用 Drizzle 的 onConflictDoUpdate,一条 SQL 搞定「有则更新、无则插入」:
ts
const rows = await db
.insert(learningProgress)
.values({ userId, bookId, lastWordRank, updatedAt: now })
.onConflictDoUpdate({
target: [learningProgress.userId, learningProgress.bookId],
set: { lastWordRank, updatedAt: now },
})
.returning();
首页的「最近学习」按 updated_at 倒序取前 3 条,再 INNER JOIN books 带出书名和总词数,进度百分比 = lastWordRank / wordCount。
4.5 MOCK 开关:本地开发不连库
连 Supabase 需要网络和权限,做 UI 时反复开关太麻烦。我加了一个 MOCK=1 环境变量开关,所有数据层函数都先判断 isMock(),是就走内存假数据:
ts
export async function getWordBooks() {
if (isMock()) return mockGetWordBooks();
return db.select().from(books).orderBy(desc(books.createdAt));
}
Mock 数据里内置了 20 个 PEP 词 + 40 个 CET6 词,进度存在内存数组里(还做了按 bookId:rank 去重,防止上一个/下一个来回翻产生重复记录)。这样本地 MOCK=1 pnpm start 就能完整跑一遍流程,不用连数据库。
五、移动端 UI 的小心思
5.1 主题隔离:H5 用 indigo 高级紫,不动后台
后台是默认主题,H5 想单独用一套 indigo→violet 的渐变高级感。做法是在 H5 根布局包一层 theme-h5 class,用后代选择器覆盖 shadcn 的 CSS 变量:
css
.theme-h5 {
--primary: oklch(0.511 0.262 276.9); /* indigo */
--radius: 1rem;
}
因为 shadcn 的工具类都是在使用处 引用 var(--primary) 的,所以后代覆盖能天然级联生效,后台完全不受影响。
5.2 一个配色 + 圆角 + 渐变,撑起整页质感
- 词卡和 hero 用
bg-gradient-to-br from-indigo-500 via-indigo-500 to-violet-500,叠加两个blur-2xl的白色圆点做高光 - 所有卡片
rounded-2xl+border-indigo-100/70+ 轻投影 - 底部操作栏
sticky bottom-0+backdrop-blur,滑到哪都能点 - Tab 高亮用 indigo 胶囊背景 + 顶部渐变指示条
5.3 桌面预览:限宽居中
移动端页面在桌面浏览器打开会太宽,布局里用 mx-auto w-full max-w-md 把内容限制成手机宽度,居中显示,方便调试预览。
六、踩坑记录
-
Next 16 的
params/searchParams变成了 Promise :动态路由[bookId]页面里必须await params,不是解构直接用。客户端useSearchParams还要包<Suspense>,否则构建报错。我的做法是尽量在 Server Component 里读数据,把搜索参数留在服务端解析。 -
audio.play()必须 catch :iOS Safari 上如果返回的 Promise 没被处理,会报Unhandled Promise Rejection,而且点击后立刻再点会中断上次播放 。用 try/catch 包住,onended/onerror双路重置状态。 -
postgres-js 的 SQL 是标签模板 :Drizzle 生成的查询里拼条件要小心,LIKE 通配符转义要自己做(见 4.3)。另外 JSON 操作符
->>在 drizzle 里要显式写sql模板,别指望它自动处理。 -
错误信息被 Drizzle 包装 :数据库的唯一约束冲突(SQLSTATE 23505)会包成
DrizzleQueryError,真正 code 在err.cause.code上,判断时要两层都取。
七、效果与后续计划
现在能完整跑通的闭环是:注册/登录 → 首页选书 → 背单词(发音 + 详情 + 进度条)→ 自动上报进度 → 首页「最近学习」实时显示 → 我的页查看全部进度。
接下来的方向:
- 艾宾浩斯遗忘曲线复习提醒
- 背单词统计图表(连续打卡天数)
- 单词本收藏 + 生词本
- 更多词库导入(考研、雅思、托福)
数据与词库的清洗流程也很值得单独写一篇:怎么把高星的第三方词库仓库,清洗成可以入库的 content JSON 结构。如果这篇有收获,点个赞关注,下一篇见。
项目源码:Next.js 16 + Supabase + Drizzle + Tailwind v4 + shadcn/ui,H5 学习端 + 后台管理端双端一体。