- 为存储、词库、记忆调度、MIME 解析、单词提取、测验生成及备份处理添加 Node.js 测试 - 添加可复用的浏览器环境与 localStorage 测试辅助工具 - 修复移除 HTML 时单词边界丢失的问题,并拒绝无效的测验答案 - 校验导入备份中的日期,并添加 npm 测试脚本
18 KiB
18 KiB
src/ 重构 TODO
分支:
dev-refactor。每个 Phase 结束时应用可正常运行并单独提交一次 commit。 核心约束:不改变任何用户可见行为、不改变 localStorage 数据结构(老用户数据必须无缝兼容)。
进度说明(2026-07-19 核对更新):代码级目标已全部达成。以下为与本文档原计划的已知差异,均已核对/对齐:
- 提交粒度:实际合并为 4 个 commit 提交(
9e5ddef→8686e65→9c4fc0b→56a0040),非逐 Phase/逐页粒度;下文各「提交」项据此勾选。- CSS 结构:已补齐
pages/home.css/pages/words.css,并把原components-extended.css/learn-session.css拆解归位、新增全局responsive.css(见「三」已更新的目录树)。- 路由:
core/router.js采用单个registerPage(page, renderFn),与「六」示例一致。- window 桥接:改为一次性重构,终态无桥接残留(与计划最终目标一致)。
- 仍未完成:Phase 0 的
before-refactortag 未打;尚未合并回main;「验收清单」等人工冒烟项需实跑。
一、现状分析
| 文件 | 行数 | 问题 |
|---|---|---|
src/script.js |
5139 | 单文件巨石:约 230 个函数全部挂在全局作用域,常量/状态/服务/8 个页面混在一起 |
src/styles.css |
3346 | 单文件,含 40+ 个注释分节,主题变量与页面样式耦合 |
src/index.html |
84 | 基本干净,仅需改脚本引入方式 |
关键技术债:
- 119 处内联
onclick=(另有 7 处oninput=/onchange=/onkeydown=)写在 JS 拼接的 HTML 字符串里,强依赖函数全局可见 —— 这是拆分 ES 模块的最大障碍。 - 全局可变单例
state(script.js:97)+ 分散的saveXxx()持久化函数,无统一入口。 - TTS 部分(script.js:356-753,约 400 行)内含多个模块级可变缓存(
_ttsRequestSeq、云端音频缓存、有道预取缓存),需整体封装。 - Chart.js 实例
_studyChart需在重渲染时销毁,生命周期隐蔽。 - 手动维护缓存版本号
?v=20260715-1(index.html:17、82)。 - 无测试、无 package.json、无构建工具。
二、重构目标与原则
- 目标:script.js 已按职责拆为 ES 模块(常量 / 状态存储 / 核心 UI / 领域服务 / 页面);大页面进一步拆出自动播放、批量文件、测验会话/音频和设置数据模块,单文件控制在 ~500 行内。
- 目标:消灭全部内联
onclick=,改为data-action事件委托。 - 目标:styles.css 按现有注释分节拆分为多个 CSS 文件。
- 原则:保持无构建(no-build),使用浏览器原生 ES Modules(
<script type="module">)。项目本来就依赖fetch('data/*.json'),已要求 HTTP 静态服务,原生模块无额外成本。(备选方案:引入 Vite,见「八、可选增强」,本轮不做。) - 原则:小步迁移。终态已达成——改为一次性重构,最终无
window.fn = fn桥接残留(结果与计划一致,未走渐进桥接过程;现存window.均为合法浏览器 API)。 - 原则:localStorage 键名与值结构(
words/records/schedule/favorites/settings/mailEmails/theme/词库前缀键等)一律不动。
三、目标目录结构
src/
index.html
styles/
variables.css # :root 主题变量 + [data-theme=dark](styles.css:1-63)
base.css # Reset & Base
layout.css # 布局 / Sidebar / Header / Main
components.css # 卡片/按钮/表单/徽章/Toast/Modal/分页/空状态/Loading/复选框/动画/滚动条/开关
responsive.css # 全局 @media 响应式(必须在页面样式之后、extract/mail-learn/reader 之前加载)
pages/
home.css words.css learn.css quiz.css review.css
stats.css settings.css extract.css mail-learn.css reader.css
# learn.css 含 Flip Card/Setup Card/Learn Controls/Learn V2;quiz.css 含 Batch Selector;home.css 含 Streak
js/
main.js # 入口:init、全局事件绑定、页面注册
constants.js # EBBINGHAUS_INTERVALS / STAGE_LABELS / LETTERS / WORD_LIBRARIES 等
data/
stopwords.js # STOP_WORDS、COMMON_NAMES 两个大 Set(script.js:8-83)
core/
state.js # state 单例 + loadState(script.js:89-222)
storage.js # loadJsonSetting/saveJsonSetting/词库前缀键/saveXxx 系列
router.js # navigate/onHashChange/updateNav/renderPage + 页面注册表
actions.js # data-action 事件委托分发器(新增,见 Phase 4)
ui/
theme.js sidebar.js toast.js modal.js
dom.js # escapeHtml/formatMarkdown/autoResizeTextarea/throttle/debounce
services/
tts.js # 全部 TTS:云端/有道/WebSpeech 三级回退 + 缓存 + 预取(script.js:356-753)
ai.js # callAI/generateAIQuiz/translateWord(s)/filterNamesWithAI/deduplicateWithAI
ebbinghaus.js # 日期工具/getDueWords/initWordSchedule/updateSchedule/addRecord/trimRecords
stats.js # getMastery/getStats/getStreak/getQuizErrorWords/getLast30DaysData/getErrorTopWords
quiz-generator.js # shuffle/generateLocalQuiz/sanitizeAiQuestion/extractJsonValue
extractor.js # extractEnglishWords/extractWithFrequency/detectProperNouns/deduplicateBasic/findWordContext
mime.js # EML 解析:createTextDecoder/decodeQuotedPrintable/decodeBase64/extractMimeText 等(script.js:3566-3684)
library.js # 词库加载:getWordLibrary/fetchWordLibrary/switchWordLibrary/autoLoadWords(script.js:5003-5120)
favorites.js # isFavorite/toggleFavorite/getFavoriteWords
pages/
home.js words.js learn.js quiz.js review.js
stats.js extract.js mail-learn.js reader.js settings.js
filter-words.js # 过滤词管理(script.js:4813-4947,从 settings 独立)
四、Phase 0 —— 准备与安全网
- 打 tag:
git tag before-refactor,保留回滚点。 - 写一份手工冒烟测试清单存到
docs/smoke-test.md(见「九、验收清单」),每个 Phase 结束跑一遍。 - 确认本地静态服务器启动方式(如
python -m http.server或 VSCode Live Server),记录到 README。 - 导出一份完整数据备份(设置页「导出全部数据」),用于迁移后做导入回归验证。
五、Phase 1 —— 入口切换 + 纯函数先行
先拆无副作用、无 DOM 依赖的部分,风险最低。
index.html:已改为<script type="module" src="js/main.js">;最终结构已删除迁移期 legacy。- module 默认 defer,入口在 DOM 可用后显式执行
init()。 - 新建
js/constants.js与js/data/stopwords.js。 - 新建
ui/dom.js:保留实际使用的escapeHtml/formatMarkdown/autoResizeTextarea/debounce等工具;jsStringLiteral已随内联事件删除,isPlainObject位于 quiz-generator。 - 新建
services/ebbinghaus.js+services/stats.js,错题缓存由saveWords/saveRecords失效。 - 新建
services/quiz-generator.js。 - 新建
services/extractor.js与services/mime.js。 - 最终结构已删除 legacy.js 与临时全局桥接层。
- 提交:
refactor: 引入 ES 模块入口并拆分纯函数模块(已并入合并提交,见顶部进度说明)。
六、Phase 2 —— 核心服务
core/storage.js:实现加载/保存、词库前缀键、legacy fallback 与saveXxx系列,并通过STORAGE_KEYS集中维护键名。core/state.js导出共享state单例;依赖存储的loadState()位于core/storage.js,避免 state 层反向依赖持久化实现。core/router.js:navigate/onHashChange/updateNav/renderPage。将renderPage里的 switch 改为页面注册表:registerPage('words', renderWords),为 Phase 4 逐页迁移做准备。ui/theme.js、ui/sidebar.js、ui/toast.js、ui/modal.js(script.js:287-355)。services/favorites.js(script.js:230-244)。- 提交:
refactor: 拆分存储、状态、路由与基础 UI 模块(已并入合并提交)。
七、Phase 3 —— 领域服务
services/tts.js:三级回退、缓存、预取和请求中断状态均已模块私有化;额外导出设置页配置/测试接口与测验预取常量。services/ai.js:AI 调用、出题、翻译、过滤、去重及模块私有冷却状态已迁移;连接测试的 UI 编排保留在 settings 页面。services/library.js(script.js:5003-5120):词库获取/切换/自动加载。- 提交:
refactor: 拆分 TTS、AI 客户端与词库服务(已并入合并提交)。
八、Phase 4 —— 页面逐个迁移 + 事件委托(工作量最大)
4.0 事件委托机制(先做)
- 新建
core/actions.js:在#page-content与#modal-root上各挂一个click委托监听器(另需覆盖change/input,对应 7 处内联oninput=/onchange=),按data-action分发:<!-- 之前 --> <button onclick="deleteWord(3)"> <!-- 之后 --> <button data-action="word.delete" data-id="3">registerActions('word', { delete: (el) => deleteWord(Number(el.dataset.id)) }); - 参数一律走
data-*,删除jsStringLiteral转义拼接(消灭一类注入/转义 bug)。 - 命名约定:
页面名.动作名,如quiz.answer、learn.rate、settings.testAI。
4.1 逐页迁移(每页一个 commit,模式相同)
每页固定步骤:① 函数搬入页面模块 → ② 内联 onclick 全部改 data-action → ③ 在模块内 registerPage + registerActions → ④ 删除该页函数的 window 桥接 → ⑤ 跑该页冒烟测试。
- home.js(script.js:1472-1557):最简单,先拿它验证整套模式。
- words.js(script.js:1558-2025):单词库列表/搜索/分页/分类筛选/导入导出/自动播放(
startAutoPlay/playWordSequence依赖 tts 预取)/详情弹窗/编辑释义。注意搜索框oninput的 debounce。 - learn.js(script.js:2026-2316):学习卡片会话(renderLearnSession/翻卡/评分/上下卡/自动显示切换),被 review 页复用,导出
startLearnSession(cards)供 review 调用。键盘快捷键监听(script.js:4956-5001)搬入此模块,仅在会话激活时生效。 - quiz.js(script.js:2317-2908):批次选择器/本地与 AI 出题/答题流程/音频预取窗口/结果页。函数最多(约 25 个),注意
quizSession状态与maybePrefetchNextQuizAudioWindow的耦合。 - review.js(script.js:2909-3057):错题列表 + 调 learn.js 启动复习会话。
- stats.js(页面)(script.js:3058-3302):图表渲染。Chart 实例改为模块内私有,
renderStudyChart前先destroy()旧实例(保持现有行为)。 - extract.js(script.js:3303-3831):邮件提词/词 chips 选择/批量 EML 解析(依赖 services/mime.js)/AI 翻译入库。
- mail-learn.js(script.js:3832-3992):已存邮件管理/AI 分析/全文翻译/高亮。
- reader.js(script.js:3993-4304):全文阅读模式(分句/朗读/逐句翻译/生词 tooltip)。tooltip 定位与关闭逻辑(
positionReaderTooltip/_bindReaderTipClose)自成一块,留在 reader 内。 - settings.js(script.js:4305-4812):设置表单/TTS 与 AI 连接测试/数据导入导出/清空数据/强制刷新缓存。
- filter-words.js(script.js:4813-4947):自定义过滤词管理弹窗。
- 全部页面完成后:删除 legacy.js 与整个 window 桥接层;全局
grep -n "onclick=\|window\." src/js复查,确认无残留。 - 提交(每页一个):
refactor: 迁移 XX 页至独立模块并移除内联事件(实际为合并提交,非逐页粒度)。
九、Phase 5 —— CSS 拆分
- 按「三、目标目录结构」把 styles.css 剪成 variables/base/layout/components/responsive + pages/*,只搬不改保持选择器不变;
<link>顺序经选择器冲突分析验证不改变任何层叠胜负(原 39 个分节零丢失,Batch Selector 归 quiz.css、全局 @media 归 responsive.css 并保持靠后加载)。 - index.html 用多个
<link>按序引入(无构建方案下@import会串行阻塞,不用)。 - 清理已知废弃样式:styles.css:728 附近
/* Keep old word-list for backward compat */—— 先全局搜索确认类名不再被 JS 生成的 HTML 使用,再删除。 - 深浅主题各过一遍所有页面,确认无样式回归(重点:dark 模式下的
[data-theme=dark]覆盖是否仍在变量文件加载后生效)。 - 提交:
refactor: 按组件与页面拆分样式表(已并入合并提交)。
十、Phase 6 —— 清理与收尾
- 缓存版本策略:入口
main.js/CSS 保留?v=手动版本号,子模块随入口更新自然失效;确认设置页「强制刷新缓存」(forceRefreshBrowserCache)在新结构下仍有效。 - 死代码清扫:已检查导出与 action 调用,删除未使用的
throttle,并保留有实际调用方的 AI 冷却查询和批次辅助函数。 - 命名统一:内部函数去掉
_前缀(模块私有性已由 ES 模块保证)。 - 为每个 services 模块头部补一段 JSDoc 说明(输入/输出/副作用/依赖的 localStorage 键)。
- 更新 README:新目录结构、本地启动方式、模块职责表。
- 全量跑一遍「验收清单」,导入 Phase 0 的备份数据验证兼容性。
- 提交:
docs: 更新 README 反映模块化结构,然后合并回main。(README 已更新;尚未合并回main,仍在dev-refactor。)
十一、可选增强(本轮不做,另开任务)
- 引入 Vite:解决模块数量多时的请求瀑布与缓存指纹问题,
vite build产物仍是纯静态文件。 - 添加
node:test单元测试(54 项):覆盖 ebbinghaus / quiz-generator / mime / extractor,以及 storage 数据兼容与异常、复习状态持久化回滚、备份导入导出与清空、词库切换/加载/竞态回滚;测试发现并修复相邻 HTML 标签导致单词粘连、空 AI 答案被误判为索引 0、备份接受无效日历日期的问题。 - state 写入收口为 action 函数,配合
saveXxx自动持久化。 - HTML 模板字符串改为
<template>或轻量渲染函数,减少字符串拼接。 - PWA(manifest + Service Worker)替代手动
?v=缓存控制。
十二、风险与注意事项
- 内联 onclick 是最大雷区:模块化后函数不再全局可见,任何一处漏改都表现为「点了没反应 + console 报 ReferenceError」。迁移期靠 window 桥接兜底,每页迁完立即删桥接暴露漏网之鱼。
- 函数重名:
stats既是页面又是服务、renderStats与getStats等,拆分时靠模块路径区分,import 时注意别名。 - localStorage 兼容:
getLibraryStorageKey的词库前缀键逻辑(script.js:167-186,含 legacy fallback)搬移时逐行对照,迁移前后用同一份浏览器 Profile 验证老数据可读。 - 顶层副作用:legacy 里
init()调用、事件绑定都在顶层执行,拆分时统一收进main.js的显式init(),避免 import 顺序引发的隐式依赖。 - TTS 并发状态:
_ttsRequestSeq的中断语义(快速连点只播最后一个)容易在搬移时弄丢,迁移后专门测「连续快速点击多个单词发音」。 - file:// 协议不可用:ES 模块要求 HTTP 服务;README 需写明(现状 fetch 词库其实已有此要求)。
十三、验收清单(每 Phase 结束执行)
- 8 个页面(仪表盘/单词库/AI 测试/错题复习/邮件提词/邮件学习/统计/设置)hash 路由均可进入、返回。
- 单词库:搜索、分类筛选、翻页、收藏、删除、详情弹窗、编辑释义、自动播放(含滚动跟随)、JSON 导入/导出。
- 测验:本地出题 + AI 出题(配置 key 后)、三种模式切换、批次选择、答题/上一题/下一题、结果页、发音自动预取。
- 复习:错题列表、开始复习会话、翻卡/评分(1/2/3 键与空格/方向键快捷键)、完成页。
- 邮件提词:粘贴文本提词、EML 批量导入解析、chips 全选/反选、AI 翻译入库。
- 邮件学习:保存/载入/删除邮件、AI 分析、全文翻译、全文阅读模式(分句朗读、tooltip、加词)。
- 统计:卡片数据、30 天图表(深浅主题下重绘正常)。
- 设置:保存 AI/TTS 配置、连接测试、词库切换、全量导出/导入、清空数据、过滤词管理。
- 全局:深浅主题切换、移动端侧栏开合、Toast、Esc 关弹窗、刷新后 state 完整恢复。
十四、本次逐任务提交记录
- Phase 0:准备与安全网(人工项已由用户验证)。
- Phase 1:引入模块入口并拆分常量、DOM 与纯函数服务。
- Phase 2:拆分存储、状态、路由与基础 UI 模块。
- Phase 3:拆分 TTS、AI 与词库领域服务。
- Phase 4.0:建立
data-action事件委托机制。 - Phase 4.1:迁移仪表盘页面。
- Phase 4.2:迁移单词库页面与自动播放模块。
- Phase 4.3:迁移学习会话与快捷键。
- Phase 4.4:迁移测验页面、会话与音频预取。
- Phase 4.5:迁移错题复习页面。
- Phase 4.6:迁移统计页面并管理图表生命周期。
- Phase 4.7:迁移邮件提词与批量文件处理。
- Phase 4.8:迁移邮件学习页面。
- Phase 4.9:迁移全文阅读页面。
- Phase 4.10:迁移设置与数据管理页面。
- Phase 4.11:迁移自定义过滤词管理。
- Phase 4.12:注册全部页面并确认无内联事件或全局桥接残留。
- Phase 5:按组件与页面拆分样式表(人工主题检查已由用户验证)。
- Phase 6:完成缓存、死代码、命名、服务说明与 README 收尾(人工验收已由用户验证)。
- 最终审查:修复旧版浏览器因缺少
structuredClone无法加载词库的问题。 - 补充审查:修复跨词库清理、空词库进度、备份往返、文件读取竞态与持久化回滚问题。