爱意满满的作品展示区。
yuanyuan11

一起来背单词吧!

  •  
  •   yuanyuan11 · 4h 40m ago · 246 views

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

    欢迎一起来背单词。

    No Comments Yet
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Solana   ·   2696 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 33ms · UTC 12:28 · PVG 20:28 · LAX 05:28 · JFK 08:28
    ♥ Do have faith in what you're doing.