做注意力训练这几年,我见过太多人把舒尔特表格当成“测智商的尺子”:第一次做 5×5,掐表 50 秒,觉得自己完了;练两周掉到 25 秒,又觉得自己开窍了。真实情况是——这两个数字说明的东西都比你以为的少得多。舒尔特表格本质上是一个高度标准化的视觉搜索任务,它的价值不在“测”,而在“可控地反复练一个特定环节”,并且这个环节恰好可以被拆解、被调参、被记录。问题是,大多数人手里的工具是纸质打印表或者带广告的手机应用,尺寸改不了、模式换不了、数据留不下,练到一定阶段就卡住了。所以我后来干脆用原生 HTML 加 CSS Grid 写了一个单文件生成器,打开浏览器就能跑,尺寸、模式、计时、记录全在自己手里。下面我把自己对这套东西的理解和实现过程完整摊开:前半部分讲清楚舒尔特表格背后到底是哪几个认知环节在起作用,后半部分给出一份可以直接复制运行的完整代码,以及我实测中踩到的坑。
1. 二十五格子里到底发生了什么:舒尔特表格的认知机制拆解
1.1 从“找数字”到“视觉搜索吞吐率”的概念转换
大多数人描述这个任务时会说“就是在一堆数字里按顺序找”,这个描述没错,但它把任务的核心环节藏起来了。真正决定成绩的,不是你认识不认识这些数字,而是你的视觉系统在单位时间内能从一片杂乱信息中确认多少个有效目标。我把这个指标叫“视觉搜索吞吐率”,它由三部分构成:候选目标被发现的效率、候选目标被确认的准确率、以及无效注视被及时终止的比例。
拿 5×5 举例,25 个数字。假设你每次只能靠眼睛正中央那一小块区域(中央凹)去逐个确认,每确认一个数字平均要花一次注视,一次注视加上识别大约需要 200 毫秒,那么光确认环节就是 25×0.2 = 5 秒。但现实中成人成绩普遍在 25 到 45 秒之间,远大于 5 秒,这说明大量时间花在了“找不到”“看错了”“犹豫了”上面,而不是“确认”本身。反过来看,顶尖成绩能压到 10 秒出头,靠的是把无效注视压到极低——眼睛几乎一直在有效移动,每一次落点都能命中下一个目标。
理解了这个构成,你就会明白为什么单纯“多练”效果有限:如果你每次都在用同一种低效的搜索策略反复练,练的是肌肉记忆而不是搜索效率,成绩会因为熟悉布局而虚高,换一张新表立刻打回原形。这也是我在设计生成器时坚持每轮必须重新洗牌的原因。
1.2 眼球扫视、注视与周边视野的三方协作
人眼不是一台均匀扫描的相机。中央凹负责高精度识别,覆盖范围大概只有视野中心 1 到 2 度;周边视野分辨率低得多,但对“这里有东西”“这里有变化”非常敏感。两者之间靠扫视连接——眼球快速跳动,一次扫视耗时 30 到 80 毫秒,跳完之后必须停下来注视才能看清。
做舒尔特表格时,高效的人在做的事情其实是:用周边视野先粗筛出一批候选,再用中央凹做定点确认。低效的人则相反:盯着一个格子看到底,确认完了再去找下一个,全程没用到周边视野的预筛能力,等于把一个并行任务做成了纯串行。
这里有个很反直觉的结论:眼睛该动就要动。网上流传一种说法,说练这个要“眼睛盯住中心不动,靠余光找”,这对训练周边视野有好处,但作为标准训练方式并不合理。5×5 表格的物理尺寸通常在一张 A4 纸大小,周边视野在这么大的范围内根本达不到识别数字所需的清晰度,强行不动眼只会让成绩变得很差并且非常挫败。合理的做法是让眼球自由扫视,但尽量少转头、少整体挪动身体,把搜索范围控制在眼动可及的区域里。
1.3 5×5 这个规格是怎么定下来的
规格不是随便定的,它背后是一条“难度-时长-疲劳”的曲线。3×3 只有 9 个数字,成人做起来通常在 5 秒内结束,区分度太低,几乎测不出差异;4×4 是 16 个,适合入门和儿童;5×5 是 25 个,成人完成时间落在 25 到 60 秒的区间,这个区间刚好卡在“注意力能保持高质量”的窗口内。
再往上,6×6 是 36 个,时间拉长到 60 到 120 秒;7×7 是 49 个,很多人做完会出现明显的眼部酸胀和注意力涣散。这不是意志力问题,而是持续视觉搜索的疲劳曲线在这个时长附近开始陡峭上升。一旦疲劳介入,后半段的成绩下滑会污染整个数据,你测到的其实是“抗疲劳能力”而不是“搜索效率”。
我在自己的生成器里默认给 3、4、5、6、7 五档,但标注了推荐场景:
| 规格 | 数字量 | 大致耗时(成人) | 适用场景 |
|---|---|---|---|
| 3×3 | 9 | 3-8 秒 | 儿童入门、热身 |
| 4×4 | 16 | 10-20 秒 | 青少年、成人热身 |
| 5×5 | 25 | 25-60 秒 | 标准训练主力规格 |
| 6×6 | 36 | 60-120 秒 | 进阶,每周不超过两次 |
| 7×7 | 49 | 90-180 秒 | 专项挑战,容易疲劳 |
还有一个容易被忽略的点:这个任务存在很强的学习效应。同一批人连续做十次 5×5,即使每次都是新布局,成绩也会明显下降,因为策略在优化。所以拿第一次和第十次的成绩对比“进步了多少”,结论是不可靠的。真正有意义的是看一段周期内的中位数变化,而不是最好成绩。
2. 训练收益的真实边界:哪些能力会被带起来,哪些只是错觉
2.1 迁移效应:练表格和练专注不是一回事
这是我最想先说清楚的一件事。你在舒尔特表格上变快,直接说明的是“你在这个任务上变强了”,这叫任务特异性提升。它能不能迁移到别的地方,取决于目标场景和这个任务的相似程度。
如果目标场景是“在密集信息里快速定位目标”——比如在一堆表格里找某个数据、在一张复杂的界面截图里找一个按钮、在货架上找某个规格的商品——那迁移是相对靠谱的,因为底层调用的都是视觉搜索和候选筛选。
但如果目标场景是“坐下来连续写两小时代码不走神”,那舒尔特表格帮不上什么忙。后者考验的是持续注意和抑制干扰,需要的是延时任务、番茄工作法、环境隔离这类手段。用 30 秒的短任务去练两小时的耐力,方向就错了。
我一般会这么跟人解释:舒尔特表格练的是“短时间内的注意力爆发力”,不是“注意力的续航”。两者都需要,但不能互相替代。
2.2 不同人群的起步规格与节奏差异
同一个工具,不同人用起来的入口完全不一样。我按自己接触过的情况整理了一版参考:
- 学龄前到小学低年级:从 3×3 起步,甚至可以先做“1 到 9 的图形版”降低数字识别负担。这个阶段最重要的是别让孩子产生挫败感,宁可比实际能力低一档。单次不超过 3 组,中间必须休息。
- 小学高年级到青少年:4×4 过渡到 5×5,每天 3 到 5 组,组间休息 30 秒到 1 分钟。这个阶段开始可以记录成绩,但强调“和自己比”,别搞排名。
- 成人日常训练:5×5 为主,6×6 作为每周一到两次的挑战。成人最大的问题是“想快速见效”,一天做二十组,结果第三天就烦了,彻底放弃。宁可每天三组坚持三个月。
- 中老年日常活动:4×4 或 5×5,重点在“活动”而不在“成绩”。这个群体做视觉搜索类活动对保持用眼和手眼协调有帮助,但不要把它当成任何形式的干预手段去宣传,那是不负责任的说法。
组间休息这件事我要单独强调。连续做五组不休息,后三组的成绩一定比前三组差,你会误以为自己在退步。实际上这是典型的疲劳效应,休息 60 秒再测,成绩能回来一大截。
2.3 三个把训练做成“眼保健操”的常见误区
我在旁边看人做这个,最常见的三个错误动作,每一个都会让训练效果大打折扣。
第一个:用手指或笔尖划。手指移动的速度上限远低于眼动速度,而且手指一旦指到某个位置,注意力就锚定在那里,周边视野彻底不工作了。这个习惯一旦形成,成绩会稳定在 40 秒左右上不去,而且完全练不到搜索能力。正确的做法是手只负责点击,眼睛先到、手后到。
第二个:只练一种布局不变。有人打印了一张表反复做,做了三十遍,成绩从 50 秒降到 15 秒,觉得自己神功大成。换一张新表立刻回到 45 秒。这就是典型的记住了位置而不是提升了搜索。每轮必须重新洗牌,这是硬要求。
第三个:追求极限速度,导致误点。有人为了刷新纪录,开始瞎点,反正点错了也不算数。这就把训练变成了抽奖。生成器在设计上必须让误点有明确反馈并且被记录,否则数据毫无意义。
这三个问题都不是“态度问题”,而是工具设计没跟上。纸质表没法强制洗牌、没法记录误点,所以我才决定自己写一个。
3. 自己写一个生成器:需求拆解与技术选型
3.1 现成工具的四个不够用
先说清楚我为什么不用现成的。市面上的方案基本分两类:纸质打印和手机应用。纸质的问题很直接——布局固定、无法计时、无法记录、浪费纸。手机应用的问题更隐蔽一些:
一是广告和打断。注意力训练这件事,最怕的就是过程中被打断,一个弹窗就能毁掉整组的节奏感。二是强制登录和云端同步,我只是想在本地看一眼成绩,不想注册账号。三是参数不可调,尺寸、是否倒序、是否显示误点反馈,这些全都锁死。四是数据不透明,你永远不知道它统计的是均值、最好成绩还是别的什么,也没法导出原始数据。
这四个问题,用一百多行原生代码就能全部解决,而且不需要任何依赖、不需要联网、换台电脑复制过去就能用。这个投入产出比实在太划算了。
3.2 功能清单与取舍表
动手之前我先把需求列了一遍,然后砍掉了一半。砍需求比加需求更重要,尤其是这种自己用的小工具,功能一多就会开始长 bug。
| 功能 | 是否保留 | 取舍理由 |
|---|---|---|
| 多规格网格(3-7) | 保留 | 核心可变参数,切换成本极低 |
| 每次重新洗牌 | 强制 | 不洗牌的训练等于无效训练 |
| 首次点击开始计时 | 保留 | 避免“准备时间”污染成绩 |
| 误点反馈与计数 | 保留 | 唯一能约束“乱点”的机制 |
| 每步时间戳记录 | 保留 | 用于分析后段掉速,几乎零成本 |
| 成绩本地存储 | 保留 | localStorage 足够,不需要后端 |
| 倒序模式 | 保留 | 实现成本一行代码 |
| 双色模式 | 保留 | 进阶变式,成本略高但值得 |
| 动态移动模式 | 砍掉 | 需要动画循环,收益不确定 |
| 音效 | 砍掉 | 训练场景下反而是干扰 |
| 云端同步 | 砍掉 | 单人单机使用,没必要 |
| 排行榜 | 砍掉 | 只和自己比才有意义 |
这张表里有两条是我踩过坑之后才定下来的。第一条是“首次点击开始计时”,早期版本我是点“开始”按钮就计时,结果从按下按钮到眼睛扫到表格的时间全算进去了,成绩虚高 1 到 2 秒,非常影响数据一致性。第二条是“每步时间戳”,一开始觉得没必要,后来想看“是不是后半段掉速”才发现没有原始数据根本分析不了。
3.3 DOM 网格与 Canvas 两条路线的对比
技术上有两条路可走:用 DOM 元素加 CSS Grid 排版,或者用 Canvas 画。我最终选了 DOM,理由如下表:
| 维度 | DOM + CSS Grid | Canvas |
|---|---|---|
| 点击判定 | 浏览器原生处理,事件委托即可 | 需要自己换算坐标,处理边界 |
| 自适应 | CSS 媒体查询、clamp 直接搞定 | 需要监听尺寸变化重绘 |
| 可访问性 | 原生支持键盘、读屏 | 基本不支持 |
| 样式调整 | 改 CSS 即可,改配色不用碰逻辑 | 改颜色要改绘制代码 |
| 性能 | 49 个元素完全无压力 | 优势在这个量级体现不出来 |
| 调试 | 浏览器元素面板直接看 | 只能靠日志 |
25 到 49 个格子这个量级,Canvas 的性能优势一点意义都没有,而 DOM 在点击判定和调试便利性上的优势是压倒性的。唯一需要 Canvas 的场景是“数字动态移动”,这属于进阶变式,我把它砍掉了,所以 DOM 是毫无疑问的选择。
排版用 CSS Grid,核心就一行:grid-template-columns: repeat(var(--n), 1fr),通过 CSS 变量--n控制列数,切换规格时只改这个变量就行,不用重写样式。
4. 生成器的核心代码逐段拆解
4.1 洗牌算法:Fisher-Yates 与那个流传很广的错误写法
洗牌是整个工具的地基,洗不好,后面的数据全废。网上流传最广的写法是这个:
// 错误示范,不要用 arr.sort(() => Math.random() - 0.5);这个写法看起来简洁,实际上有问题。sort的比较函数要求返回一个稳定的大小关系,但这里每次比较都返回随机值,导致排序过程本身不可预测。更实际的问题是分布不均匀:不同位置的元素被换到前面的概率并不相等,尤其是在元素数量较少的时候偏差更明显。25 个元素跑一万次统计,某些数字出现在首位的频率会明显偏离 1/25。
正确写法是 Fisher-Yates,从后往前遍历,每次和前面(含自身)的随机位置交换:
function shuffle(list) { for (let i = list.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [list[i], list[j]] = [list[j], list[i]]; } return list; }这段代码的正确性来自一个简单的归纳:处理到第 i 个位置时,前面 i+1 个元素中的任意一个被放到位置 i 的概率都是 1/(i+1),逐层递推下去,每种排列出现的概率都是 1/n!。这才是真正的均匀随机。
怎么验证?我自己写了个小脚本,把 1 到 25 洗一万次,统计每个数字出现在第一个位置和其他固定位置的次数,理想值应该是 10000/25 = 400。Fisher-Yates 的结果在 380 到 420 之间波动,符合随机波动;sort那个写法会出现 250 和 550 这种明显偏离的数值。这个验证脚本我建议你自己跑一遍,几分钟的事,比看十篇文章都管用。
还有一个容易忽略的点:Math.random()不是密码学安全的随机源,但对这个场景完全够用。如果你打算做在线对战或者防作弊要求极高的场景,那需要换crypto.getRandomValues,但自用训练工具没必要。
4.2 网格渲染、事件委托与状态推进
渲染部分的核心思路是:一次生成完整数组,一次建完所有格子,之后只改样式不重建 DOM。这样点击响应最快,也不会有重建导致的闪烁。
状态机非常简单,只有一个变量next,表示“下一个要找的数字”。初始为 1,每次正确点击后加一,超过总数就结束。
事件绑定我用了事件委托:在网格容器上监听一次pointerdown,通过e.target.closest('.cell')找到实际被点的格子。这么做有三个好处:一是只绑一个监听器,49 个格子也不会有性能问题;二是格子重建后不需要重新绑定;三是可以用同一个处理函数统一做判定。
这里有个细节值得说一下:判定用pointerdown而不是click。click在移动端有历史遗留的延迟问题,而且在按住拖动时行为不明确;pointerdown在手指按下的瞬间就触发,响应更跟手,也统一了鼠标和触摸两套事件。配合touch-action: manipulation可以关掉移动端的双击缩放等待。
误点的处理方式我选了“不推进但记录”:点到非目标格子时,格子短暂抖动一下,误点计数加一,next保持不变。这个设计很关键——它让“乱点”变得毫无收益,因为点错不往前推进,反而增加误点计数。如果设计成“点错也跳过”,那最快策略就变成了疯狂乱点,数据直接作废。
4.3 计时精度:performance.now() 的几个坑
计时我用performance.now()而不是Date.now()。原因很实在:Date.now()返回的是毫秒整数,受系统时钟调整影响,如果训练期间系统对时,可能一下子跳几十毫秒甚至更多。performance.now()是单调递增的高精度时间戳,不受系统时间调整影响,精度在浏览器里通常是微秒级(不过浏览器会做精度模糊化处理,一般是 100 微秒或 1 毫秒级别,具体看环境和隔离策略)。
更需要警惕的是页面切后台。performance.now()在页面不可见时仍然继续走,如果你中途切出去看了一眼消息再切回来,这段时间会被算进成绩里,数据就废了。所以我加了visibilitychange监听:一旦页面隐藏,立刻把这次训练标记为“无效”,切回来时提示重来。这个处理看起来严格,但数据一致性比“多练一组”重要得多。
还有一个反直觉的点:不要用setInterval累加计时。很多人写计时器喜欢开一个 10 毫秒的定时器每次加 10,这个做法误差会不断累积,而且浏览器在后台会节流定时器。正确做法是只记录开始时间和结束时间,中间不做任何累加,需要显示实时用时时再用requestAnimationFrame去读差值。
4.4 完整可运行的单文件实现
下面这份代码是一个完整的单文件版本,保存为.html双击打开就能用。包含多规格切换、首次点击计时、误点记录、每步时间戳、本地成绩存储。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <title>视觉搜索训练表</title> <style> :root { --n: 5; } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; font-family: -apple-system, "Segoe UI", "PingFang SC", sans-serif; background: #fbfbfd; color: #222; padding: 16px; touch-action: manipulation; } .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; margin-bottom: 12px; } .bar button, .bar select { font-size: 15px; padding: 6px 14px; border: 1px solid #d5d9e0; border-radius: 8px; background: #fff; color: #222; cursor: pointer; } .bar button.active { background: #2f6fed; color: #fff; border-color: #2f6fed; } .stat { font-size: 15px; color: #555; margin-bottom: 10px; letter-spacing: .5px; } .stat b { color: #2f6fed; font-size: 20px; font-weight: 600; } #grid { display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 6px; width: min(92vw, 520px); aspect-ratio: 1 / 1; margin: 0 auto; } .cell { display: flex; align-items: center; justify-content: center; background: #f1f3f7; border-radius: 8px; font-size: clamp(15px, 4.6vmin, 36px); font-weight: 500; color: #222; user-select: none; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background .12s, color .12s; } .cell.done { background: #e2ecff; color: #a9bde0; } .cell.wrong { animation: shake .18s; } @keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } } #tip { margin-top: 14px; font-size: 14px; color: #888; min-height: 20px; } </style> </head> <body> <div class="bar"> <select id="size"> <option value="3">3 × 3</option> <option value="4">4 × 4</option> <option value="5" selected>5 × 5</option> <option value="6">6 × 6</option> <option value="7">7 × 7</option> </select> <button id="mode-asc" class="active">正序</button> <button id="mode-desc">倒序</button> <button id="restart">重新开始</button> </div> <div class="stat">用时 <b id="time">0.00</b> s | 误点 <span id="miss">0</span> | 最佳 <span id="best">--</span></div> <div id="grid"></div> <div id="tip">第一个数点下去才开始计时</div> <script> (function () { const gridEl = document.getElementById('grid'); const timeEl = document.getElementById('time'); const missEl = document.getElementById('miss'); const bestEl = document.getElementById('best'); const tipEl = document.getElementById('tip'); const sizeEl = document.getElementById('size'); let n = 5; let mode = 'asc'; // asc 正序 / desc 倒序 let next = 1; // 下一个要找的数 let total = 25; let miss = 0; let running = false; let finished = false; let startAt = 0; let rafId = 0; let marks = []; // 每步时间戳,用于分析后段掉速 // Fisher-Yates 洗牌,保证均匀分布 function shuffle(list) { for (let i = list.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [list[i], list[j]] = [list[j], list[i]]; } return list; } // 正序:下一个是 next+1;倒序:下一个是 next-1 function stepValue(v) { return mode === 'asc' ? v + 1 : v - 1; } function startValue() { return mode === 'asc' ? 1 : total; } function isOutOfRange(v) { return v < 1 || v > total; } function build() { cancelAnimationFrame(rafId); n = parseInt(sizeEl.value, 10); total = n * n; next = startValue(); miss = 0; marks = []; running = false; finished = false; startAt = 0; timeEl.textContent = '0.00'; missEl.textContent = '0'; tipEl.textContent = '第一个数点下去才开始计时'; bestEl.textContent = loadBest(); gridEl.style.setProperty('--n', n); gridEl.innerHTML = ''; const nums = shuffle( Array.from({ length: total }, (_, i) => i + 1) ); const frag = document.createDocumentFragment(); for (const v of nums) { const div = document.createElement('div'); div.className = 'cell'; div.dataset.v = v; div.textContent = v; frag.appendChild(div); } gridEl.appendChild(frag); } function tick() { if (!running) return; timeEl.textContent = ((performance.now() - startAt) / 1000).toFixed(2); rafId = requestAnimationFrame(tick); } function finish() { running = false; finished = true; cancelAnimationFrame(rafId); const cost = performance.now() - startAt; timeEl.textContent = (cost / 1000).toFixed(2); const gaps = marks.slice(1).map((t, i) => t - marks[i]); const maxGap = gaps.length ? Math.max(...gaps) : 0; tipEl.textContent = `完成,用时 ${(cost / 1000).toFixed(2)} 秒,误点 ${miss} 次,` + `最长一步 ${(maxGap / 1000).toFixed(2)} 秒`; saveBest(cost, miss); bestEl.textContent = loadBest(); } // 事件委托:一次绑定,处理所有格子 gridEl.addEventListener('pointerdown', function (e) { const cell = e.target.closest('.cell'); if (!cell || finished) return; const v = parseInt(cell.dataset.v, 10); if (!running) { running = true; startAt = performance.now(); marks.push(startAt); rafId = requestAnimationFrame(tick); tipEl.textContent = '进行中'; } if (v === next) { cell.classList.add('done'); marks.push(performance.now()); next = stepValue(next); if (isOutOfRange(next)) { finish(); } } else { miss++; missEl.textContent = miss; cell.classList.add('wrong'); setTimeout(() => cell.classList.remove('wrong'), 200); } }); // 页面切到后台,本次成绩作废 document.addEventListener('visibilitychange', function () { if (document.hidden && running) { running = false; finished = true; cancelAnimationFrame(rafId); tipEl.textContent = '检测到页面切换,本次成绩已作废,请重新开始'; } }); function bestKey() { return `vs_best_${n}_${mode}`; } function loadBest() { const raw = localStorage.getItem(bestKey()); if (!raw) return '--'; const obj = JSON.parse(raw); return `${(obj.cost / 1000).toFixed(2)}s / 误 ${obj.miss}`; } function saveBest(cost, missCount) { const raw = localStorage.getItem(bestKey()); if (!raw) { localStorage.setItem(bestKey(), JSON.stringify({ cost, miss: missCount })); return; } const old = JSON.parse(raw); // 只有在误点不超过旧记录时才认作新纪录 if (cost < old.cost && missCount <= old.miss) { localStorage.setItem(bestKey(), JSON.stringify({ cost, miss: missCount })); } } sizeEl.addEventListener('change', build); document.getElementById('restart').addEventListener('click', build); const ascBtn = document.getElementById('mode-asc'); const descBtn = document.getElementById('mode-desc'); ascBtn.addEventListener('click', () => { mode = 'asc'; ascBtn.classList.add('active'); descBtn.classList.remove('active'); build(); }); descBtn.addEventListener('click', () => { mode = 'desc'; descBtn.classList.add('active'); ascBtn.classList.remove('active'); build(); }); build(); })(); </script> </body> </html>这份代码里几个细节是有意这么写的,值得单独说:
最佳成绩的比较逻辑。我加了“误点不超过旧记录”这个条件,否则会有人靠疯狂乱点蒙出一个极快的时间,把记录刷坏。这个条件让“快”和“准”必须同时满足。
最长一步的统计。marks数组记录了每一步的时间戳,结束后算出相邻两步的最大间隔。这个数字非常有诊断价值——它通常对应你那一次“卡住了”的瞬间,比总成绩更能反映问题出在哪。
切后台作废。这个处理会让人觉得有点严,但数据一致性比便利性重要。如果允许带着后台时间计入成绩,那所有记录都失去可比性。
5. 实测中的问题清单与调优记录
5.1 移动端点击:从 click 到 pointerdown
最早我用的是click,在手机上测出来问题一堆。第一个是延迟感,按下到响应之间有肉眼可察的滞后,尤其是快速连点的时候体验很差。第二个是误触,手指按下去的位置和实际触发的格子偶尔对不上,原因是手指接触面积大,加上页面有轻微滚动。
解决办法分三步。第一步换成pointerdown,按下瞬间就触发,响应跟手。第二步在body上加touch-action: manipulation,关掉双击放大带来的等待。第三步在body上加user-select: none和-webkit-tap-highlight-color: transparent,去掉长按选中文字和点击时的高亮闪烁,这两个视觉反馈在快速点击时非常干扰。
还有一个隐蔽的坑:格子的圆角和间距会吞掉点击。gap: 6px让格子之间有 6 像素的空隙,手指落在空隙里时e.target是容器而不是格子,closest('.cell')返回null,这次点击就被吞了,用户会觉得“点了没反应”。我的处理是让空隙保持较小(6px 是个平衡点),并且空隙里点击直接忽略而不是算误点——算误点会冤枉人。
5.2 视觉工效:字号、间距与配色的取舍
这块我调了很久,因为它的影响比想象中大。字号用clamp(15px, 4.6vmin, 36px),下限保证小屏幕上还能看清,上限防止大屏幕上数字大得离谱。vmin这个单位比vw好,因为它取的是宽高较小的那个,横屏竖屏都不会溢出。
间距我试过 2px、4px、6px、10px。2px 太挤,格子边界不明显,视觉上糊成一片;10px 太空,格子变小,数字也跟着小。6px 是手感最好的。
配色这块有个反直觉的发现。我一开始用的是白底浅灰格子加深色数字,后来试了深色模式(深灰底白字),结果成绩普遍变差 3 到 5 秒。后来想明白了:周边视野对亮度对比敏感,但对颜色分辨能力很弱。深色背景配白字,整体对比度高,周边视野里全是“亮点”,干扰信号太多,反而更难筛出目标。而浅灰底深字的情况下,数字是暗的、背景是亮的,周边视野里的“信号”更稀疏,筛选效率更高。
所以最终方案是:浅色背景(#f1f3f7)+ 深色数字(#222),已完成的格子变成浅蓝底浅蓝字(#e2ecff / #a9bde0),对比度低到几乎看不见,这样避免已完成格子持续干扰视觉。这个“已完成格子必须淡化”的设计很多人会忽略,实际上它能让后半段的搜索效率明显提升。
5.3 成绩可信度:如何识别“乱点出来的好成绩”
自用工具最大的敌人是自欺欺人。我总结了三种典型的数据异常,都能从marks数组里看出来。
第一种是最小间隔过小。正常人确认一个数字的反应时下限大概在 150 到 200 毫秒,如果某一步的间隔小于 80 毫秒,基本可以判定是“提前看到了位置然后连点”,而不是真的搜索到了。这种情况通常出现在布局重复使用的时候。
第二种是误点集中在开头。有些人开局会快速乱点几下“试探”,然后才开始正经找。这种开局乱点会导致前几步的间隔异常,拉低整体成绩的参考价值。
第三种是成绩分布的双峰。同一规格下,如果一组数据的成绩忽高忽低,波动超过 40%,那说明训练状态不稳定,可能是环境干扰、疲劳或者注意力没集中,这组数据应该整体剔除。
我的建议是:每组训练结束后不要只看总时间,把“最长一步”和“误点数”一起看。三个数字结合起来,才勉强能判断这一组是“真的练到了”还是“运气好”。单看总时间,信息量其实很少。
6. 把工具用出效果:训练方案与进阶变式
6.1 单次训练的节奏安排
工具做出来只是开始,怎么用才是关键。我自己的节奏是每次 12 分钟左右,结构固定:
| 阶段 | 内容 | 时长 | 目的 |
|---|---|---|---|
| 热身 | 4×4 两组 | 约 1 分钟 | 唤醒视觉搜索状态,不计成绩 |
| 主组 | 5×5 三组,组间休息 60 秒 | 约 4 分钟 | 核心训练,记录数据 |
| 变式 | 倒序或双色一组 | 约 1 分钟 | 打破固定策略,防止形成惯性 |
| 冷却 | 6×6 一组,只做一次 | 约 2 分钟 | 拉长持续注意,不追求速度 |
| 记录 | 写下三组主组的中位数 | 1 分钟 | 唯一的进步判断依据 |
几个关键约束:每天不超过一次,因为视觉搜索疲劳恢复得慢,练多了第二天状态会明显下降;主组三组之间必须休息,60 秒是最低要求;冷却组不追求速度,它的目的就是把注意力拉长,做完之后眼睛会有轻微酸胀感,这属于正常范围,但如果出现明显不适要立刻停。
判断进步只看主组三组的中位数,一周比较一次。单组的最好成绩没有参考价值,波动太大。
6.2 四种变式的实现思路
基础版练熟之后,加变式能打破策略惯性。我在代码里已经实现了倒序,另外三种给思路。
倒序模式。这个最简单,起始值改成total,每步减一,结束条件是小于 1。心理上倒序比正序难,因为人对递减序列的直觉不如递增序列,实测成绩大概慢 15% 到 25%。代码里stepValue和startValue两个函数就是干这个的。
双色模式。准备两组数字,一组红色 1 到 12,一组蓝色 1 到 12,混在一个 5×5 的格子里(多出来的一个格子空着或者放个干扰符)。要求红色按升序点、蓝色按降序点,交替进行。这个变式难度陡增,因为它同时要求“维持两条独立序列”和“抑制另一条序列的干扰”,练的是工作记忆和抑制控制。实现上就是给每个格子加一个颜色属性,然后维护两个next指针。
干扰模式。在格子里混入几个非数字符号(比如简单的几何图形),点击这些符号算误点。这个变式的目的是模拟真实场景里的信息噪声。实现成本很低,只是生成数组的时候多塞几个特殊值。
动态模式。每点中一个数字,剩余数字重新洗牌一次。这个需要 DOM 重建,实现上不难但要处理动画和点击冲突,收益也不确定,我最终没做。如果你想挑战,记得每次重排之后要保留已完成格子的状态,否则会出现“已点过的格子又变回可点”的 bug。
6.3 怎么判断自己是真的进步了
最后说一个我反复强调的点:别用最好成绩判断进步。最好成绩受当天状态、环境、甚至屏幕亮度影响,波动极大。
我用的判断方法是这样的:连续两周,每周至少五天训练,每天记录主组三组的中位数,然后比较这两周的中位数中位数。第二周比第一周快 5% 以上,才算真的有变化;快 2% 以内属于正常波动,不要过度解读;如果变慢了,先检查是不是练太猛导致疲劳积累,而不是怀疑方法有问题。
另外一个很有价值的观察角度是后段掉速比。把一次 5×5 的成绩拆成前 12 个和后 13 个,分别算平均每步耗时。如果后段比前段慢了 50% 以上,说明持续注意不够,这时候该做的是降低训练量、增加休息,而不是继续加练。这个指标比总成绩更能告诉你“下一步该练什么”,也是我为什么坚持在代码里记录每一步时间戳的原因。
最后分享一个小技巧:把生成器存成书签或者放在浏览器的新标签页,打开就是白板,不用登录不用等待。工具的价值在于“零启动成本”,一个需要打开应用商店、看开屏广告、跳过弹窗的工具,你坚持不了两周。而一个双击就能开始的页面,你可能真的会用上半年。