不少人手机里都存着一堆从各种地方下载的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 type="file" id="fileInput" 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 class="empty-tip">还没有书,点右上角导入TXT</div>'; return; } for (const book of books) { const card = document.createElement("div"); card.className = "book-card"; card.innerHTML = ` <div class="book-name">${escapeHtml(book.name)}</div> <div class="book-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加TextDecoder,fatal模式降级。额外可以加一道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捕获异常,给用户一个清晰提示“存储空间不足,请删除部分书籍”。至少不会让应用静默崩溃。
如果要做得更进一步,可以把大正文挪到IndexedDB,localStorage只存书名和进度等元数据。这样书架加载还是很快,正文走真正的数据库存储。迁移时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渲染、移动端交互这一整条链路。尤其章节解析和分页翻页这些思路,换个项目照样用得上。目前我还在给这个阅读器加划线笔记和导出书摘功能,理论上也可以再接有声朗读接口。你完全可以照着这个思路先搭一个能跑的最小版本,然后按自己需求一点一点加东西,这种从零到一再到定制的过程,才是练手项目最大的价值。