4. 盘口核心组件设计
盘口回放页面里,最“重”的组件不是K线,而是买卖五档/十档报价区。它看起来只是两列数字,但回放场景下,这个区域每帧都可能变化:价格跳动、挂单量增减、档位颜色切换、量柱伸缩。如果DOM结构设计得随意,回放一加速,页面就会明显掉帧。本节围绕三件事展开:报价区的DOM骨架怎么搭、价格与量柱如何做视觉编码、动态更新时怎样只改该改的地方。
一、报价区的DOM结构:先定骨架,再谈样式
买卖十档报价在语义上是一个“档位—方向—价格—数量”的二维结构。推荐用“容器 + 方向分组 + 档位行”的三层结构,而不是把二十行平铺成兄弟节点。方向分组的好处是:卖盘(Ask)和买盘(Bid)可以各自独立做布局、独立做局部更新,也方便在中间插入“最新价/涨跌”分隔条。
<div class="orderbook" data-symbol="600519">
<div class="ob-side ob-ask"> <!-- 卖盘:十档,价格由高到低 -->
<div class="ob-row" data-level="10">
<span class="ob-label">卖十</span>
<span class="ob-price">1720.00</span>
<span class="ob-vol"><i class="ob-bar"></i><em>326</em></span>
</div>
<!-- ... 卖九至卖一 ... -->
</div>
<div class="ob-last">最新 1712.50 +0.86%</div>
<div class="ob-side ob-bid"> <!-- 买盘:十档,价格由高到低 -->
<div class="ob-row" data-level="1">
<span class="ob-label">买一</span>
<span class="ob-price">1712.40</span>
<span class="ob-vol"><i class="ob-bar"></i><em>512</em></span>
</div>
<!-- ... 买二至买十 ... -->
</div>
</div>
几个关键约定。第一,data-level 固定写在行上,回放时按档位定位行,而不是按“第几个子节点”定位,避免增删档位导致索引错位。第二,价格和数量分别用独立的 span 承载,量柱用行内的 i.ob-bar 做背景层,数字用 em 叠在上层,这样量柱宽度变化不会引起文字重排。第三,卖盘价格从高到低、买盘价格从高到低,是行情软件的通行排布,回放数据源若按升序给出,需要在渲染层统一翻转,并在文档里写清楚这个约定,避免后续对不上。
二、价格与量柱的视觉编码
价格编码主要靠颜色和方向。以“相对昨收”为参照:高于昨收用暖红,低于昨收用冷绿,等于昨收用中性灰。这里要明确参照系——颜色表达的是“相对昨收的涨跌”,不是“相对上一帧的变化”,两者在回放中会打架,必须二选一并在全组件统一。若还需要表达“本帧价格相对上一帧是升是降”,建议用价格数字的一次性闪烁或箭头,而不是再改一次底色,否则颜色语义会过载。
量柱编码的是“该档挂单量在可见档位中的相对大小”。常见做法是取当前十档的最大挂单量作为基准,量柱宽度 = 本档量 / 最大量 × 100%。但直接用单帧最大值会让量柱在回放中频繁“整体缩放”,视觉上很跳。更稳的做法是维护一个滑动窗口基准,例如取最近若干帧十档最大量的较高分位数作为分母,并设一个下限,避免极小的量被放大成满格。示例参数:窗口取最近 60 帧,分母 = max(窗口内十档最大量的 90 分位, 100 手),量柱宽度限制在 2%~100%。这些数值属于教学示例的合理假设,实际应按标的流动性调整。
上图把两件事放在一起看:左边是DOM三层骨架,右边是同一份数据在量柱上的呈现。注意卖一量326手、买一量512手,买一量柱明显更长,但两者用的是同一个分母,所以横向可比;如果每行各自归一化,买一和卖一都会变成满格,反而丢失了“哪边挂单更厚”的信息。
三、动态更新:局部渲染与性能考量
回放的数据通常按“帧”推进,每帧可能只有一两档变化。最忌讳的做法是每帧用 innerHTML 重建整个报价区——这会让浏览器丢弃并重建二十行节点,触发大量样式计算和布局,档位越多越明显。正确思路是“建一次、改多次”:初始化时一次性创建二十行并缓存行引用,之后每帧只更新发生变化的行的文本和量柱宽度。
// 初始化:缓存行引用,避免每帧查询 DOM
const rows = new Map();
document.querySelectorAll('.ob-row').forEach(row => {
rows.set(`${row.closest('.ob-side').classList.contains('ob-ask') ? 'ask' : 'bid'}-${row.dataset.level}`, {
price: row.querySelector('.ob-price'),
bar: row.querySelector('.ob-bar'),
vol: row.querySelector('.ob-vol em')
});
});
// 每帧:只改变化项
function applyFrame(frame, denom) {
for (const item of frame.changes) { // 仅含变化档位
const ref = rows.get(`${item.side}-${item.level}`);
if (!ref) continue;
if (ref.price.textContent !== item.price) ref.price.textContent = item.price;
ref.vol.textContent = item.volume;
const w = Math.min(100, Math.max(2, item.volume / denom * 100));
ref.bar.style.width = w.toFixed(1) + '%';
}
}
性能上还有几个容易忽略的点。其一,量柱宽度用 style.width 改百分比,属于布局属性,但因为它在一个固定高度的行内、且不改变文字位置,代价可控;若追求更平滑,可改用 transform: scaleX() 配合固定宽度基准,把布局改为合成层处理。其二,价格文本变化会触发重绘,若同一帧内多档变化,可先把更新收集到数组,再在一个 requestAnimationFrame 回调里统一写入,避免一帧内多次强制同步布局。其三,回放倍速较高时,应对更新做节流:例如 4 倍速下每 50ms 合并一次档位变化,只渲染最终状态,中间态丢弃——盘口回放看的是“结果快照”,不是逐笔动画,合并不会损失有效信息。
最后提醒一个数据一致性细节:价格和数量必须来自同一帧快照。若价格先更新、数量后到,会出现“新价格配旧量柱”的瞬时错位,回放中尤其明显。工程上应让每帧数据携带帧序号,渲染层只接受序号递增的完整帧,丢弃乱序或残缺帧,这样DOM更新逻辑才能保持简单可靠。