React 19 + Vite 6 实战:从教材 PDF 到 866 词间隔重复单词 App

0. 先说结论

这个项目一共 2915 行前端代码(.ts/.tsx/.css),38 次提交,106 个测试。

真正花时间的不是这 2915 行,是两件事:

  1. 866 个词的例句校对,821 条要配中文翻译;
  2. 手机上"没有喇叭图标"这个问题。

技术选型没有秘密,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-GEC token 的生成,一套改过的 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),教材内容版权属人民教育出版社,项目仅用于个人学习研究。

本文技术方案仅供参考,生产环境请充分测试。


相关推荐
Dwzun1 小时前
基于SpringBoot3+Vue3的油莎豆种植管理系统
java·前端·vue.js·spring boot·后端·python·计算机网络
小码哥_常7 小时前
一套自己部署的 IM,客户端是同一份 Flutter
前端·后端
蜗牛攻城狮10 小时前
微前端四大方案选型对比与最佳实践:qiankun、micro-app、wujie、Module Federation
前端·微前端·qiankun·前端架构·micro-app·wujie
她说可以呀11 小时前
Vue插槽
前端·javascript·vue.js
qq_4480111611 小时前
Vue + Jinja2两套模版与法冲突造成undefinedError
前端·javascript·vue.js
aixingpan11 小时前
aixingpan.cn API开发文档:前端查询夏令时和时区接口指南
前端·php
2601_9583529012 小时前
23.5×19mm 换掉一整条音频链?|A-68 集成了什么:ADC / DAC / 24.576MHz 时钟 / AEC / 波束降噪,主控仅需 6 根线
前端·音视频·ai降噪·回声消除·语音模块
志尊宝13 小时前
AI原生前端开发(039):AI辅助的Nuxt 4全栈开发
前端·vue.js·ai-native
FRing0313 小时前
JavaScript入门到精通三十二(深拷贝、浅拷贝、递归函数)
开发语言·前端·javascript