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>
1
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>
1
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);
};
1
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 更容易讲清楚:

  1. 只处理该看的数据:保存历史数据,但只绘制可见区间;向左拖动时分页补历史,密集蜡烛按时间聚合,不能随意抽样丢掉高低价。
  2. 数据频率与绘制频率分开:行情每到一条就更新数据,用 requestAnimationFrame 把同一帧的刷新合并;不要每个 Tick 都触发整页 Vue/React 更新。
  3. 按变化分层:网格和坐标背景、K 线与指标、十字光标与交互各自更新;移动光标通常不用重画全部蜡烛。坐标范围改变时,旧背景缓存和局部区域必须失效。
  4. 按依赖增量计算:只重算受影响的指标区间。未收盘 K 线改了,要基于上一根已收盘指标状态重新计算;EMA 不能把同一根 K 线的每次 Tick 都当成新周期。
  5. 先测再升级:记录帧耗时、主线程长任务、交互延迟和内存;确认指标计算或绘制阻塞后,再考虑 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 让主线程更空闲不代表总耗时一定更短;如果通信和复制成为新瓶颈,就要重新调整任务边界。

# 官方资料

接着学习 看盘指标与聪明钱,理解图表上的线和区域代表什么,再回到 CFD 与永续合约业务面试指南 练习业务回答。