Canvas 渲染与 K 线图开发面试
# Canvas 渲染与 K 线图开发面试
面向有 PDF 编辑器经验、准备交易平台前端岗位的读者。重点不是背绘图 API,而是讲清楚怎样画得清晰、交互不偏、实时更新不卡,以及什么时候应该用现成图表库。
Canvas 的通用原理见 图形基础,缓存、分层和局部重绘见 可视化性能优化。本篇集中讲 PDF 编辑器与 K 线图中的应用和面试追问。
# PDF 编辑器经验怎样迁移到 K 线图
能迁移的是 Canvas 的高清渲染、坐标转换、交互和性能优化经验,不是把 Fabric 搬去开发 K 线图。PDF 编辑器以对象编辑为主,K 线图以实时行情展示为主,解决相似的绘图问题并不代表使用同一个库。
| PDF 编辑器中的问题 | K 线图中的对应问题 |
|---|---|
| 高分屏、页面缩放后文字和批注仍清晰 | K 线、价格刻度和指标线在高分屏下不模糊 |
| 缩放页面后,仍能准确选中、拖动批注 | 缩放或拖动图表后,十字光标和趋势线仍定位准确 |
| 多页文档按需渲染、缓存已渲染页面 | 只画可见 K 线,向左拖动时分批加载历史数据 |
| 批注与页面坐标绑定 | 趋势线和订单标记与时间、价格绑定,不能固定在屏幕像素上 |
| WebSocket 同步批注 | 接收行情更新,同时处理断线、重复、乱序和切换品种后的旧消息 |
面试时可以说:我在 PDF 编辑器中积累的 Canvas 渲染与交互经验,可以用于理解 K 线图的高清、坐标和性能问题。但交易图表我会优先评估 TradingView 或 Lightweight Charts,趋势线和标注也先使用图表库自身的能力,再处理行情聚合、指标计算和历史数据衔接,不会因为熟悉 Fabric 就选择它做 K 线图。
# PDF 编辑器中的 Canvas 与 Fabric.js
- Canvas:浏览器提供的画布。你发出画线、文字、图片等指令,最后留下的是像素;浏览器不会自动记住每根线代表哪个业务对象。
- Fabric.js:在 Canvas 上维护可编辑对象,例如文本框、矩形和图片,并提供选中、拖拽、缩放、事件和序列化。它不是 PDF 解析器,也不是 PDF 文件写回引擎。
- PDF 编辑器的典型分工:PDF 引擎负责解析、渲染及文档修改,Canvas 显示页面,Fabric 管理可交互的编辑层;导出时要把编辑数据交回 PDF 引擎,不能只把画布截图当成可编辑 PDF。
Fabric 的对象编辑能力适合 PDF 批注、白板等场景,但它不提供完整的金融时间轴、价格轴、行情接入和指标体系。K 线图优先选择专用图表库,画线和标注也不意味着需要额外接入 Fabric。Web3 与否主要影响行情和交易数据来源,不决定图表必须使用哪种绘图库。
# 高清渲染:显示尺寸和像素尺寸要分开
高清渲染的核心:画布在页面上不变大,但内部用更多像素绘制。例如显示区域仍是 600 × 300,DPR 为 2 时,画布内部使用 1200 × 600 个像素。否则浏览器只能把较少的像素拉伸到高分屏上,文字和线条就容易模糊。
DPR 是什么
Device Pixel Ratio(设备像素比)表示设备像素与 CSS 像素的比例。例如 DPR = 2 时,一个 CSS 像素在宽和高两个方向都对应两个设备像素。浏览器页面缩放、切换显示器时,DPR 也可能变化。
面试可以直接这样说:
我会把显示尺寸和画布内部的像素尺寸分开。先用 CSS 设置页面上显示多大,再读取 DPR,把 canvas 的 width、height 设置为显示宽高乘 DPR。比如显示 600 × 300,DPR 为 2,内部就设成 1200 × 600。然后用 setTransform 把绘图坐标放大 2 倍,绘图代码仍按 600 × 300 来写,不需要每个坐标都手动乘 2。容器尺寸或 DPR 变化时,重新设置并绘制。
增加 width、height 是为了提供足够的像素;setTransform 是为了让图形保持原来的显示大小和位置。例如 fillText(..., 16, 32) 仍显示在距画布左上角 16、32 CSS 像素的位置,只是内部使用了更密的像素绘制。业务坐标和鼠标位置不需要再乘一次 DPR。
下面的例子演示怎样让 Canvas 上的文字和线条在高分屏下清晰显示,不是完整 K 线图。保存为 HTML 后用浏览器打开,会看到深色背景、白色文字和一条绿色斜线;画布宽度随容器变化,最大为 600,显示高度为 300。
<canvas id="chart" style="display:block;width:100%;max-width:600px;height:300px"></canvas>
<script>
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function draw() {
// 1. 读取 CSS 显示尺寸,后面的绘图坐标也使用这套尺寸。
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (!width || !height) return; // 隐藏或尚无布局尺寸时不绘制。
const dpr = window.devicePixelRatio || 1;
// 2. 增加内部像素:显示 600 × 300、DPR = 2 时,内部为 1200 × 600。
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
// 3. 把绘图坐标映射到内部像素,图形在页面上的大小和位置不变。
// width/height 会清空画布并重置状态,所以要重新设置变换和字体。
// setTransform 覆盖旧变换,不会像反复 scale() 那样累积倍数。
ctx.setTransform(canvas.width / width, 0, 0, canvas.height / height, 0, 0);
ctx.fillStyle = '#162338';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#fff';
ctx.font = '16px sans-serif';
ctx.fillText('高清 Canvas:文字与线条', 16, 32);
ctx.strokeStyle = '#26a69a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(16, 150);
ctx.lineTo(width - 16, 70);
ctx.stroke();
}
new ResizeObserver(draw).observe(canvas);
window.addEventListener('resize', draw);
// DPR 变化不一定伴随元素尺寸变化;重新监听新的 DPR。
function watchDpr() {
const query = matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
query.addEventListener('change', () => { draw(); watchDpr(); }, { once: true });
}
watchDpr();
draw();
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
代码中的 fillRect、fillText 和 stroke 分别画背景、文字和线条,高清处理真正对应的是 width、height 和 setTransform 这三行。ResizeObserver 与 DPR 监听负责在尺寸变化后重画。
在 DPR 大于 1 的屏幕上,可以临时把 dpr 改成 1 对比文字和斜线的清晰度:显示大小相同,但内部像素变少了。在 DPR = 1 的屏幕上,两种设置没有区别。
项目中还要注意三点:
- Fabric 提供
enableRetinaScaling、setDimensions()等能力,由它管理画布分辨率;业务缩放使用setZoom()或视口变换,DPR 与业务缩放是两回事。 - 细线要按最终设备像素对齐;不能不管 DPR 和线宽就统一加
0.5。关闭图片平滑也不是修复文字模糊的通用办法。 - DPR 翻倍,像素数会变成四倍。长 PDF 不应给所有页面同时建超大画布;高倍率编辑可按需重绘,导出分辨率单独设置。
# 坐标转换:为什么缩放后会点不准
鼠标给的是浏览器视口坐标,业务保存的却是文档坐标或时间、价格。绘制时正向转换,点击时反向转换,二者必须用同一套变换。
例如,文档横坐标 100,放大 2 倍再向右平移 30,屏幕上的横坐标就是 230;点击 230 时,反算 (230 − 30) / 2 = 100,才能命中原来的对象。
- 先用
clientX/clientY减去画布getBoundingClientRect()的左上角,得到画布内的位置;如果 CSS 显示尺寸与逻辑尺寸不同,还要换算比例。 - 再对平移、缩放等视口变换求逆,得到业务坐标。这里已经使用逻辑坐标,不应再乘一次 DPR。
- Fabric 提供场景与视口坐标转换能力,优先使用库的接口;有旋转或嵌套变换时,不要只手写除以缩放倍数。
- K 线的趋势线端点保存
{ time, price };绘制时再转成屏幕坐标。这样拖动、缩放或加载历史数据后,线仍跟着同一时间和价格。
原生 Canvas 的命中检测也要自己做:十字光标可以按横坐标找最近的可见 K 线,折线或画线工具可以计算点到线段的距离;不需要给每根 K 线都模拟完整 DOM 事件。
# 离屏 Canvas:缓存和 Worker 不是一回事
离屏表示不直接放在页面上显示;是否在另一个线程运行,要看有没有配合 Web Worker。
| 方式 | 怎样使用 | 解决什么问题 | 边界 |
|---|---|---|---|
| 离屏缓存 | 在隐藏的画布或 OffscreenCanvas 中预画图形,再用 drawImage() 复制到可见画布 | 避免重复画不变的背景、图标或复杂形状 | 在主线程执行时,仍可能阻塞主线程;缓存也占内存 |
| Worker 中绘制 | 把可见画布的控制权转给 Worker,在 Worker 里绘制 | 减少绘图计算对输入、滚动等交互的阻塞 | 需要消息通信;Worker 不能直接读 DOM,尺寸与交互要由主线程传入 |
最小 Worker 示例包含两个文件,需通过 HTTP 服务打开 HTML,而不是直接双击本地文件。下面只演示把画布交给 Worker,不代表把 Fabric 或 PDF 引擎整体搬过去。
<!-- index.html:主线程处理 DOM,并把画布控制权和尺寸交给 Worker。 -->
<canvas id="worker-chart" style="width:600px;height:240px"></canvas>
<script>
const canvas = document.getElementById('worker-chart');
if (typeof Worker !== 'undefined' && 'transferControlToOffscreen' in canvas) {
// 必须在 canvas.getContext() 之前转移,同一画布不能转移两次。
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('./draw-worker.js');
worker.postMessage({
canvas: offscreen, width: 600, height: 240,
dpr: window.devicePixelRatio || 1
}, [offscreen]); // 转移控制权,不是复制一份画布。
} else {
// 不支持时退回主线程绘制;完整项目在此复用同一绘图函数。
canvas.getContext('2d').fillText('当前环境使用主线程绘制', 10, 30);
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// draw-worker.js:尺寸来自主线程,Worker 内不访问 DOM。
self.onmessage = ({ data: { canvas, width, height, dpr } }) => {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#26a69a';
ctx.fillRect(30, 40, 20, 100);
};
2
3
4
5
6
7
8
9
生产实现还要传递 resize、DPR、视口和数据更新,并在离开页面时终止 Worker。Fabric 的交互画布依赖 DOM 和事件,不能直接假设整套 Fabric 可无改造地放进 Worker。更常见的边界是主线程保留交互,Worker 处理指标计算或独立的底图绘制。是否迁移要先确认性能瓶颈,消息复制和线程同步也有成本。
# K 线怎样从行情变成图形
一根 K 线记录一个时间区间的开、高、低、收与成交量,也就是 OHLCV:Open、High、Low、Close、Volume。例如一分钟内按时间先后成交 100、103、99、102,K 线就是开 100、高 103、低 99、收 102;成交量是这段时间成交数量的累计,不是价格之和。
- 实体从开盘价画到收盘价;影线从最低价画到最高价。收高于开与收低于开用不同颜色,红涨绿跌或绿涨红跌应由产品配置决定。
- 横轴把时间或交易序号映射到位置,纵轴把价格映射到高度;线性轴通常是价格越高,屏幕
y越小。 - 同一根未收盘 K 线持续更新,新周期开始才新增下一根。交易时间决定归属周期,不能用前端收到消息的时刻代替。
- 优先使用服务端提供的聚合 K 线;若前端用逐笔成交聚合,必须按成交 ID 去重、按事件时间处理开收盘,并处理迟到成交。不同消息是累计成交量还是单笔量,也要先分清。
- 品种、周期、行情来源和价格口径共同决定一组数据。最新成交价 K 线、标记价 K 线不能混在同一个缓存里;CFD 还要确认画的是 Bid、Ask 还是中间价。
历史加载和实时更新的正确衔接是:先缓冲订阅消息,再获取历史数据,按时间和版本合并,最后进入增量更新。发现断流或数据缺口时补历史区间;切换品种时取消旧请求、旧订阅,并忽略已过期回调。具体顺序要遵守行情服务的快照和增量协议。
行情状态要完整,绘制可以合并。例如一帧内收到 100 条成交,应先完整更新高低价和成交量,再只画最终状态;直接丢掉中间成交,可能把最高价和成交量算错。
# 高频更新怎样不卡
按这个顺序优化,比直接上 Worker 更容易讲清楚:
- 只处理该看的数据:保存历史数据,但只绘制可见区间;向左拖动时分页补历史,密集蜡烛按时间聚合,不能随意抽样丢掉高低价。
- 数据频率与绘制频率分开:行情每到一条就更新数据,用
requestAnimationFrame把同一帧的刷新合并;不要每个 Tick 都触发整页 Vue/React 更新。 - 按变化分层:网格和坐标背景、K 线与指标、十字光标与交互各自更新;移动光标通常不用重画全部蜡烛。坐标范围改变时,旧背景缓存和局部区域必须失效。
- 按依赖增量计算:只重算受影响的指标区间。未收盘 K 线改了,要基于上一根已收盘指标状态重新计算;EMA 不能把同一根 K 线的每次 Tick 都当成新周期。
- 先测再升级:记录帧耗时、主线程长任务、交互延迟和内存;确认指标计算或绘制阻塞后,再考虑 Worker、OffscreenCanvas,或更适合大规模绘图的 GPU 方案。
PDF 编辑器也适用按需渲染与缓存,但缩放和内容修改后要使缓存失效。纯像素缓冲区的基础内存约为 宽 × 高 × 4 字节;800 × 400 的画布在 DPR = 2 时约占 5.12 MB,多页、多图层和缓存会叠加,不能只看单次绘制快不快。
# K 线图技术选型
| 方案 | 适合什么需求 | 需要承担什么 |
|---|---|---|
| TradingView Widget (opens new window) | 快速嵌入 TradingView 提供的行情图表 | 受数据源、配置和嵌入方式约束,不等于可任意接入自己的交易行情 |
| TradingView Advanced Charts (opens new window) | 完整的金融图表、丰富画线和指标体验 | 接入自己的 Datafeed(行情适配接口),处理历史与实时数据,并按其授权条款使用 |
| Lightweight Charts (opens new window) | 轻量 K 线、成交量与自定义交易页面 | 自己补指标计算、业务标记和需要的交互;不是 TradingView 完整终端的免费替代品 |
| 原生 Canvas 自研图表 | 现有图表库确实无法满足的特殊绘图需求 | 自己处理坐标、命中、时间轴、刷新与兼容,维护成本更高 |
选型顺序是:先用图表库提供的 K 线、指标与画线能力,不够时再评估它的扩展接口,最后才考虑自定义绘制。Fabric 在这里是 PDF 项目的技术背景,不是推荐的 K 线图方案;额外叠加编辑层还要同步时间、价格坐标与交互事件,不能只因为它支持拖拽就引入。
# 高频面试题
1. 你的 PDF 编辑器经验对开发 K 线图有什么帮助?参考答案
能迁移的是 Canvas 高清渲染、坐标转换、图层和交互优化经验,不是 Fabric 这个库。PDF 编辑器侧重对象编辑,K 线图还需要时间轴、价格轴、指标和实时行情。我会优先评估 TradingView 或 Lightweight Charts,画线也先用图表库自身的能力,再用已有经验分析渲染和交互问题,同时补好历史与实时数据衔接。
2. Canvas 为什么模糊?怎样高清渲染?参考答案
高分屏下模糊,常见原因是画布内部像素不够。我会保持 CSS 显示尺寸不变,把 canvas 的 width、height 设为显示宽高乘 DPR,再用 setTransform 按相同比例放大绘图坐标。比如显示 600 × 300、DPR 为 2,内部用 1200 × 600 个像素,但绘图代码仍按 600 × 300 来写。这样图形不变大,只是用更多像素画得更清晰。尺寸或 DPR 变化时重新设置并重画;Fabric 已有 Retina 支持,不再叠加这套手动缩放。
3. 离屏 Canvas 一定能让主线程不卡吗?参考答案
不一定。离屏缓存只是先画好再复用,如果还在主线程执行,重计算仍会阻塞交互。把 OffscreenCanvas 交给 Worker 才能把绘制移到其他线程,但需要传入尺寸、数据和交互消息,也要考虑通信成本与兼容性。因此我先确认瓶颈:重复绘制用缓存,重计算或重绘阻塞再考虑 Worker,而不是看到离屏就认为天然多线程。
4. 缩放后点击偏移,怎样排查?参考答案
先检查鼠标坐标、CSS 显示尺寸、DPR 和业务坐标有没有混用。点击坐标先减画布偏移,再做视口变换的逆运算,绘制则走相反方向。Fabric 对象优先使用库的坐标转换;K 线标记保存时间和价格,再映射成屏幕位置。只保存像素位置,或者在事件里重复乘 DPR,都会在缩放或拖动后产生偏移。
5. 每秒几百条行情,怎样保证 K 线既正确又不卡?参考答案
我会把数据更新与绘制更新分开:完整合并成交,保证高低价、成交量和最后价格正确,再用 requestAnimationFrame 一帧只刷新一次。只画可见 K 线,指标按受影响区间计算,光标使用独立交互层。不能为了节流直接丢掉中间成交,否则最高价和成交量可能不对;后台标签页恢复时,还要补齐数据,而不是假设动画帧一直在执行。
6. 实时 K 线重复了,或者切换品种后出现旧行情,怎么办?参考答案
同一品种、周期、价格源和起始时间对应同一根 K 线,未收盘时更新它,不是每条消息都新增。切换品种时取消旧订阅和请求,再用订阅标识过滤迟到回调;断线重连后补齐缺失区间并去重。历史数据与实时流重叠时按服务协议合并,不能把两份累计成交量相加,也不能用本地接收时间重新分桶。
7. Canvas、SVG 和 WebGL 怎样选择?参考答案
少量图形、需要原生 DOM 交互和可访问性时,SVG 更方便;大量二维图形和频繁刷新时,Canvas 适合批量绘制,但命中和对象状态要自己管理;数据规模很大、适合 GPU 批量计算时再考虑 WebGL。交易产品通常优先评估成熟图表库。没有统一的对象数量分界线,最终看可见图形、更新频率和目标设备的实测结果。
8. Fabric 的撤销重做和多页内存怎样处理?参考答案
撤销重做保存可恢复的对象数据或操作,不保存画布像素;一次拖拽结束算一个操作,不能把每次鼠标移动都存成完整快照。撤销期间避免重复写历史,多人协作时还要区分自己的操作与远端更新。多页文档只保留可见页和少量邻近页的画布,淘汰缓存并释放不用的资源;对象数据仍保留,重新进入页面时可以重建。
9. 怎样证明优化有效,而不只是说用了缓存和 Worker?参考答案
用相同设备、数据量和操作场景比较优化前后:观察拖动与光标响应、帧耗时、主线程长任务、内存,以及行情刷新延迟。还要验证 DPR 变化后是否清晰,坐标是否准确,乱序与重连后数据是否一致。Worker 让主线程更空闲不代表总耗时一定更短;如果通信和复制成为新瓶颈,就要重新调整任务边界。
# 官方资料
- MDN:devicePixelRatio 与高清 Canvas (opens new window)
- MDN:transferControlToOffscreen 的用法与限制 (opens new window)
- Fabric.js Canvas API (opens new window)、Fabric.js 6 升级说明 (opens new window)
接着学习 看盘指标与聪明钱,理解图表上的线和区域代表什么,再回到 CFD 与永续合约业务面试指南 练习业务回答。