一直想把打字速度练一练,但现成的练习站要么满屏广告,要么必须注册登录,统计也只给一个速度数字,看不错在哪、也看不出进步。干脆自己写一个:文章练习、指法课程、打字小游戏、多人实时对战、成绩趋势图,数据全部存在浏览器本地。
技术上分两条线:练习判定和统计全在前端,实时对战用 SignalR 的房间模型。下面记录几个关键实现。
【插入截图:练习主界面(左侧参考文章、右侧打字区、顶部统计条)】
一、需求拆解
先把模块列清楚,后面每个模块都能独立改:
| 模块 | 技术要点 |
|---|---|
| 文章打字练习 | 隐藏 input 捕获输入、逐字符比对、实时统计速度/准确率/进度 |
| 键位指法练习 | 19 个渐进课程,虚拟键盘高亮 + 手指引导 |
| 趣味输入游戏 | 字母掉落与整词输入两种玩法,连击/生命值/等级加速 |
| 对战房间 | SignalR,6 位房间号,进度实时同步,聊天,大屏看板 |
| 历史成绩 | localStorage 存储,Canvas 手绘速度趋势图 |
前端不引框架、不引图表库,全部原生 JS 按职责拆分模块;后端只做页面渲染和对战 Hub。
二、打字判定:中文输入法是最容易踩的坑
练习判定的核心是「每输入一个字符,就跟原文对应位置比一次」,实现上用一个隐藏 input 接收输入,逐字符交给判定引擎:
javascript
function feedText(v) {
if (!v) return;
for (var i = 0; i < v.length; i++) {
engine.handleKey(v[i]);
}
if (window.DZSound) window.DZSound.play();
}
真正麻烦的是中文输入法。拼音、五笔在组字过程中会往 input 里写入中间态字符,如果这时照常判定,就会把拼音当成错字,准确率直接崩掉。
解决办法是组字期间不参与判定,等 compositionend 拿到上屏的最终结果再统一喂给引擎:
javascript
function inComposition(e) {
return !!(e && (e.isComposing || e.keyCode === 229));
}
// 只拦截功能键,可打印字符不拦,保证中文 IME 能正常启动组字
input.addEventListener("keydown", function (e) {
if (inComposition(e)) return;
if (e.key === "Backspace") { e.preventDefault(); engine.handleKey("Backspace"); }
else if (e.key === "Enter") { e.preventDefault(); engine.handleKey("\n"); }
});
input.addEventListener("compositionstart", function () {
input.dataset.composing = "1";
});
input.addEventListener("compositionend", function () {
input.dataset.composing = "0";
var v = input.value;
if (v) { input.value = ""; feedText(v); } // 上屏完成后再判定
});
input.addEventListener("input", function (e) {
if (inComposition(e)) return;
var v = input.value;
if (v) { input.value = ""; feedText(v); }
});
要点有三条:keyCode === 229 是各家浏览器在组字过程中的统一信号;keydown 里只 preventDefault 功能键、绝不拦可打印字符,否则输入法无法启动组字;组字结束后立刻清空 input,避免重复判定。
三、数据层:文章、历史、偏好全在 localStorage
不登录就意味着没有服务端存储,所有数据落在浏览器本地。存储层做一层薄封装,好处是后面要换成 IndexedDB 只用改这一个文件:
javascript
var KEYS = {
ARTICLES: "dz_articles", // 我的文章
HISTORY: "dz_history", // 练习历史
PREFS: "dz_prefs", // 偏好设置
LAST_ARTICLE: "dz_last_article",
PANEL_RATIO: "dz_panel_ratio",// 双栏宽度比例
PAPER_SIZE: "dz_paper_size" // A4 / A3
};
function safeGet(key, fallback) {
try {
var raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch (e) { return fallback; }
}
function safeSet(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
}
safeGet / safeSet 都把异常吞掉:localStorage 在隐私模式下可能直接抛异常,读写失败不应该让整个练习流程中断。历史记录每次练习追加一条(速度、准确率、用时、字数、类型、文章 ID),趋势图就是基于这个数组渲染的。
四、实时对战:SignalR 房间设计
这是最花时间的部分。需求是创建房间拿到 6 位房间号、好友加入、对局中进度实时同步、结束后统一排名。
1. 房间与玩家状态
房间和玩家都放在内存里,用 ConcurrentDictionary 承载,天然支持多连接并发写入:
cs
public class RoomStore
{
private readonly ConcurrentDictionary<string, Room> _rooms = new();
private readonly ConcurrentDictionary<string, byte> _activeConnections = new();
private static readonly Random _rnd = new();
public void MarkConnected(string connectionId) => _activeConnections[connectionId] = 1;
public void MarkDisconnected(string connectionId) => _activeConnections.TryRemove(connectionId, out _);
// 6 位数字房间号,保证不重复
private string GenerateCode()
{
string code;
do { code = _rnd.Next(100000, 999999).ToString(); }
while (_rooms.ContainsKey(code));
return code;
}
// 惰性清理:无玩家且创建超过 2 小时的空房间
private void CleanupStaleRooms()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var kv in _rooms)
{
if (kv.Value.Players.IsEmpty && now - kv.Value.CreatedAt > 2 * 3600 * 1000)
_rooms.TryRemove(kv.Key, out _);
}
}
}
房间不落库、不做持久化。清理策略是惰性的:每次创建房间时顺手扫一遍,把「没有玩家且超过 2 小时」的空房间删掉------用最小的代价避免内存泄漏,也不需要额外的后台定时任务。
2. Hub:创建与加入
SignalR 的 Group 语义和房间天然契合,房间号直接当 Group 名用:
cs
public class TypingHub : Hub
{
private readonly RoomStore _store;
public TypingHub(RoomStore store) => _store = store;
// 创建房间,返回房间号
public async Task<object> CreateRoom(string nickname, string articleTitle, string articleContent)
{
var room = _store.CreateRoom(Context.ConnectionId, nickname, articleTitle, articleContent);
await Groups.AddToGroupAsync(Context.ConnectionId, room.Code);
return new
{
ok = true,
code = room.Code,
article = new { title = room.ArticleTitle, content = room.ArticleContent },
isCreator = true
};
}
// 加入房间(昵称不可重复)
public async Task<object> JoinRoom(string code, string nickname)
{
var (ok, message, room) = _store.JoinRoom(code, Context.ConnectionId, nickname);
if (!ok || room == null) return new { ok = false, message };
await Groups.AddToGroupAsync(Context.ConnectionId, room.Code);
// 房主已断线时,把房主身份转移给当前加入者,避免房间变成"无主"
if (string.IsNullOrEmpty(room.CreatorConnectionId) ||
!room.Players.ContainsKey(room.CreatorConnectionId))
{
room.CreatorConnectionId = Context.ConnectionId;
}
// 只通知房间内其他人,不用广播给所有人
var me = room.Players[Context.ConnectionId];
await Clients.OthersInGroup(room.Code).SendAsync("PlayerJoined", new
{
me.ConnectionId, me.Nickname, me.Wpm,
me.Accuracy, me.Progress, me.Finished
});
return new { ok = true, players = /* 完整玩家列表 */ };
}
}
两个设计取舍:加入时只通知 OthersInGroup,房主断线后由新加入者接管房主身份。后者一开始没做,结果房主关掉页面后,房间里的「开始游戏」按钮再也没人能点。
3. 进度同步与结算
打字过程中客户端按固定节奏上报进度,服务端只做转发、不参与计算,避免每敲一个键都发消息:
cs
public async Task UpdateProgress(int wpm, double accuracy, double progress, int pos)
{
var room = _store.GetRoomByConnection(Context.ConnectionId);
if (room == null) return;
var p = _store.GetPlayer(Context.ConnectionId);
if (p != null)
{
p.Wpm = wpm; p.Accuracy = accuracy; p.Progress = progress; p.Pos = pos;
p.LastSeen = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
// 转发给房间内其他人,自己不需要收到自己的进度
await Clients.OthersInGroup(room.Code).SendAsync(
"ProgressUpdated", Context.ConnectionId, wpm, accuracy, progress, pos);
}
完成后调用 Finish 上报成绩,服务端记录该玩家的完成时间;当房间内所有玩家都完成时,统一计算排名并广播 GameFinished,客户端据此展示结算榜。
大屏看板单独走 WatchRoom,以观众身份加入 Group 但不占用玩家名额,所以投影的排行榜不会影响正常对局人数。
【插入截图:对战房间(多人实时进度条)或大屏看板(实时排名)】
五、趋势图:用 Canvas 手绘,不引图表库
历史页需要「柱形 + 折线 + 虚线」的组合图,为一个页面引入图表库不划算,直接用 Canvas 画更可控。
高分屏适配是必踩的一步:Canvas 的 width/height 是物理像素,CSS 尺寸是逻辑像素,不做 DPR 缩放会导致图表发虚:
javascript
function setupCanvas(canvas, cssHeight) {
var dpr = window.devicePixelRatio || 1;
var w = canvas.clientWidth || canvas.parentElement.clientWidth;
var h = cssHeight || 260;
canvas.width = Math.round(w * dpr); // 物理像素
canvas.height = Math.round(h * dpr);
canvas.style.height = h + "px"; // 逻辑像素
var ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按逻辑像素坐标绘制即可
return { ctx: ctx, w: w, h: h };
}
配色不写死,从 CSS 变量读取,这样深色主题切换时图表自动跟随:
javascript
function cssVar(name, fallback) {
var v = getComputedStyle(document.documentElement).getPropertyValue(name);
return (v || "").trim() || fallback;
}
折线部分用相邻点作为控制点做平滑路径,比直接 lineTo 连直线观感好很多:
javascript
function smoothPath(ctx, pts) {
if (pts.length === 0) return;
ctx.moveTo(pts[0].x, pts[0].y);
if (pts.length === 1) { ctx.lineTo(pts[0].x, pts[0].y); return; }
for (var i = 0; i < pts.length - 1; i++) {
var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1];
// 以 p1、p2 为锚点绘制二次贝塞尔,形成平滑波动曲线
ctx.quadraticCurveTo(p1.x, p1.y, (p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
}
}
【插入截图:历史成绩页(速度柱形 + 波动曲线 + 准确率虚线)】
六、踩坑记录
-
输入法组字 :不处理
compositionstart/compositionend,中文用户的准确率会直接失真。别用keydown里的key判断字符,拼音场景拿到的不是上屏结果。 -
keyCode === 229:不同浏览器在组字期的表现不一致,把它和e.isComposing一起判断才稳。 -
localStorage 异常:隐私模式下读写会抛异常,必须 try/catch 兜底,否则一个存储失败就白屏。
-
房主断线:房间状态里房主 ID 指向一个已断开的连接,会导致房间没人能开始游戏,需要在加入时做身份转移。
-
空房间泄漏:内存房间不做清理,服务跑久了就是内存泄漏。惰性清理比定时任务简单,代价也可接受。
-
Canvas 发虚 :忘了按
devicePixelRatio缩放,高分屏上图表边缘会糊。
七、小结
整个项目没有复杂的架构,四件事做对就够了:前端用隐藏 input + 组字事件处理把打字判定做准;存储层做薄封装,让本地数据可迁移;对战用 SignalR Group 映射房间,状态放内存、惰性清理;图表用 Canvas 手绘,顺带避开高分屏和主题适配的坑。
线上演示:
https://dazi666.com
如果你也在做类似的实时对战或打字判定功能,欢迎在评论区交流。