ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

TypeScript实现SM-2间隔重复算法:从原理到npm发布实战

TypeScript实现SM-2间隔重复算法:从原理到npm发布实战 1. 项目概述当经典算法遇见现代前端如果你接触过像 Anki、SuperMemo 这类记忆软件大概率听说过“间隔重复”这个神奇的概念。它不是什么魔法而是一种基于人类记忆曲线设计的科学复习方法核心思想是在你即将遗忘某个知识点时恰到好处地安排一次复习从而将记忆痕迹从短期记忆“夯”入长期记忆。而SM-2 算法正是这套理论中一个里程碑式的、简洁高效的实现方案。我最初接触 SM-2 是在自己开发一个背单词小工具的时候。市面上的库要么功能过于庞杂要么接口设计得不够“前端友好”。于是我决定自己动手用TypeScript从头实现一个纯净、类型安全、且易于集成的 SM-2 算法库并发布到npm上。这不仅仅是一次编码练习更是一次对经典算法的深度解构与现代工程化实践的结合。本文将分享这个sm2-ts库从零到一的完整过程包括算法原理的 TypeScript 化实现、npm 包开发的完整流程、以及在实际项目中集成应用时遇到的种种“坑”和解决方案。无论你是想学习间隔重复算法还是对 TypeScript 库开发、npm 发布流程感兴趣这篇文章都能给你提供一份可直接“抄作业”的实战指南。2. SM-2 算法核心原理与 TypeScript 实现拆解2.1 算法参数解析五个决定记忆命运的变量SM-2 算法的精妙之处在于其简洁性。它仅用五个核心参数来模拟和管理一个知识点的整个记忆生命周期易度因子 (Easiness Factor, EF)取值范围通常在 1.3 到 2.5 之间。它代表了当前记忆项的“容易程度”。EF 值越高复习间隔增长得越快。初始值一般设为 2.5。间隔 (Interval)表示下一次复习距离今天的天数。对于新项目初始间隔为 1 天明天复习之后根据复习质量动态计算。重复次数 (Repetitions)记录该项目已被连续成功复习的次数。初始为 0。上次复习日期 (Last Review Date)记录上一次复习的日期用于计算下一次复习是否到期。复习质量 (Quality of Response, Q)每次复习时用户自评的反馈通常是一个 0 到 5 的整数。这是算法唯一的输入也是驱动所有参数变化的源头。算法的核心逻辑就围绕这五个参数展开。每次复习时用户根据回忆的吃力程度给出一个 Q 值例如5完美回忆4稍有迟疑但正确3困难但最终想起2错误但看答案后觉得熟悉1错误且看答案后陌生0完全遗忘。算法根据这个 Q 值动态更新 EF、间隔和重复次数。2.2 核心计算流程的 TypeScript 建模用 TypeScript 实现的第一步是定义清晰的数据结构。这不仅能利用类型检查避免低级错误也让代码意图一目了然。// 定义单次复习的结果质量 type ReviewQuality 0 | 1 | 2 | 3 | 4 | 5; // 定义记忆项的核心状态接口 interface SM2Item { easeFactor: number; // 易度因子 interval: number; // 下次复习间隔天 repetitions: number; // 连续成功复习次数 lastReviewDate: Date | null; // 上次复习日期 nextReviewDate: Date; // 计算得出的下次复习日期 } // 算法的默认配置 interface SM2Config { initialEaseFactor?: number; // 默认 2.5 easeFactorMin?: number; // 默认 1.3EF下限 intervalModifier?: number; // 间隔乘数默认 1 }接下来是核心的review函数。它接收一个当前记忆项状态和本次复习质量返回更新后的状态。这里的关键是严格遵循 SM-2 的原始论文逻辑如果 Q 3复习质量较差重置重复次数为 0间隔重置为 1 天。易度因子会降低EF EF - 0.8 0.28*Q - 0.02*Q*Q并确保不低于最小值如 1.3。这意味着算法认为该项目对你来说变难了需要更频繁地回顾。如果 Q 3复习质量合格易度因子更新EF EF (0.1 - (5 - Q) * (0.08 (5 - Q) * 0.02))。这个公式保证了 Q 越高回忆越好EF 增加得越多Q 刚好为 3 时EF 微增Q 为 4 或 5 时EF 有显著增长。根据重复次数计算新间隔如果repetitions 0新间隔 1 天。如果repetitions 1新间隔 6 天。如果repetitions 1新间隔 旧间隔 * EF取整。重复次数加 1。注意网上很多 SM-2 的实现版本在 EF 更新公式上存在细微差异有些是简化版。我建议采用 SuperMemo 原始论文中的公式以保证算法效果。上述第二个公式就是原始公式的变形它确保了计算的一致性。用 TypeScript 实现这个逻辑重点在于数值计算的精确性和边界处理function review(item: SM2Item, quality: ReviewQuality, config: SM2Config {}): SM2Item { const { initialEaseFactor 2.5, easeFactorMin 1.3, intervalModifier 1 } config; let { easeFactor, interval, repetitions } item; let nextInterval: number; if (quality 3) { // 质量不佳重置 repetitions 0; nextInterval 1; // 更新易度因子质量差时降低 easeFactor easeFactor (0.1 - (5 - quality) * (0.08 (5 - quality) * 0.02)); easeFactor Math.max(easeFactor, easeFactorMin); // 确保不低于最小值 } else { // 质量合格 // 首先更新易度因子 easeFactor easeFactor (0.1 - (5 - quality) * (0.08 (5 - quality) * 0.02)); // 然后根据重复次数计算间隔 if (repetitions 0) { nextInterval 1; } else if (repetitions 1) { nextInterval 6; } else { nextInterval Math.round(interval * easeFactor); } repetitions 1; } // 应用全局间隔修饰符可用于调整整体复习频率 nextInterval nextInterval * intervalModifier; const nextReviewDate new Date(); nextReviewDate.setDate(nextReviewDate.getDate() nextInterval); return { easeFactor: parseFloat(easeFactor.toFixed(2)), // 保留两位小数 interval: nextInterval, repetitions, lastReviewDate: new Date(), // 更新为本次复习时间 nextReviewDate, }; }这样我们就得到了一个纯函数式的、类型安全的 SM-2 算法核心。它不依赖任何外部状态易于测试和集成。3. 从代码到 npm 包工程化实践全流程3.1 项目初始化与开发环境搭建有了核心算法下一步是把它包装成一个专业的 npm 包。首先初始化项目并安装必要的开发依赖。# 创建项目目录并初始化 package.json mkdir sm2-ts cd sm2-ts npm init -y接下来编辑package.json设置合理的入口、脚本和基本信息。一个关键的细节是设置type: module以支持 ES 模块这是现代前端库的趋势。{ name: sm2-ts, version: 1.0.0, description: A TypeScript implementation of the SM-2 spaced repetition algorithm., main: ./dist/index.js, types: ./dist/index.d.ts, type: module, scripts: { build: tsc, test: vitest run, prepublishOnly: npm run build }, keywords: [spaced-repetition, sm2, typescript, memory], author: Your Name, license: MIT, devDependencies: { types/node: ^20.x, typescript: ^5.x, vitest: ^1.x } }实操心得在devDependencies中固定主版本号如^5.x是一个好习惯它能确保团队或 CI 环境使用兼容的版本避免因次要版本升级带来的意外破坏。同时prepublishOnly脚本确保了在运行npm publish之前一定会先执行构建防止发布未编译的源代码。然后创建tsconfig.json配置文件。这里的目标是生成兼容性良好的 ES2020 代码并生成类型声明文件。{ compilerOptions: { target: ES2020, module: ESNext, lib: [ES2020, DOM], declaration: true, outDir: ./dist, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, moduleResolution: node }, include: [src/**/*], exclude: [node_modules, dist, **/*.test.ts] }3.2 源码组织、测试与构建在src目录下我们组织代码。通常将核心算法放在src/core.ts对外暴露的 API 放在src/index.ts。// src/index.ts export { review, createItem } from ./core.js; // 注意扩展名ES Module 要求 export type { SM2Item, ReviewQuality, SM2Config } from ./core.js;接下来是重头戏单元测试。我选择Vitest因为它速度快且与 Vite 生态结合好。在src/core.test.ts中我们需要覆盖算法的各种边界情况。import { describe, it, expect } from vitest; import { review, createItem } from ./core.js; describe(SM2 Algorithm, () { it(should reset repetitions and interval on poor review (Q2), () { const item createItem(); const result review(item, 2); expect(result.repetitions).toBe(0); expect(result.interval).toBe(1); expect(result.easeFactor).toBeLessThan(item.easeFactor); // EF 应降低 }); it(should correctly calculate interval for first successful review (Q4), () { const item createItem(); const result review(item, 4); expect(result.repetitions).toBe(1); expect(result.interval).toBe(1); // 第一次成功间隔1天 }); it(should correctly calculate interval for second successful review (Q5), () { let item createItem(); item review(item, 5); // 第一次复习质量5 const result review(item, 5); // 第二次复习质量5 expect(result.repetitions).toBe(2); expect(result.interval).toBe(6); // 重复次数为1后间隔应为6天 }); it(should not let ease factor drop below minimum, () { let item createItem({ initialEaseFactor: 1.3 }); // 连续给出低质量评价 for (let i 0; i 10; i) { item review(item, 0, { easeFactorMin: 1.3 }); } expect(item.easeFactor).toBe(1.3); // 应等于最小值而非低于 }); });运行npm test确保所有测试通过。这是保证库可靠性的基石。最后执行npm run build即tscTypeScript 编译器会根据配置将src下的代码编译到dist目录同时生成.d.ts类型声明文件。检查dist目录确保生成的文件正确。3.3 npm 发布与版本管理实战在发布之前有几件重要的事情必须做创建.npmignore文件防止将测试文件、源码、配置文件等无关内容发布到 npm减少包体积。src/ *.test.ts tsconfig.json vitest.config.ts .gitignore完善README.md这是包的“门面”应包含安装、快速开始、API 文档、示例和 License。登录 npm如果你还没有账号去 npm 官网注册。然后在终端登录npm login你会被提示输入用户名、密码和邮箱。如果遇到npm ERR! code E403错误通常是因为包名已被占用需要去package.json里修改name字段。发布npm publish --accesspublic--accesspublic对于首次发布的 scoped 包如yourname/sm2-ts是必须的。踩坑实录我第一次发布时遇到了npm ERR! 402 Payment Required。这是因为我把包名起成了sm2这是一个看起来比较“通用”的名称npm 将其视为“高级”包名需要付费账户。解决方法有两个一是注册付费账户二是更名比如加个后缀变成sm2-ts或使用 scoped 包名yourname/sm2。我选择了前者因为sm2-ts更直观。发布成功后你就可以在任何项目中通过npm install sm2-ts来使用它了。4. 在真实项目中集成与应用模式4.1 前端集成示例构建一个简易复习卡片组件假设我们在一个 React TypeScript 的项目中使用这个库。首先安装npm install sm2-ts然后我们可以构建一个简单的复习卡片组件。这个组件的状态会管理当前卡片的 SM2 数据并提供按钮让用户反馈复习质量。// ReviewCard.tsx import React, { useState } from react; import { review, createItem, type SM2Item, type ReviewQuality } from sm2-ts; interface CardData { id: string; question: string; answer: string; sm2Data: SM2Item; } const ReviewCard: React.FC{ card: CardData; onReview: (updatedData: SM2Item) void } ({ card, onReview }) { const [showAnswer, setShowAnswer] useState(false); const { question, answer, sm2Data } card; const handleReview (quality: ReviewQuality) { const updatedSm2Data review(sm2Data, quality); onReview(updatedSm2Data); // 将更新后的数据传回父组件保存 setShowAnswer(false); // 重置状态准备下一张卡片 }; return ( div classNamereview-card h3{question}/h3 {!showAnswer ? ( button onClick{() setShowAnswer(true)}显示答案/button ) : ( p{answer}/p div classNamequality-buttons p回忆得如何/p {([5, 4, 3, 2, 1, 0] as ReviewQuality[]).map((q) ( button key{q} onClick{() handleReview(q)} className{quality-${q}} {getQualityText(q)} /button ))} /div / )} div classNamecard-stats smallEF: {sm2Data.easeFactor.toFixed(2)} | 下次复习: {sm2Data.nextReviewDate.toLocaleDateString()}/small /div /div ); }; // 辅助函数将数字质量转换为文本描述 function getQualityText(q: ReviewQuality): string { const map: RecordReviewQuality, string { 5: 完美, 4: 顺利, 3: 困难, 2: 错误但熟悉, 1: 错误且陌生, 0: 完全忘记, }; return map[q]; } export default ReviewCard;父组件负责管理卡片队列并根据nextReviewDate过滤出今天需要复习的卡片。// App.tsx 部分逻辑 const [cards, setCards] useStateCardData[](initialCards); // 获取今日需复习的卡片 const dueCards cards.filter(card { const today new Date(); today.setHours(0, 0, 0, 0); const dueDate new Date(card.sm2Data.nextReviewDate); dueDate.setHours(0, 0, 0, 0); return dueDate today; }); const handleCardReview (cardId: string, updatedSm2Data: SM2Item) { setCards(prevCards prevCards.map(card card.id cardId ? { ...card, sm2Data: updatedSm2Data } : card ) ); };4.2 数据持久化策略本地存储与后端同步SM2 算法的状态需要持久化保存。对于纯前端应用localStorage或IndexedDB是常见选择。// storage.ts const STORAGE_KEY sm2-flashcards-data; export function saveCards(cards: CardData[]): void { try { // 注意Date 对象需要序列化 const dataToSave cards.map(card ({ ...card, sm2Data: { ...card.sm2Data, lastReviewDate: card.sm2Data.lastReviewDate?.toISOString() || null, nextReviewDate: card.sm2Data.nextReviewDate.toISOString(), }, })); localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave)); } catch (error) { console.error(保存数据失败:, error); } } export function loadCards(): CardData[] { try { const saved localStorage.getItem(STORAGE_KEY); if (!saved) return []; const parsed JSON.parse(saved); // 反序列化时恢复 Date 对象 return parsed.map((item: any) ({ ...item, sm2Data: { ...item.sm2Data, lastReviewDate: item.sm2Data.lastReviewDate ? new Date(item.sm2Data.lastReviewDate) : null, nextReviewDate: new Date(item.sm2Data.nextReviewDate), }, })); } catch (error) { console.error(加载数据失败:, error); return []; } }对于需要多端同步的应用你需要将数据保存到后端。设计 API 时一个高效的做法是每次复习后只将更新的sm2Data发送到服务器而不是整个卡片对象。服务器端可以简单地用新的SM2Item数据覆盖旧数据。4.3 算法调优与高级用法基础的 SM-2 算法已经很强大但在实际应用中我们可能需要进行一些调整以适应特定场景。调整初始难度对于不同难度的内容可以设置不同的initialEaseFactor。比如非常难的专业术语初始 EF 可以设为 2.0让间隔增长慢一些简单的常识可以设为 2.5 甚至更高。全局间隔修饰符SM2Config中的intervalModifier非常有用。如果你想加快整体复习节奏比如备考冲刺可以将其设为 0.8这样所有计算出的间隔都会打八折。反之如果觉得复习太频繁可以设为 1.2。处理“复习队列”一个成熟的系统需要智能调度。不仅仅是找出所有到期的卡片还可以根据 EF 值进行排序。优先复习 EF 值低的卡片即你觉得更难的因为它们在记忆中更脆弱。批量复习与新卡片引入可以设定每日新卡片学习上限如 10 张以及每日复习卡片上限如 50 张。算法可以优先保证到期卡片的复习在额度内再引入新卡片。// 一个高级的调度函数示例 function getTodaysReviewQueue(allCards: CardData[], dailyNewLimit: number, dailyReviewLimit: number): { toReview: CardData[], newCards: CardData[] } { const today new Date(); today.setHours(0, 0, 0, 0); // 1. 筛选出到期卡片 const dueCards allCards.filter(card { const dueDate new Date(card.sm2Data.nextReviewDate); dueDate.setHours(0, 0, 0, 0); return dueDate today; }); // 2. 按易度因子排序先复习最难的 dueCards.sort((a, b) a.sm2Data.easeFactor - b.sm2Data.easeFactor); // 3. 应用复习上限 const toReview dueCards.slice(0, dailyReviewLimit); // 4. 筛选未学习过的新卡片重复次数为0 const newCards allCards .filter(card card.sm2Data.repetitions 0) .slice(0, dailyNewLimit); return { toReview, newCards }; }5. 开发与集成中的常见问题排查5.1 TypeScript 配置与构建问题问题在项目中导入sm2-ts后TypeScript 报错“找不到模块”或“没有默认导出”。排查首先检查package.json中的main和types字段是否指向了正确的文件dist/index.js和dist/index.d.ts。确保你已经成功运行了npm run build并生成了这些文件。解决如果使用 ES 模块type: module在src/index.ts中导出时引入本地文件需要写完整的扩展名.js即使源文件是.ts。这是 ES Module 在 Node.js 环境下的要求。同时确保消费方项目的tsconfig.json中moduleResolution设置为node或bundler。问题npm run build时TypeScript 报错“选项‘baseUrl’已弃用”。排查这是一个 TypeScript 版本升级带来的警告。baseUrl是tsconfig.json中的一个选项用于设置非相对模块导入的基目录。在较新的 TypeScript 版本中它可能与paths选项的行为有变化。解决如果你没有显式使用baseUrl可以安全地从tsconfig.json中移除这个选项。如果你确实需要配置模块路径映射请使用paths选项并确保理解其与baseUrl的配合关系。对于这个库项目通常不需要复杂的路径映射直接移除baseUrl即可。5.2 npm 依赖安装与发布问题问题npm install失败提示npm ERR! code EBADENGINE说 Node.js 或 npm 版本不兼容。排查这个错误表明package.json中的engines字段指定了 Node.js 或 npm 的版本范围而当前环境不满足。解决检查本地 Node.js 版本 (node -v) 和 npm 版本 (npm -v)。如果库是你开发的考虑engines字段是否限制得过于严格。对于通用库一个宽松的配置如node: 16.0.0可能更合适。如果是安装别人的包遇到此问题可以尝试升级你的 Node.js 版本或者使用npm install --ignore-engines不推荐长期使用可能存在兼容性问题来暂时忽略。问题npm publish失败提示npm ERR! 402 Payment Required。排查你尝试发布的包名可能被视为“高级”名称通常是短小、通用的单词需要 npm 付费账户。解决最直接的方法是更改包名添加有意义的后缀例如将sm2改为sm2-spaced-repetition。或者注册 npm 的付费计划。也可以在包名前加上你的 npm 用户名即 scoped package如your-username/sm2scoped 包对于个人通常是免费的。问题安装依赖时网络超时或速度极慢。排查默认的 npm 源 (registry.npmjs.org) 在国内访问可能不稳定。解决切换为国内镜像源。可以使用nrm工具管理源或者直接使用cnpm。# 临时使用淘宝镜像安装某个包 npm install sm2-ts --registryhttps://registry.npmmirror.com # 永久切换镜像源 npm config set registry https://registry.npmmirror.com # 使用 nrm 切换 npm install -g nrm nrm use taobao5.3 算法逻辑与数据一致性疑难问题复习间隔增长得异常快或异常慢不符合预期。排查检查 Q 值映射确认前端按钮传递的 Q 值0-5是否正确对应到算法的ReviewQuality类型。一个常见的错误是传递了字符串5而不是数字5。验证 EF 更新公式对照本文或 SM-2 原始论文仔细核对review函数中 EF 的计算公式特别是当Q 3时那个看似复杂的公式。一个符号错误就会导致 EF 变化趋势完全相反。检查初始状态确保新创建的卡片其repetitions为 0interval为 1或 0lastReviewDate为null。错误的状态会导致首次复习的间隔计算错误。解决编写针对性的单元测试。模拟一个卡片连续进行“完美复习Q5”的序列打印出每次复习后的 EF 和间隔观察其增长曲线是否符合预期间隔应大致按 1, 6, 约16, 约40... 的天数增长。问题nextReviewDate计算出现时区问题导致卡片提前一天或延后一天到期。排查JavaScript 的Date对象包含时区信息。new Date()创建的是本地时间而setDate操作也是基于本地时间。如果服务器存储的是 UTC 时间而前端用本地时间计算和比较就会产生时区偏移。解决在涉及日期比较和存储时坚持使用UTC 时间或时间戳。// 使用时间戳进行计算和存储 function calculateNextReview(intervalDays: number): number { const now Date.now(); // 当前时间戳毫秒 const next now intervalDays * 24 * 60 * 60 * 1000; return next; } // 比较是否到期 function isDue(nextReviewTimestamp: number): boolean { return Date.now() nextReviewTimestamp; } // 只在显示给用户时根据需要转换为本地日期字符串 new Date(nextReviewTimestamp).toLocaleDateString()在SM2Item接口中可以将lastReviewDate和nextReviewDate的类型改为number时间戳从而从根本上避免时区混淆。问题在 React 状态更新中SM2 数据似乎没有正确更新。排查React 的状态更新可能是异步的并且依赖于前一个状态。如果连续快速点击复习按钮或者更新逻辑写得不正确可能导致状态不同步。解决确保使用函数式更新来依赖前一个状态。确保review函数是纯函数每次调用都返回全新的对象而不是修改输入对象。在复杂的更新逻辑中使用useReducer可能比多个useState更易于管理。// 使用 useReducer 管理卡片状态 function cardsReducer(state: CardData[], action: { type: REVIEW; cardId: string; quality: ReviewQuality }) { switch (action.type) { case REVIEW: return state.map(card { if (card.id action.cardId) { return { ...card, sm2Data: review(card.sm2Data, action.quality) }; } return card; }); default: return state; } } // 在组件中 const [cards, dispatch] useReducer(cardsReducer, initialCards); const handleReview (cardId: string, quality: ReviewQuality) { dispatch({ type: REVIEW, cardId, quality }); };将 SM-2 算法从理论公式转化为一个健壮的、可发布的 TypeScript 库整个过程是一次对细节的深度打磨。每一个参数、每一次日期计算、每一个类型定义都直接影响着最终用户的学习体验。我个人的体会是算法库的边界情况处理如 EF 最小值保护、时区处理和开发者体验清晰的类型、完整的文档、简单的 API与算法核心逻辑本身同等重要。最后一个小技巧在package.json的scripts里加一个prepack: npm run build这样无论是npm publish还是npm pack都会自动确保发布的是最新构建的产物避免意外。
返回列表