K 线图项目完整渲染流程详解
一、渲染触发入口
1.1 初始化触发(组件挂载)
typescript
// KLineChart.vue - onMounted
onMounted(() => {
// 1. 创建 Chart 实例
const chart = new Chart({ container, canvasLayer, xAxisCanvas }, options)
// 2. 设置渲染器链
chart.setPaneRenderers('main', [GridLinesRenderer, CandleRenderer, ...])
// 3. 更新数据
chart.updateData(props.data)
// 4. 初始布局和绘制
chart.resize()
})关键路径:
onMounted
→ new Chart() (初始化 DOM 和 Pane)
→ chart.updateData() (更新数据源)
→ chart.resize() (计算布局 + 调度绘制)
→ chart.scheduleDraw() (RAF 请求)
→ requestAnimationFrame(chart.draw)1.2 交互触发(鼠标/触摸事件)
typescript
// KLineChart.vue - 事件转发
onMouseMove(e: MouseEvent) {
chartRef.value?.interaction.onMouseMove(e) // 转发到交互控制器
syncHoverState() // 同步状态到 Vue 响应式
}
onScroll() {
chartRef.value?.interaction.onScroll()
syncHoverState()
}
onWheel(e: WheelEvent) {
chartRef.value?.interaction.onWheel(e) // 触发缩放
syncHoverState()
}关键路径:
用户操作
→ Vue 事件监听器
→ InteractionController.onMouseMove/onScroll/onWheel
→ chart.scheduleDraw()
→ requestAnimationFrame(chart.draw)1.3 数据变更触发
typescript
// KLineChart.vue - watch 监听
watch(
() => [props.data, props.yPaddingPx, props.showMA],
async () => {
chartRef.value?.updateOptions({ yPaddingPx: props.yPaddingPx })
chartRef.value?.updateData(props.data) // 数据更新
if (props.autoScrollToRight) {
await nextTick()
scrollToRight() // 自动滚动到最新
} else {
scheduleRender()
}
}
)关键路径:
props.data 变化
→ watch 触发
→ chart.updateData()
→ chart.scheduleDraw()二、调度机制
2.1 RAF 合并机制
typescript
// src/core/chart.ts
class Chart {
private raf: number | null = null
scheduleDraw() {
// 核心优化:多次调用只触发一次 RAF
if (this.raf != null) cancelAnimationFrame(this.raf)
this.raf = requestAnimationFrame(() => {
this.raf = null
this.draw() // 执行绘制
})
}
}原理说明:
- 每次
scheduleDraw()先取消之前的 RAF(如果存在) - 然后重新请求 RAF
- 这样在连续多次调用(如快速拖拽)时,浏览器只会在下一帧执行一次绘制
- 避免了频繁重绘导致的性能问题
应用场景:
typescript
// 快速拖拽时会产生大量 scheduleDraw 调用
onMouseMove() → scheduleDraw() // 第1次
onMouseMove() → scheduleDraw() // 第2次(取消第1次)
onMouseMove() → scheduleDraw() // 第3次(取消第2次)
// ...
// 最终只在下一帧执行一次 draw()三、完整绘制流程
3.1 Chart.draw () 主流程
typescript
// src/core/chart.ts - draw() 方法
draw() {
// === 阶段1:计算视口信息 ===
const vp = this.computeViewport()
if (!vp) return
// 设置 xAxis 上下文和 DPR
const xAxisCtx = this.dom.xAxisCanvas.getContext('2d')
xAxisCtx.setTransform(1, 0, 0, 1, 0, 0)
xAxisCtx.scale(vp.dpr, vp.dpr)
xAxisCtx.clearRect(0, 0, vp.plotWidth, this.opt.bottomAxisHeight)
// === 阶段2:计算可视数据范围 ===
const { start, end } = getVisibleRange(
vp.scrollLeft,
vp.plotWidth,
this.opt.kWidth,
this.opt.kGap,
this.data.length
)
const range: VisibleRange = { start, end }
// === 阶段3:遍历绘制每个 Pane ===
for (const renderer of this.paneRenderers) {
renderer.draw({
data: this.data,
range,
scrollLeft: vp.scrollLeft,
kWidth: this.opt.kWidth,
kGap: this.opt.kGap,
dpr: vp.dpr,
crosshairPos: this.interaction.crosshairPos,
crosshairIndex: this.interaction.crosshairIndex,
title: renderer.getPane().id === 'sub' ? '副图(占位)' : undefined,
})
}
// === 阶段4:绘制全局时间轴 ===
drawTimeAxisLayer({
ctx: xAxisCtx,
data: this.data,
scrollLeft: vp.scrollLeft,
kWidth: this.opt.kWidth,
kGap: this.opt.kGap,
startIndex: range.start,
endIndex: range.end,
dpr: vp.dpr,
crosshair: this.interaction.crosshairPos ?
{ x: this.interaction.crosshairPos.x, index: this.interaction.crosshairIndex } : null,
})
// === 阶段5:绘制十字线(覆盖在所有 pane 上)===
if (this.interaction.crosshairPos) {
const { x, y } = this.interaction.crosshairPos
const activePaneId = this.interaction.activePaneId
for (const renderer of this.paneRenderers) {
const pane = renderer.getPane()
const plotCtx = renderer.getDom().plotCanvas.getContext('2d')
const isActive = pane.id === activePaneId
const localY = isActive ? y - pane.top : 0
drawCrosshair({
ctx: plotCtx,
plotWidth: vp.plotWidth,
plotHeight: pane.height,
dpr: vp.dpr,
x,
y: localY,
drawVertical: true, // 垂直线始终绘制
drawHorizontal: isActive, // 水平线只在活跃 pane 绘制
})
}
}
// === 阶段6:绘制 MA 图例(屏幕坐标)===
const mainRenderer = this.paneRenderers.find((r) => r.getPane().id === 'main')
if (mainRenderer) {
drawMALegend({ ... })
}
// === 阶段7:绘制全局边框 ===
drawAllPanesBorders({ ... })
}3.2 PaneRenderer.draw () 流程
typescript
// src/core/paneRenderer.ts
draw(args) {
const { data, range, scrollLeft, kWidth, kGap, dpr, crosshairPos, crosshairIndex, title } = args
// === 步骤1:更新 Pane 的价格范围 ===
this.pane.updateRange(data, range) // 计算 minPrice/maxPrice
// === 步骤2:清空 Canvas ===
plotCtx.setTransform(1, 0, 0, 1, 0, 0)
plotCtx.scale(dpr, dpr)
plotCtx.clearRect(0, 0, paneWidth, paneHeight)
yAxisCtx.setTransform(1, 0, 0, 1, 0, 0)
yAxisCtx.scale(dpr, dpr)
yAxisCtx.clearRect(0, 0, this.opt.rightAxisWidth, paneHeight)
// === 步骤3:执行渲染器链(插件化绘制)===
plotCtx.save()
plotCtx.beginPath()
plotCtx.rect(0, 0, paneWidth, paneHeight)
plotCtx.clip()
// 按顺序调用渲染器:网格 → K线 → MA
for (const r of this.pane.renderers) {
r.draw({
ctx: plotCtx,
pane: this.pane,
data,
range,
scrollLeft,
kWidth,
kGap,
dpr,
})
}
plotCtx.restore()
// === 步骤4:绘制 Y 轴刻度 ===
createYAxisRenderer({ ... }).draw({ ... })
// === 步骤5:绘制十字线价格标签 ===
if (crosshairPos && crosshairIndex !== null) {
drawCrosshairPriceLabelForPane({ ... })
}
// === 步骤6:绘制 Pane 标题 ===
if (title) {
drawPaneTitle({ ... })
}
}3.3 具体渲染器示例
网格线渲染器:
typescript
// src/core/renderers/gridLines.ts
export const GridLinesRenderer: PaneRenderer = {
draw(args) {
const { ctx, pane, data, range, scrollLeft, kWidth, kGap, dpr } = args
// 绘制水平线(价格网格)
const ticks = pane.yAxis.getTicks(5) // 获取5个价格刻度
ticks.forEach(tick => {
const y = pane.yAxis.priceToY(tick.price)
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(plotWidth, y)
ctx.strokeStyle = '#e0e0e0'
ctx.stroke()
})
// 绘制垂直线(时间网格)
for (let i = range.start; i <= range.end; i++) {
const x = kGap + i * (kWidth + kGap) - scrollLeft
if (x < -kWidth || x > plotWidth) continue // 视口裁剪
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, pane.height)
ctx.stroke()
}
}
}蜡烛图渲染器:
typescript
// src/core/renderers/candle.ts
export const CandleRenderer: PaneRenderer = {
draw(args) {
const { ctx, pane, data, range, scrollLeft, kWidth, kGap } = args
for (let i = range.start; i <= range.end; i++) {
const k = data[i]
if (!k) continue
// 计算位置
const x = kGap + i * (kWidth + kGap) - scrollLeft
if (x < -kWidth || x > plotWidth) continue
// 价格转 Y 坐标
const openY = pane.yAxis.priceToY(k.open)
const closeY = pane.yAxis.priceToY(k.close)
const highY = pane.yAxis.priceToY(k.high)
const lowY = pane.yAxis.priceToY(k.low)
// 颜色判断
const isUp = k.close >= k.open
ctx.fillStyle = isUp ? '#ef5350' : '#26a69a' // 涨红跌绿
// 绘制影线
ctx.beginPath()
ctx.moveTo(x + kWidth / 2, highY)
ctx.lineTo(x + kWidth / 2, lowY)
ctx.strokeStyle = ctx.fillStyle
ctx.stroke()
// 绘制实体
const bodyTop = Math.min(openY, closeY)
const bodyHeight = Math.abs(closeY - openY)
if (bodyHeight < 1) {
// 涨跌幅过小时至少绘制1px
ctx.fillRect(x, bodyTop, kWidth, 1)
} else {
ctx.fillRect(x, bodyTop, kWidth, bodyHeight)
}
}
}
}四、坐标变换系统
4.1 时间坐标变换
typescript
// 屏幕坐标 → 数据索引
function getVisibleRange(scrollLeft: number, plotWidth: number, kWidth: number, kGap: number, dataLength: number) {
const unit = kWidth + kGap
// 可见范围(包含左右边界各一根 K 线)
const start = Math.floor(scrollLeft / unit)
const end = Math.ceil((scrollLeft + plotWidth) / unit)
// 边界限制
return {
start: Math.max(0, start - 1),
end: Math.min(dataLength - 1, end + 1)
}
}
// 数据索引 → 屏幕坐标
const x = kGap + index * (kWidth + kGap) - scrollLeft
const centerX = x + kWidth / 2 // K 线中心点原理图:
scrollLeft
↓
|───────────────────────────────────────|
| [K0] [K1] [K2] [K3] [K4] [K5] [K6] |
↑ ↑ ↑
start currentIndex end4.2 价格坐标变换
typescript
// src/core/scale/priceScale.ts
class PriceScale {
priceToY(price: number): number {
const { maxPrice, minPrice } = this.range
const range = maxPrice - minPrice || 1
const ratio = (price - minPrice) / range
const viewHeight = this.height - this.paddingTop - this.paddingBottom
// 价格越高 Y 越小(Canvas Y 轴向下)
return this.paddingTop + viewHeight * (1 - ratio)
}
}原理图:
Y=0 (paddingTop)
↓
┌───────────────────┐
│ maxPrice │
│ │
│ price │ ← priceToY(price)
│ │
│ minPrice │
└───────────────────┘
↑
Y=height (paddingBottom)五、数据流转总结
5.1 完整数据流
┌─────────────────────────────────────────────────────────────┐
│ 触发层 │
│ 用户操作 / 数据变更 / 窗口 resize │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Vue 层 │
│ KLineChart.vue 事件监听器 → 交互状态同步 → scheduleRender() │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 调度层 (Chart) │
│ scheduleDraw() → RAF 合并 → draw() │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 计算层 (Viewport) │
│ computeViewport() → getVisibleRange() → 计算价格范围 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 渲染层 (PaneRenderer) │
│ 遍历 Pane → 清空 Canvas → 调用渲染器链 → 绘制轴 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 输出层 (Canvas 2D) │
│ ctx.fillRect() / ctx.stroke() / ctx.fillText() │
└─────────────────────────────────────────────────────────────┘5.2 关键性能优化点
RAF 合并机制
- 多次
scheduleDraw()只触发一次绘制 - 避免高频交互时的性能抖动
- 多次
视口裁剪
typescript// 只绘制可见范围内的 K 线 if (x < -kWidth || x > plotWidth) continue分层 Canvas
- plotCanvas: K 线、网格
- yAxisCanvas: 价格轴
- xAxisCanvas: 时间轴
- 互不干扰,独立更新
坐标变换缓存
pane.yAxis.priceToY()内部缓存价格范围- 避免重复计算
六、对比 TradingView 的差异
| 特性 | 你的项目 | TradingView | 差异说明 |
|---|---|---|---|
| 无效化级别 | 无,每次全绘 | Cursor/Light/Full | 缺少精细控制 |
| 画布分层 | 3 层(全局) | 双层/Pane | 每个 Pane 双层更优 |
| 时间轴缩放 | 散落在多处 | TimeScale 类 | 缺少统一抽象 |
| Model-View | Pane 直接持有 Renderer | PaneView 抽象层 | 耦合度较高 |
| 动画系统 | 无 | KineticAnimation | 缺少惯性滚动 |
核心优势:
- ✅ 架构清晰(Chart-Pane-Renderer)
- ✅ RAF 调度机制完善
- ✅ 插件化渲染器设计良好
- ✅ 坐标变换系统可用
主要短板:
- ❌ 无效化级别缺失(交互时全量重绘)
- ❌ 画布分层不够细(静态/动态未分离)
七、渲染流程时序图
用户操作
│
├─→ onMouseMove(e)
│ ↓
│ InteractionController.onMouseMove()
│ ↓
│ 更新 crosshairPos/crosshairIndex
│ ↓
│ chart.scheduleDraw()
│ ↓
├────────────────── requestAnimationFrame ─────────────┐
│ │
│ 等待下一帧
│ │
│ ↓
│ chart.draw ()
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 1. computeViewport () - 计算视口 │ │
│ ├─────────────────────────────────────────────┤ │
│ │ 2. getVisibleRange () - 计算数据范围 │ │
│ ├─────────────────────────────────────────────┤ │
│ │ 3. for each PaneRenderer: │ │
│ │ - pane.updateRange () - 更新价格范围 │ │
│ │ - 清空 Canvas │ │
│ │ - for each renderer in pane: │ │
│ │ - renderer.draw () - 绘制内容 │ │
│ │ - 绘制 Y 轴刻度 │ │
│ │ - 绘制十字线标签 │ │
│ ├─────────────────────────────────────────────┤ │
│ │ 4. drawTimeAxisLayer () - 绘制时间轴 │ │
│ ├─────────────────────────────────────────────┤ │
│ │ 5. drawCrosshair () - 绘制十字线 │ │
│ ├─────────────────────────────────────────────┤ │
│ │ 6. drawMALegend () - 绘制 MA 图例 │ │
│ ├─────────────────────────────────────────────┤ │
│ │ 7. drawAllPanesBorders () - 绘制边框 │ │
│ └─────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────┘
↓
浏览器呈现画面这就是你的 K 线图项目的完整渲染流程!整体架构设计合理,核心机制与 TradingView 类似,但在精细化渲染控制方面还有提升空间。