项目仓库:
Gitee:https://gitee.com/yyt363045841/klinechart
Github:https://github.com/363045841/klinechart
NPM:https://www.npmjs.com/package/@363045841yyt/klinechart
安装:npm i @363045841yyt/klinechart
项目持续更新中!欢迎提出宝贵建议!如果对您有帮助可以给个 Star!
本项目采用 Canvas 绘制 K 线图,行情数据通过 AKTools 获取。
布局
html
<template>
<div class="chart-container" ref="containerRef" @scroll.passive="scheduleRender">
<div class="scroll-content" :style="{ width: totalWidth + 'px' }">
<canvas class="chart-canvas" ref="canvasRef"></canvas>
</div>
</div>
</template>
窗口化渲染的关键在于,每次不重绘所有的 K 线,而是计算出当前可视范围内的所有 K 线,每次仅重绘这部分区域。这就带来一个问题,Canvas 绘制区域不像以往绘制整片区域一样,可以撑开 chart-container 的宽度,使之可以左右滚动。
因此替代方案是创建一个宽度为计算出的所有 K 线宽度的总和的一个 scroll-content div,用来撑开容器。Canvas 正常在窗口区域绘制区域 K 线数据,而不是每次绘制全部区域。
区域绘制
外层 chart-container 容器响应 scroll 事件,触发区域重绘节流函数 scheduleRender。
节流限制
js
let rafId: number | null = null
function scheduleRender() {
if (rafId !== null) cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
rafId = null
render()
})
}
由于用户一次滚动会触发多次滚动事件,不节流的话会产生大量重绘开销。节流函数会取消掉之前的排队渲染帧,因为已经过时,没必要再渲染。
requestAnimationFrame 调用后会返回一个 rafId,标记当前的渲染帧,这里渲染前置 null 是为了避免当前渲染帧被取消导致渲染不完全。
渲染计算

拿到外层 container 的 ref 引用,然后通过 getBoundingClientRect() 拿到视口宽高,进而动态设置 Canvas 宽高为视口区域,避免全部重绘。
接下来确定重绘哪些 K 线和对应的重绘坐标 :
基础数据:
| 符号 | 含义 | 对应代码 |
|---|---|---|
| W v i e w W_{view} Wview | 视口宽度(屏幕能看到的宽度) | viewWidth |
| S l e f t S_{left} Sleft | 当前滚动距离( scrollLeft ) | scrollLeft |
| W k W_k Wk | 单根 K 线宽度 | kWidth |
| W g W_g Wg | K 线间隙宽度 | kGap |
| N t o t a l N_{total} Ntotal | 数据总数量 | n |
| U U U | 单元宽度(1 根 K 线 + 1 个间隙) | unit |
- 通过 container 的 scrollLeft DOM 属性拿到水平移动距离
- 确定渲染 K 线范围:
单根 K 线宽度: U = W k + W g U = W_k + W_g U=Wk+Wg,那么渲染起始区域为
S t a r t = max ( 0 , ⌊ S l e f t U ⌋ − 1 ) Start = \max(0, \lfloor \frac{S_{left}}{U} \rfloor - 1) Start=max(0,⌊USleft⌋−1)
注意这里要 -1,为左侧区域提供缓冲区,避免边缘闪烁。
终止区域为:
E n d = min ( N t o t a l , ⌈ S l e f t + W v i e w U ⌉ + 1 ) End = \min(N_{total}, \lceil \frac{S_{left} + W_{view}}{U} \rceil + 1) End=min(Ntotal,⌈USleft+Wview⌉+1)
+1 同理。 - 移动 Canvas 坐标系
javascript
/* 重置变换并缩放 */
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, viewWidth, height) // 擦除上一帧
/* 保存状态,平移坐标系 */
ctx.save() // 压入状态栈
ctx.translate(-scrollLeft, 0)
/* 恢复状态 */
ctx.restore() // 将栈顶状态覆盖当前状态
首先,要理解 Canvas 绘图的底层机制:变换矩阵,每一次对 ctx 上下文的操作都是基于上一个变换结果的,为了确保每次都是从原来的坐标原点变换,每次做坐标变换之后都要重置回当前状态,否则会导致累积变换导致绘制错误。
将整个 Canvas 坐标系向左移动 scrollLeft 距离
( 0 , s c r o l l L e f t ) ← ( 0 , 0 ) (0,scrollLeft )\leftarrow(0,0) (0,scrollLeft)←(0,0)
此时 Canvas 坐标系原点就和滚动 DOM 左上角对齐,统一了 DOM 内部坐标和 Canvas 坐标系,实现了坐标系对齐 。
接下来调用封装好的绘制函数绘制 K 线和 MA 线:
javascript
/* 画K线 */
kLineDraw(ctx, kdata, opt, height, dpr, start, end, priceRange)
/* 画MA */
if (props.showMA.ma5) {
drawMA5Line(ctx, kdata, opt, height, dpr, start, end, priceRange)
}
if (props.showMA.ma10) {
drawMA10Line(ctx, kdata, opt, height, dpr, start, end, priceRange)
}
if (props.showMA.ma20) {
drawMA20Line(ctx, kdata, opt, height, dpr, start, end, priceRange)
}
Canvas 高清适配
核心在于联系 CSS 逻辑像素 和屏幕物理像素 。

先了解为什么不特殊处理会导致 Canvas 绘制模糊:
在右边 dpr 为 2 的屏幕中,屏幕实际上有 640 px,但是 Canvas 内部的逻辑像素只分配了 320 个像素,那么浏览器就必须将 320 px 拉伸到 640 px 上显示,此时边缘被线性插值来平滑过渡,导致边缘看起来模糊。
解决方法:
分配和屏幕像素相等的 CSS 逻辑像素,放大画布的同时放大坐标系:
javascript
canvas.width = Math.round(viewWidth * dpr)
canvas.height = Math.round(height * dpr)
ctx.scale(dpr, dpr) // x,y 轴的绘制宽度和高度都乘以 dpr
相当于抵消了放大 CSS 逻辑像素带来的影响,实际上就是 CSS 逻辑像素和屏幕像素的对齐 。
当然,如果现在要画 K 线影线,我们以屏幕像素为结果导向,要画一个 2 px 宽度的影线,但是如果画 2 px CSS 逻辑像素,就会画出实际 4 px 的影线,因此 CSS 绘制实际想要的屏幕像素,需要绘制除以 dpr 的宽度,即 2 d p r = 1 C S S p x \frac{2}{dpr}=1\; CSS \; px dpr2=1CSSpx 宽度的影线。