首页 / 资讯中心 / 文章详情

纯前端实现TXT小说阅读器:解析、书架与阅读进度完整方案

纯前端实现TXT小说阅读器:解析、书架与阅读进度完整方案 ★ FEATURED ARTICLE
不少人手机里都存着一堆从各种地方下载的TXT小说正经阅读APP不是要登录就是广告满天飞自己又不想为看个小说装一个全家桶。我这个前端练手项目就是这么来的花了一个周末纯HTML CSS JavaScript撸了个能在手机浏览器里直接跑的小说阅读器没有后端、不用打包本地导入TXT就能看。这篇文章把完整实现过程拆给你看怎么解析章节、怎么做书架、阅读进度怎么存、字号和主题怎么调以及我踩过的中文乱码、长文本卡顿这些坑。如果你正好在学前端、需要一个小而完整的练手项目或者单纯想要一个没有任何广告的私人阅读器可以直接照这个思路动手做。1. 动手之前的设计与选型把要做什么想清楚1.1 需求清单先列出来MVP不能贪多做项目最忌讳一上来就写代码。我最初想得很膨胀书架、目录、书签、划线、夜间模式、字体设置、语音朗读、多端同步全做完那得按年算。冷静下来以后我把第一版需求砍成四块导入、解析、阅读、记忆。导入能把手机本地TXT文件装进阅读器。解析自动识别章节标题生成目录。阅读正文展示、翻页、字号和主题切换。记忆离开后回来还能接着上次进度看。这四件事就是整个项目的MVP。书签、划线、听书、云同步全部砍掉等第一版能跑通再加不迟。为什么这样砍因为MVP里每一块都对应一类前端基础能力文件API、文本处理与正则、DOM渲染、本地存储。把这四块吃透比堆一堆花哨功能有用得多。这个砍需求的过程本身就是练手学会分清哪些功能是“核心骨架”哪些是“锦上添花”比会写某段代码更重要。1.2 技术选型对比为什么是原生三件套技术选型我对比过三个方向各有各的取舍。方案优点缺点我的结论原生 HTML/CSS/JS零依赖、逻辑摊开、无构建步骤代码组织要自己规划第一版用它前端框架如Vue/React开发效率高、组件化舒服需要脚手架和依赖安装跑通后再迁移原生APP / 跨端框架系统能力调用强、体验最好门槛高、要签名打包当前没必要最终选了原生三件套理由很实际。第一这个工具的形态是网页起个本地静态服务手机浏览器访问局域网地址就能用分享也方便。第二没有构建工具就没有npm install不需要理解打包产物到底是什么所有逻辑直接摊开在几个JS文件里出了问题好查。第三如果你之后想迁移到某个前端框架核心的解析、存储、分页逻辑都是纯JavaScript能直接搬过去。与其一开始就被工程化工具淹没不如先把浏览器原生API摸熟。1.3 项目文件结构两个页面三个模块文件结构分得很简单只有两个HTML和几个JS文件。reader/ index.html 书架页 reader.html 阅读页 css/style.css 公共样式 js/store.js 数据读写 js/parser.js 章节解析 js/shelf.js 书架页逻辑 js/reader.js 阅读页逻辑两个HTML分别对应书架和阅读界面公共的存储、解析逻辑抽成独立文件。这个划分强烈建议保留。书架页和阅读页是两种完全不同的交互场景硬塞在一个页面里就要做路由或状态切换新手容易搞乱。先按页面分文件等同一个页面的逻辑也复杂到需要拆分时再考虑组件化这符合渐进重构的原则。2. 存储与解析书、进度、章节数据怎么装进浏览器2.1 localStorage装三样东西书、进度、设置“没有后端”意味着所有数据都得留在浏览器里最适合起步的就是localStorage。它就像挂在域名下面的小仓库字符串键值对API极简单。整个阅读器的数据分两块书籍列表和设置项。书籍列表里每本书是一个对象结构如下。{ id: book_1700000000000, name: 测试小说.txt, addedAt: 1700000000000, chapters: [ { title: 序章, paragraphs: [正文段落……] }, { title: 第一章 风起, paragraphs: [……] } ], currentChapter: 2, pageIndex: 1, percent: 0.35 }currentChapter存当前读到第几章pageIndex存这一章翻到第几页percent存总进度百分比。阅读页把这些值写回去书架页的卡片就靠percent显示进度条。既然是多本书我先读出来放到数组增删改查都在数组上做完成后整个写回去。function getBooks() { return JSON.parse(localStorage.getItem(reader.books.v1) || []); } function saveBooks(books) { localStorage.setItem(reader.books.v1, JSON.stringify(books)); }这里必须记住localStorage存的是字符串任何对象都要先JSON.stringify再存读出来再JSON.parse。这个封装是整个应用的地基后面所有功能都跑在它上面。设置项同理单独用reader.settings.v1这个key内容包含fontSize、theme等。2.2 章节解析算法从txt到章节目录章节解析是整个项目里最有含金量的一段逻辑也是最容易写崩的地方。常见TXT小说格式长这样第一章 风起 正文内容…… 第二章 雨落 正文内容……但实际文件里什么写法都有有的用“第001章”有的用“Chapter 1”有的开头是楔子或序章有的正文里会出现长得像标题的短行。我的策略是先按换行把整本书拆成行数组然后逐行判断哪些行是章节标题碰到标题就开一个新章节其余行全部归到当前章节。判断标题我用的正则。function parseChapters(text) { const lines text.split(/\r?\n/); const chapters []; let current { title: 序章, paragraphs: [] }; chapters.push(current); const pattern /^(第\s*[0-9零一二三四五六七八九十百千万]\s*[章节卷回部集]|楔子|序章|尾声|番外)/; for (const line of lines) { const trimmed line.trim(); if (pattern.test(trimmed) trimmed.length 30) { current { title: trimmed, paragraphs: [] }; chapters.push(current); } else if (trimmed.length 0) { current.paragraphs.push(trimmed); } } return chapters.filter((ch) ch.paragraphs.join().length 0); }两个细节很关键。第一正则里强制要求“第”开头和“章/节/卷”结尾避免正文里的短句被误判成标题。第二限制行长度小于30因为标题通常短正文即使碰巧以“第”开头也不会那么短。阈值可以根据实际小说调整有的散文式标题特别长那就放宽到40或50。文件开头如果有一段作者的话或简介就归到初始化好的“序章”里。解析完以后过滤掉正文为空的章节避免目录出现空章节。2.3 存储容量的边界什么时候换成IndexedDB说实话localStorage有大约5MB容量限制一本正常长度的TXT在UTF-8编码下大概1到3MB存个两三本还行再多就满了。而且JSON.stringify之后体积还会膨胀。所以在我这个项目里localStorage定位是“轻量存储方案”。如果想更踏实进阶路线是IndexedDB。它同样是浏览器内置数据库没有5MB限制支持存二进制、Blob查询能力也强。我后来加的“直接导入大文件包”功能就是用IndexedDB实现的。第一次做的话建议先走localStorage把整个数据流跑通再考虑迁移。迁移时store.js是唯一的改动点只要把读写函数的内部实现换掉书架和阅读页几乎不用动这就是封装存储层带来的好处。3. 书架页怎么做导入TXT、渲染卡片、管理删书3.1 用File API把本地TXT读进来书架页需要一个隐藏的文件选择框用户点按钮就触发它。input typefile idfileInput accept.txt,text/plain multiple为什么input要藏起来原生文件选择框样式很丑用自己写的按钮更统一。选完文件后拿到的是一个File对象接下来关键一步是读内容。这里我一开始踩了个大坑用readAsText(file, UTF-8)遇到GBK编码的中文TXT直接乱码。正确做法是读成ArrayBuffer再用TextDecoder解码先试UTF-8失败就回退GBK。const reader new FileReader(); reader.readAsArrayBuffer(file); reader.onload () { const buffer reader.result; let text; try { text new TextDecoder(utf-8, { fatal: true }).decode(buffer); } catch (err) { text new TextDecoder(gbk).decode(buffer); } const chapters parseChapters(text); const book createBook(file.name, chapters); saveBook(book); renderShelf(); };fatal: true的意思是解码遇到非法字符直接抛错这样UTF-8解码失败时能立刻知道转去试GBK。国内很多下载站的TXT是GBK编码这个降级逻辑几乎必不可少。如果文件开头有UTF-8的BOM标记EF BB BF可以提前嗅探到直接用UTF-8解码成功率更高。3.2 书架卡片渲染进度条和空状态书架用CSS Grid做响应式卡片每本书一张卡片封面上放书名底部一条进度信息。进度数据就是数据层里的percent。function renderShelf() { const books getBooks(); const list document.getElementById(bookList); list.innerHTML ; if (books.length 0) { list.innerHTML div classempty-tip还没有书点右上角导入TXT/div; return; } for (const book of books) { const card document.createElement(div); card.className book-card; card.innerHTML div classbook-name${escapeHtml(book.name)}/div div classbook-progress${Math.round(book.percent * 100)}%/div ; card.addEventListener(click, () openReader(book.id)); list.appendChild(card); } }这里有个容易被忽略的安全点书名是用户自己导入的文件名如果直接拼进innerHTML会有注入风险。我写了个escapeHtml函数把所有动态内容先转义再渲染。function escapeHtml(str) { const div document.createElement(div); div.textContent str; return div.innerHTML; }空状态也不能忘。第一次打开时书籍列表是空的界面不能白茫茫一片要给用户一个明确的“点右上角导入”提示。这个小细节很影响第一印象。3.3 删除书籍的细节处理删除书时弹一个二次确认确认后从数组里filter掉这一本再写回存储。注意删除只是从localStorage里删不会动手机里的原文件。另外这个项目里没有“重新读取本地文件”这个能力因为纯网页拿不到用户任意路径的文件。要彻底解决得靠文件系统访问类API但跨浏览器支持一般真正想要强大文件管理能力就得做成PWA或打包成App。这是网页方案的边界之一放到第5章再细说。还需要额外做一件事导入时要检查重复书名不是完全禁止而是弹个提示“同名书已存在是否覆盖”。否则同一个文件导入两三次书架会堆满重复卡片阅读进度互相干扰。处理方式也很简单导入时先查一遍name相同的书存在就替换章节数据、重置进度不存在才新增。4. 阅读页核心分页翻页、进度回跳和视觉设置4.1 阅读页三段式布局顶部、正文、底部阅读页布局分三块顶部标题栏、中间正文滚动区、底部工具栏。顶部显示当前章节名提供返回书架的按钮底部显示页码和设置入口中间正文区是灵魂。.reader-page { display: flex; flex-direction: column; height: 100vh; } .content { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem; line-height: 1.8; background: var(--bg); color: var(--text); }正文区是唯一可滚动区域自然形成“视口”。我们用视口高度把长章节切成若干页这个“切页”概念是后面翻页实现的前提。整体结构用flex的column方向顶部和底部固定高度中间自动撑满剩余空间移动端竖屏下体验很接近原生阅读器。4.2 按屏幕高度切页左右点击翻页假设第一章有四千字字号18px、视口高度大概600px时一屏只能显示约三百字四千字会被切成十几页。切页不需要真的把文字拆开只需要计算滚动位置。公式很简单。const content document.getElementById(readerContent); const viewH content.clientHeight; const totalH content.scrollHeight; const pageCount Math.max(1, Math.ceil(totalH / viewH)); const currentPage Math.min(pageCount - 1, Math.floor(content.scrollTop / viewH));当前页数就是从滚动距离除以视口高度算出来的。翻页时把scrollTop定位到目标页的顶部。function goPage(index) { content.scrollTo({ top: index * content.clientHeight, behavior: smooth }); }再配合点击区域点击屏幕左侧区域上一页右侧区域下一页中间区域弹出工具栏。这是很多阅读APP的交互习惯放在网页里同样成立。实现时监听touch或click事件根据点击位置的clientX与屏幕宽度的比例判断方向。这里必须提醒一个点字体变大后同样屏高能放的文字变少页数会变多。所以字号、行距、主题变化后要重新计算页数并且把滚动位置重置到当前页的页首否则页码会算错。4.3 进度自动保存与回跳阅读进度怎么从阅读页写回书架每次翻页时调一次saveProgress把bookId、当前章节索引、当前页码写进localStorage。function saveProgress() { const books getBooks(); const book books.find((b) b.id currentBookId); if (!book) return; book.currentChapter currentChapterIndex; book.pageIndex currentPage; book.percent (currentChapterIndex currentPage / pageCount) / book.chapters.length; saveBooks(books); }翻一页写一次没什么压力但如果以后加了实时笔记最好做一个防抖减少写入频率。回到书架再打开这本书时在渲染正文前先读到这些数据然后把滚动位置恢复过去。const book getBookById(bookId); renderChapter(book, book.currentChapter); restorePage(book.pageIndex);恢复页码不能直接在renderChapter后面同步执行因为浏览器渲染是异步的要等正文真正上屏后scrollHeight才是新值。正确做法是包一层requestAnimationFrame否则经常取到旧的scrollHeight导致定位失败。这个坑很典型我第一次实现时进度恢复偶尔失效查了半天才发现是时序问题。4.4 字号、日夜间模式和目录跳转字号调整直接用容器上的fontSize属性加减1px范围控制在12到28px之间调整完重新渲染当前章节。日夜间模式用CSS变量切换比写两套颜色选择器清爽得多。:root { --bg: #f7f4ea; --text: #333333; --card: #ffffff; } .dark { --bg: #1f1f1f; --text: #b8b8b8; --card: #2a2a2a; }只要切换body的class所有用到变量的地方自动变色。目录跳转更直接从顶部按钮弹出目录浮层列出所有章标题点击后跳到那一章并重置页码为0。目录如果很长浮层内部要能滚动并且当前章节要高亮显示方便用户知道读到哪里。5. 实操踩坑实录乱码、卡顿、存储和移动端兼容5.1 中文乱码FileReader的坑和TextDecoder兜底最常见也最先遇到的就是乱码。我曾经从旧设备导出一个TXT打开全是“锟斤拷”三个字。原因就是文件是GBK编码FileReader.readAsText按UTF-8解。解决方式前面已经给过readAsArrayBuffer加TextDecoderfatal模式降级。额外可以加一道BOM嗅探。function decodeBuffer(buffer) { if (buffer[0] 0xEF buffer[1] 0xBB buffer[2] 0xBF) { return new TextDecoder(utf-8).decode(buffer); } try { return new TextDecoder(utf-8, { fatal: true }).decode(buffer); } catch (err) { return new TextDecoder(gbk).decode(buffer); } }加上BOM判断后解码成功率基本接近100%。这个坑提醒我遇到用户提供的文件永远不要假设它一定是某一种编码。凡是做文件导入功能编码检测和降级逻辑必须提前设计进去。5.2 上千章大文本怎么防卡顿有的TXT有上千章如果导入时把所有章节一次性渲染成整棵DOM树手机直接卡死。我的处理原则是永远只渲染当前章节的DOM目录浮层也要在需要时动态创建用完清理。解析大文件本身也耗时可以包一层setTimeout先让UI画出“正在导入”的提示再异步执行解析避免页面长时间无响应。如果导入期间用户又点了别的按钮要加一个标志位防止重复解析。章节数量特别多时目录浮层的渲染也要克制。我试过直接用innerHTML拼一千多行div手机还是会卡。后来改成只渲染前100个章节标题滚动到底部时再追加下一批本质上就是最简单的分页渲染。这个优化本身不难但能让打开目录的耗时从一秒降到几乎无感。5.3 localStorage写满的处理策略当localStorage写满时setItem会抛QuotaExceededError。我的对策分两层。第一层导入前估算当前占用算出剩余空间如果新书体积超过剩余空间就提前阻止。第二层写入时用try-catch捕获异常给用户一个清晰提示“存储空间不足请删除部分书籍”。至少不会让应用静默崩溃。如果要做得更进一步可以把大正文挪到IndexedDBlocalStorage只存书名和进度等元数据。这样书架加载还是很快正文走真正的数据库存储。迁移时store.js是整个项目的重点改造对象其余模块几乎不动。5.4 移动端点击与滚动冲突翻页点击和滚动手势有时会撞车。比如用户想上下滚动看一段文字却误触了“上一页”或“下一页”。我给点击翻页加了一层判断只有当触摸开始和结束的坐标距离小于10像素时才认为是一次“点击”否则当作滚动手势直接忽略。实现时分别在touchstart和touchend记录clientX和clientY计算两点距离。这里还要注意一个细节点击翻页事件的触发区域。如果在屏幕中间位置也翻页用户想选中文字时会很难受。所以我把中间宽度约40%的区域留作“无操作区”只有左右两侧才响应翻页。牺牲一点翻页空间换取更自然的阅读体验很值。5.5 调试技巧设备模拟和存储检查移动端布局一定要在浏览器开发者工具的设备模拟里测iPhone尺寸和Android尺寸都要看一遍。我一般先跑在桌面端设备模拟里调逻辑再用真机打开同一个局域网地址实测。这里有几个实用技巧。第一Console里直接执行JSON.parse(localStorage.getItem(reader.books.v1))可以实时查看每本书的章节数和进度字段。第二用Performance面板分析渲染耗时如果某次导入特别慢八成是解析正则写得太复杂或者DOM太大。第三真机调试时浏览器自带的移动端调试模式比反复刷新方便得多可以看到报错和网络状态。这类本地工具项目的难点往往不在单个技术上而在把一串技术串起来后出现的各种排列组合问题。编码、存储、渲染、交互每一层都可能有坑真机测一遍最靠谱。5.6 一个提高使用体验的小优化最后分享一个很小的优化记忆阅读位置时不要把页码存成整数就完事。因为字号调整后页面数量会变整数页码直接恢复可能偏前或偏后。更好的办法是同时记一个“字符偏移量”也就是当前页顶部对应的正文文字位置。恢复时按偏移量定位无论字号怎么变都能回到差不多的地方。这个字段加在章节对象里是个性价比非常高的体验提升。动手做成这件事之后我最大的收获不是“我会写阅读器”了而是亲自动手串起了文件读取、文本解析、状态存储、DOM渲染、移动端交互这一整条链路。尤其章节解析和分页翻页这些思路换个项目照样用得上。目前我还在给这个阅读器加划线笔记和导出书摘功能理论上也可以再接有声朗读接口。你完全可以照着这个思路先搭一个能跑的最小版本然后按自己需求一点一点加东西这种从零到一再到定制的过程才是练手项目最大的价值。
阅读完成 · 觉得有帮助?
咨询建站