舒尔特表格这东西,我最早是在一堆纸质训练卡里见到的:5×5 的方格,1 到 25 打乱填进去,用手指或眼睛按顺序找。看着简陋到有点寒酸,但它是我见过的、成本最低又最容易被量化的注意力训练工具之一。后来有人找我做一个能自己出题、自己计时、还能存成绩的版本,我干脆把它写成了一个纯前端的 HTML 生成器——一个文件、双击就能用、断网也能跑。这篇就把它彻底拆开:舒尔特表格到底在训练什么,参数该怎么定,这个 HTML 生成器从骨架到洗牌校验怎么写,训练节奏怎么排,以及我自己踩过的那些坑。不管你是想给孩子找一个不伤眼的练习工具,还是自己想写一个拿来练手的小项目,下面的内容都能直接抄。
1. 舒尔特表格到底在训练什么
1.1 一张方格里的四个认知环节
很多人以为舒尔特表格练的是"眼快手快",这个理解偏了。一张 5×5 的表,从点下"1"到点完"25",中间至少同时跑着四件事,任何一环掉链子,成绩都会明显变慢。
第一件事是视觉搜索。你需要在 25 个形状相似、尺寸相同的符号里,定位一个特定的目标。人眼的清晰视野其实很窄,真正能看清细节的区域只有中心几度,剩下的全靠周边视野的模糊感知来"提示这里有东西"。舒尔特表格的效率高低,很大程度上取决于你能不能在保持中心注视的同时,用余光扫出一个大范围的候选区域。
第二件事是序列维持。你脑子里始终挂着一个"下一个是几"的计数器,点完 7 之后要立刻切换成 8。这个切换看起来简单,但它占用了工作记忆的资源,一旦被干扰打断,就会出现"我刚才点到哪了"的卡顿。
第三件事是干扰抑制。目标数字旁边通常挨着几个同样醒目的数字,它们不停地争夺你的注意力。你能不能压住"看一眼旁边那个"的冲动,直接决定了扫视路径的干净程度。
第四件事是动作执行。眼睛找到了,手要跟得上,落点还要准。触屏设备上这一环尤其明显,手指比鼠标粗得多,误触会直接拉高错误率。
这四件事叠在一起,才构成了舒尔特表格的训练价值。所以你在设计工具的时候,不能只考虑"把数字排出来",还得考虑怎么让这四个环节都被调动起来——这就是后面所有参数设计的出发点。
1.2 三个被过度包装的说法
网上关于舒尔特表格的说法,有些吹得比较过。我在做这个工具的过程中,反复被问到几个问题,这里统一说清楚,免得大家抱错期待。
说法一:成绩好就代表注意力好。不成立。舒尔特表格测的是特定任务下的表现,它是一个"熟练度 + 状态"的混合指标。练两周成绩提升,很可能是你熟悉了这个任务的搜索策略,而不一定是注意力本身变强了。真正要看的是这种改善能不能迁移到别的场景,这一点需要长期观察,不是一张表能下结论的。
说法二:越快越好。这是最容易把人带沟里的说法。单纯追求速度,人会不自觉地用"手指跟着眼睛走""嘴里默念"这类代偿策略,把训练变成了纯粹的熟练度练习。更值得关注的是错误率和稳定性:同样 30 秒完成,一个错 0 次,一个错 5 次,后者的实际水平明显更差。
说法三:这是小孩专用。成人一样能练,只是目标不同。孩子更多是练基础的搜索和序列维持,成人往往是想在长时间高强度工作里保持住视觉扫视的耐力。我自己的体验是,连续写代码两小时后做一组 6×6,能明显感觉到眼睛"重新对焦"的过程。
注意:舒尔特表格是训练工具,不是评估工具,更不是诊断工具。如果确实存在持续的注意力困扰,该找专业人士就找,别指望靠一个网页解决问题。
2. 表格参数怎么定:尺寸、排布与干扰
2.1 尺寸阶梯与难度的非线性增长
尺寸是第一个要定的参数,也是最容易被随意对待的参数。很多人一上来就 5×5,做不动就换 7×7 硬扛,结果练了两周全是挫败感。
我建议按这个阶梯走,每个尺寸至少做满 5 组再考虑升级:
| 尺寸 | 格子数 | 适合人群 | 单组参考用时 | 训练重点 |
|---|---|---|---|---|
| 3×3 | 9 | 初次接触、低龄段 | 8-15 秒 | 建立规则感,熟悉流程 |
| 4×4 | 16 | 过渡阶段 | 15-25 秒 | 开始出现明显扫视路径 |
| 5×5 | 25 | 标准基准 | 25-45 秒 | 最通用的训练尺寸 |
| 6×6 | 36 | 进阶 | 45-80 秒 | 周边视野分配压力明显 |
| 7×7 | 49 | 高阶 | 80-140 秒 | 耐力与抗干扰 |
强调一下,表里的用时只是我观察到的常见区间,个体差异非常大,别拿这个去和别人比,只和你自己的历史数据比。
难度增长不是线性的。从 5×5 到 6×6,格子数只增加了 44%,但搜索空间从 25 个候选涨到 36 个候选,同时干扰项密度也在上升,实际体感的时间增幅通常在 60% 到 90% 之间。到了 7×7 往上,收益开始递减——超过 8×8 之后,屏幕上的格子会小到必须凑近看,反而破坏了"保持中心注视、用余光搜索"这个核心要求,练的东西就变味了。
所以我给生成器的尺寸选项就卡在 3 到 7 之间,没有再往上加。这不是偷懒,是刻意收敛。
2.2 视觉参数:字号、间距与对比度
这一块是纯经验,但每一条都是我在实际测试里改出来的。
字号。字号太小,眼睛必须逐个聚焦,训练退化成"认字";字号太大,格子里的空白太多,扫视路径被拉长,也不对。我最后定的比例是:字号约为格宽的 0.42 倍。计算方式很直接——假设面板宽度 480px,5 列、间距 6px,那么单格宽度是 (480 - 6×4) / 5 = 91.2px,字号就是 91.2 × 0.42 ≈ 38px。这个比例在 3×3 到 7×7 上都能保持视觉密度接近,换尺寸时不会出现"突然变稀"的割裂感。
间距。6 到 10px 比较合适。间距太小,格子之间没有视觉边界,容易点错;间距太大,整个面板会被撑得很散,扫视成本上升。我用 6px 是因为触屏上手指宽度大概 8-10mm,间距再小一点不影响点击,但能让整体更紧凑。
对比度。别用纯黑字配纯白底,长时间看会有眩光感。我用的是深灰 #1f2937 配近白 #ffffff,完成的格子降到浅蓝灰 #dfe7f3 配 #9aa6b6 的字色——这个处理很关键,已完成的数字必须"退到背景里",否则它们会持续干扰你对剩余目标的搜索。
字体。必须用等宽数字(font-variant-numeric: tabular-nums)。原因很实在:比例字体的"1"特别窄、"8"特别宽,格子里的数字视觉重量不一致,某些数字会因为"看起来小"而被优先忽略,形成隐性的搜索偏差。
中心注视点。这是最容易被忽略的一个细节。原始版的舒尔特表格要求视线固定在表格中心,用余光找数字。但绝大多数人拿到表就开始眼睛乱飞,训练效果大打折扣。所以我在面板正中放了一个 8px 的小红点,就是为了给视线一个锚。
2.3 变体设计:什么时候该换玩法
同一个 5×5 做久了,人会形成固定的搜索习惯,这时候成绩还在涨,但训练价值已经在下降。这时候就该上变体了。
字母版:把 1-25 换成 A-Y,规则不变。它比数字版难一点,因为字母的序列是背诵出来的,不像数字那样有天然的数值直觉,工作记忆的负担更重。
数字字母交替版:1-A-2-B-3-C……这个变体的妙处在于,它强制你在两套序列之间来回切换,对"序列维持"那一环的压力成倍增加。我第一次做这个版本,用时直接翻了一倍。
双色版:红色数字 1-13 升序,黑色数字 1-13 降序,交替点击。这是难度最高的变体,本质上是在做任务切换训练。它不适合新手,做之前至少 5×5 稳定在 40 秒以内。
动态重排版:每点对一个数字,剩下的数字重新洗牌。这个版本彻底堵死了"记住位置"这条捷径,是纯粹靠搜索能力的硬碰硬。但它的体验比较挫败,建议偶尔用一次就够了。
视觉干扰版:给一部分数字加上随机的大小变化或者轻微旋转。这个变体我做得比较克制,因为加多了整个表就变得很丑,反而不想做了。
3. 手写一个 HTML 生成器:从骨架到计时
3.1 为什么选单文件纯前端
做这个工具的时候我考虑过几种方案,最后落在一个单 HTML 文件上,理由有四条。
零依赖。不需要装环境、不需要起服务器、不需要联网。发给别人就是一个文件,双击打开就能用。给家里长辈或者老师转发的时候,这一步省下来的沟通成本非常高。
离线可用。这一点在给孩子用的场景里特别重要。设备可能是个旧平板,网络不稳定,或者你压根不想让孩子碰网络。纯前端页面天然满足这个要求。
便于二次修改。老师想把 5×5 改成 6×6 默认、想把配色改成护眼色,打开文件改两行就行。如果是个需要构建的项目,这一步就能劝退 90% 的人。
便于存档。一个文件就是一个版本。你改完想回退,把旧文件留着就行,不用操心依赖版本漂移那些事。
代价当然也有:没有后端,成绩只能存在本地;不同设备之间不同步;数据清理了就没了。但这些都是可接受的,因为训练本身是个低频、单人、长期的事,同步的价值没那么高。
3.2 页面骨架与样式
整个页面的结构非常简单:顶部一行状态栏(用时 + 进度),中间一个方形面板,下面一排控件,最底下是提示文字。核心布局靠 CSS Grid 完成。
<!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> * { box-sizing: border-box; } html, body { margin: 0; min-height: 100%; background: #eef1f5; font-family: system-ui, sans-serif; color: #1f2937; -webkit-tap-highlight-color: transparent; user-select: none; } .app { display: flex; flex-direction: column; align-items: center; padding: 16px 12px 32px; gap: 14px; } .bar { width: min(92vw, 520px); display: flex; justify-content: space-between; align-items: baseline; font-variant-numeric: tabular-nums; } .bar .time { font-size: 26px; font-weight: 700; } .bar .meta { font-size: 14px; color: #6b7280; } .board-wrap { position: relative; width: min(92vw, 520px); } .board { display: grid; gap: 6px; width: 100%; aspect-ratio: 1 / 1; } .cell { display: flex; align-items: center; justify-content: center; background: #ffffff; border-radius: 8px; font-variant-numeric: tabular-nums; font-weight: 600; color: #1f2937; cursor: pointer; touch-action: manipulation; transition: background 0.08s linear, color 0.08s linear; } .cell.done { background: #dfe7f3; color: #9aa6b6; } .cell.wrong { background: #f7c9c2; color: #8c2f22; } .dot { position: absolute; left: 50%; top: 50%; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: #e05d3f; opacity: 0.85; pointer-events: none; } .controls { width: min(92vw, 520px); display: flex; flex-wrap: wrap; gap: 8px; } button, select { font: inherit; font-size: 14px; padding: 8px 14px; border-radius: 8px; border: 1px solid #cfd6e0; background: #fff; color: #1f2937; cursor: pointer; } button.primary { background: #2f6bd8; border-color: #2f6bd8; color: #fff; } .hint { width: min(92vw, 520px); font-size: 13px; color: #6b7280; line-height: 1.7; } </style> </head> <body> <div class="app"> <div class="bar"> <div class="time" id="time">00.00</div> <div class="meta" id="meta">目标 1 / 25</div> </div> <div class="board-wrap"> <div class="board" id="board"></div> <div class="dot"></div> </div> <div class="controls"> <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 class="primary" id="start">开始新的一组</button> <button id="clear">清空记录</button> </div> <div class="hint" id="hint">视线盯住中间的红点,用余光找数字,按顺序点击。</div> </div> <script> // 逻辑部分见下一节 </script> </body> </html>几个样式上的取舍值得说一下。aspect-ratio: 1 / 1保证了面板永远是正方形,配合grid-template-columns: repeat(n, 1fr),每一格自动就是正方形,不需要手动算行高——这是整个布局能这么短的关键。user-select: none和-webkit-tap-highlight-color: transparent是移动端的必备处理,不加的话长按会弹出文字选择,点击会有灰色高亮闪一下,体验很糟糕。touch-action: manipulation则关闭了双击缩放,避免连点两下页面被放大。
maximum-scale=1,user-scalable=no这一对参数有争议,无障碍上确实不友好,但在这个场景下它是必要的:训练本质上是高频快速点击,允许缩放就意味着误触会把页面搞乱,训练直接中断。
3.3 核心生成逻辑与洗牌校验
这是整个工具里最容易被写错的部分。很多人写随机排列就是一行arr.sort(() => Math.random() - 0.5),看着能用,实际上有严重的分布问题。
sort的比较函数要求是稳定的、自洽的,而Math.random()每次返回不同的值,导致排序结果严重偏向某些排列。表现出来就是:生成的表格里数字经常出现"扎堆",或者某些数字总在角落。我早期版本就是这么写的,连续几组都感觉"这个表怎么这么顺",后来才反应过来是分布出了问题。
正确的做法是Fisher-Yates 洗牌,从后往前遍历,每次和前面随机一个位置交换:
function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }这个算法保证每种排列出现的概率完全相等,时间复杂度 O(n),对于 49 个元素的数组来说开销可以忽略。
洗牌之后还有一道校验:相邻数字不能落在相邻格子里。如果 7 和 8 正好上下挨着,人扫到 7 的时候余光已经看到了 8,这一步就白给了,训练价值打折。这个校验很简单,遍历一遍检查上下左右四个方向有没有差值为 1 的邻居:
function hasNeighborSequence(grid, k) { for (let r = 0; r < k; r++) { for (let c = 0; c < k; c++) { const v = grid[r * k + c]; if (c + 1 < k && Math.abs(v - grid[r * k + c + 1]) === 1) return true; if (r + 1 < k && Math.abs(v - grid[(r + 1) * k + c]) === 1) return true; } } return false; } function buildGrid(k) { const base = []; for (let i = 1; i <= k * k; i++) base.push(i); let grid = shuffle(base.slice()); let guard = 0; // 最多重试 200 次,避免极小尺寸下死循环 while (hasNeighborSequence(grid, k) && guard < 200) { grid = shuffle(base.slice()); guard++; } return grid; }这里的guard计数器是必须的。3×3 只有 9 个格子,想完全避开相邻连续数字其实相当困难,某些初始排列可能怎么洗都过不了校验。设一个上限,超了就接受当前结果,比卡死强得多。实测下来,5×5 平均重试 1 到 3 次就能通过,7×7 基本一次过,性能上完全没问题。
3.4 交互、计时与成绩存储
渲染方式我用的是事件委托,而不是给每个格子单独绑事件。原因有两个:一是 49 个格子绑 49 个监听器,虽然有事件池优化不至于卡,但没必要;二是不需要在重新生成表格时清理旧监听器,减少了一类内存泄漏的可能。
(function () { const boardEl = document.getElementById('board'); const timeEl = document.getElementById('time'); const metaEl = document.getElementById('meta'); const hintEl = document.getElementById('hint'); const sizeEl = document.getElementById('size'); const startBtn = document.getElementById('start'); const clearBtn = document.getElementById('clear'); const STORE_KEY = 'schulte-history-v1'; let n = 5, total = 25, target = 1; let started = false, finished = false; let t0 = 0, rafId = 0, errors = 0; // —— 洗牌与校验(同上,此处省略)—— function shuffle(a) { /* ... */ } function hasNeighborSequence(g, k) { /* ... */ } function buildGrid(k) { /* ... */ } function render() { boardEl.style.gridTemplateColumns = `repeat(${n}, 1fr)`; const frag = document.createDocumentFragment(); const grid = buildGrid(n); grid.forEach(v => { const d = document.createElement('div'); d.className = 'cell'; d.dataset.v = v; d.textContent = v; frag.appendChild(d); }); boardEl.innerHTML = ''; boardEl.appendChild(frag); fitFont(); } function fitFont() { const gap = 6; const w = boardEl.clientWidth; if (!w) return; const cellW = (w - gap * (n - 1)) / n; const fs = Math.max(13, Math.round(cellW * 0.42)); boardEl.querySelectorAll('.cell') .forEach(el => { el.style.fontSize = fs + 'px'; }); } function updateMeta() { metaEl.textContent = `目标 ${Math.min(target, total)} / ${total} 错误 ${errors}`; } function tick() { if (!started || finished) return; timeEl.textContent = ((performance.now() - t0) / 1000).toFixed(2); rafId = requestAnimationFrame(tick); } function startTimer() { started = true; t0 = performance.now(); rafId = requestAnimationFrame(tick); } function finish() { finished = true; cancelAnimationFrame(rafId); const sec = (performance.now() - t0) / 1000; timeEl.textContent = sec.toFixed(2); saveRecord({ n, sec: +sec.toFixed(2), errors, at: Date.now() }); renderHistory(); } boardEl.addEventListener('pointerdown', e => { const cell = e.target.closest('.cell'); if (!cell || finished || cell.classList.contains('done')) return; if (!started) startTimer(); const v = Number(cell.dataset.v); if (v === target) { cell.classList.add('done'); target++; updateMeta(); if (target > total) finish(); } else { errors++; cell.classList.add('wrong'); setTimeout(() => cell.classList.remove('wrong'), 160); updateMeta(); } }); function loadHistory() { try { return JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); } catch (err) { return []; } } function saveRecord(rec) { try { const list = loadHistory(); list.push(rec); localStorage.setItem(STORE_KEY, JSON.stringify(list.slice(-200))); } catch (err) { /* 隐私模式下静默失败 */ } } function renderHistory() { const same = loadHistory().filter(r => r.n === n); if (!same.length) { hintEl.textContent = '视线盯住中间的红点,用余光找数字,按顺序点击。'; return; } const best = Math.min(...same.map(r => r.sec)); const last3 = same.slice(-3); const avg = last3.reduce((s, r) => s + r.sec, 0) / last3.length; hintEl.textContent = `${n}×${n} 已完成 ${same.length} 组 | 最好 ${best.toFixed(2)} 秒 | 近三组均值 ${avg.toFixed(2)} 秒`; } function newRound() { cancelAnimationFrame(rafId); started = false; finished = false; errors = 0; target = 1; total = n * n; timeEl.textContent = '00.00'; render(); updateMeta(); renderHistory(); } startBtn.addEventListener('click', () => { n = Number(sizeEl.value); newRound(); }); clearBtn.addEventListener('click', () => { try { localStorage.removeItem(STORE_KEY); } catch (err) {} renderHistory(); }); window.addEventListener('resize', fitFont); n = Number(sizeEl.value); newRound(); })();几个关键决策解释一下。
为什么计时从第一次点击开始,而不是从渲染完成开始。如果从渲染开始计时,那"我盯着表想了两秒才动手"这段时间也会被算进去,成绩就变成了反应时而不是搜索能力。从第一次有效点击起算,把准备阶段排除在外,数据更能反映实际表现。代价是:如果你点了第一个数字之后中途走神,惩罚会全额落在成绩上——但这本来就是应该的。
为什么用pointerdown而不是click。click在触屏上要等手指抬起,某些场景下还会有延迟,高频点击时体感很闷。pointerdown在按下的瞬间就响应,跟手得多。代价是按住不放不会重复触发(其实这正好是我们想要的),但如果用户按下后滑动,理论上会在原地触发一次。实际使用中面板不大,滑动误触的概率很低,这个取舍是值得的。
为什么已完成的格子要拦截。cell.classList.contains('done')这个判断很重要。不加的话,你点完 7 之后手抖又点了一下 7,会立刻被记一次错误,非常冤枉。拦截掉之后,重复点击变成无效操作,体验顺畅很多。
为什么用requestAnimationFrame而不是setInterval。setInterval在页面切到后台时会被节流甚至暂停,而requestAnimationFrame跟屏幕刷新同步,计时显示更平滑。虽然真正的计时基准是performance.now()的差值,跟刷新率无关,但显示的流畅度对训练中的心理节奏是有影响的。
3.5 变体扩展示例
上面这套骨架改几个地方就能变成变体。核心思路是把"生成数字数组"这一步替换掉,其余逻辑不用动。
字母版最简单:把for (let i = 1; i <= k*k; i++) base.push(i)换成String.fromCharCode(64 + i),然后在比较时统一转成字符串即可。注意dataset存的是字符串,比较时要保证类型一致。
数字字母交替版需要一个映射表:位置 0 是 1,位置 1 是 A,位置 2 是 2……生成时按索引取对应值,同时把序列数组单独存一份,点击时用sequence[target - 1]做比较。
双色版稍微复杂一点,需要在格子上加一个>