手把手教你撸个小说阅读器这个题目我惦记很久了。很多人觉得做阅读器是件小事无非是把文字搬上屏幕但真动手做起来你会发现里面藏着不少门道书架怎么管理、章节怎么切、进度怎么存、字号换了之后页面怎么不跳、缓存做到什么程度才叫读起来顺畅。这些细节单独看都不难拼在一起却特别考验工程思维的完整度。这篇文章就用一个纯前端、零依赖的小项目——轻阅小说阅读器把我的完整实现思路和踩坑记录捋一遍。项目不强依赖框架原生JavaScript加上一点现代CSS就能跑适合前端初学者拿来做综合练习也适合有经验的同学快速捡起一套离线阅读器的核心设计模板。1. 项目整体设计与技术选型1.1 核心功能拆解一个阅读器到底要做什么在动笔写代码之前我先把阅读器的功能拆了一遍。别小看这一步你脑子里如果只有展示文字这么个模糊概念写到最后大概率会代码越堆越乱然后推倒重来。我最终敲定的最小可用功能集包含五块书架以卡片形式展示书籍支持多本书的管理显示每本书的阅读进度章节列表从当前书籍进入章节总览支持上下章切换阅读页正文展示支持字号调节、行距调节、背景主题切换进度记忆自动记录每本书最后阅读的章节和位置下次打开恢复离线缓存章节内容和阅读设置存入本地断网也能正常阅读这五块功能环环相扣书架是入口章节是导航阅读页是核心体验进度和缓存是让你不想再换读书软件的关键。实际开发中我就是按这个顺序来的先做出能跑通主流程的版本再逐步打磨体验。1.2 为什么选择原生JavaScript而非框架技术选型我纠结了不到五分钟就定了原生JavaScript ES Modules 本地存储。原因很简单这个项目不需要依赖任何框架。框架的价值在于管理复杂状态和组件复用但阅读器的核心状态其实就三样书架列表、当前阅读位置、阅读设置。这三样用几个全局对象加自定义事件就能管理得明明白白没必要引入一套状态管理体系增加构建成本和学习成本。另外还有一个很实际的考量这个项目的核心逻辑非常适合用来训练拆解DOM操作的能力。阅读器大量的工作是处理用户交互——点击翻章、滚轮定位、按钮调节——用原生事件处理会迫使你把事件委托、防抖、节流这些东西想清楚而这些恰恰是面试和实际工作中高频考察的基本功。1.3 整体架构三个模块怎么组织在一起我把代码分成三个模块加上一个入口文件形成了非常清晰的单向依赖关系src/ ├── index.js // 入口负责路由切换、初始化 ├── store.js // 数据层localStorage 读写、状态管理 ├── render.js // 渲染层书架、目录、阅读页的 DOM 渲染 └── data.js // 模拟书籍数据书籍信息、章节内容这么分层的逻辑很简单data提供内容store管理状态render把状态变成界面index连接所有交互。每层只关心自己的事出了问题很容易定位想扩展功能也不会牵一发动全身。2. 数据结构设计把书架和阅读进度管明白2.1 书籍数据的模型定义一个阅读器首先要定义清楚一本书长什么样。我设计的数据模型如下// 书籍基本信息 const book { id: book_001, // 唯一标识 title: 代码之外的生存指南, // 书名 author: 虚构作者, // 作者 description: 一本讲程序员成长的书, coverColor: #5B8C5A, // 封面背景色简化处理 totalChapters: 208, // 总章节数 lastRead: null, // 最近阅读信息见下 } // 最近阅读信息 const lastRead { chapterIndex: 12, // 读到第几章 paragraphIndex: 34, // 章内第几段 scrollRatio: 0.62, // 段落内滚动比例 updatedAt: 1717043200000, // 更新时间用于书架排序 }这个设计里有几个我特别想强调的点。段落索引和滚动比例分开存储是我做完一版之后总结出来的教训。一开始我只存章节号结果用户每次重新打开小说都要从头开始翻体验极差。后来加上了段落索引但还是有问题如果小说段落特别长用户停在这一段的中间刷新后却只能跳到段首。只有段落索引加滚动比例两个维度组合才能把阅读位置精确到百分之几这个颗粒度。书架排序用updatedAt而不是固定的加入时间这个细节能让书架看起来活起来——你最近在读的书会自动排到最前面符合直觉。2.2 阅读设置的独立存储阅读设置必须和书籍数据分开存。我单独用了一个keyconst settings { fontSize: 18, // 正文字号单位px lineHeight: 1.8, // 行高倍数 bgMode: sepia, // 背景主题white / sepia / dark }为什么要单独存试想一下如果你把字号放在每本书的数据里用户切换字号就会破坏这本书的进度数据结构更麻烦的是换一本书还得重新设置字号。阅读习惯是跟着人走的不是跟着书走的所以它必须是全局唯一的配置。这里我顺手做了一个小优化字号以数值记录而非用小、中、大这种枚举。原因很简单——数值可以直接用来计算依赖字号的其他样式和布局比如行数估算、分页位置而枚举每次都要做一层映射纯属浪费。2.3 localStorage封装防止踩坑的读写工具本地存储用的API本身很简单但有几个坑不处理就会在开发中反复踩取出来可能是null、JSON解析会报错、存满了会抛异常。我建议封装一层读写工具统一处理这些边界情况// store.js const STORAGE_PREFIX qingyue_; function readStore(key) { try { const raw localStorage.getItem(STORAGE_PREFIX key); return raw ? JSON.parse(raw) : null; } catch (e) { console.error(读取存储失败, e); return null; } } function writeStore(key, value) { try { localStorage.setItem(STORAGE_PREFIX key, JSON.stringify(value)); return true; } catch (e) { console.error(写入存储失败, e); return false; } }JSON.parse那一步我最开始没加try-catch结果有一次手动改测试数据时少写了一个引号整个应用的存储读取直接崩了。这种崩溃极其隐蔽因为本地数据是你自己写的你根本不会想到它有一天会格式错误。封装之后读取失败顶多让应用回到默认状态不会白屏。2.4 模拟数据不依赖后端也能开发完整功能真实项目里内容一般来自接口但阅读器练手阶段最怕的就是后端没就绪前端干等着。我的做法是直接在data.js里内置模拟数据模拟异步接口的形态返回// data.js export function fetchBookList() { return new Promise((resolve) { setTimeout(() { resolve([ { id: book_001, title: 代码之外的生存指南, author: 虚构作者, totalChapters: 208, coverColor: #5B8C5A }, { id: book_002, title: JavaScript高级程序设计, author: 虚构作者, totalChapters: 86, coverColor: #3A6EA5 }, ]); }, 200); }); } export function fetchChapter(bookId, chapterIndex) { return new Promise((resolve) { setTimeout(() { resolve({ chapterIndex, title: 第${chapterIndex 1}章 示例章节, content: generateParagraphs(chapterIndex), }); }, 150); }); }用Promise包裹模拟数据有一个额外的好处你的业务代码从一开始就按照异步取数的方式写后续要替换成真实fetch接口几乎不用改动页面逻辑。这种接口先行的开发习惯比事后再重构要省事得多。3. 书架模块入口页的渲染与交互3.1 书架页布局与卡片设计书架页的视觉方案我参考了主流阅读软件的思路用卡片网格布局每张卡片包含封面区域和下方文字信息。卡片渲染直接写在render.js里配合事件委托绑定交互div classshelf idshelf div classbook-card>// render.js function renderShelf(bookList) { const shelf document.getElementById(shelf); shelf.innerHTML bookList.map((book) { const progress book.lastRead ? 已读 ${book.lastRead.chapterIndex 1}/${book.totalChapters} 章 : 未开始; return div classbook-card>// index.js document.getElementById(shelf).addEventListener(click, (e) { const card e.target.closest(.book-card); if (!card) return; const bookId card.dataset.bookId; const book bookList.find((item) item.id bookId); openBook(book); // 进入书籍详情 });closest这个方法是我用得最顺手的一个DOM API它能从点击目标开始向上查找最近匹配的祖先节点完美适配卡片内部可能点击到封面、标题、进度条不同子元素的情况。很多初学者会在每个子元素上都绑事件然后收到一堆事件处理不好去重的困扰其实一个closest就全解决了。3.4 本地存储同步操作数据后立刻持久化书架模块容易忽略的一点是操作后即时同步。我处理的时机是这样的书架渲染前从localStorage读取最近阅读记录合并到书籍数据中用户点击书籍进入阅读更新updatedAt用户退出阅读页写入lastRead同步操作并不需要频繁执行关键时间点抓好就够了。这里我把一个必要的注意事项讲透localStorage是同步API写入大对象会阻塞UI线程。如果某一天你开始缓存整本小说几十万字的内容JSON序列化后几百KB甚至上MB写入时可能看到轻微卡顿。我的经验是章节内容不写入localStorage而是放内存里按需加载需要离线时再特别处理后面第5部分专门讲缓存方案。4. 阅读页核心体验的打磨细节4.1 阅读页的基本结构区分工具栏和正文区阅读页是整个阅读器体验的核心布局上我分成三块顶部工具栏、正文滚动区、底部工具栏。顶部工具栏放返回按钮和目录入口底部工具栏放字号调节、主题切换、章节切换。中间正文区域只做一件事——展示文字绝不让其他元素干扰阅读。有一个设计细节特别值得说章节切换按钮为什么不放在顶部而是放在底部因为阅读时手指的操作区域通常在屏幕中下方底部按钮自然更顺滑。经过我实际测试这种布局在单手操作时尤其舒服返回和目录放顶部是因为它们相对低频。div classreader-header button idbtnBack返回书架/button span idchapterName第1章 开始/span button idbtnCatalog目录/button /div div classreader-content idreaderContent !-- 正文段落动态渲染 -- /div div classreader-footer label字号 input typerange min14 max24 idfontSizeRange/label button idbtnPrev上一章/button span idpageInfo12/208/span button idbtnNext下一章/button /div4.2 章节内容渲染为什么按段落切分而不是一大坨正文渲染的核心思路是段落化。我从接口拿到的章节内容是数组每个元素是一段独立的文字。渲染时每一段用一个p标签包裹function renderChapter(chapterData) { const container document.getElementById(readerContent); const paras chapterData.content.map((text, index) p classcontent-para>:root { --content-font-size: 18px; --content-line-height: 1.8; } .reader-content { font-size: var(--content-font-size); line-height: var(--content-line-height); }JS这边只需要修改CSS变量不需要去遍历每个段落改stylefunction applySettings(settings) { const root document.documentElement.style; root.setProperty(--content-font-size, settings.fontSize px); root.setProperty(--content-line-height, settings.lineHeight); root.setProperty(--content-bg, getBgColor(settings.bgMode)); }这种改源头、全同步的思路比我最初的版本写一个for循环遍历所有p标签改fontSize不知道高明到哪里去了。CSS变量是现代前端极其好用的利器强烈建议在多元素联动样式场景优先想到它。4.4 章节切换时的滚动位置恢复章节切换时如果上一章读到中间下一章应该从顶部开始返回上一章时应该保持之前读到的位置。这个位置记忆逻辑我单独封装了一个函数function restorePosition(chapterData) { const lastRead chapterData.lastRead; if (!lastRead) { scrollToTop(); return; } const paras document.querySelectorAll(.content-para); if (lastRead.paragraphIndex paras.length) { const targetPara paras[lastRead.paragraphIndex]; const contentTop document.getElementById(readerContent).offsetTop; window.scrollTo({ top: targetPara.offsetTop - contentTop targetPara.clientHeight * lastRead.scrollRatio, behavior: instant }); } }这个函数的关键点是计算滚动位置时要把内容容器相对文档的偏移减掉。我第一次写的时候直接用了targetPara.offsetTop结果页面滚动位置始终差一截排查了半天才发现是容器本身不在文档顶部导致的。offset系列数值是相对父级定位元素的不是相对视口的这个混淆是前端避坑必修课。4.5 主题切换白底、护眼黄、夜间黑的实现主题切换我支持三种模式白色、护眼黄、夜间黑。不同模式影响的不只是正文背景还有文字颜色、工具栏背景、按钮颜色甚至封面卡片的外观。同样用CSS变量解决:root[themewhite] { --bg-color: #ffffff; --text-color: #333333; --toolbar-bg: #f5f5f5; } :root[themesepia] { --bg-color: #f5e6c8; --text-color: #5b4636; --toolbar-bg: #e8d5b0; } :root[themedark] { --bg-color: #1e1e1e; --text-color: #c8c8c8; --toolbar-bg: #2a2a2a; }切换时只需设置document.documentElement.setAttribute(theme, mode)所有匹配该主题的变量都会生效。这里有一个小坑要提夜间模式下文字颜色不能调成纯白。超高对比度在黑暗环境中长时间阅读会让眼睛疲劳加倍所以我特意选了#c8c8c8这种柔和的浅灰。这是一个看似微小但对阅读体验影响巨大的细节。5. 阅读进度存储与缓存让应用记得你5.1 进度数据格式与更新时机进度更新的时机我选了三个翻页滚动停止后、切换章节时、退出阅读时。前两个时机用节流控制最后一个在beforeunload事件里兜底。// 滚动停止后记录进度 let scrollTimer null; window.addEventListener(scroll, () { clearTimeout(scrollTimer); scrollTimer setTimeout(saveCurrentProgress, 300); }, { passive: true }); function saveCurrentProgress() { const scrollTop window.scrollY; const viewportHeight window.innerHeight; const docHeight document.documentElement.scrollHeight; if (docHeight - scrollTop - viewportHeight 20) { saveProgress({ paragraphIndex: lastParagraphIndex, ratio: 1 }); return; } // 找到当前视口第一段 const paras document.querySelectorAll(.content-para); for (let i 0; i paras.length; i) { const rect paras[i].getBoundingClientRect(); if (rect.top 0 rect.bottom 0) { const ratio Math.abs(rect.top) / rect.height; saveProgress({ paragraphIndex: i, ratio }); break; } } }这个查找当前段落的方式用getBoundingClientRect获取每段相对视口的位置找到第一个出现在视口内的段落然后用它在视口内的偏移占该段高度的比例作为第二维度进度。注意我是从当前页可见的第一段开始找的而不是用当前滚动偏移直接换算这样在字号变化导致段落高度变化后进度依然能保持相对准确。5.2 章节缓冲内存Map与预加载策略用户翻到下一章时如果还要经历点击→异步请求→渲染在弱网下会有一段明显的白屏等待。为了改善这个体验我在内存里做了一个章节缓存Map并采用当前章后一章的预加载策略。const chapterCache new Map(); function getChapter(bookId, chapterIndex) { const cacheKey ${bookId}_${chapterIndex}; if (chapterCache.has(cacheKey)) { return Promise.resolve(chapterCache.get(cacheKey)); } return fetchChapter(bookId, chapterIndex).then((data) { chapterCache.set(cacheKey, data); return data; }); } function preloadNextChapter(bookId, currentIndex, totalChapters) { if (currentIndex 1 totalChapters) { getChapter(bookId, currentIndex 1); } }每次渲染完当前章我都会立刻预加载下一章。用户可能根本不会翻到下一章但一旦翻了体验就是零等待。这点付出微不足道提升却很显著。5.3 持久化缓存策略哪些进localStorage哪些只在内存这里必须说清楚我的缓存分层策略。有些开发者会把章节内容全量存进localStorage图一个离线也能读但代价是每次写入大对象都阻塞UI而且localStorage容量只有5MB左右一本几百万字的小说序列化后轻松超过这个限额。我的做法是书架元数据和最近阅读的几章内容存localStorage单章平均十几KB存最近五章完全没有压力其余内容保持在内存缓存里应用关闭即释放。这个方案兼顾了秒开体验和存储安全是阅读器项目比较稳妥的黄金分割线。// 保存缓存时限制章节数量只保留最近5章 function saveChapterCache(bookId, chapterData) { const cacheKey cache_${bookId}; let cached readStore(cacheKey) || []; cached cached.filter((item) item.chapterIndex ! chapterData.chapterIndex); cached.push({ chapterIndex: chapterData.chapterIndex, data: chapterData }); if (cached.length 5) cached cached.slice(cached.length - 5); writeStore(cacheKey, cached); }6. 常见问题与排查技巧实录6.1 经典问题速查表我整理了开发过程中最常遇到的问题以及对应的排查路径可以直接当速查表用问题现象可能原因排查方案书架渲染出来是空的localStorage里存了旧数据与代码版本不匹配打开开发者工具Application面板删掉对应key重新加载阅读页滚动位置跳来跳去进度保存的时机太频繁与滚动事件冲突给保存逻辑加防抖滚动停止300ms后再计算字号调大后页面内容错乱段落高度变化后没有重新计算滚动位置字号变更后强制滚动到当前段落的相对位置夜间模式下字体看不清文字颜色和背景对比度不足用对比度检查工具验证前景色与背景色比率不低于7:1章节切换后白屏异步请求还没返回就渲染了空容器渲染前检查数据是否加载完成数据获取期间显示loading提示数据更新后书架卡片不刷新只改了数据对象没有调用渲染函数确认修改数据后执行了renderShelf()6.2 一次典型的滚动位置丢失排查过程我印象最深的一次bug是从书架重新打开书籍后永远定位到第一段进度记录完全失效。一开始我在saveCurrentProgress里打了三个console.log发现保存的paragraphIndex永远是0。继续排查后发现问题出在我绑定滚动事件时用了{ passive: true }然后在回调里调用了window.scrollTo重新设置滚动位置。passive模式声明的监听器不能调用preventDefault它本身没问题但我的保存逻辑里有一段滚动到上次位置的功能在页面还没加载完DOM时就执行了此时段落还只有空容器计算出的位置自然是0。解决思路分两步先把恢复滚动位置的时机延后确保章节渲染完成后执行再在保存进度前判断段落元素是否已渲染完成没渲染完就不保存。这类问题归根结底还是操作DOM的时机不对排查时沿着什么时候拿到了空数据的线索走会快很多。6.3 在线体验与调试的额外建议如果你按照这个项目结构自己动手写有两点额外建议第一功能完成后一定要试一遍完整的用户路径从书架进目录从目录进章节读几段后退回书架换一本书再回到之前那本。这个过程能一次性暴露出进度丢失、缓存失效、路由混乱等最伤体验的问题。第二用开发者工具的设备模拟模式分别测试小屏手机和大屏平板的阅读布局。字号、行高在小屏上尤其敏感我调试时发现默认的24px在小屏手机上反而比20px更难阅读——每行太短频繁换行会打断阅读节奏行高也可能挤占纵向空间。写在最后的一个小技巧最后分享一个小技巧。我在做主题切换时最初把背景色、文字色等直接写死在JavaScript里每次切换执行一堆赋值代码又长又容易漏。后来全部改成CSS变量方案后一个新主题只需要在CSS里加一个属性块JavaScript里完全不用动。这个经验给我的启发是界面状态的管理尽量用样式层的能力去承接逻辑层保持轻薄这样的架构改起来总是最快的那一个。整个阅读器做下来最大的感受是细节即体验。用户可能不会注意到你把进度精确到了段内比例但他一定能感受到重新打开还停在原处的顺畅他可能不会分析字号和行距的联动逻辑但他一定能感受到长时间阅读时眼睛的舒适度。这些藏在代码细节里的体贴恰恰是一个工具类项目最值得打磨的地方。
阅读完成 · 觉得有帮助?