easy-word 的核心功能是按遗忘曲线安排复习。单词学完后不是”学过了”,而是进入一个调度系统,在最佳时间点提醒学生复习。

艾宾浩斯遗忘曲线

德国心理学家赫尔曼·艾宾浩斯发现,记忆遗忘遵循指数衰减规律。复习的最佳时间点是在遗忘即将发生时:

1
2
3
4
5
6
7
8
第 1 次复习:学完后 20 分钟
第 2 次复习:1 小时后
第 3 次复习:9 小时后(通常安排到第二天)
第 4 次复习:1 天后
第 5 次复习:2 天后
第 6 次复习:6 天后
第 7 次复习:15 天后
第 8 次复习:30 天后

调度间隔定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 间隔数组(单位:毫秒)
const REVIEW_INTERVALS = [
20 * 60 * 1000, // 20 分钟
60 * 60 * 1000, // 1 小时
9 * 60 * 60 * 1000, // 9 小时
24 * 60 * 60 * 1000, // 1 天
2 * 24 * 60 * 60 * 1000, // 2 天
6 * 24 * 60 * 60 * 1000, // 6 天
15 * 24 * 60 * 60 * 1000, // 15 天
30 * 24 * 60 * 60 * 1000, // 30 天
]

function getNextReviewTime(reviewCount: number, lastReviewAt: number): number {
const index = Math.min(reviewCount, REVIEW_INTERVALS.length - 1)
return lastReviewAt + REVIEW_INTERVALS[index]
}

reviewCount 从 0 开始,每次复习 +1。到达第 8 次复习后,间隔固定为 30 天。

调度器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ReviewScheduler {
constructor(private dao: LearnRecordDao) {}

// 获取今天的待复习单词
async getDueWords(userId: string, limit: number = 20): Promise<LearnRecord[]> {
const predicates = new relationalStore.RdbPredicates('learn_records')
predicates.equalTo('user_id', userId)
predicates.lessThanOrEqualTo('next_review_at', Date.now())
predicates.equalTo('status', 1) // 状态:学习中
predicates.limit(limit)
predicates.orderByAsc('next_review_at') // 最早到期的优先
return this.dao.query(predicates)
}

// 记录复习结果
async recordReview(recordId: number, correct: boolean): Promise<void> {
const record = await this.dao.getById(recordId)
if (!record) return

const newCount = record.review_count + 1
const newCorrect = correct ? record.correct_count + 1 : record.correct_count
const nextReview = getNextReviewTime(newCount, Date.now())

await this.dao.update(recordId, {
review_count: newCount,
correct_count: newCorrect,
last_review_at: Date.now(),
next_review_at: nextReview,
status: newCount >= REVIEW_INTERVALS.length ? 2 : 1 // 全部复习完 → 已掌握
})
}

// 学习新词
async learnNewWord(userId: string, wordId: number): Promise<void> {
await this.dao.insert({
word_id: wordId,
user_id: userId,
status: 1, // 学习中
review_count: 0,
correct_count: 0,
next_review_at: getNextReviewTime(0, Date.now()), // 20 分钟后第一次复习
last_review_at: null
})
}
}

答错惩罚

学生答错时,不推进间隔,而是回到上一级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async recordReview(recordId: number, correct: boolean): Promise<void> {
const record = await this.dao.getById(recordId)
if (!record) return

let newCount: number
if (correct) {
newCount = record.review_count + 1 // 答对,推进
} else {
newCount = Math.max(0, record.review_count - 1) // 答错,回退一级
}

const nextReview = getNextReviewTime(newCount, Date.now())
// ...更新记录
}

回退一级意味着:如果学生在第 5 次复习(2 天间隔)时答错,回到第 4 次(1 天间隔),明天再复习一次。

每日学习流程

1
2
3
4
5
首页 → 显示今日待复习数量
→ 闪卡模式:翻卡 → 认识/不认识
→ 拼写模式:听发音 → 输入拼写
→ 选择题:四选一
→ 完成后更新 next_review_at

三种学习模式(闪卡、拼写、选择题)共享同一个调度器。不管用哪种模式学习,调度逻辑统一。

热力图数据

每次学习结束后,更新 daily_logs 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
async updateDailyLog(userId: string, learned: number, reviewed: number, accuracy: number) {
const today = formatDate(Date.now()) // YYYY-MM-DD
const predicates = new relationalStore.RdbPredicates('daily_logs')
predicates.equalTo('user_id', userId)
predicates.equalTo('date', today)

const existing = await this.dao.query(predicates)
if (existing.length > 0) {
await this.dao.updateDaily(userId, today, learned, reviewed, accuracy)
} else {
await this.dao.insertDaily(userId, today, learned, reviewed, accuracy)
}
}

热力图读取 daily_logs 表,按日期聚合展示学习强度。第四篇会详细讲热力图的实现。

间隔重复的核心不是”多复习”,是”在对的时间复习”。20 分钟到 30 天的 8 级间隔,覆盖了从短期记忆到长期记忆的完整转化过程。