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 的 Advanced Charts (opens new window) 或 Lightweight Charts (opens new window),趋势线和标注也先使用图表库自身的能力,再处理行情聚合、指标计算和历史数据衔接。
# PDF 编辑器中的 Canvas 与 Fabric.js
Canvas:浏览器提供的画布。你发出画线、文字、图片等指令,最后留下的是像素;浏览器不会自动记住每根线代表哪个业务对象。
Fabric.js:在 Canvas 上维护可编辑对象,例如文本框、矩形和图片,并提供选中、拖拽、缩放、事件和序列化。它不是 PDF 解析器,也不是 PDF 文件写回引擎。
Fabric 的交互画布依赖 DOM 和鼠标等事件,不能像原生 OffscreenCanvas 示例那样直接整套搬进 Worker。需要优化时,可将独立的计算或底图绘制交给 Worker,选中、拖拽等交互仍留在主线程。
PDF 编辑器的典型分工:PDF 引擎负责解析、渲染及文档修改,Canvas 显示页面,Fabric 管理可交互的编辑层;导出时要把编辑数据交回 PDF 引擎,不能只把画布截图当成可编辑 PDF。
Fabric 的对象编辑能力适合 PDF 批注、白板等场景,但它不提供完整的金融时间轴、价格轴、行情接入和指标体系。K 线图优先选择专用图表库,画线和标注也不意味着需要额外接入 Fabric。Web3 与否主要影响行情和交易数据来源,不决定图表必须使用哪种绘图库。
# 高清渲染:显示尺寸和像素尺寸要分开
高清渲染的核心:画布在页面上不变大,但内部用更多像素绘制。例如显示区域仍是 600 × 300,DPR 为 2 时,画布内部使用 1200 × 600 个像素。否则浏览器只能把较少的像素拉伸到高分屏上,文字和线条就容易模糊。
# 显示尺寸与内部像素尺寸
Canvas 有两套尺寸,分别控制显示大小和绘图分辨率:
- CSS 显示尺寸:由 CSS 的
width、height控制,可以写在样式表中,也可以通过canvas.style.width = '600px'、canvas.style.height = '300px'设置,表示画布在页面上显示为 600 × 300 CSS 像素。 - 内部像素尺寸:由
canvas.width、canvas.height控制,表示画布内部位图的宽、高各有多少个像素。例如设为1200和600,就有 1200 × 600 个像素用于绘制,最终显示在上面的 600 × 300 区域中。
# DPR:设备像素比
Device Pixel Ratio(设备像素比)表示设备像素与 CSS 像素的比例。例如 DPR = 2 时,一个 CSS 像素在宽和高两个方向都对应两个设备像素。浏览器页面缩放、切换显示器时,DPR 也可能变化。
# 使用 setTransform 实现高清绘制
面试可以直接这样说:
我会把 CSS 显示尺寸和画布内部的像素尺寸分开。先用 CSS 的 width、height 设置页面上显示多大,再读取 DPR,把 canvas.width、canvas.height 设置为显示宽高乘 DPR。比如显示 600 × 300 CSS 像素,DPR 为 2,内部就设成 1200 × 600 个像素。然后用 setTransform 把绘图坐标放大 2 倍,绘图代码仍按 600 × 300 来写,不需要每个坐标都手动乘 2。容器尺寸或 DPR 变化时,重新设置并绘制。
例如,在 DPR = 2 的屏幕上,要在画布左上角画一个显示为 100 × 50 CSS 像素的矩形:
<!-- CSS 控制页面上的显示尺寸。 -->
<canvas id="rectangle" style="width:600px;height:300px"></canvas>
<script>
const canvas = document.getElementById('rectangle');
const ctx = canvas.getContext('2d');
// 1. 按 DPR = 2 设置内部像素尺寸;赋值会清空画布并重置绘图状态。
canvas.width = 1200;
canvas.height = 600;
// 2. 让后续绘制的坐标和尺寸自动映射为 2 倍的内部像素。
ctx.setTransform(2, 0, 0, 2, 0, 0);
// 3. 仍按显示尺寸填写宽高,不用手动写成 200、100。
ctx.fillRect(0, 0, 100, 50);
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
这个矩形实际占用 200 × 100 个内部像素。整张 1200 × 600 的位图显示在 600 × 300 的 CSS 区域中,所以矩形在页面上仍是 100 × 50 CSS 像素。如果省略 setTransform,矩形只占 100 × 50 个内部像素,显示出来就只有 50 × 25 CSS 像素。
执行顺序是:设置画布尺寸 → 设置坐标变换 → 绘制内容。setTransform 只影响后续绘制,所以要放在 fillRect 前面;它也支持平移、旋转和倾斜,这里仅使用缩放能力。它会替换原有变换,重复设置仍是 2 倍;scale 会叠加变换,连续缩放两次 2 倍就会变成 4 倍。
增大内部像素尺寸是为了提供足够的绘图像素;配合 setTransform 缩放绘图坐标,图形在页面上的大小和位置保持不变。绘图代码仍按 CSS 显示尺寸计算,业务坐标和鼠标位置不需要再乘一次 DPR。
实际项目中,通过 window.devicePixelRatio 获取当前 DPR;容器尺寸或 DPR 变化时,重新读取显示尺寸,设置画布内部像素尺寸和坐标变换,再绘制内容。
# 高清渲染的实践注意事项
Fabric 适配:使用 Fabric 时,由它管理画布的 DPR 适配,避免再对同一画布手动放大内部尺寸和绘图坐标。高清适配只提高清晰度;例如,一个显示宽度为 100 CSS 像素的批注,在 DPR = 2 时仍显示为 100 CSS 像素宽,用户把文档视图从 100% 放大到 200% 时,它才显示为 200 CSS 像素宽。
细线清晰度:像素数量足够时,细线仍可能因为边缘没有对齐像素边界而发虚。例如,在 DPR = 1、没有其他变换时,宽 1 像素的竖线以
x = 10为中心,会各占相邻两列像素的一半;改为x = 10.5,就能完整占满一列。实际偏移量要结合 DPR、线宽和缩放计算,不能统一加0.5。内存与性能:显示尺寸不变时,DPR 翻倍,内部宽高各翻倍,像素数量就变成四倍,内存和绘制开销也会增加。多页 PDF 优先渲染可见页及邻近页,避免所有页面同时占用大画布;需要导出高清图片时,再单独生成目标分辨率的图片。
# 坐标转换:为什么缩放后会点不准
缩放后点不准,通常是因为鼠标位置和图形原始坐标没有统一。放大或平移整个 PDF 文档视图时,批注在屏幕上的显示位置会变化,但它仍标记文档中的同一处内容,因此保存的文档坐标不变。如果直接拿鼠标位置去和文档坐标比较,就会出现看着点中了、实际却没选中的情况。
以横坐标为例:批注上的一个点在文档的 x = 100。将整个文档视图放大 2 倍、再向右平移 30 后,这个点显示在画布内的 100 × 2 + 30 = 230,但文档坐标仍是 100。画布左边又距离浏览器可视区域左边 50,所以点击该点时,鼠标事件给出的 clientX 是 280。
还原位置分两步:
- 先减去画布的位置:
280 − 50 = 230,得到鼠标在画布内的位置。代码中用clientX − rect.left,其中rect来自画布的getBoundingClientRect()。 - 再撤销内容的平移和缩放:
(230 − 30) ÷ 2 = 100,回到文档原始坐标,再判断是否落在批注范围内。纵坐标同理。
这个例子假设画布没有边框,也没有额外的 CSS 缩放。若 CSS 把画布整体拉伸了,第一步后还要按比例换回绘图使用的尺寸。DPR 只负责高清像素密度,这里的坐标已经按页面显示尺寸计算,不要再乘一次 DPR。
面试时可以说:缩放后点不准,通常是鼠标坐标和图形坐标没有统一。我会先把鼠标位置换算成画布内的位置,再反向扣除平移、除以缩放倍数,回到图形原始坐标后判断是否选中。如果有旋转或多层变换,就不能只除以缩放倍数;使用 Fabric 时,我会优先用它提供的坐标转换接口。
在 K 线图里也是这个思路:趋势线端点保存时间和价格,而不是固定的屏幕像素位置。例如端点表示某天 10:00、价格 100,拖动或缩放图表时,由图表库重新计算它的显示位置,这样线才会始终跟着对应的行情。
坐标转换后,怎样判断点中了什么?这叫命中检测。矩形可以判断点是否在范围内,线段可以判断鼠标离线是否足够近;原生 Canvas 不会像 DOM 那样自动给每个图形分发点击事件,需要自行判断,或使用 Fabric、图表库提供的交互能力。
# 离屏 Canvas:缓存和 Worker 不是一回事
离屏表示不直接放在页面上显示;是否在另一个线程运行,要看有没有配合 Web Worker。
| 方式 | 怎样使用 | 解决什么问题 | 边界 |
|---|---|---|---|
| 离屏缓存 | 在隐藏的画布或 OffscreenCanvas 中预画图形,再用 drawImage() 复制到可见画布 | 避免重复画不变的背景、图标或复杂形状 | 在主线程执行时,仍可能阻塞主线程;缓存也占内存 |
| Worker 中绘制 | 把可见画布的控制权转给 Worker,在 Worker 里绘制 | 减少绘图计算对输入、滚动等交互的阻塞 | 需要消息通信;Worker 不能直接读 DOM,尺寸与交互要由主线程传入 |
隐藏的普通 Canvas 与 OffscreenCanvas 有什么区别
- 隐藏的普通 Canvas:用
document.createElement('canvas')创建,不放进页面即可做缓存;它仍是 DOM 元素,绘图在主线程进行。 - OffscreenCanvas:用
new OffscreenCanvas(宽, 高)创建,不依赖 DOM,既能在主线程做缓存,也能在 Worker 中绘图。
只做缓存时,两者都能配合 drawImage() 复用图形;需要把绘图移到 Worker 时,使用 OffscreenCanvas。仅换成 OffscreenCanvas 不会自动开启多线程,也不保证更快。
获取 OffscreenCanvas 的两种方式有什么区别
new OffscreenCanvas(宽, 高):创建独立画布,不直接关联页面元素,适合缓存图形或后台处理图片。例如在 Worker 中画好 K 线图后,可将结果以ImageBitmap(可传递的图像对象)传回主线程,再显示到页面。canvas.transferControlToOffscreen():返回与原来的页面<canvas>关联的 OffscreenCanvas。将它转移给 Worker 后,Worker 绘制的结果会由浏览器显示在原画布上,不需要每帧手动传回图片。主线程仍负责布局和鼠标事件,但不再直接绘制这块画布。
区别在于:创建独立画布,还是接管页面上已有画布的绘制。两种方式得到的都是 OffscreenCanvas,都能在 Worker 中使用。
下面的示例采用第二种,通过 postMessage 转移给 Worker。注意:transferControlToOffscreen() 必须在原画布调用 getContext() 之前执行,且同一页面画布只能调用一次。两种方式主要是用途和显示方式不同,不是谁天然更快。
最小 Worker 示例包含两个文件,需通过 HTTP 服务打开 HTML,而不是直接双击本地文件。示例将页面画布的绘制交给 Worker,画出一个绿色矩形。
<!-- 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() 之前转移,同一画布不能转移两次。
// 返回的是 OffscreenCanvas 对象,与页面上的 canvas 保持显示关联。
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
18
// draw-worker.js:尺寸来自主线程,Worker 内不访问 DOM。
self.onmessage = ({ data: { canvas, width, height, dpr } }) => {
// 这里的 canvas 是传入的 OffscreenCanvas,不是页面上的 DOM 元素。
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
10
实际项目中,主线程负责页面布局和鼠标交互,Worker 负责绘制。画布尺寸、DPR、视图缩放或行情数据变化时,主线程把更新发送给 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 线图技术选型
Advanced Charts 和 Lightweight Charts 都由 TradingView 开发,但不是同一个库。前者自带丰富的指标和画线工具;后者是轻量开源图表库,指标计算和复杂交互需要自行补充。Widget 则是可直接嵌入网页的现成组件。
| 方案 | 适合什么需求 | 需要承担什么 |
|---|---|---|
| TradingView Widget (opens new window) | 快速嵌入 TradingView 提供的行情图表 | 受数据源、配置和嵌入方式约束,不等于可任意接入自己的交易行情 |
| TradingView Advanced Charts (opens new window) | 完整的金融图表、丰富画线和指标体验 | 接入自己的 Datafeed(行情适配接口),处理历史与实时数据,并按其授权条款使用 |
| Lightweight Charts (opens new window) | 轻量 K 线、成交量与自定义交易页面 | 自己补指标计算、业务标记和需要的交互;不是 TradingView 完整终端的免费替代品 |
| 原生 Canvas 自研图表 | 现有图表库确实无法满足的特殊绘图需求 | 自己处理坐标、命中、时间轴、刷新与兼容,维护成本更高 |
选型顺序是:先用图表库提供的 K 线、指标与画线能力,不够时再评估它的扩展接口,最后才考虑自定义绘制。
# 高频面试题
1. 你的 PDF 编辑器经验对开发 K 线图有什么帮助?
建议回答:能迁移的是 Canvas 高清渲染、坐标转换、图层和交互优化经验。PDF 编辑器侧重对象编辑,K 线图还需要时间轴、价格轴、指标和实时行情。我会优先评估 TradingView 的 Advanced Charts 或 Lightweight Charts,画线也先用图表库自身的能力,再用已有经验分析渲染和交互问题,同时补好历史与实时数据衔接。
2. Canvas 为什么模糊?怎样高清渲染?
建议回答:高分屏下模糊,常见原因是画布内部像素不够。CSS 的 width、height 控制页面上的显示大小,canvas.width、canvas.height 控制内部位图的像素数量。我会保持 CSS 显示尺寸不变,把内部像素尺寸设为显示宽高乘 DPR,再用 setTransform 按相同比例放大绘图坐标。比如显示 600 × 300 CSS 像素、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. 怎样证明优化有效,而不只是说用了缓存和 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 与永续合约业务面试指南 练习业务回答。