刚接触前端那阵子总想找个能真正“练手”还能出成果的小项目。看了一圈最后定了个“JSH5打字练习器”——这名字听起来不复杂但要把响应速度、输入比对、实时统计、进度存储这些串起来还真能逼你把 JavaScript 的基础从头捋一遍。做完之后我最大的感受是它不是一个“玩具”而是一个能把 DOM 操作、事件机制、数组方法、本地存储、性能优化全揉在一起的实战标本。这篇文章就把我完整的实现过程、代码思路和踩坑记录写出来。适合那些已经能把var、function写顺但还没独立做过一个完整交互页面的初学者也适合想快速找一个 H5 小项目练手、顺便梳理前端基础的朋友。1. 项目定位与整体思路1.1 项目背景与核心需求打字练习器这个东西市面上的在线版本很多但自己写一个的意义完全不一样。我的出发点很简单需要一个能练英文指法、能记录每次成绩、还能直观看到自己错在哪里的本地页面。不依赖后端打开即用数据存在浏览器本地刷新不丢。具体拆解下来核心需求就这么几条展示一段待输入文本用户逐字输入实时反馈对错。记录用时、按键次数、正确率、每分钟打字速度WPM。完成后展示统计结果并保存历史记录。提供多种难度文本方便不同阶段的练习。界面要干净、反馈要即时不能有卡顿。这里面最容易被低估的是“反馈要即时”这五个字。打字练习最怕的就是你按下键之后界面“愣”了一下那整个手感就完全废了。所以后面我在事件处理和渲染更新上花了很多心思。1.2 技术方案选型为什么是原生JS H5当时我也纠结过要不要上 React 或者 Vue。后来想了想打字练习器的交互本质上就是“一个输入框 一段文本 若干统计数字”状态量不算特别复杂用框架反而要引入构建工具把一个本来就该“打开即用”的小工具搞重了。原生 JS HTML5 的好处有三个零依赖双击 HTML 文件就能跑发给别人也方便。能逼着你把底层 API 用熟比如keydown事件、performance.now()、localStorage、requestAnimationFrame这些在框架里被封装掉的东西自己写一遍印象完全不一样。性能可控没有虚拟 DOM 的中间层针对这个场景可以直接操作 DOM反而更容易做到极致的响应速度。当然原生不代表不组织代码。我还是按模块化的思路把功能拆成了几个独立的部分文本管理、输入监听、计时统计、界面渲染、数据存储。这样既好维护也为以后加功能留了余地。1.3 项目结构规划我最终的项目文件结构是这样的typing-practice/ ├── index.html ├── css/ │ └── style.css └── js/ ├── texts.js // 文本库 ├── app.js // 核心逻辑 └── storage.js // localStorage 封装texts.js里存各种难度等级的练习文本storage.js负责读取和写入历史成绩app.js是主逻辑包括事件绑定、状态管理、渲染和计时。为什么要把文本和存储单独拆开因为这两个模块以后最容易扩展。文本可以不断增加存储逻辑可能要从 localStorage 换成 IndexedDB甚至对接后端接口。拆开之后改其中一个不影响另一个这种“低耦合”的思想在做任何前端项目时都值得养成。2. 核心功能拆解与代码实现2.1 键位监听与事件处理打字练习器的基石就是键盘事件。这里有个关键选择用keydown还是keypress现代浏览器里keypress已经被废弃了所以直接用keydown是更稳妥的做法。另外一个坑是event.keyCode也逐渐被淘汰了现在推荐用event.key。比如按下字母 Aevent.key就是a小写按下回车就是Enter按下 Shift 就是Shift。我的监听逻辑是这么写的document.addEventListener(keydown, (event) { // 忽略功能键、组合键避免误判 if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.key.length 1 event.key ! Enter) return; event.preventDefault(); handleInput(event.key); });这里用event.key.length 1来过滤掉非字符键是个很实用的小技巧。因为普通字符键的key值长度永远是 1而Backspace、Shift、ArrowLeft这些功能键的长度都大于 1。还有一个容易被忽略的点为什么要在全局document上监听而不是在输入框上因为打字练习场景下用户的眼睛始终盯着上方的文本区域不应该有光标在输入框里闪来闪去所以我把整个页面当成一个“隐形输入区”所有键盘事件都在全局捕获。这种做法很常见但要注意加上preventDefault()否则浏览器默认行为比如按空格滚动页面会把体验搞乱。2.2 文本比对与错误判定文本比对是打字练习器的灵魂。逻辑上讲用户每按下一个字符我就把它和当前期待的那个字符做比较判断对错。这里我踩过一个很典型的坑用字符串直接比较以为typedText targetText就完事了。但打字练习需要的是“逐字反馈”而不是“等全部输入完再告诉你哪里错了”。所以我选择了数组索引对比的方式。具体实现思路如下let targetChars targetText.split(); let typedChars []; let currentIndex 0; function handleInput(key) { if (key Backspace) { if (currentIndex 0) { currentIndex--; typedChars.pop(); render(); } return; } if (currentIndex targetChars.length) return; typedChars.push(key); currentIndex; render(); }渲染的时候我会把每个字符包装成独立的span然后根据 typedChars 中的对应字符判断它的状态未输入、正确、错误。function render() { const container document.getElementById(text-display); container.innerHTML ; targetChars.forEach((char, index) { const span document.createElement(span); span.textContent char ? \u00A0 : char; if (index typedChars.length) { span.classList.add(typedChars[index] char ? correct : wrong); } else if (index typedChars.length) { span.classList.add(current); } container.appendChild(span); }); }这段代码里有个细节空格字符要转换成\u00A0不间断空格否则多个连续空格会被浏览器折叠成一个导致排版错乱。这是我在实际测试时发现的一开始怎么都觉得渲染出来字数对不上查了半天才发现是 HTML 把普通空格合并了。2.3 计时与速度统计计时这块最原始的想法是setInterval每秒加 1。但这样有两个问题一是定时器本身有漂移不精确二是为了更新一个数字每秒唤醒一次页面性能上不划算。更好的方案是用performance.now()或者Date.now()在开始和结束时各取一次时间戳差值就是真实耗时。let startTime null; let endTime null; function startTimer() { startTime performance.now(); } function stopTimer() { endTime performance.now(); const elapsedSeconds (endTime - startTime) / 1000; return elapsedSeconds; }WPMWords Per Minute每分钟单词数是打字练习最核心的指标。计算方式是总字符数除以 5 得到“标准单词数”再除以分钟数。function calculateWPM(typedCount, elapsedSeconds) { const minutes elapsedSeconds / 60; if (minutes 0) return 0; return Math.round(typedCount / 5 / minutes); }这里除以 5 是行业惯例因为英文单词平均长度大约是 5 个字符。如果你练的是中文标准不太一样一般按每字 2 个字符算但我的工具初期只做英文练习所以直接用这个公式。正确率的计算就简单了正确字符数除以总输入字符数。注意这里的“总输入字符数”不是目标文本长度而是用户实际敲击的字符数——因为用户可能多敲了字符或者漏掉某些字符这些都要算进去。2.4 进度控制与课程切换练习到一半想换一篇文本怎么办如果直接换计时和进度全乱了。所以我在状态管理里加了一个confirmSwitch的保护机制。用户点击“切换文本”按钮时如果当前进度不为空就弹一个确认提示防止误触清空进度。另外还有一个隐藏需求练习结束后能不能重新练同一篇我给“完成”状态设计了一个finished标志位练完一篇之后所有按键都不会再触发输入比对直到用户点击“重新开始”按钮重置所有状态。这块逻辑用了一个简单的状态机思想const AppState { IDLE: idle, // 未开始 TYPING: typing, // 练习中 FINISHED: finished // 已完成 };这样整个逻辑就清晰多了。每次按键处理前先判断当前状态避免出现“已经结束了还能继续输入”的奇怪现象。3. 可视化反馈与数据展示3.1 实时WPM与正确率面板打字练习如果没有实时数据反馈体验会大打折扣。我在页面顶部做了一个三格统计面板分别显示当前 WPM、实时正确率、已用时。这里有个性能上的讲究。刚开始我是在render()里每次都重新计算并更新这三个数字结果发现实际输入时每秒约有 5-8 次按键事件每次事件都要触发一次 DOM 更新。虽然是简单改文本节点但在低端设备上还是能感到轻微的掉帧。改进方案是“分频更新”字符的逐字高亮必须即时渲染但统计数据不需要那么高的刷新频率。我用一个简单的节流函数把统计面板的更新频率限制在每秒 2 次。let lastStatUpdate 0; const STAT_UPDATE_INTERVAL 500; function updateStats(force false) { const now performance.now(); if (!force now - lastStatUpdate STAT_UPDATE_INTERVAL) return; lastStatUpdate now; // 更新 WPM、正确率、时间显示 }实测下来这个优化对长文本练习的提升非常明显。尤其是字母高亮和统计面板同时刷新时以前偶尔会有顿挫感现在完全顺畅了。3.2 错误字符热力图单纯显示“你这行字打错了”还不够直观。我后来加了一个功能错误字符热力图。也就是统计每个字符被敲错的次数用颜色深浅表示错误频率。这个功能的数据来源是错误日志数组。每打错一个字符我就记录一次let errorLog {}; function recordError(targetChar, typedChar) { const key ${targetChar}-${typedChar}; errorLog[key] (errorLog[key] || 0) 1; }练习结束后会生成一个弹窗展示高频错误映射比如e-r出现了 6 次说明你经常把 e 打成 r。这个功能对指法矫正特别有帮助能直接暴露你手指切换中的薄弱环节。实现这个功能用到了对象的键值对结构其实背后也用到了类似“字符串是否包含某个字符”的判断比如判断errorLog里是否已经存在某个键就用key in errorLog或者Object.prototype.hasOwnProperty.call()。这些都是日常开发里高频用到的知识点。3.3 结果页与历史记录每次练习结束我会展示一个结果弹窗内容包括WPM、正确率、用时、总字符数、错误字符数。同时自动保存到 localStorage。storage 模块我封装得很轻量const Storage { KEY: typing-practice-history, getHistory() { const raw localStorage.getItem(this.KEY); try { const data raw ? JSON.parse(raw) : []; return Array.isArray(data) ? data : []; } catch { return []; } }, addRecord(record) { const history this.getHistory(); history.unshift({ ...record, timestamp: Date.now() }); // 最多保留 100 条 history.splice(100); localStorage.setItem(this.KEY, JSON.stringify(history)); }, clearHistory() { localStorage.removeItem(this.KEY); } };JSON.parse 外面一定要包 try-catch因为 localStorage 里的数据可能被手动改坏也可能因为之前版本的程序写入了不兼容的格式。解析失败直接返回空数组比让整个页面崩溃好得多。历史记录列表展示时我会把时间戳格式化成可读的日期同时用textContent来渲染内容避免用户文本中包含 HTML 字符导致 XSS 风险。这是安全编码的基本意识。4. 设置面板与用户体验优化4.1 键盘布局切换不同用户的输入习惯不一样有人用 QWERTY 标准键盘有人习惯 Dvorak 布局。我的设置面板里做了一个键盘布局切换选项切换后文本比对规则会相应变化。具体做法是维护一个布局映射表const LAYOUTS { QWERTY: { q: q, w: w, e: e, r: r, t: t // ...完整映射 }, DVORAK: { q: , w: ,, e: ., r: p, t: y // ...完整映射 } };当用户选择 Dvorak 布局时按键产生的event.key会先经过映射转换成对应的 QWERTY 字符再和文本比对。这个功能虽然小众但实现起来不难而且价值在于证明了你的程序架构不僵硬——换个布局只是改一组映射数据核心逻辑完全不用动。4.2 文本难度分级与自定义导入文本库按难度分为三级初级常用词短句、中级长句标点、高级混合大小写数字特殊符号。每种难度准备 10 篇以上文本足够轮换练习。后来我还加了一个“自定义文本”功能用户可以在文本框里粘贴自己的练习内容存到 localStorage 里下次打开还在。这个功能的实现很直接就是给设置面板加一个 textarea 和“保存”按钮。但要注意的是粘贴进来的文本要处理一下function sanitizeText(text) { return text .replace(/\r\n/g, \n) // 统一换行符 .replace(/\n{3,}/g, \n\n) // 压缩多个空行 .trim(); }为什么压缩空行因为练习文本如果上下空行太多用户往下翻页的频率就会变高打断练习节奏。这个细节是用过一次自定义文本功能后才发现的。4.3 自动滚动与焦点管理当文本超过一屏时当前输入位置很容易滚出视野。我的解决方案是“光标跟随滚动”当前字符所在行超出可视区域下边界时自动把文本区域滚动到合适位置。这里有个技术细节不要用element.scrollTop value直接赋值那样太生硬。我做了一个简易的平滑滚动函数用requestAnimationFrame在一小段时间内逐步改变scrollTop值。function smoothScrollTo(container, targetY, duration 150) { const startY container.scrollTop; const distance targetY - startY; const startTime performance.now(); function step(now) { const progress Math.min((now - startTime) / duration, 1); const eased progress 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress 2, 2) / 2; container.scrollTop startY distance * eased; if (progress 1) { requestAnimationFrame(step); } } requestAnimationFrame(step); }这里的缓动函数是 easeInOutQuad能让滚动看起来更自然。顺带说一句用transform或scrollTop做动画时优先用requestAnimationFrame而不是setInterval因为前者会和浏览器刷新率同步既流畅又省资源。4.4 视觉主题与高对比模式打字练习往往会在光线较暗的环境下进行我做了两套主题亮色和暗色。实现方式简单粗暴——在根元素上切换>:root[data-themelight] { --bg-color: #f5f5f5; --text-color: #333333; --correct-color: #2e7d32; --wrong-color: #c62828; } :root[data-themedark] { --bg-color: #1e1e1e; --text-color: #e0e0e0; --correct-color: #66bb6a; --wrong-color: #ef5350; }主题切换的代码就一行document.documentElement.setAttribute(data-theme, currentTheme);另外我还给“当前待输入字符”设计了一个醒目的下划线或背景色效果方便眼睛快速锁定位置。这个小小的视觉锚点对提升输入速度帮助很大算是个低投入高回报的细节。5. 常见问题排查与性能优化5.1 高频事件卡顿与直觉误区第一次把所有功能做完后我信心满满地打开测试结果发现连续快速敲击时字符高亮偶尔会“吞”掉一次输入。排查了半天问题居然出在render()里用了innerHTML重建整个 DOM。每敲一个键就要重建几十个甚至上百个span节点这个开销在快速输入时确实吃不住。解决办法有两个方向一是用DocumentFragment批量操作减少重排二是“增量更新”只更新需要变化的部分。我最后选择的是增量更新。具体思路是第一次渲染时创建好所有span节点并存在一个数组里。之后按键时只修改对应索引的那个 span 的 class而不是全部重建。let charNodes []; function buildTextNodes() { const container document.getElementById(text-display); container.innerHTML ; charNodes targetChars.map((char) { const span document.createElement(span); span.textContent char ? \u00A0 : char; container.appendChild(span); return span; }); } function updateCharVisual(index) { const span charNodes[index]; if (span) { span.className typedChars[index] targetChars[index] ? correct : wrong; } }这个优化效果立竿见影。现在每次按键只需要修改一个节点的 class浏览器几乎感觉不到重排压力。这里也让我体会到性能和代码优雅度往往是同一件事——好的结构自然会有好的性能。5.2 移动端触摸输入的兼容问题打字练习器虽然主要面向桌面键盘用户但保不齐有人想在平板上看历史成绩或者在手机上临时练两句。真把页面放到手机上测试还是发现了不少问题软键盘弹出会遮挡页面内容导致统计面板看不见。event.key在移动端虚拟键盘上的表现不一致有些输入法连发事件。物理键盘没有的修饰键在软件盘上可能变成长按触发。我的处理方案是检测设备类型在触屏设备上提供“虚拟键盘”模式或直接提示用户切换到桌面环境获得完整体验。实际上因为打字练习的核心是实体键盘所以我把移动端定位成“查看数据”模式而不是强迫它在小屏幕上完成练习。这也算是一个产品定位的取舍——不是所有功能都适合全平台适配有时明确边界反而是更好的体验。5.3 localStorage 数据容量与安全边界localStorage 的容量一般有 5MB 左右但如果你存了很多自定义文本和练习记录还是有可能逼近上限。我做了几个保护措施历史记录最多保留 100 条超出自动丢弃最老的。自定义文本限制长度比如单篇不超过 5000 字符。每次写入前用JSON.stringify(data).length估算大小超过 4MB 就不再写入并弹出提示。function safeSave(key, value) { const serialized JSON.stringify(value); if (serialized.length 4 * 1024 * 1024) { alert(存储空间不足无法保存); return false; } localStorage.setItem(key, serialized); return true; }防御这种边界情况的语法习惯最好从项目初期就养成。毕竟前端页面一旦在别人的浏览器里报错你连看到错误日志的机会都没有。6. 几个我在实际踩坑后的核心心得这个项目做完我觉得最有价值的收获全都来自那些“看起来不起眼”的问题。最后集中分享几条都是实践里摸出来的。关于事件对象属性event.key虽然推荐但它受输入法影响很大。如果你要做一个支持中文输入的打字器直接用keydown反而拿不到最终上屏的中文字符必须监听input事件或者用compositionend配合处理。我这个版本只做了英文但架构上已经给中文输入留了扩展位。关于函数拆分我一开始把渲染状态更新、统计计算、错误记录全写在一个大函数里改起来极其痛苦。后来照着“单一职责”的原则拆成小函数整个项目清爽了一倍不止。如果你觉得自己的代码“能跑但不敢改”大概率就是函数拆得太粗。关于 CSS 变量暗色主题上线之前我用的是全局搜索替换颜色值改了一晚上眼睛都快瞎了。后来换成 CSS 变量五分钟就搞定了。以后做任何有点规模的前端页面请第一时间定义好设计变量。关于图标和样式我选择的风格是尽量少用外部图标库纯 CSS 做图形。比如错误的红色波浪下划线就是用border-bottom: 2px solid red加上一点自定义动画实现的不依赖任何字体图标。这样整个项目零外部资源离线也能用。关于可持续扩展现在这个打字练习器已经能在纯本地环境稳定运行了。我再往后的思路是给它加一个在线词库的接口或者在多人场景下做一个实时比赛模式。如果要在 WebSocket 环境下做多人同步需要注意 H5 页面在浏览器里的连接方式和服务端推送的频率控制这些就不仅仅是前端的事了。做小项目最怕的就是“要么不想做要么想一口气做到完美”。这个打字练习器从第一行代码到稳定能用的状态总共也就花了两个完整的周末。如果你也想练手一定要控制好第一次迭代的功能范围先把最基本的“显示文本、监听按键、标记对错、记录成绩”跑通再去锦上添花。功能一点点加坑一个个踩这个过程本身就是程序员最值钱的经验积累。
阅读完成 · 觉得有帮助?