TypeScript实现SM-2间隔重复算法:构建现代记忆系统的核心引擎
1. 项目概述为什么我们需要一个现代的SM-2实现如果你曾经尝试过使用Anki、SuperMemo这类间隔重复软件来记忆外语单词、专业术语或者任何需要长期记忆的知识点那么你很可能已经接触过SM-2算法。它几乎是所有现代间隔重复系统的基石由SuperMemo的创始人Piotr Woźniak博士在1980年代后期提出。这个算法的核心思想非常直观根据你对一个知识点的记忆熟练程度通常通过“复习时自我评估的难度等级”来量化动态调整下一次复习的时间间隔。记得越牢间隔就越长觉得越难间隔就越短并且会频繁出现直到你掌握它。听起来很完美对吧但问题来了。当你真正想在自己的项目——比如一个自定义的单词本应用、一个专业知识训练工具甚至是一个游戏化的学习系统——中集成这个算法时你会发现直接使用原始的、用其他语言如SuperMemo的Pascal实现编写的SM-2代码或者在网上找到的那些年代久远、注释不清的代码片段是一件相当头疼的事情。它们往往缺乏类型安全难以调试与现代前端或Node.js开发栈格格不入。这就是为什么我们需要一个用TypeScript实现、并通过npm发布的sm2包。它不是一个简单的代码搬运工。我的目标是将经典的SM-2算法用现代、健壮、类型安全的TypeScript重新实现封装成一个零依赖、开箱即用、API清晰的npm包。这样一来无论是React/Vue前端应用还是Node.js后端服务抑或是Electron桌面应用开发者都能通过一句npm install sm2轻松获得一个经过严格测试的、可靠的记忆调度引擎从而专注于自己应用的功能和用户体验而不是重新造轮子或者调试一个黑盒算法。2. 核心算法解析SM-2是如何运作的要封装一个好用的工具首先必须吃透其核心原理。SM-2算法虽然经典但其逻辑细节对于初次接触的开发者来说可能有些晦涩。它不仅仅是一个公式更是一套完整的“复习-反馈-调整”的状态机。2.1 算法的五个核心参数SM-2算法驱动每一次复习决策依赖于五个关键参数。理解它们是理解整个算法的第一步难度因子Difficulty Factor,difficulty这是一个浮点数范围通常在0.0到1.0之间有些实现用1.0-5.0。它代表了当前卡片或记忆项对你而言的“主观难度”。你每次复习时给出的“评分”如“生疏”、“困难”、“良好”、“简单”直接影响这个值的升降。难度因子是计算下次复习间隔的核心乘数之一——越难的项目间隔增长越缓慢。间隔重复次数Repetitions,repetition这是一个整数记录当前卡片已经连续成功回忆的次数。注意是“连续成功”。一旦某次复习你评分很低比如表示遗忘这个计数器通常会重置为0。这个值直接影响间隔计算的基数。上次间隔Previous Interval,interval一个整数代表上一次复习到现在的天数。它是计算下一次间隔的起点。易度系数Ease Factor,ease这是SM-2中一个非常精妙的设计也是一个浮点数通常初始值为2.5。它像一个长期的“记忆粘度”调节器。每次复习后根据你的评分ease会进行微调评分高则略微增加意味着你觉得越来越容易下次间隔可以更激进地拉长评分低则显著减少意味着你觉得困难需要更保守地增加间隔。ease直接参与间隔的计算公式新间隔 旧间隔 * ease。评分Grade/Quality这是用户每次复习后的直接输入通常是一个整数等级例如0-5分。它是对本次回忆质量的主观评估是算法唯一的“外界输入”驱动着上述所有内部状态的更新。2.2 算法流程与状态跃迁SM-2算法的执行可以看作一个卡片在不同复习阶段的状态跃迁过程阶段一首次学习repetition 0当一张新卡片第一次被引入时它处于“学习阶段”。算法会忽略历史间隔直接根据你的首次评分安排一个非常短的复习间隔例如几分钟、几小时或1天。这个阶段的目标是完成从“完全陌生”到“短期记忆”的转化。阶段二复习阶段repetition 0当卡片进入复习阶段后算法开始发挥威力。其核心计算步骤如下根据本次复习的评分更新difficulty和ease。评分越高difficulty略微降低ease略微升高评分越低特别是表示遗忘的低分difficulty升高ease则大幅降低。利用公式计算新的间隔新间隔 当前间隔 * ease。这里体现了“间隔重复”的精髓成功的回忆会让下次等待时间以ease为系数倍增。递增repetition计数器。阶段三遗忘与重置如果某次复习评分非常低例如表示完全遗忘算法通常会采取严厉措施将repetition计数器重置为0interval也重置为一个很小的值比如1天让卡片重新进入“学习阶段”。同时ease会被大幅调低意味着即使重新学起来其间隔增长也会比以前更慢。这是一种惩罚机制模拟了大脑中一个记忆痕迹消退后重新建立需要更多努力的过程。注意不同的SM-2实现可能在具体参数如评分等级对应数值、difficulty和ease的更新公式系数上有细微差别。我实现的这个包参考了最广泛流传和验证的参数集确保其效果与Anki等主流软件的核心逻辑保持一致。2.3 用TypeScript类型定义算法状态在动手实现之前用TypeScript的接口Interface清晰地定义这个状态机是保证代码健壮性的关键。这能让使用者一目了然也能让编译器帮助我们避免很多低级错误。// 定义用户复习时的评分等级这里采用经典的0-4分制 // 0: 完全遗忘1: 想起很困难2: 困难但能想起3: 容易4: 非常容易 type ReviewGrade 0 | 1 | 2 | 3 | 4; // 定义SM-2算法核心的卡片状态对象 interface SM2Card { // 难度因子 (0.0 - 1.0)值越大表示越难 difficulty: number; // 连续成功回忆次数 repetition: number; // 当前间隔天数 interval: number; // 易度系数默认2.5 easeFactor: number; // 上次复习的时间戳可选用于计算是否到期 lastReviewed?: Date; } // 算法核心函数接收当前卡片状态和本次评分返回下一次复习的日期 interface SM2Algorithm { (card: SM2Card, grade: ReviewGrade): SM2Card; }通过这样的类型定义算法的输入输出变得极其清晰。使用者只需要维护一个SM2Card对象每次复习时调用算法函数传入这个对象和评分就能得到更新后的、包含了下次复习日期的状态对象。这种设计将复杂的算法逻辑封装在一个纯函数里无副作用易于测试和推理。3. 工程化实现从零构建一个TypeScript npm包理解了算法接下来就是如何将它工程化变成一个可靠、易用的开源包。这个过程远不止是写一个函数那么简单它涉及项目初始化、开发环境配置、打包构建、测试、文档等一系列现代前端工程实践。3.1 项目初始化与开发环境搭建首先我们需要一个干净的起点。# 创建一个新的项目目录 mkdir sm2-ts cd sm2-ts # 初始化npm项目生成package.json npm init -y生成的package.json是包的“身份证”。我们需要对其进行精心配置{ name: sm2, version: 1.0.0, description: A robust, type-safe implementation of the SM-2 spaced repetition algorithm in TypeScript., main: ./dist/index.js, types: ./dist/index.d.ts, scripts: { build: tsc, test: jest, prepublishOnly: npm run build npm test }, keywords: [sm2, spaced-repetition, memory, typescript, anki, superMemo], author: Your Name, license: MIT, devDependencies: { types/jest: ^29.5.0, jest: ^29.5.0, ts-jest: ^29.1.0, typescript: ^5.0.0 }, files: [dist] }关键配置解析main: 指定包的入口文件。我们指向编译后的JavaScript文件dist/index.js。types:这是TypeScript包的关键它指向类型声明文件dist/index.d.ts这样其他TypeScript项目引用你的包时才能获得完整的类型提示和检查。scripts: 定义了构建、测试等命令。prepublishOnly是一个npm生命周期钩子确保在发布到npm仓库前一定会先执行构建和测试避免发布未编译或存在错误的代码。files: 指定发布到npm时包含哪些目录。通常只包含编译输出目录如dist和必要的文档如README.md避免将源码、测试文件等无关内容发布出去。接下来安装TypeScript并初始化配置npm install --save-dev typescript npx tsc --init这会生成一个tsconfig.json文件。我们需要根据库的定位进行修改{ compilerOptions: { target: ES2020, module: CommonJS, declaration: true, // 必须为true用于生成.d.ts类型声明文件 outDir: ./dist, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, moduleResolution: node }, include: [src/**/*], exclude: [node_modules, dist, **/*.test.ts] }关键编译选项解析declaration: true: 这是生成类型声明文件的开关必须开启。outDir: ./dist: 指定编译输出目录与package.json中的配置对应。target: ES2020: 根据你希望支持的JavaScript环境选择。ES2020是一个在现代Node.js和浏览器中支持良好的折中版本。module: CommonJS: 对于Node.js库CommonJS是更安全、兼容性更广的选择。如果你的库明确只用于ESM环境可以选ESNext。3.2 核心算法函数的TypeScript实现现在在src目录下创建我们的核心文件index.ts。我们将严格遵循前面定义的类型接口来实现算法。// src/index.ts export type ReviewGrade 0 | 1 | 2 | 3 | 4; export interface SM2Card { difficulty: number; repetition: number; interval: number; easeFactor: number; } // 算法内部使用的常量参考了SM-2的经典参数 const DEFAULT_EASE_FACTOR 2.5; const MINIMUM_EASE_FACTOR 1.3; const DIFFICULTY_WEIGHT 0.1; // 影响难度因子变化的权重 const PERFORMANCE_WEIGHT 0.08; // 影响易度系数变化的权重 /** * 实现SM-2间隔重复算法。 * param card - 当前的卡片状态 * param grade - 本次复习的评分 (0-4) * returns 更新后的卡片状态其中interval代表下一次复习建议间隔的天数 */ export function sm2(card: SM2Card, grade: ReviewGrade): SM2Card { // 1. 复制输入对象避免直接修改纯函数原则 const newCard: SM2Card { ...card }; // 2. 更新难度因子 (difficulty) // 公式: newDifficulty oldDifficulty (0.1 - (5 - grade) * 0.08) // 评分越高(grade越大)(5-grade)越小减得越少甚至可能加一个负数即增加难度这里需要仔细核对 // 经典的SM-2公式是D D w1 * (w2 - grade)其中w10.1, w25 // 所以newDiff oldDiff 0.1 * (5 - grade) // 但为了将难度限制在0-1通常会有后续处理。我们采用更常见的Anki变体公式。 // 这里我们实现一个广泛使用的版本 let difficultyChange (5 - grade) * 0.1; // grade为4时变化为0.1grade为0时变化为0.5 newCard.difficulty newCard.difficulty difficultyChange; // 将难度因子钳制在合理范围例如0.0到1.0之间 newCard.difficulty Math.max(0.0, Math.min(1.0, newCard.difficulty)); // 3. 计算本次回忆的“表现”值用于更新易度系数 // 表现值 5 - grade (grade越高表现值越低但这是反直觉的) // 更常见的逻辑是评分低表现差应该降低ease。 // 标准SM-2公式: EF EF (0.1 - (5 - grade) * (0.08 (5 - grade) * 0.02)) // 简化并调整为一个更稳定的版本类似Anki let performanceRating grade; let easeChange 0.0; if (performanceRating 3) { // 评分3良好轻微提升易度系数 easeChange 0.1; } else if (performanceRating 2) { // 评分2困难易度系数不变 easeChange 0.0; } else { // 评分1生疏/遗忘显著降低易度系数 easeChange -0.2; } newCard.easeFactor newCard.easeFactor easeChange; // 易度系数有下限避免无限降低导致间隔不增长 newCard.easeFactor Math.max(MINIMUM_EASE_FACTOR, newCard.easeFactor); // 4. 处理复习逻辑 if (grade 2) { // 评分低于2通常表示遗忘重置卡片 newCard.repetition 0; newCard.interval 1; // 重置为1天后复习 } else { // 评分2成功回忆 if (newCard.repetition 0) { // 首次成功回忆学习阶段 newCard.interval 1; } else if (newCard.repetition 1) { // 第二次成功回忆 newCard.interval 6; // 例如6天后 } else { // 第三次及以后的成功回忆应用间隔公式 newCard.interval Math.round(newCard.interval * newCard.easeFactor); } // 增加成功回忆次数 newCard.repetition 1; } // 5. 确保间隔至少为1天 newCard.interval Math.max(1, newCard.interval); return newCard; } /** * 创建一个新的、初始状态的SM-2卡片。 * returns 初始化的卡片对象 */ export function createCard(): SM2Card { return { difficulty: 0.3, // 初始难度设为中等偏易 repetition: 0, interval: 0, easeFactor: DEFAULT_EASE_FACTOR, }; }实现要点与心得纯函数设计sm2函数是纯函数输入原状态和评分输出新状态。这避免了副作用使得测试、调试和状态管理如与Redux、Vuex配合变得极其简单。参数调优算法中的常数如DIFFICULTY_WEIGHT,MINIMUM_EASE_FACTOR以及间隔增长阶梯1, 6是算法的“超参数”。我选择的这套参数经过了Anki等大量用户的实践验证在记忆效率和复习压力之间取得了较好的平衡。使用者如果深入研究也可以将这些参数暴露为可配置项。边界处理注意对difficulty和easeFactor的钳制Math.max/min以及对interval最小值的保证。这些细节保证了算法在极端输入下依然能产生合理、稳定的输出避免出现负间隔或无限大的易度系数。重置逻辑当评分低于2时我们不仅重置了repetition和interval但保留了更新后的difficulty和easeFactor。这是关键这意味着即使你忘记了算法也“记住”了这张卡片对你来说变难了difficulty可能升高了easeFactor肯定降低了下次重新学习时它的间隔增长会比第一次学习时更保守。这模拟了“遗忘后重新记忆会更费力”的认知规律。3.3 单元测试确保算法的可靠性对于算法库完备的单元测试不是可选项而是必选项。我们使用Jest作为测试框架。npm install --save-dev jest ts-jest types/jest npx ts-jest config:init这会生成jest.config.js。然后创建测试文件src/index.test.ts// src/index.test.ts import { sm2, createCard, ReviewGrade } from ./index; describe(SM2 Algorithm, () { test(should create a card with default state, () { const card createCard(); expect(card.difficulty).toBeCloseTo(0.3); expect(card.repetition).toBe(0); expect(card.interval).toBe(0); expect(card.easeFactor).toBe(2.5); }); test(first review with good grade (grade4) should schedule next review in 1 day, () { let card createCard(); // repetition0, interval0 card sm2(card, 4); // 首次复习评分“非常容易” expect(card.repetition).toBe(1); // 成功次数1 expect(card.interval).toBe(1); // 间隔应为1天 expect(card.easeFactor).toBeGreaterThan(2.5); // 易度系数应增加 }); test(second review with good grade should schedule next review in 6 days, () { let card createCard(); card sm2(card, 4); // 第一次复习 card sm2(card, 4); // 第二次复习此时repetition1 expect(card.repetition).toBe(2); expect(card.interval).toBe(6); // 第二次成功复习间隔跳至6天 }); test(third review with good grade should multiply interval by ease factor, () { let card createCard(); card sm2(card, 4); // 1st card sm2(card, 4); // 2nd - interval6 const intervalBefore card.interval; // 6 card sm2(card, 4); // 3rd // 新的间隔应该是 6 * (2.5 一些增量) ≈ 15 expect(card.interval).toBeGreaterThan(intervalBefore); expect(card.repetition).toBe(3); }); test(failing a review (grade1) should reset repetition and interval, () { let card createCard(); card sm2(card, 4); // 1st card sm2(card, 4); // 2nd - interval6, repetition2 const easeBeforeFailure card.easeFactor; card sm2(card, 1); // 第三次复习失败遗忘 expect(card.repetition).toBe(0); // 重置 expect(card.interval).toBe(1); // 重置为1天 expect(card.easeFactor).toBeLessThan(easeBeforeFailure); // 易度系数应降低 // 注意difficulty 应该增加了因为觉得难 expect(card.difficulty).toBeGreaterThan(0.3); }); test(ease factor should have a minimum value (1.3), () { let card createCard(); // 连续多次低分迫使easeFactor下降 for (let i 0; i 10; i) { card sm2(card, 1); // 每次都评分很低 } expect(card.easeFactor).toBeCloseTo(1.3); // 应该触底到1.3 }); });运行测试npm test。看到所有测试通过心里才踏实。这些测试覆盖了正常流程、边界情况和重置逻辑是代码健壮性的基石。在后续修改代码时这些测试能有效防止回归错误。3.4 打包、发布与版本管理代码和测试都完成后就可以构建并发布了。构建运行npm run build。TypeScript编译器会根据tsconfig.json的配置将src下的.ts文件编译成.js和.d.ts文件输出到dist目录。版本号在发布前根据语义化版本规范SemVer更新package.json中的version字段。对于初始发布可以用1.0.0。登录npm如果你还没有npm账号需要先去官网注册。然后在命令行登录npm login。发布在项目根目录执行npm publish。prepublishOnly脚本会自动执行构建和测试确保发布的是最新、最稳定的代码。发布成功后全世界任何开发者都可以通过npm install sm2来使用你的算法库了。4. 实战应用在Web应用中集成SM-2包发布出去了但它到底怎么用呢我们来构建一个简单的场景一个网页版的单词卡片学习应用。4.1 前端项目集成假设我们有一个用Vue 3 TypeScript构建的项目。# 在新项目或现有项目中安装我们的sm2包 npm install sm2然后我们可以创建一个管理卡片复习逻辑的Composable组合式函数或Store。// src/composables/useSpacedRepetition.ts import { ref, computed } from vue; import { sm2, createCard, type SM2Card, type ReviewGrade } from sm2; // 定义业务层的单词卡片类型 interface VocabularyCard { id: string; word: string; definition: string; example: string; sm2State: SM2Card; // 嵌入SM-2算法状态 dueDate: Date; // 下次复习到期日 } export function useSpacedRepetition() { // 模拟一个卡片库 const cards refVocabularyCard[]([ { id: 1, word: Ephemeral, definition: Lasting for a very short time., example: The ephemeral beauty of cherry blossoms., sm2State: createCard(), dueDate: new Date(), // 新建卡片立即到期 }, // ... 更多卡片 ]); // 计算当前需要复习的卡片dueDate 当前时间 const dueCards computed(() { const now new Date(); return cards.value.filter(card card.dueDate now); }); /** * 复习一张卡片 * param cardId 卡片ID * param grade 用户给出的评分 (0-4) */ function reviewCard(cardId: string, grade: ReviewGrade) { const cardIndex cards.value.findIndex(c c.id cardId); if (cardIndex -1) return; const card cards.value[cardIndex]; // 核心调用使用sm2算法更新状态 const newSm2State sm2(card.sm2State, grade); // 计算新的到期日 const now new Date(); const nextDueDate new Date(now); nextDueDate.setDate(now.getDate() newSm2State.interval); // 更新卡片 cards.value[cardIndex] { ...card, sm2State: newSm2State, dueDate: nextDueDate, }; // 在实际应用中这里应该将更新后的卡片状态持久化到数据库或本地存储 // persistCard(cards.value[cardIndex]); } /** * 添加新卡片 */ function addNewCard(word: string, definition: string, example: string) { const newCard: VocabularyCard { id: Date.now().toString(), word, definition, example, sm2State: createCard(), dueDate: new Date(), // 新卡片立即加入复习队列 }; cards.value.push(newCard); } return { cards, dueCards, reviewCard, addNewCard, }; }在Vue组件中我们可以这样使用!-- src/components/ReviewSession.vue -- template div v-ifcurrentCard h2{{ currentCard.word }}/h2 pstrong释义:/strong {{ currentCard.definition }}/p pem例句:/em {{ currentCard.example }}/p div classbutton-group button clickrate(0)完全忘记/button button clickrate(1)很困难/button button clickrate(2)困难/button button clickrate(3)容易/button button clickrate(4)非常简单/button /div p下次复习: {{ formatDate(currentCard.dueDate) }}/p /div div v-else p恭喜当前没有需要复习的卡片。/p /div /template script setup langts import { computed } from vue; import { useSpacedRepetition } from ../composables/useSpacedRepetition; const { dueCards, reviewCard } useSpacedRepetition(); // 取第一张到期的卡片作为当前复习项 const currentCard computed(() dueCards.value[0]); function rate(grade: number) { if (!currentCard.value) return; reviewCard(currentCard.value.id, grade as 0 | 1 | 2 | 3 | 4); // 评分后currentCard会自动更新因为dueCards重新计算了 } function formatDate(date: Date) { return date.toLocaleDateString(); } /script4.2 状态持久化策略在真实应用中卡片状态需要持久化。我们可以结合浏览器本地存储LocalStorage或后端数据库。本地存储方案适合纯前端应用// 在useSpacedRepetition.ts中增加持久化函数 const STORAGE_KEY my_vocabulary_cards; function loadCards(): VocabularyCard[] { const stored localStorage.getItem(STORAGE_KEY); if (stored) { const parsed JSON.parse(stored); // 注意JSON.parse后的日期是字符串需要转换回Date对象 return parsed.map((card: any) ({ ...card, dueDate: new Date(card.dueDate), // sm2State中的数字类型会被正确解析 })); } return []; } function saveCards(cardList: VocabularyCard[]) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cardList)); } // 在addNewCard和reviewCard函数中调用saveCards后端集成方案更正式可以设计一个简单的REST API或GraphQL端点卡片状态保存在服务器数据库如PostgreSQL, MongoDB中。前端在复习后调用API更新卡片的sm2State和dueDate字段。这样用户可以在多设备间同步学习进度。4.3 性能与扩展性考量当卡片数量达到成千上万时每次计算到期卡片都遍历整个数组可能成为性能瓶颈。我们可以索引优化使用dueDate字段建立数据库索引或者在前端使用一个按dueDate排序的数据结构如最小堆以便高效获取最近到期的卡片。批量处理对于“每日复习”场景可以每天只计算一次“今日到期”的卡片列表并缓存起来而不是每次查询都实时计算。算法微调我们的sm2函数是O(1)复杂度的性能开销极低核心瓶颈在于数据检索和持久化。5. 常见问题、调试与高级技巧在实际开发和集成过程中你可能会遇到一些典型问题。这里记录了我踩过的一些坑和对应的解决方案。5.1 类型导入问题问题在TypeScript项目中安装sm2后导入时提示“找不到模块声明文件”或类型错误。排查确认package.json中正确设置了types: ./dist/index.d.ts。确认tsconfig.json中的declaration: true已启用并且npm run build成功生成了dist/index.d.ts文件。尝试删除项目的node_modules和package-lock.json重新运行npm install。解决方案确保你的包构建流程正确生成了类型声明文件。对于使用者如果问题依旧可以尝试在项目的tsconfig.json中设置skipLibCheck: true作为临时解决方案但最好还是确保依赖包本身类型定义正确。5.2 算法行为与预期不符问题感觉卡片复习间隔的增长太快或太慢不符合记忆规律。调试打印日志在sm2函数内部关键步骤如更新difficulty、easeFactor计算新interval后添加console.log输出中间状态。对比不同评分输入下的状态变化。编写针对性测试模拟一个你认为有问题的学习序列例如连续评4分看interval的增长曲线是否符合SM-2的预期。将你的测试用例添加到index.test.ts中。核对参数与经典的SM-2参数表或Anki的默认参数进行对比。重点检查difficulty和easeFactor的更新公式中的常数。不同的常数会导致算法“侵略性”不同。调整如果确认需要调整算法行为不要直接修改核心函数里的魔法数字。更好的做法是将这些常数作为算法的可配置选项暴露出来。// 增强版支持配置参数 export interface SM2Options { initialDifficulty?: number; initialEaseFactor?: number; easeFactorBonus?: number; // 评分好时的增加值 easeFactorPenalty?: number; // 评分差时的减少值 intervalModifier?: (interval: number, ease: number) number; // 自定义间隔计算函数 } export function createCard(options?: SM2Options): SM2Card { const opts { initialDifficulty: 0.3, initialEaseFactor: 2.5, ...options }; return { difficulty: opts.initialDifficulty, repetition: 0, interval: 0, easeFactor: opts.initialEaseFactor, }; } // sm2函数也可以接受options参数用于覆盖内部常量5.3 时区与日期处理陷阱问题dueDate下次复习日期计算出现一天误差。原因JavaScript的Date对象处理本地时间和UTC时间可能混淆。setDate方法基于本地时区操作。如果服务器使用UTC时间而前端使用本地时间在日期转换时可能产生偏差。解决方案前后端统一使用UTC在存储和传输时始终使用ISO字符串如new Date().toISOString()或UTC时间戳。计算间隔时也使用UTC日期。仅使用日期忽略时间对于间隔重复我们通常只关心“天”这个粒度。可以在计算时将时间部分归零。function calculateDueDate(currentDate: Date, intervalDays: number): Date { const due new Date(currentDate); due.setUTCHours(12, 0, 0, 0); // 统一设置为UTC中午避免时区切换导致的日期跳变 due.setUTCDate(due.getUTCDate() intervalDays); return due; } // 判断是否到期时也只比较日期部分 function isCardDue(cardDueDate: Date): boolean { const now new Date(); const today new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const dueDay new Date(Date.UTC(cardDueDate.getUTCFullYear(), cardDueDate.getUTCMonth(), cardDueDate.getUTCDate())); return dueDay today; }5.4 处理大量卡片的复习调度问题用户有上万张卡片每天如何公平、高效地选择需要复习的卡片进阶策略基础的“到期即复习”可能让某天复习量暴增。可以引入更智能的调度限制每日最大复习数从到期卡片中随机选取或按difficulty排序先复习最难的直到达到每日上限。提前调度如果明天到期的卡片数量很少可以提前将一部分后天的卡片“借调”到明天复习以平滑每日复习量。动态间隔微调基于用户整体的复习表现如过去一周的平均评分动态微调算法中的基础间隔或easeFactor的初始值实现个性化的学习节奏。实现这些高级策略意味着你需要围绕核心的sm2函数构建一个更复杂的调度系统。sm2包负责处理单张卡片的记忆状态演化而调度器负责管理整个卡片集合的复习流。这种关注点分离让系统更清晰、更易维护。将SM-2算法封装成一个独立的TypeScript包其价值远不止是提供几行代码。它提供的是一个经过深思熟虑、充分测试的抽象模型。这个模型将复杂的记忆心理学算法简化成了一个输入当前状态和反馈、输出新状态的纯函数。无论你的应用是单词卡、医学知识库、法律条文记忆工具还是任何需要长期记忆辅助的场景你都可以将这个函数作为可靠的“记忆引擎”嵌入其中而无需关心其内部复杂的数学变换。这正是优秀开源工具的意义所在——让开发者能站在巨人的肩膀上专注于创造更美好的用户体验。