一起来背单词吧!

7 小时 9 分钟前
 yuanyuan11

平时看文献、写论文都离不开英语,词汇量一直是个绕不过去的坎。市面上背单词的 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 能是一个干净、不打扰你的工具,每天打开背一会儿就行,不用被广告和打卡绑架。

欢迎一起来背单词。

321 次点击
所在节点    分享创造
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1232910

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX