简介:这是一份基于JavaScript与VexFlow库编写的钢琴视奏练习工具源码,面向音乐学习者、钢琴爱好者以及希望了解浏览器端乐谱渲染的开发者。项目利用VexFlow绘制五线谱、音符与节奏,通过交互界面让用户无需实体钢琴即可进行基础视奏训练,是一份将音乐教学与前端技术结合的完整示例。压缩包共23个文件,以17个JavaScript文件为主体,涵盖音阶生成、音型组合、乐谱绘制、交互控制与工具函数等模块;另有3个HTML页面作为入口、1个Markdown说明文档及wav和mp3音频文件各1个,整体大小约293KB,目录结构清晰,便于按模块学习,目前已有270人学习下载。通过阅读源码,可掌握VexFlow常用API的调用方式、视奏功能的设计思路,以及从音符渲染到交互反馈的完整调用链,并直接获得一套可运行的钢琴视奏页面。项目对音阶生成、音型组合、交互事件绑定等模块做了清晰拆分,适合在此基础上扩展自定义练习曲库,或嵌入其他音乐教学场景。
1. sightreading-vexflow:为什么我用 JavaScript 的 VexFlow 库搭了一个钢琴视奏练习器
练视奏最劝退的不是认谱慢,而是找不到「刚好比自己当前水平难一点」的题。固定曲目练几遍就成了背谱,随机出题如果谱面渲染跟不上,练习体验直接清零。我最终选定的方案是用 JavaScript 的 VexFlow 库在浏览器里直接渲染钢琴谱:VexFlow 负责把音符对象画成标准五线谱,开发者只需要关心出题逻辑、琴键对应和进度统计。sightreading-vexflow 这种项目形态的好处在于,出题逻辑可以随时改,谱面实时刷新,练完一题点一下就是新题,适合想长期迭代练习工具的人。后面几章按「渲染骨架 → 出题引擎 → 交互反馈 → 排错」的顺序展开,每一步都给能直接跑起来的代码。
2. VexFlow 渲染骨架:从 Renderer 到钢琴大谱表的最小可运行代码
2.1 渲染模型:Renderer、Stave、StaveNote 三个对象到底谁管谁
第一次打开 VexFlow 文档的人,很容易被对象层级绕晕。我一开始也把 Stave 理解为「一整行乐谱」,后来才发现它只是「一行五线谱」。真正完整的一行谱面,需要 Stave 负责画线、谱号、拍号,StaveNote 负责画单个音符,Voice 负责按拍号把音符组织成一个声部,Formatter 负责计算每个音符的横向位置。Renderer 是这些对象和页面之间的桥梁,它创建一个绘图后端并持有上下文。
选型上,SVG 后端更适合视奏工具。SVG 的每个音符都是真实 DOM 节点,可以绑事件、改样式,做点击高亮非常顺手;Canvas 后端性能好但不方便局部交互。VexFlow 的 SVG 渲染路径是Renderer.Backends.SVG,创建后拿到的 context 传给所有绘图对象,最后统一 draw。
对象之间的调用顺序也有讲究。必须先画 Stave,再画 Voice 里的音符,顺序反了音符会被谱线盖住或丢失层级。Formatter 必须在 draw 之前跑,它不直接画图,只负责算出每个音符的 x 坐标,让同一拍在不同声部间对齐。理解这一层之后,写渲染代码就不会瞎试。
近几个大版本的 VexFlow 对 SVG 类名有过调整,但 Renderer + Stave + StaveNote + Voice + Formatter 这条主线一直没变。如果你搜到的旧教程里还有VF.Renderer这种全局命名空间的写法,那是对应旧版本,模块化导入之后要写成import ... from 'vexflow'。这一章的代码基于模块化引入方式,ESM 环境下直接可用。
2.2 最小可运行代码:单手高音谱表渲染一条 4/4 拍旋律
只用 VexFlow 渲染出一行能看的谱子,其实只有五步。先跑通这段,再往上面加钢琴谱、随机出题和交互。
import { Renderer, Stave, StaveNote, Voice, Formatter } from 'vexflow'; // 1. 创建 SVG 渲染器,挂到页面里的 div 上 const container = document.getElementById('score'); const renderer = new Renderer(container, Renderer.Backends.SVG); renderer.resize(600, 220); const ctx = renderer.getContext(); // 2. 画高音谱表:x=10, y=50, 宽=580 // y 是谱表顶部线的 y 坐标,不是中心线 const stave = new Stave(10, 50, 580); stave.addClef('treble').addTimeSignature('4/4'); stave.setContext(ctx).draw(); // 3. 构造 4 个四分音符:C5 D5 E5 F5 const notes = [ new StaveNote({ keys: ['C/5'], duration: 'q' }), new StaveNote({ keys: ['D/5'], duration: 'q' }), new StaveNote({ keys: ['E/5'], duration: 'q' }), new StaveNote({ keys: ['F/5'], duration: 'q' }), ]; // 4. 包成 4/4 拍的一个声部 const voice = new Voice({ num_beats: 4, beat_value: 4 }); voice.addTickables(notes); // 5. 格式化并绘制 new Formatter().joinVoices([voice]).format([voice], 500); voice.draw(ctx, stave);先解释 keys 的格式:'C/5'是「音名/八度」,这是 VexFlow 内部统一的音高表达。八度编号从低到高,钢琴中央 C 对应C/4,往上一个八度是C/5。duration 的'q'是四分音符,'h'是二分,'8'是八分,'16'是十六分,后面可以接点号变成附点节奏,比如'qd'表示附点四分音符。
num_beats和beat_value是和拍号对应的,4/4 拍就是 4 和 4,3/4 拍就是 3 和 4。Formatter 的format([voice], 500)里,500 是可用宽度,VexFlow 会在不重叠的前提下把音符均匀分布在这个宽度内。
跑通这段之后,可以试着改 keys 里的音高和 duration 的值,把常见的「显示不对」提前碰一遍。我第一次改成两个八分加两个四分时,因为总时值超过 4 拍,Voice.draw 直接抛了异常。出现这种异常不一定是你的代码写错,更多时候是音符时值总和超出小节容量,VexFlow 在保护你,别生成时值对不上的谱子,改回合法时值就行。
2.3 双手大谱表:高音谱表与低音谱表纵向对齐的做法
钢琴视奏必须同时显示高音谱表和低音谱表。新手最容易犯的错误是左右手各建一个 Formatter 各自排版,结果上下行的音符 x 对不上。正确做法是让两个声部共享同一个 Formatter。
// 左右手两个谱表,画出后中间的高度差要足够容纳音符 const staveTreble = new Stave(10, 40, 600); staveTreble.addClef('treble').addTimeSignature('4/4'); staveTreble.setContext(ctx).draw(); const staveBass = new Stave(10, 130, 600); staveBass.addClef('bass').addTimeSignature('4/4'); staveBass.setContext(ctx).draw(); // 右手旋律,左手简单的根音加五度 const rightNotes = [ new StaveNote({ keys: ['C/5'], duration: 'h' }), new StaveNote({ keys: ['E/5'], duration: 'h' }), ]; const leftNotes = [ new StaveNote({ keys: ['C/3', 'G/3'], duration: 'h' }), new StaveNote({ keys: ['A/3', 'E/4'], duration: 'h' }), ]; const rightVoice = new Voice({ num_beats: 4, beat_value: 4 }); rightVoice.addTickables(rightNotes); const leftVoice = new Voice({ num_beats: 4, beat_value: 4 }); leftVoice.addTickables(leftNotes); // 关键:同一个 Formatter 同时格式化两个声部 const formatter = new Formatter(); formatter.joinVoices([rightVoice]).joinVoices([leftVoice]); formatter.format([rightVoice, leftVoice], 560); rightVoice.draw(ctx, staveTreble); leftVoice.draw(ctx, staveBass);注意leftNotes里我写了两个音名的数组,这在 VexFlow 里表示和弦。keys: ['C/3', 'G/3']会画出相隔五度的两个符头,共用同一个符干,时值由 duration 决定。左手伴奏通常就是把单音改成这种轻量和弦,出题丰富度会上来。
两个 Stave 的 y 差我一般给 90 到 110 像素。太小会把低音谱表的音符和右手谱表的下加线挤在一起;太大整屏只装得下两行。120 像素的高度差加谱表自身 40 像素高度,是一个视觉上舒服的起点。
真正钢琴谱还要在最左边画花括号连接两行。VexFlow 用 StaveConnector 实现,很多人会漏掉这一步:
import { StaveConnector } from 'vexflow'; const connector = new StaveConnector(staveTreble, staveBass); connector.setType(StaveConnector.type.BRACE); connector.setContext(ctx).draw();BRACE是钢琴谱的花括号,SINGLE_LEFT是单边直线,DOUBLE_LEFT是双竖线。视奏工具用BRACE就够了,画完以后整套钢琴谱的观感才完整。
如果你希望谱面宽度跟随容器变化,不要在创建 Renderer 后固定 resize,而应在窗口尺寸变化时先读取container.clientWidth,再重新 resize 并重新跑一遍 draw。这里要注意renderer.resize()会整个清空重绘,之前挂在上面的高亮框、事件标记都要同步清理,否则会出现幽灵元素叠加。
3. 随机出题逻辑:让 VexFlow 画出有练习价值的视奏谱
3.1 出题参数设计:调号、音域、节奏型怎么定才不劝退
随机出题不是「随机音符堆叠」。纯随机生成的旋律,音程跳跃大、无调性、节奏零散,练习价值很低。视奏训练的核心是「可预读性」,所以出题参数必须尊重调性和乐句感。
我把出题参数收敛成一张配置表,难度按梯度拆开:
| 难度 | 调号 | 右手音域 | 左手音域 | 节奏型 |
|---|---|---|---|---|
| L1 | C 大调 | C4-G4 | C3-G3 | 全音符、二分音符 |
| L2 | G 大调 | C4-G5 | C3-G4 | 四分音符 |
| L3 | F 大调 | C4-A5 | C3-E4 | 四分 + 八分混 |
| L4 | D 大调 | C4-C6 | C3-C5 | 附点、切分 |
这里的音域用的是 VexFlow 的 key 写法,比如 C4 就是中央 C。实际生成时要把音域映射成 MIDI 编号来运算,因为 MIDI 编号加减才是线性音高距离,字符串拼接没法做数学运算。
限制音域非常重要。新手阶段的右手旋律跨度不要超过一个八度,不然频繁换手位,挫败感直接劝退。左手伴奏我只给根音加五度的轻量和弦,避免左手视奏难度超过右手。节奏型从全音符开始,是因为视奏第一课是「看谱面能提前知道下一拍在哪」,不是认复杂节奏。
难度之间只改一个变量,是我后来坚持的原则。L1 升 L2 只把节奏型从二分改成四分,调号不变;L2 升 L3 加一个调号,节奏型维持四分。这样练习者每次进入新难度只有一个新挑战点,不会同时面对新调号、新音域和新节奏。
3.2 MIDI 编号到 VexFlow 音符对象:旋律生成与映射代码
出题引擎的核心就两个函数:生成 MIDI 编号序列,把 MIDI 编号转成 VexFlow 的 keys 字符串。下面的实现是「步进随机」,每次在上一个音高附近做小幅移动,让旋律有连贯性。
// MIDI 编号转 VexFlow keys function midiToVexKey(midi) { const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const pc = names[midi % 12]; const octave = Math.floor(midi / 12) - 1; return pc + '/' + octave; } // 步进旋律生成器:在音域内做小幅移动 function generateMelody(length, minMidi, maxMidi) { const melody = []; let current = Math.floor((minMidi + maxMidi) / 2); for (let i = 0; i < length; i++) { // 权重数组:向下2度,向下1度*2,向上1度*2,向上2度 const step = [-2, -1, -1, 1, 1, 2][Math.floor(Math.random() * 6)]; current = Math.max(minMidi, Math.min(maxMidi, current + step)); melody.push(current); } return melody; }先说midiToVexKey的八度算法。MIDI 编号 60 是中央 C,对应 VexFlow 的C/4。公式Math.floor(midi / 12) - 1把 60 变成 4,midi % 12得到 0 对应 C。这套换算我用了很多次,唯一要警惕的是:VexFlow 的键名里八度是阿拉伯数字,和 MIDI 标准记法差一个八度序号,很容易混。
步进权重数组[-2, -1, -1, 1, 1, 2]看起来随意,其实刻意为之。同向二度的权重比三度跳跃多,让旋律倾向级进,偶尔一个小跳跃,对应视奏入门曲的旋律特征。想让旋律更有起伏,可以改成[-4, -2, -1, 1, 1, 2, 3]配合自定义权重抽法,但那会引入「随机数非均匀分布」的复杂度,建议先保持简单。
把 MIDI 编号转成 VexFlow 音符时,还需要一个关键操作:对调号里的升降号统一处理。比如 G 大调要生成 F#,D 大调要生成 F# 和 C#。我在工程里用一个常量表来约束:
const keyPitchSets = { 'C': [], 'G': ['F#'], 'D': ['F#', 'C#'], 'F': ['Bb'], }; // 根据调号过滤随机生成结果,确保落在调内 function transposedMidi(midi, key) { const keyStr = midiToVexKey(midi); const pitch = keyStr.split('/')[0]; // 如果这个音名在调号中需要升/降,则调整半音 if (keyPitchSets[key].includes(pitch + '#')) { return midi + 1; } if (keyPitchSets[key].includes(pitch + 'b')) { return midi - 1; } return midi; }这样生成旋律后统一过一次transposedMidi,再调midiToVexKey,就不会出现「调号是 G 大调但谱面全是还原 F」的低级错误。左手根音同样按这个调号表选择,比如 C 大调配['C', 'F', 'G', 'C'],整个题的左手和声走向是清晰的 I-IV-V-I。
3.3 调号与临时记号:VexFlow 升降号的真实渲染规则
VexFlow 的调号处理是一个容易误解的点。在 Stave 上调用addKeySignature('G'),VexFlow 只会在谱号旁边画出升号符号,它并不会自动改变音符的渲染。音符里写F/5,画出来就是还原 F,写F#/5画出来就是升 F。调号和音符升降号是两套独立逻辑。
我一开始以为加了调号,音符里就不用再写升降号了,结果生成 G 大调练习时谱面全是还原 F,听起来完全不是 G 大调。后来改成「调号只决定生成音符时的音高集合」,做法就是上一节代码里的keyPitchSets查表,生成阶段直接产出正确的带升降号音名。
手写F#/5这种字符串没有问题,但要注意写完之后 VexFlow 会把它当作带临时记号的音符,即使调号里已有 F#,它仍会在符头旁边额外渲染一个升号。功能上没错,只是谱面会显得啰嗦。如果不想这样,可以生成时不加修饰符,只在必要的临时变音场景里用addModifier(new Accidental('#'))。
如果你确实需要还原号——比如 F 大调的调号只有一个降 B,但旋律里偶发要写 B 还原——VexFlow 支持在 keys 里写Bn/5,n就是 natural。调号是 F 大调时,写B/5表示还原 B,写Bb/5是降 B,写Bn/5也是还原 B。这三个状态容易混,我建议在出题逻辑里用常量枚举,避免字符串散落各处。
最后提一下临时变音的适用场景。当你的视奏题刻意包含临时变音来增加难度时,才需要给 StaveNote 调用addModifier(new Accidental('#'))。这种场景下,音符本身可以写F/5,再手动加升号修饰符,这样谱面既有临时升号,音高计算也清晰。不要把随机临时变音和调号混在一起,否则调试时你会分不清是生成逻辑错还是渲染逻辑错。
4. 交互与反馈闭环:音符高亮、琴键对应和练习记录
4.1 音符高亮:用 getBoundingBox 做命中判断,绕开 SVG 类名坑
视奏工具最核心的交互是「点击谱面某处,告诉用户这个音在哪个琴键」。实现高亮前要先解决「怎么知道点到了哪个音符」。网上很多方案依赖 SVG 的 class 名去找音符元素,比如.vf-stavenote,但这个类名在 VexFlow 版本之间改过,不同音符类型(普通音、附点音、和弦)对应 DOM 结构也不一样,很容易翻车。
更稳的做法是完全不碰 DOM 结构,用音符自己的getBoundingBox()做几何命中。每个 StaveNote 在 draw 之后都有包围盒,里面包含 x、y、宽、高。在容器上监听 click,把鼠标坐标换算成容器内坐标,再遍历音符判断点是否落在包围盒内。
function bindNoteHit(container, notes, onHit) { container.addEventListener('click', (e) => { const rect = container.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 从后往前找,保证最上层音符优先命中 for (let i = notes.length - 1; i >= 0; i--) { const box = notes[i].getBoundingBox(); if (!box) continue; if ( x >= box.getX() && x <= box.getX() + box.getW() && y >= box.getY() && y <= box.getY() + box.getH() ) { onHit(notes[i], i); return; } } }); }坐标换算只减容器边框偏移,不要做 devicePixelRatio 换算,因为 SVG 元素按 CSS 像素定位,VexFlow 的包围盒坐标也是逻辑坐标,处理多余反而导致点击偏移。从后往前遍历是因为后 draw 的音符层级更高,重叠时优先命中最新一个。
高亮我不建议重绘音符,直接往容器里塞一个绝对定位的高亮框更快,不破坏 VexFlow 渲染状态:
function highlightBox(container, note, className = 'note-glow') { const box = note.getBoundingBox(); const div = document.createElement('div'); div.className = className; div.style.left = (box.getX() - 3) + 'px'; div.style.top = (box.getY() - 3) + 'px'; div.style.width = (box.getW() + 6) + 'px'; div.style.height = (box.getH() + 6) + 'px'; container.appendChild(div); }CSS 里给.note-glow做半透明背景色和圆角即可。高亮框覆盖音符区域,符干、符尾、连音线都包在包围盒里,视觉上像整个音符被圈上。每次点击前先移除旧的.note-glow元素,再画新的。
提示:
getBoundingBox()必须在voice.draw()之后调用,否则音符还没有坐标,返回的结果不具备参考价值。
4.2 音高识别:VexFlow 键名与 MIDI 编号互转
点击到了音符只是第一步,练习者还需要知道应该按钢琴上的哪个键。这里把 VexFlow 的 keys 转成 MIDI 编号,MIDI 编号可以直接驱动 Web Audio 播放和琴键高亮。
const PITCH_TO_SEMITONE = { 'C': 0, 'C#': 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5, 'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11, }; function vexKeyToMidi(key) { // key 形如 'C#/5',拆成音名和八度 const match = key.match(/^([A-G][#b]?)\/(\d+)$/); if (!match) return null; const semitone = PITCH_TO_SEMITONE[match[1]]; const octave = parseInt(match[2], 10); // VexFlow 的 C/4 是中央 C = MIDI 60 return (octave + 1) * 12 + semitone; }这段代码有个容易踩的坑:VexFlow 的键名可能含有临时记号,比如Bb/4、Bn/4。如果正则里只允许[A-G],Bb会被错误匹配成B加一个不存在的中音。上面正则把#b合并进第一个捕获组,Bb/4能正确得到音高。Bn/4的n表示自然音,和B/4是同一音高,但显示状态不同。
和弦场景下,一个 StaveNote 含多个 keys,要遍历转成数组:
function staveNoteToMidiList(note) { return note.keys.map(key => vexKeyToMidi(key)); }拿到 MIDI 编号后,Web Audio 播放的常见做法是用OscillatorNode加GainNode。我不建议刚起步就引入高音质钢琴采样,先用内置的正弦波或三角波,能听清音高就够。等基础功能完整,再替换成采样音色,整体架构不会受影响。
4.3 节拍器和练习进度:跑一个不阻塞渲染的计时循环
视奏练习必须配节拍器,否则练习者会无限放慢。浏览器里做节拍器有个坑:setInterval在后台标签页会掉到 1Hz,而且和动画渲染争抢主线程。我一般用requestAnimationFrame跑计时循环,它跟随显示刷新频率,不可见标签页不会猛跑。
class BeatKeeper { constructor(bpm) { this.bpm = bpm; this.interval = 60000 / bpm / 4; // 一个四分音符内的细分间隔 this.beatIndex = 0; this.lastTime = 0; this.running = false; } start() { this.running = true; this.lastTime = performance.now(); this._loop(this.lastTime); } _loop(now) { if (!this.running) return; const elapsed = now - this.lastTime; if (elapsed >= this.interval) { this.beatIndex = (this.beatIndex + 1) % 4; this.onBeat && this.onBeat(this.beatIndex); this.lastTime = now - (elapsed % this.interval); } requestAnimationFrame((t) => this._loop(t)); } stop() { this.running = false; } }这个 BeatKeeper 把一拍细分成 4 份,onBeat回调每秒进入 4 次,适合做四分音符的视觉脉冲。onBeat里更新谱面上方的拍点指示器,并用AudioContext发出短促 click。要注意AudioContext必须在用户点击页面之后才能创建,被浏览器静音策略拦住是最常见的翻车原因,我第一次就是因为提前创建,按钮点了没声音。
进度记录用localStorage足够,不必引数据库。每次答完一题,把难度、正确率、耗时存成 JSON 数组:
function saveAttempt(level, correctCount, totalCount, durationMs) { const key = 'sightreading_attempts'; const list = JSON.parse(localStorage.getItem(key) || '[]'); list.push({ level, correctCount, totalCount, durationMs, timestamp: Date.now(), }); // 只保留最近 200 条,避免 localStorage 膨胀 if (list.length > 200) list.splice(0, list.length - 200); localStorage.setItem(key, JSON.stringify(list)); }splice是容易忽视的细节。localStorage 空间在 5MB 左右,日志类数据不做上限早晚写满。存 200 条足以画学习曲线。读取时注意getItem在键不存在时返回 null,要|| '[]'兜底。
进度展示也不要每笔都重绘整个图表。把读取结果缓存成内存变量,只在练习结束后刷新当前难度对应的那一条曲线。视奏工具不需要实时监控,练完一题再看统计,延迟感知很小。
5. 避坑:钢琴视奏工具开发中我踩过的 6 个常见问题
5.1 音符渲染成黑块或直接消失
现象:页面其他元素正常,但音符区域出现黑色矩形,或者什么都不画。 原因:最常见是 keys 格式写错。比如'C5'少了斜杠,VexFlow 解析不到音名和八度,就用占位黑块表示解析失败;或者 duration 传了'q'但 Voice 时值总和超过小节拍数,draw 抛异常,页面渲染停在半路。 解决:打开控制台看异常,然后把生成的 keys 数组打印出来,和'C/5'标准格式对比。我习惯在渲染前用正则校验:
function assertValidKeys(keys) { const valid = /^[A-G](?:#|b|n)?\/[0-9]$/; keys.forEach(key => { if (!valid.test(key)) throw new Error('非法键名: ' + key); }); }5.2 大谱表上下行小节对不齐
现象:右手高音谱表 4 个四分音符,左手低音谱表也是 4 个四分音符,但上下行同一拍的符头不在一条竖线上。 原因:用了两个独立的 Formatter 分别格式化左右手。Formatter 布局算法根据每个声部自己的音符分布计算 x,两次计算结果自然对不上。 解决:用同一个 Formatter 实例,先joinVoices([rightVoice]).joinVoices([leftVoice]),再format([rightVoice, leftVoice], width)。同时保证左右手每个小节的时值严格相等,一个声部多一个八分音符,对齐也会错位。
5.3 随机出题重复率高得离谱
现象:刷新几道题后旋律走向大同小异,甚至连续三题开头都是 C D E F。 原因:随机步进的权重数组里正负 1 度概率高,音域又限制在一个八度内,可变化空间小,纯随机很快撞车。 解决:加「最近 N 个音符黑名单」去重,或使用带种子的伪随机生成器。我推荐后者,因为能复现问题路径。和上一道题的旋律指纹(音高序列拼字符串)对比,相同就重新生成,最多重试三次。
5.4 临时记号显示错乱
现象:调号是 G 大调,F 有升号,但谱面上一处 F 是升号,另一处同样位置的 F 是还原。 原因:VexFlow 不会根据调号自动推断临时记号。F/5永远是还原 F,F#/5永远是升 F。生成逻辑里对 F 的处理分支不一致,就会造成同一个音两种状态。 解决:把调号对应音级集合做成常量表(keyPitchSets),统一走transposedMidi转换,不要散落 if 判断。同时用midiToVexKey推导键名,避免手写字符串不一致。
5.5 移动端触摸事件不触发
现象:桌面浏览器点击高亮正常,手机或平板上点音符没反应。 原因:移动端点击有延迟,且 SVG 容器上如果有其他透明覆盖层,click 事件根本到不了容器。 解决:改用pointerup事件替代click,去掉拦截事件的透明覆盖层,容器设touch-action: manipulation消除 300ms 延迟。统一封装一个onTap函数,桌面用 pointerdown,移动用 pointerup,共享同一套坐标换算。
5.6 刷新后练习记录全丢
现象:练了一周,打开页面记录全部消失。 原因:localStorage 只在同一浏览器同一域名下存活。用file://协议打开 HTML 时,部分浏览器会隔离或禁用 localStorage,保存失败却不报错。 解决:用try...catch包住读写,检测不可用时降级为内存存储,并提示用户记录不会被持久化。部署到 Web 服务器后用 HTTP 协议访问即正常。键名也要统一,我之前同时用过sight_progress和sightreading_progress两个键值,排查了半天才发现是读取错键。
6. 进阶技巧:局部重绘与多页预渲染,让翻题不再卡顿
视奏工具练到后期,用户翻题频率很高。每翻一次都重新创建 Renderer、重新渲染整个 SVG,页面会有肉眼可见的闪白。我后来的做法是给每道题维护独立的 SVG 容器,用 CSS 控制显隐,渲染一次后不再销毁。
function renderExercisePage(hostDiv, exercise) { const renderer = new Renderer(hostDiv, Renderer.Backends.SVG); // 在这里完成谱表、音符、格式化、draw // 渲染完以后不再碰这个 renderer } // 翻题时只切换 display,不重新渲染 function switchPage(fromIdx, toIdx) { pages[fromIdx].style.display = 'none'; pages[toIdx].style.display = 'block'; }这种多页预渲染的代价是内存占用,但一页谱面只有几十个音符,SVG 节点数量很小,预渲染 5 到 10 页完全没有压力。我实际测试过,预渲染 8 页再翻页,浏览器进程的 DOM 节点增量不到几百,切换是零延迟。
第二个技巧是局部修正小节宽度。当你只改动单个音符的时值,不必让整个 Formatter 重算一遍,可以拿到对应 tickContext 手动调整 x。这个 API 偏底层,适合做「给某个小节额外加宽」的修谱操作,日常出题不用频繁调用。
第三个是我自己坚持的习惯:把每次的渲染参数(难度、种子、调号)存进 URL query string。刷新后恢复到同一道题,也方便把某道难题发给别人复现。用URLSearchParams读写只要几行,收益却远比预期高。
我最后最受益的不是某个具体技巧,而是「出题和渲染彻底分层」的习惯。出题引擎只输出干净的 MIDI 数据,VexFlow 层永远是纯渲染器,加新功能时几乎不用回头改画谱代码。希望帮到你。
本文还有配套的精品资源,点击获取