0. 先说结论
这个项目一共 2915 行前端代码(.ts/.tsx/.css),38 次提交,106 个测试。
真正花时间的不是这 2915 行,是两件事:
- 866 个词的例句校对,821 条要配中文翻译;
- 手机上"没有喇叭图标"这个问题。
技术选型没有秘密,React 19 + Vite 6 + TypeScript 5.9 + ts-fsrs,全是常规组合。下面按数据、算法、UI、音频四段拆。
线上地址:https://word.codinglcj.eu.org/
源码:https://github.com/liuchangng/word-sprout
1. 为什么不从网上抓单词表
网上流传的 PEP 单词表有几个通病:混入超纲词、释义张冠李戴、例句和单元对不上。给孩子用的东西,错一个词就是错一个词。
所以数据源只有一个:8 本教材 PDF 的附录词表,正文抽例句。
最终数据:
| 项目 | 数值 |
|---|---|
| 词条 | 866 |
| 册数 / 单元 | 8 册 / 46 单元 |
| 3A / 3B | 108 / 115 |
| 4A / 4B | 110 / 139 |
| 5A / 5B | 119 / 156 |
| 6A / 6B | 144 / 90 |
| 核心词 | 527 |
| 带音标 | 681 |
| 带中文释义 | 866(100%) |
| 带例句 | 821(例句中文 821) |
两个必须如实说明的点:
- 5B、6B 只有 2013 旧版,没有新课标版本,界面上给"旧版"徽标,不假装是新课标。
- 教材词表与例句版权属人民教育出版社,项目仅用于个人学习研究,不商用。
2. 数据管线:三段式,每段独立可跑
bash
npm run data:extract # 抽取附录词表
npm run data:examples # 从正文抽例句
npm run data:build # 生成 src/data/index.json 与 books/*.json
拆成三段的好处是每段都能单独验证。抽错了不用重跑全流程,中间产物落盘直接看。
2.1 例句匹配要收紧范围
一开始我用全文匹配,结果一个三年级词匹配到了六年级课文里的句子。后来改成三级回退:
text
先按词所在单元的页码范围找
↓ 找不到
退到本册范围
↓ 还是找不到
退到全库
同时过滤填空句、课堂指令("Read and write" 之类)和词表噪声。
2.2 人工校对不写进生成逻辑
821 条例句的中文翻译是人工过的。但翻译不能硬编码在抽取脚本里,否则脚本一改就全丢。
做法是覆盖文件:scripts/overrides/examples.json,按词 id 存:
json
{
"w:name": { "zh": "名字" },
"w:a box of": { "en": "", "zh": "" }
}
en 为空串表示删掉这个词的例句(原句残缺修不好,宁可不给)。抽取脚本读完自动应用,生成逻辑一行没改。
这个模式值得记住:人工校对数据和机器生成逻辑分离,用 id 做键。换任何语言、任何字段都适用。
3. FSRS 接入:让算法只负责算法
ts-fsrs 是 FSRS 算法的 TypeScript 实现。配置如下(src/store/srs.ts):
ts
s = fsrs({
request_retention: 0.9,
enable_fuzz: true,
enable_short_term: true,
learning_steps: ["1m", "10m"],
relearning_steps: ["10m"],
});
几个决定:
request_retention: 0.9:目标记忆保持率 90%。调高会变密,调低会变疏。enable_fuzz: true:给到期时间加随机扰动。不加的话,同一批词会在同一天集体到期,复习量锯齿状起伏。- 学习步长 1 分钟 / 10 分钟,重学步长 10 分钟。
持久化时把 Card 拍平成可序列化结构,日期转毫秒时间戳:
ts
const fromCard = (c: Card, seen: number): CardState => ({
due: c.due.getTime(),
s: c.stability,
d: c.difficulty,
el: c.elapsed_days,
sd: c.scheduled_days,
ls: c.learning_steps,
reps: c.reps,
lapses: c.lapses,
st: c.state as CardState["st"],
last: c.last_review?.getTime(),
seen,
});
s 是稳定度(stability,单位天),d 是难度。这两个值是后面所有展示逻辑的唯一来源。
3.1 状态只存一份
这是我觉得最值得抄的一个设计。UI 上有四档植物形态:
text
种子 → 嫩芽 → 叶 → 花
很容易顺手在存档里再加一个 stage: 0|1|2|3 字段。但那就是第二份真相,一定会和卡片状态不同步。
实际做法是全部推导(src/store/garden.ts):
ts
export function stageOf(card?: CardState): Stage {
if (!card || card.reps === 0) return 0;
if (card.reps >= 2 && card.sd >= 1) return card.s >= MASTERED_STABILITY ? 3 : 2;
return 1;
}
MASTERED_STABILITY = 21,即稳定度 21 天以上算"已掌握",开成花。
掌握率、花园进度、每册完成度,全都是这个函数的派生结果。存档里只有 FSRS 卡片,没有别的。
规则:能从已有状态算出来的东西,不要存。
4. 四步学习:听音 → 选义 → 拼写 → 揭晓
ts
export type StepKind = "listen" | "meaning" | "spell" | "reveal";
每天按目标数量组一个会话,步骤是固定顺序的四步循环。
4.1 干扰项取样有个坑
选义题的三个错误选项从词池里取。第一版用固定步长 13 往后跳:
ts
const w = pool[(seed + i * 13) % pool.length];
5B 有 156 个词,gcd(13, 156) = 13,只能走到 12 个下标,干扰项反复就那 12 个。
修法是让步长和池长互质:
ts
let stride = 13;
while (gcd(stride, pool.length) !== 1) stride++;
再混入词 id 的哈希打散正确项位置:
ts
const rotate = (Math.abs(seed) + hashId(word.id)) % all.length;
用稳定哈希(h * 31 + charCodeAt)而不是 Math.random(),同一个会话里重试同一步,选项顺序不变。
4.2 拼写用字母磁贴
把词拆成字母打乱,点选拼回去。种子同样用稳定哈希:
ts
const letters = word.text.toLowerCase().replace(/[^a-z']/g, "").split("");
return shuffle(letters, (step?.wordId.length ?? 1) * 31 + (session?.index ?? 0));
不用随机数,是因为中途重渲染会打乱顺序,孩子拼到一半字母跳位置。
5. 手机微信里为什么没有喇叭
这是整个项目返工次数最多的问题,值得单独一节。
5.1 现象
电脑微信里打开,喇叭图标在,点击有声音。手机微信里打开,喇叭图标根本不存在。
5.2 原因
组件里第一行是:
tsx
if (!canSpeak()) return null;
而 canSpeak() 当时判断的是:
ts
export function canSpeak(): boolean {
return typeof window !== "undefined" && "speechSynthesis" in window;
}
手机微信(Android WebView)里没有 speechSynthesis 这个 API。判断为 false,组件返回 null,图标整个不渲染。
电脑微信用的是真正的浏览器内核,有 API,所以正常。
5.3 修法:能力判断要放宽
先改判断,只要能用 Audio 就显示喇叭:
ts
export function canSpeak(): boolean {
if (typeof window === "undefined") return false;
return typeof Audio !== "undefined" || "speechSynthesis" in window;
}
然后补上真正能出声的路径。
5.4 为什么不用在线 TTS 直连
试过微软 Edge TTS。它免费、不用 key、音质是神经语音级别,比词典发音好听一档。
但浏览器调不了。edge_tts 包里有两个东西说明原因:
constants.py里伪装了 Chromium 版本号,走wss://speech.platform.bing.com/...的私有 WebSocket 协议;drm.py里实现了Sec-MS-GECtoken 的生成,一套改过的 SHA256 变体。
每次连接要算动态签名,浏览器改不了 Origin 头。所以它只能被服务端或本地脚本调用。
三条路摆在面前:
| 方案 | 音质 | 成本 | 风险 |
|---|---|---|---|
| 本地预生成 mp3 | 神经语音,最好 | 一次性生成 1732 个文件,13.9 MB | 无 |
| Cloudflare Worker 代理 | 同上 | 要写 Worker 并移植签名 | 微软改算法就挂 |
| 词典接口直连 | 一般 | 零文件 | 依赖第三方 |
选第一种。词库是固定教材,生成一次就完事。
5.5 生成脚本
scripts/build_audio.py,核心就几行:
python
VOICES = {"uk": "en-GB-SoniaNeural", "us": "en-US-AriaNeural"}
async def synth(text, voice, path, sem, done, total):
if os.path.exists(path) and os.path.getsize(path) > 0:
done[0] += 1
return # 断点续跑
async with sem: # 并发 4
for attempt in range(1, RETRIES + 1):
try:
await edge_tts.Communicate(spoken(text), voice).save(path)
break
except Exception:
if attempt == RETRIES:
return
await asyncio.sleep(attempt)
文件名规则前后端必须一致:
python
def slug(text):
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
ts
export function audioSlug(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
866 个唯一文本,866 个唯一 slug,零冲突。
5.6 运行时三级回退
ts
void (async () => {
for (const url of [audioUrl(text, accent), ttsUrl(spoken, accent)]) {
if (await playUrl(url, rate)) return;
}
speakLocal(spoken, accent, rate);
})();
playUrl 返回 Promise,onerror 和 play() 的 reject 都归为 false:
ts
function playUrl(url: string, rate: number): Promise<boolean> {
return new Promise((resolve) => {
const audio = new Audio(url);
audio.preservesPitch = true; // 变速不变调
audio.playbackRate = rate;
let settled = false;
const finish = (ok: boolean) => { if (!settled) { settled = true; resolve(ok); } };
audio.onerror = () => finish(false);
audio.play().then(() => finish(true)).catch(() => finish(false));
});
}
preservesPitch = true 很关键。语速可调 0.6--1.2,默认 0.8,不变调孩子才听得清。
5.7 一个组合 bug
修完"PE 只读出一个音"之后,5 个缩写词(PE、TV、UK、USA、RSVP)的音频全 404。
原因:
spokenText("PE")返回"P E",为了逐字母朗读;- 拼音频地址时用了
spoken,slug 得到p-e; - 生成脚本按原词存,文件名是
pe-uk.mp3。
两处规则各自都对,组合起来对不上。
修法是职责分开:
ts
// 本地音频按原词命名,缩写拆字母只在合成和在线接口里用
for (const url of [audioUrl(text, accent), ttsUrl(spoken, accent)]) {
同时生成脚本对缩写按字母合成,pe-uk.mp3 里存的确实是 "P E" 的读音(文件从 6192 字节变成 7488 字节,能验证)。
教训:两条规则共享同一个输入时,要明确谁负责转换、谁负责原始值。文件名用原始值,内容用转换值。
6. 应用例图




7. 部署与体积
- 构建产物:主 JS 463 KB,gzip 138 KB
- 音频:1732 个文件,13.9 MB,走静态资源
- 部署:Cloudflare Pages,域名
word.codinglcj.eu.org vite.config.ts里base: process.env.SPROUT_BASE || "/",方便部署到子路径
13.9 MB 对静态托管不算负担,而且是按需加载,只有点喇叭才请求对应文件。
8. 可直接复用的清单
数据类项目
- 数据源单一,不从二手渠道抄
- 管线拆成"抽取 → 加工 → 构建",每段独立可跑
- 人工校对放覆盖文件,按 id 索引,不写进生成逻辑
- 数据缺口如实标注,不补不编
间隔重复
- 算法用现成实现(FSRS / SM-2),别自己发明
- 开
enable_fuzz,避免复习量锯齿 - 存档只存算法状态,展示层全部推导
- 序列化时日期统一转时间戳
移动端音频
- 能力判断用
Audio,不要只判断speechSynthesis - 预生成静态文件优先,第三方接口兜底
-
preservesPitch = true变速不变调 - 文件名规则前后端写两份时,写测试锁住
缩写词发音
- 全大写 2-4 字母按字母拆分朗读
- 文件名用原词,朗读内容用拆分版
9. 讨论
如果你也做过给孩子的工具,有个问题想听你的看法:
你会为了"数据绝对正确"而放弃自动化,改成一个词一个词手校吗?
我这次的做法是:机器抽 + 人工覆盖文件校,821 条例句全过了一遍。代价是慢,收益是每个词的释义都敢给孩子看。
源码在 https://github.com/liuchangng/word-sprout ,代码 MIT,图标 OpenMoji(CC BY-SA 4.0),教材内容版权属人民教育出版社,项目仅用于个人学习研究。
本文技术方案仅供参考,生产环境请充分测试。