Skip to content

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     end

4.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 关键性能优化点

  1. RAF 合并机制

    • 多次 scheduleDraw() 只触发一次绘制
    • 避免高频交互时的性能抖动
  2. 视口裁剪

    typescript
    // 只绘制可见范围内的 K 线
    if (x < -kWidth || x > plotWidth) continue
  3. 分层 Canvas

    • plotCanvas: K 线、网格
    • yAxisCanvas: 价格轴
    • xAxisCanvas: 时间轴
    • 互不干扰,独立更新
  4. 坐标变换缓存

    • pane.yAxis.priceToY() 内部缓存价格范围
    • 避免重复计算

六、对比 TradingView 的差异

特性你的项目TradingView差异说明
无效化级别无,每次全绘Cursor/Light/Full缺少精细控制
画布分层3 层(全局)双层/Pane每个 Pane 双层更优
时间轴缩放散落在多处TimeScale 类缺少统一抽象
Model-ViewPane 直接持有 RendererPaneView 抽象层耦合度较高
动画系统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 类似,但在精细化渲染控制方面还有提升空间。