平时看文献、写论文都离不开英语,词汇量一直是个绕不过去的坎。市面上背单词的 App 不少,扇贝、百词斩、墨墨之类的我也都用过,但是总有些地方不太舒服,得登录、有广告、打卡提醒烦人,还老是惦记着你的数据。作为一个喜欢自己重复造轮子的让,我写了一个背单词的网站,自己需要什么功能就写什么功能。
纯静态网站,部署在 GitHub Pages 上,数据全部存在你自己的浏览器里,这可能也是我最喜欢的服务部署方式了哈哈哈。地址:jiaoyuan.org/recite,源码在 GitHub。
一次完整的背诵流程
打开首页,是一排词表卡片,每个词表一张。卡片上写着总词数、今天到期要复习的词有多少、还能新背几个,下面一条进度条显示你背到哪了。想背哪个点进去就行。
每张卡片先是正面:英文单词、音标、词性,点一下单词或小喇叭可以发音。你在脑子里想过意思之后,按空格(或点按钮)翻开,背面是中文翻译、英文释义和一条例句。然后给自己打个分:
- 重来:完全没印象
- 困难:想起来了,但很吃力
- 良好:正常想起来
- 简单:秒懂
这个打分不是走形式,它直接决定这个词下一次什么时候再出现。打完分这张卡就过,进入下一张,直到今天的队列清空。
踩在快要遗忘的点上
背单词的核心难题就是遗忘。今天背得再熟,过两周不看大概率忘光。所以 recite 的核心是间隔重复( spaced repetition ),用的是 SM-2 算法,也就是 Anki 那一套。
思路说起来不复杂:你打分越高,说明记得越牢,下次复习的间隔就拉得越长;打分低、甚至完全忘了的词,间隔就缩短,很快再来一遍。理想状态下,每个词总在它快要被你忘掉的那一刻重新出现,既不浪费时间去反复看熟词,也不至于让生词冷在一边。
具体到代码,每次打分都会更新这个词的状态:
export function schedule(listId: string, word: string, q: number, now = Date.now()): WordState {
let st = getWordState(listId, word) || newState(now);
st = { ...st };
if (q < 3) {
st.reps = 0;
st.interval = 1;
} else {
if (st.reps === 0) st.interval = 1;
else if (st.reps === 1) st.interval = 6;
else st.interval = Math.max(1, Math.round(st.interval * st.ef));
st.reps += 1;
}
let ef = st.ef + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
if (ef < 1.3) ef = 1.3;
st.ef = Math.round(ef * 100) / 100;
st.seen = (st.seen || 0) + 1;
st.lastReviewed = now;
st.diff = q < 3 ? true : q >= 4 ? false : !!st.diff;
st.due = now + st.interval * DAY;
setWordState(listId, word, st);
return st;
}
打分映射成 0 到 5 的一个质量值 q:重来是 1 ,困难 3 ,良好 4 ,简单 5 。q < 3 就算没记住,复习次数清零、间隔回到 1 天,明天又会出现;q >= 3 算过了,间隔就往前推,第一次 1 天、第二次 6 天,之后每次乘上一个难度系数 ef ( ease factor )。ef 本身也会根据打分动态调整,老答"简单"它就慢慢变大、间隔越拉越长,老答"困难"它就缩水,最低不低于 1.3 。这么一套自我修正的机制,熟词越背越省事,生词越盯越紧。
每天背哪些词:复习优先,新词按额度补
每天进到一个词表,到底安排哪些词,是有讲究的。逻辑是:先把所有今天到期该复习的词全部排上,再按当天的额度补几个新词。
export function buildSession(list: WordEntry[], listId: string, limit: number, now = Date.now()): Session {
const state = getState();
const due: WordEntry[] = [];
const fresh: WordEntry[] = [];
let freshCount = 0;
for (const e of list) {
const st = state[`${listId}:${e.word}`];
if (!st) {
if (freshCount < limit) {
fresh.push(e);
freshCount += 1;
}
} else if (st.due <= now) {
due.push(e);
}
}
shuffle(due);
shuffle(fresh);
return { due, fresh };
}
这里有个关键设计:每日额度(dailyLimit)只卡新词,复习是永远全收的。也就是说,哪怕你攒了好几天没背,今天到期该复习的词一个都不会漏,全都会排进队列;只有新词才受额度限制。这样就不会出现"今天额度用完了,于是该复习的词也被跳过"的情况,毕竟漏掉一次复习,遗忘就直接发生了。新词和复习两组各自打乱,再拼到一起,也避免你总在同一块连续啃一堆生面孔。
每个词表的进度还是互相独立的,背四级和背考研完全是两套记录,互不干扰,这一点下面讲存储时再说。
四种背法
光看是不够的,得能写、能听。recite 内置了几种模式,从首页每个词表的卡片上都能进去:
- 识记:就是上面说的翻卡流程,看词想意思,翻开核对,再打分。适合第一遍过新词,也适合日常复习。
- 拼写:给你中文释义,让你把英文单词拼出来,可以开倒计时给自己加点压力。识记认得了不代表写得对,这一关专门治"看着眼熟、提笔就错"。
- 听写:只放发音、不显示单词,靠耳朵把词写出来,顺便练听力和拼写。
- 查词:不在背诵流程里,就是随手搜一个词,看它的意思、音标、例句,相当于内置了个小词典。
另外首页右上角点统计,能看到连续打卡天数和一个 17 周的活动热力图,看着小格子一点点填满,多少有点成就感,也算是个督促。
词表与数据
词库用的是 ECDICT,按词频从高到低排,最常用的词先背,这样背完马上就能在阅读里碰上、用得上。例句来自 Tatoeba,覆盖率大概 92%。目前内置了这些词表:
| 词表 | 词数 |
|---|---|
| 中考 | 1,603 |
| 高考 | 3,677 |
| 四级 | 3,849 |
| 六级 | 5,407 |
| 考研 | 4,801 |
| 托福 | 6,974 |
| 雅思 | 5,040 |
| GRE | 7,504 |
| 读博 AWL | 570 |
其中那个学术英语词表 AWL ,是我自己加进去的。
这些数据不是运行时去网上抓的,而是构建阶段就处理好、打包成静态 JSON 一起部署的:词表按 ECDICT 的标签分桶、按词频排序、去重、只留必要字段。打开一个词表时才去加载对应的 JSON ,加载一次就缓存在内存里。例句是可选的,加载失败了也不影响背词,大不了这一张卡少显示一条例句而已。
数据全在本地
这是我最在意的一点。recite 没有账号、没有后端、没有数据库,你所有的进度都存在自己浏览器的 localStorage 里,一共就三个 key:
const STATE_KEY = 'recite:state'; // 每个词的复习状态
const META_KEY = 'recite:meta'; // 全局设置
const ACT_KEY = 'recite:activity'; // 每日打卡记录
其中词的状态是按 ${词表 id}:${单词} 作 key 存的,所以前面说每个词表进度独立,就是因为 key 天然带上了词表 id ,四级和考研的状态各存各的,不会串:
function wordKey(listId: string, word: string): string {
return `${listId}:${word}`;
}
既然数据都在本地,就得能搬走。设置里可以一键把全部进度导出成一个 JSON 文件,换设备、换浏览器时再导回去就行。设置也都在本地:发音可选美音或英音、每日新词数、拼写倒计时、主题(跟随系统/亮/暗)、界面语言(自动/中文/英文)。
发音用的是浏览器自带的语音合成( Speech Synthesis ),不联网、不依赖任何第三方服务。第一次进去点一下"下载离线数据",会把所有词表缓存下来,之后完全断网也能背。这些都是为了让它真正成为一个随时随地、只属于你自己的背单词工具。
实现上的取舍
最后说点技术实现。recite 没有用任何前端框架,整个界面是用一个叫 h() 的小函数,把 DOM 元素一个个拼出来的:
export function h(tag: string, attrs: Record<string, any> = {}, ...children: unknown[]): HTMLElement {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === null || v === undefined || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'html') el.innerHTML = v;
else if (k === 'style') el.setAttribute('style', v);
else if (k.startsWith('on') && typeof v === 'function')
el.addEventListener(k.slice(2).toLowerCase(), v);
else el.setAttribute(k, String(v));
}
for (const c of children.flat() as unknown[]) {
if (c === null || c === undefined || c === false || c === true) continue;
el.appendChild(
typeof c === 'string' || typeof c === 'number'
? document.createTextNode(String(c))
: (c as Node),
);
}
return el;
}
页面切换靠地址栏的 hash 路由,一组正则配一个视图函数,切走之前会调用上一个视图的 cleanup 清理掉自己挂的事件,这样不会留下僵尸监听器:
const routes: Route[] = [
{ re: /^#\/?$/, view: home },
{ re: /^#\/study\/([\w-]+)$/, view: study },
{ re: /^#\/spell\/([\w-]+)$/, view: spell },
{ re: /^#\/dictation\/([\w-]+)$/, view: dictation },
{ re: /^#\/search\/?$/, view: search },
];
和我的博客是一个思路,不引一堆依赖,自己掌控所有逻辑,想咋改咋改,出了问题也知道去哪修。整个项目加起来也就两千来行 TypeScript ,没有状态管理库、没有构建模板,一个 vite 直接打包成静态文件,往 GitHub Pages 上一扔就能跑。
写到最后还是那句老话,背单词这件事,工具只是辅助,贵在坚持。我希望 recite 能是一个干净、不打扰你的工具,每天打开背一会儿就行,不用被广告和打卡绑架。
欢迎一起来背单词。