在 Github 上翻项目翻到第 87 期的时候lightweight-charts 这个仓库让我停下来多看了两眼。原因很直接我手头正好有一个行情列表页面需要在一个不到 300px 高的卡片里塞进几万根K线还得保证手机端滑动不掉帧。ECharts 能画但配置项写到最后我自己都不想维护了Highcharts 的股票模块体验不错可授权成本摆在那里。lightweight-charts 是 TradingView 开源出来的轻量级图表库MIT 协议压缩之后体积在几十 KB 这个量级只做金融时序图这一件事上手门槛低到几乎不需要学习成本。它适合谁做量化看板的前端、写交易工具的个人开发者、需要在嵌入式页面里放个迷你走势图的后端同学甚至只是想在文档里画一条漂亮的净值曲线的人。这篇就把我从选型、跑通、定制到踩坑的整个过程摊开讲一遍代码都能直接抄。1. 先搞清楚这个库到底在解决什么问题1.1 从一次真实的需求说起去年我接手一个持仓管理页面需求方给的原型很朴素顶部一个大图表区显示某只标的的日线右侧一列价格刻度下面一条时间轴鼠标移上去出现十字光标和浮层显示开高低收。听起来是很标准的需求但真做起来坑不少。第一版我用 ECharts 的 candlestick 配 dataZoom功能都有了问题出在细节上十字光标的价格标签位置对不齐、Y 轴自适应范围在数据剧烈波动时会跳、移动端缩放和页面滚动打架。这些都能调但每个都要写一堆 option改一处崩一处。后来我把时间线拉长看其实我需要的不是通用图表而是金融图表。这两者的差别很大。通用图表库要考虑饼图、雷达图、桑基图、地图、关系图它的抽象层级注定会比较高配置项注定会很杂。而金融图表的诉求集中在几件事上K线和高低价的正确渲染、时间轴的等距排布与休市处理、实时增量更新的性能、十字光标和价格刻度的精密配合。lightweight-charts 就是把这些做透了的产物它放弃了其他所有图表类型换来的是极小的体积和极顺滑的交互。1.2 它和 ECharts、Highcharts 不是同一类东西我做过一个对比表贴在这里方便你快速判断维度lightweight-chartsEChartsHighcharts Stock渲染方式Canvas 2D 分层绘制Canvas / SVG 可切换SVG 为主压缩体积官方给出的量级是几十 KB按需打包也在百 KB 级别数百 KB图表类型覆盖只做金融时序图几乎所有类型通用 股票模块金融场景开箱能力强K线/十字光标/价格线都是内置中需要自己拼装强移动端手势内置拖拽、缩放、惯性滑动需要配置内置授权MITApache-2.0商用需授权无障碍与DOM语义弱纯 Canvas部分支持较好看出门道了吗它不是 ECharts 的替代品而是我只要一张走势图这个场景下的专用工具。你要是做的是运营大屏需要在一页里同时放折线、饼图、地图那就老老实实用 ECharts别硬拗。反过来如果你的页面里图表只有一个K线用通用库就是拿大炮打蚊子包体积和运行时开销都会白白浪费。1.3 什么时候你不该选它这点必须提前说清楚免得你写到一半发现方向错了。第一需要导出矢量图的场景不要选Canvas 渲染天生输出位图甲方要 SVG 的话你会很痛苦。第二需要高度自定义的图形标注比如在K线上画斐波那契扇形、画头肩顶识别框虽然它提供了自定义系列接口但开发成本比直接用 D3 或原生 Canvas 高。第三需要无障碍支持的政务、金融合规页面要谨慎Canvas 内部的图形对读屏软件是不透明的你得额外补一套文本描述。第四需要 3D 或者复杂交互联动比如点击K线弹出可拖拽的复杂面板并跟随坐标的场景它的坐标转换 API 够用但事件模型比较薄复杂交互还是自己接管一层更省心。我个人判断标准很简单如果一张图表的类型固定是折线、面积、K线、柱状这四种之一数据是按时间递增的序列交互只需要缩放、拖拽、十字光标那就闭眼选它。只要有一条不满足先别急评估一下工作量再决定。2. 拆开看内核Canvas 分层与数据模型2.1 渲染分层与重绘策略很多人以为 Canvas 图表就是在一个画布上把东西画出来其实性能差距全在重绘策略上。lightweight-charts 内部把绘制拆成了若干层最底下是网格线和坐标轴文字中间是数据系列最上面是十字光标和浮层元素。这样拆分的好处是当你的鼠标在图表上移动时只有十字光标那一层需要重绘网格和几十万根K线的层完全不动。如果把所有东西画在同一张 Canvas 上鼠标每动一像素就得把全部内容重画一遍几万根K线的情况下直接卡死。这背后还有一个逻辑坐标的概念。库内部维护一套与屏幕像素解耦的坐标系然后通过缩放和平移矩阵映射到真实像素。你拖动时间轴时改变的是可视范围这个逻辑参数而不是去改每个数据点的坐标。数据点只在初始化时被转换成内部的紧凑结构之后的时间轴缩放基本是 O(1) 级别的操作。这也是它能扛住大量数据的原因之一。顺便提一个实际影响因为分层是用多个 Canvas 叠起来的你用浏览器的开发者工具去审查元素会看到容器里叠了好几层 canvas 标签这不是 bug。有些同学第一次见到会以为库在重复创建节点其实那是设计的一部分。你如果自己写 CSS 给 canvas 加样式记得别用通配符选择器把层级顺序或者 pointer-events 改坏了否则十字光标会失效。2.2 数据点的结构约定数据格式是新手最容易栽跟头的地方我把常用的几种类型列个表系列类型数据字段说明折线 Linetime, value最基础只有单值面积 Areatime, value折线加填充柱状 Histogramtime, value, color 可选常用于成交量K线 Candlesticktime, open, high, low, close五元组顺序固定基准柱 Baselinetime, value相对基准值上下分色关键在于 time 这个字段它有三种写法。第一种是 Unix 时间戳注意单位是秒不是毫秒。这是我踩过的第一个坑我把Date.now()的结果直接传进去图表直接报错因为那个数字大了一千倍。第二种是字符串YYYY-MM-DD适合日线数据写起来最省事。第三种是对象{ year: 2024, month: 3, day: 15 }这个形式有个隐蔽的坑month 是从 1 开始计数的不是 JavaScript Date 那种从 0 开始。我第一次写的时候按 Date 的习惯写了 2结果渲染出来是 2 月对着数据核对半天才反应过来。还有一条硬约束传给同一个系列的数据时间必须严格递增不能有重复。库内部用二分查找来定位可视区间数据无序会让查找逻辑完全失灵。你从接口拿到的数据最好先做一次排序和去重别指望库帮你兜底。2.3 时间刻度的两种模式时间轴这块有个设计取舍值得单独说。lightweight-charts 的横轴默认按数据点等距离排布而不是按真实时间等比例排布。什么意思呢假设你的日线数据在周五之后直接跳到下周一中间隔了一个周末图上这两根K线的间距和周二到周三的间距是一样的不会因为中间隔了两天就空出一块。这个设计对交易员来说非常友好因为休市期间本来就没有行情留白反而干扰阅读。但如果你做的是 7×24 小时连续交易的品种或者需要在图上体现某段时间没有数据这件事就要自己想办法。常见做法是把缺失的时间点补上值用 null 或者前值填充前提是库的版本支持 null 值的断线。另一个做法是用自定义系列接管绘制逻辑自己算坐标。这两种方案我都试过前者简单但会让数据量虚增后者灵活但代码量翻倍具体选哪个看你的数据缺口有多大。还有个小细节默认情况下横轴的标签只显示日期时分秒是不显示的。做分钟线、秒线的时候你会看到每个标签都是同一天必须手动打开timeVisible做秒级行情还要再打开secondsVisible。这两个开关在初始化参数里别忘了设。2.4 坐标转换与十字光标十字光标是金融图表的灵魂。默认形态下鼠标横线的右侧会跟着一个价格标签竖线的下方会跟着一个时间标签这两个标签是库内置的。但产品经理往往不满足于此他们要在光标旁边弹一个浮层显示当前这根K线的开高低收和涨跌幅。这时候就需要坐标转换 API 了。四个最常用的方法series.priceToCoordinate(price)把价格换成 Y 像素series.coordinateToPrice(y)反过来timeScale().timeToCoordinate(time)把时间换成 X 像素coordinateToTime(x)反过来。浮层定位的典型流程是在subscribeCrosshairMove的回调里拿到参数对象里面已经带了当前指向的时间点和该点的系列数据用它算出价格再用 priceToCoordinate 拿到 Y 值把浮层的 top 设成这个值附近同时用 CSS 的 transform 做一下偏移微调。注意坐标转换得到的值是相对于图表容器左上角的不是相对于页面。如果你的浮层挂在 body 上记得把容器的getBoundingClientRect()也加上否则页面一滚动浮层就飘了。这个坑我调了半小时才想明白。3. 手把手跑通第一个图表3.1 装包与最小页面骨架先说安装一行命令的事npm install lightweight-charts如果你不用构建工具想直接在静态页面里引也可以从 CDN 加载 UMD 版本但注意线上环境别依赖不稳定的第三方源把文件下到本地更稳妥。我不建议在正式项目里走 script 标签因为拿不到类型提示业务代码一多就容易写错参数。接下来是页面骨架。这里必须强调一句容器必须有明确的高度。这是新手翻车率最高的地方没有之一。库在初始化时会读取容器的宽高如果高度是 0canvas 就是 0 像素高你会在页面上看到一片空白控制台还不报错。div idchart stylewidth: 100%; height: 400px;/div如果你用 flex 布局父容器要有一层能撑开高度的东西光给flex: 1是不够的因为 flex 子项的默认min-height是 auto父级高度不确定的时候它会塌成内容高度而 canvas 的内容高度是 0于是就成了死循环式的塌陷。稳妥写法是给容器加上min-height: 0和明确的像素高度兜底。然后是初始化import { createChart } from lightweight-charts; const container document.getElementById(chart); const chart createChart(container, { autoSize: true, layout: { background: { color: #0e1117 }, textColor: #c9d1d9, fontFamily: system-ui, sans-serif, fontSize: 12, }, grid: { vertLines: { color: rgba(42, 46, 57, 0.6) }, horzLines: { color: rgba(42, 46, 57, 0.6) }, }, rightPriceScale: { borderColor: rgba(42, 46, 57, 0.8), scaleMargins: { top: 0.12, bottom: 0.12 }, }, timeScale: { borderColor: rgba(42, 46, 57, 0.8), timeVisible: true, secondsVisible: false, rightOffset: 6, }, crosshair: { mode: 0, }, handleScroll: true, handleScale: true, });3.2 初始化参数逐项过一遍上面的配置里每一个都有存在的理由我逐条说说为什么。autoSize: true是我强烈建议打开的。打开之后图表会自动跟随容器的尺寸变化内部用的是 ResizeObserver。不开的话你得自己监听 window 的 resize然后手动调chart.resize(width, height)还得处理容器宽度在侧边栏收起时变化、但窗口尺寸没变的情况麻烦且容易漏。代价是它会常驻一个观察器页面里图表数量特别多比如几十个迷你图的时候要留意开销。layout.scaleMargins里的 top 和 bottom 是价格轴上下留白的比例取值 0 到 1。默认值也算合理但如果你的图表要显示最高价和最低价的价格线标签留白太小标签会被裁掉。我一般给到 0.1 到 0.15 之间兼顾空间利用率和标签可见性。timeScale.rightOffset控制的是数据最后一个点距离右边缘的空档数量单位是多少根K线。这个值设成 0 的话最新一根K线会紧贴右边框视觉上很局促而且实时更新时新数据点出现的位置太靠边手感不好。我给 6 到 10 这个区间具体看你的K线周期周期越小给的值可以越小。crosshair.mode有三个取值0 是自由模式1 是磁吸模式会吸附到最近的收盘价2 是磁吸到 OHLC 四个价格里最近的那个。做交易界面我推荐用 2用户想看清某一根K线的具体价位时体验更好做纯数据展示用 0 就够了。3.3 灌数据setData 的正确姿势创建系列和喂数据的写法在 v4 和 v5 里不太一样。v4 用的是chart.addCandlestickSeries()这种按类型命名的方法v5 改成了统一入口chart.addSeries(CandlestickSeries, options)。两种写法我都在项目里用过v5 的方式更规整也方便做类型推导新项目建议直接用 v5。import { createChart, CandlestickSeries, HistogramSeries } from lightweight-charts; const chart createChart(document.getElementById(chart), { autoSize: true }); const candleSeries chart.addSeries(CandlestickSeries, { upColor: #26a69a, downColor: #ef5350, borderUpColor: #26a69a, borderDownColor: #ef5350, wickUpColor: #26a69a, wickDownColor: #ef5350, priceFormat: { type: price, precision: 2, minMove: 0.01 }, }); // 注意时间戳单位是秒 const raw [ { time: 1704067200, open: 100.2, high: 102.5, low: 99.8, close: 101.9 }, { time: 1704153600, open: 101.9, high: 103.1, low: 100.7, close: 100.9 }, ]; const data raw .map(d ({ time: d.time, open: d.open, high: Math.max(d.high, d.open, d.close, d.low), low: Math.min(d.low, d.open, d.close, d.high), close: d.close, })) .sort((a, b) a.time - b.time) .filter((d, i, arr) i 0 || d.time ! arr[i - 1].time); candleSeries.setData(data); chart.timeScale().fitContent();这里我加了两层防御是实际项目里被脏数据坑出来的经验。第一层是 high/low 的修正上游接口偶尔会出现 low 比 close 还高的情况这种数据传给图表不一定报错但画出来的蜡烛会变形看起来像穿模。第二层是排序去重前面说过无序数据会让内部的二分查找失效症状是图表能显示但缩放时数据错乱排查起来非常费劲。给成交量加一个柱状系列也很简单用价格轴之外的独立刻度就行通过priceScaleId指定然后把两个系列的刻度上下错开const volumeSeries chart.addSeries(HistogramSeries, { priceScaleId: volume, priceFormat: { type: volume }, }); chart.priceScale(volume).applyOptions({ scaleMargins: { top: 0.8, bottom: 0 }, }); volumeSeries.setData(raw.map(d ({ time: d.time, value: d.volume, color: d.close d.open ? rgba(38,166,154,0.5) : rgba(239,83,80,0.5), })));scaleMargins的 top 设成 0.8 的意思是这个刻度占容器高度的下面 20%上面 80% 全空着正好留给主图。这个数字我调过好几轮0.75 到 0.85 之间都行太小了成交量柱子会跟K线打架。3.4 增量更新update 与时间递增的硬约束实时行情是这个库的主战场核心方法就一个update()。它的行为规则值得记牢传入的时间等于当前最后一个数据点的时间就覆盖它晚于最后一个点就追加早于最后一个点什么也不做并且会在控制台给你警告。function onTick(tick) { // tick.time 单位秒tick.close 最新价 candleSeries.update({ time: tick.time, open: tick.open, high: tick.high, low: tick.low, close: tick.close, }); }看起来很美好但如果你真的把每个 tick 直接接上 update浏览器会教你做人。行情活跃的时候一秒几十条推送每条都触发一次重绘CPU 直接拉满手机发烫。我的做法是在中间加一层缓冲用对象按时间戳聚合最新的覆盖旧的然后用requestAnimationFrame或者一个 100 到 200 毫秒的定时器统一刷一次。const pending new Map(); function queueTick(tick) { pending.set(tick.time, tick); } function flush() { for (const tick of pending.values()) { candleSeries.update(tick); } pending.clear(); requestAnimationFrame(flush); } requestAnimationFrame(flush);这个缓冲层还有个额外好处如果推送乱序到达网络抖动下很常见Map 的键覆盖天然帮你做了去重最后落库的是同一秒里最新的那条。但要注意 Map 的遍历顺序是插入顺序如果先到的是一条较晚的时间、后到的是较早的时间遍历时后者会触发时间早于最后一个点的警告被忽略。所以更稳的做法是在 flush 之前按时间戳排一次序。提示如果推送的粒度是毫秒级而你的K线是分钟线那就不能直接 update 了需要先在客户端做 K 线聚合把毫秒时间戳取整到分钟累积开高低收等这一分钟结束后再 update 一次。这个聚合逻辑写在缓冲区里最合适。4. 从能用做到好看样式、标记与交互4.1 主题改造的四个关键配置块图表长得丑八成是四个地方没调。第一个是背景layout.background支持纯色和垂直渐变两种渐变的写法是传入{ type: ColorType.VerticalGradient, topColor, bottomColor }注意ColorType是需要从包里 import 的枚举不是字符串。做暗色界面时我用#0e1117到#161b22的细微渐变比纯色多一层质感但幅度千万别大金融图表的背景抢戏会严重影响读图的准确度。第二个是网格grid.vertLines和grid.horzLines的分工要明确。竖线对应时间刻度横线对应价格刻度两者建议用同一个低对比度的颜色透明度压到 0.15 到 0.3 之间。我见过有人把网格调得很显眼结果K线的影线跟网格混在一起完全分不清哪是数据哪是参考线。第三个是坐标轴边框色borderColor它决定了价格轴和时间轴那条分界线。改这个比改网格更影响观感因为它给图表和页面之间加了一道框。第四个是文字颜色和字体。layout.textColor管所有轴标签和十字光标标签fontSize我建议 11 到 12再大就会在小尺寸图表里挤成一团。字体族最好跟页面主字体一致混用字体会让整个页面显得很业余。改完主题之后你大概率还会遇到一个诉求给整个图表套一层统一的配置免得每个图表都写一遍。我的做法是把这堆 option 抽成一个函数接受一个主题名或者从 CSS 变量里读颜色返回完整的配置对象。这样切主题的时候只要改一处。4.2 价格线、标记与水印价格线是画在图表上的一条水平参考线常用于标注成本价、止盈止损位、昨日收盘价。用法上它挂在系列上不是挂在图表上const priceLine candleSeries.createPriceLine({ price: 100.5, color: #f0b90b, lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: 成本, });lineStyle是个数字枚举0 实线、1 点线、2 虚线、3 大虚线、4 稀疏虚线。我一般用 2 号虚线配高亮色来做成本线视觉上参考但不可点击的暗示比较清晰。title会显示在价格轴标签的左侧中文没问题但字数超过四五个就会把标签撑宽影响右边的留白观感建议缩写。不用的时候记得销毁candleSeries.removePriceLine(priceLine)。这个对象如果只是被垃圾回收而不显式移除有时会残留视觉元素尤其在频繁重建参考线的场景下。我吃过这个亏一个拖动修改止盈价的功能写完之后图上叠了七八条线最后发现是没清理。标记点用来在K线上打买卖信号v4 里是setMarkersv5 换成了createSeriesMarkers的独立函数形式。标记的形状支持圆形、方形、箭头位置支持上方、下方、线内颜色自定。要提醒的是标记数量别太多几百个标记同时在可视范围内会让渲染压力明显上升而且视觉上会糊成一片。做长周期图的时候我一般按只显示最近 N 个信号来做过滤。水印是那种半透明的品牌文字默认关闭。它的配置位置在不同大版本之间挪过地方从顶层的watermark选项挪到了窗格配置里。这个改动不影响功能但你从旧版代码迁移过来的时候会找不到参数在哪记得对着你本地安装的版本号去查对应的文档别照着老博客抄。4.3 自定义系列和插件机制库本身允许你接管某个系列的绘制逻辑接口叫自定义系列。你需要实现一个渲染器在draw回调里拿到图表给的绘制上下文和数据自己用 Canvas 原生 API 画。这个能力用来做K线背后的成交量热力带价格通道自定义的柱状形态都够用。真正用的时候有几个要点。第一绘制上下文提供的是相对图表的坐标转换工具你不要自己去算公式用库给的转换方法否则缩放时一定会错位。第二性能上draw会在每一帧被调用里面别做重计算数据的预处理放到外面。第三命中测试需要自己实现库不会自动帮你判断鼠标点到了你画的图形上。插件机制走的是attachPrimitive这条路把一段可复用的绘制逻辑挂到系列上它可以订阅系列的销毁、缩放、数据变化等生命周期。适合做批量标记技术指标叠加自定义图例这类功能。如果你只是想画几条静态线用价格线就够了没必要上插件。插件开发的调试成本不低我建议先把需求压缩到最小可验证形态跑通了再抽象。4.4 多窗格与主图联动v5 引入了原生窗格pane概念可以在同一个图表实例里上下排布多个窗格共享时间轴各自有独立的刻度。做法是在创建系列或者创建图表时指定窗格也可以通过addPane()显式新建再让系列移过去。这个特性的价值在于时间轴的滚动和缩放天然同步不需要额外代码。如果你用的还是 v4没有原生窗格最常见的替代方案是创建多个图表实例然后手动同步可视区间监听 A 图表的可见逻辑范围变化把同一个范围设置给 B。let syncing false; mainChart.timeScale().subscribeVisibleLogicalRangeChange(range { if (syncing || !range) return; syncing true; subChart.timeScale().setVisibleLogicalRange(range); syncing false; }); subChart.timeScale().subscribeVisibleLogicalRangeChange(range { if (syncing || !range) return; syncing true; mainChart.timeScale().setVisibleLogicalRange(range); syncing false; });那个syncing标志位是必须的。没有它A 触发 B、B 又触发 A形成无限递归浏览器直接卡死。我第一次写联动的时候没想到这一层页面卡了十秒钟才反应过来是死循环。另外跨图表的同步会有一帧的延迟感两个图表的滚动位置在快速拖拽时可能略微错开这是 Canvas 异步重绘导致的无法完全消除能接受就接受不能接受就升级到 v5 用原生窗格。5. 数据量上来以后怎么扛5.1 一次性 setData 与分批更新的取舍我先给一组我自己的实测感受不同机器差别较大只作方向性参考几千个数据点随便怎么喂都无所谓三万个点一次性 setData 基本感觉不到卡顿超过十万个点的时候首屏会有可感知的延迟但滚动和缩放依然顺滑因为交互相对于数据量来说开销很小。关键在于喂数据的方式。一次性setData传一个大数组比循环调update快一个数量级。原因是setData是一次性的批量处理会重建内部索引而每次update都可能触发一次数据结构的调整和一次重绘调度。所以我给的建议是初始化阶段永远用setData只有实时增量才用update。当数据量真的到了几十万根K线的级别就不要硬喂了做降采样。日线数据拉到十年的量也就两千多根问题不大真正会爆的是分钟线和秒线。我的做法是按当前可视范围做动态聚合用户缩放到最近一周就用原始分钟数据缩放到三年就把分钟数据按天聚合成日线再喂给图表。聚合本身不难把时间戳按目标粒度取整然后对每组求 open 取第一个、close 取最后一个、high 取最大、low 取最小、volume 求和。麻烦的是什么时候重新聚合。我的方案是监听可见逻辑范围的变化算出当前每根K线代表的秒数跟目标粒度比对差值超过阈值就重新聚合、重新 setData。重聚合会有一次短暂的重绘为了减少闪动我在重聚合期间不改变可视范围参数让用户感觉不到跳变。5.2 自适应尺寸与高频渲染节流autoSize: true打开了之后容器尺寸变化会自动同步。但有个细节要注意它内部用的是 ResizeObserver如果你的布局导致容器在短时间内剧烈抖动比如侧边栏有 CSS 动画观察器会高频触发尺寸更新进而高频重绘。我的处理办法是在外层加一层防抖或者干脆关掉 autoSize自己在 resize 回调里做节流之后再调chart.resize。高频行情的节流前面讲过用缓冲区加 rAF。这里补充一个分量除了节流刷新频率还可以减少单次刷新的工作量。如果一秒钟来一百条 tick但你的K线周期是 1 分钟那这一百条 tick 最终只会改变最后一根K线的四个价格。这种情况下不要构造一百个数据对象传给 update而是先在缓冲区里把 OHLC 合并成一条再 update 一次。这个优化能把开销压到原来的百分之几。还有一个容易被忽略的点图表不可见时不要更新。如果图表在一个折叠面板里、或者滚动出了视口、或者切到了别的浏览器标签页这时候继续 update 是纯浪费。做法是用 IntersectionObserver 判断可见性不可见时把 tick 存起来重新可见时一次性合并补上。这个技巧在手机端收益特别明显很多时候用户挂着页面就去干别的了。5.3 销毁、内存与框架集成在 React 或 Vue 里用这个库最典型的错误是内存泄漏。图表实例持有 Canvas、事件监听、ResizeObserver不显式销毁的话组件卸载了这些资源还在。React 里的正确写法是import { useEffect, useRef } from react; import { createChart, CandlestickSeries } from lightweight-charts; export default function KLineChart({ data }) { const containerRef useRef(null); const chartRef useRef(null); const seriesRef useRef(null); useEffect(() { const chart createChart(containerRef.current, { autoSize: true }); const series chart.addSeries(CandlestickSeries, {}); chartRef.current chart; seriesRef.current series; return () { chart.remove(); chartRef.current null; seriesRef.current null; }; }, []); useEffect(() { if (seriesRef.current data) { seriesRef.current.setData(data); } }, [data]); return div ref{containerRef} style{{ width: 100%, height: 400 }} /; }注意依赖数组必须是空的图表初始化只能跑一次。如果你把 data 放进初始化那个 effect 的依赖里每次数据变化都会重建图表用户正在缩放的视口会被重置体验极差。数据更新走单独的 effect用setData或者update推给已经存在的系列。React 18 的严格模式在开发环境会把 effect 执行两次挂载、卸载、再挂载。如果不写清理函数你会看到容器里叠了两个 canvas鼠标交互变得诡异。写了chart.remove()就没这个问题。Vue 里的思路一样在onUnmounted里调 remove别用div v-html之类的奇技淫巧去塞。提醒chart.remove()之后所有从该图表拿到的系列对象、价格刻度对象、时间刻度对象都失效了再调用它们的方法会报错。如果你在组件里把这些对象存进了 state记得一并清空。6. 我踩过的坑和排查速查表6.1 图表一片空白怎么查这是最高频的问题我按排查顺序列一下。第一步看容器尺寸。在控制台里选中容器元素看它的offsetWidth和offsetHeight是不是 0。高度为 0 是最常见的原因尤其在 flex 或者 grid 布局里子项没有明确高度的时候会塌陷。解决方法是给容器一个具体的像素高度或者确保父级链条上每一层都有确定的高度。第二步看有没有报错。打开控制台如果有Cannot read properties of null之类的错误可能是容器元素还没挂载就调用了 createChart。在框架里这通常意味着你在mounted之前就初始化了图表。第三步检查数据。数据是空数组的话图表会渲染出坐标轴和网格但没有任何线条看起来也是空白但跟尺寸为 0 的表现不一样——尺寸为 0 的时候你连网格都看不到容器是完全空的。这个区别可以帮你快速定位。第四步检查背景色。如果你设了白色背景而页面也是白色网格颜色又调得太淡可能会误以为没渲染。把背景改成深色试一下一秒就能确认。6.2 时间对不上怎么办时间相关的 bug 有三个来源我分开说。第一个是单位。时间戳必须是秒。Date.now()返回毫秒要除以 1000 再取整。反过来如果你从后端拿到的是秒级时间戳直接用没问题如果拿到的是毫秒级的字符串记得先转数字再除。这个错误的表现是图表渲染异常或者数据完全不显示。第二个是月份。用对象形式的 time 时month 从 1 开始。用字符串2024-03-15就没这个问题所以我现在一律用字符串省心。第三个是时区。字符串形式的时间被当作 UTC 处理而浏览器展示的时候可能按本地时区换算跨时区的用户看到的时间戳可能差一天。做跨时区产品的时候我建议统一在后端把时间转成目标时区的时间戳再传前端别做换算否则两边都在算出了问题很难定位是哪一层的锅。6.3 常见现象与处理对照表现象可能原因处理方式容器内完全空白容器高度为 0给容器明确像素高度检查 flex 链条有网格无线条数据为空或全被过滤打印 setData 前的数组长度控制台警告时间顺序错误数据未按时间升序提交前排序并去重更新最新一根K线无效update 的时间早于最后一个点检查时间源必要时改用 setData蜡烛形状异常high/low 与 open/close 关系不合法提交前做 min/max 修正缩放后数据错乱存在重复时间戳按时间去重保留最后一条图表随窗口抖动而闪autoSize 被高频触发关掉 autoSize自己做 resize 节流切换页面后卡顿不可见时仍在更新用 IntersectionObserver 暂停更新卸载组件后仍占内存未调用 remove在清理函数里显式销毁十字光标不跟随容器或 canvas 的 pointer-events 被改检查全局 CSS 是否有通配符覆盖价格标签被裁切刻度留白不足调大 scaleMargins 的 top/bottom中文显示为方块字体族不含中文字体在 fontFamily 里补上中文字体这张表里的每一条我都在项目里真真切切遇到过至少一次尤其是有网格无线条这一条有次排查了一下午最后发现是接口返回的字段名跟我的映射对不上数据全被过滤成了空数组图表本身一点问题没有。再补两个位置很隐蔽的坑。一个是全局 CSS 里的canvas { display: block }之类的影响一般没问题但如果有canvas { pointer-events: none }就会让交互彻底失效而这种样式往往藏在某个重置样式表里。另一个是图表的容器如果有overflow: hidden加上圆角十字光标的标签在边缘会被切掉看起来像是标签跑出去了其实是容器裁的。7. 顺手聊聊 Github 上的项目怎么跑起来7.1 克隆、切版本、跑示例很多人拿到一个 Github 仓库第一步就卡住了。我按最稳的流程讲一遍。先克隆下来git clone https://github.com/tradingview/lightweight-charts.git cd lightweight-charts然后别急着npm start先看一眼package.json里的scripts和engines字段。engines会告诉你需要的 Node 版本版本不匹配的话装依赖时可能报一堆看不懂的编译错误。接着看有没有 lock 文件有package-lock.json就用 npm有pnpm-lock.yaml就用 pnpm别混用。混用的后果是依赖树跟作者预期的完全不同某些示例跑不起来你还会以为是代码有问题。跑起来通常是这两条npm install npm run dev大部分库类项目会在dev里起一个本地的示例站点里面通常有一个列表把每个特性都做成了可交互的 demo。这个示例站点是最好的学习材料比文档还直观因为你可以直接改参数看效果。我学这个库的时候就是开着示例站点一边改配置一边刷新一个下午把关键参数全摸清了。关于版本切换如果你想复现某个特定版本的 API 行为比如 v4 和 v5 的系列创建方式不同可以切到对应的标签git tag | grep v5 git checkout v5.0.0 npm install切完之后记得重新npm install因为不同版本的依赖可能不一样。想回到主线就git checkout main。如果只是想临时看看某个版本的某个文件用git show v5.0.0:src/api/create-chart.ts更省事不用动工作区。至于回退本地改动git status先看清楚动了什么。改坏了单个文件用git checkout -- 文件路径恢复已经提交了但想撤销用git revert生成一个反向提交比git reset --hard更安全因为后者会丢掉提交历史。这些操作跟具体仓库无关是所有 Github 项目通用的值得花十分钟系统学一遍。7.2 看源码该从哪里进如果只是用文档和示例足够了。但你想搞清楚某个行为为什么是这样看源码是最快的路径。我推荐的入口顺序是先找src/index.ts或者包根目录的导出文件看清楚对外暴露了哪些 API再顺着createChart往下走看它构造了哪些内部对象然后进renderers目录看绘制逻辑进时间轴相关的模块看坐标换算。K线的绘制代码其实不长读完你会对为什么缩放这么快有非常具体的认知。看源码的时候有个技巧别从头到尾读带着问题读。比如你想知道update是怎么做到 O(1) 的就以update为起点跟着调用链往下追三层看到它内部的数据结构就明白了。漫无目的地读大型项目的源码效率极低而且很容易丧失信心。8. 几个我用了很久才知道的实用技巧前面讲了那么多主流用法最后补几条细碎但很香的经验都是我用了很久之后才摸出来的。第一条图表初始化的时候如果数据还没有到先用一小段占位数据把图表撑起来等真实数据到了再setData覆盖。这样做的原因是空图表的可视范围是没有意义的fitContent在空数据上不生效等数据来了之后你再去调 fitContent会发现在某些浏览器上有一帧的空白跳动。用占位数据撑起来再覆盖过渡更平滑。占位数据记得用跟真实数据同样的时间粒度不然可视范围的基准会变。第二条subscribeCrosshairMove的回调触发频率很高跟鼠标移动的帧率一致。如果你在回调里做 DOM 操作或者格式化日期最好把结果缓存起来。我做过一个测试在回调里每次都new Date().toLocaleString()指标上能明显看到这块的耗时换成自己写的轻量格式化函数之后回调耗时降到了原来的三分之一。第三条图表的系列对象上有一个价格刻度的引用你可以通过它给左右两个刻度分别设置可见性和宽度。左刻度在移动端是奢侈品屏幕窄的时候我一般把它关掉把空间留给主图和右刻度。关掉的写法是在创建图表时把leftPriceScale.visible设为 false然后在需要的时候用applyOptions动态打开。第四条给图表做截图导出的时候不要在 Canvas 上直接toDataURL。因为图表是多层 Canvas 叠加的你抓到的那一层可能只有网格。正确做法是把容器里所有 canvas 按层级顺序画到一张离屏 canvas 上再导出。这个逻辑我封装成了一个小工具函数大概二十行比引一个截图库划算得多也避免了第三方库把整个 DOM 序列化的开销。第五条做暗色和亮色双主题切换的时候不要在切主题时销毁重建图表。图表实例只需要在新数据到达的时候重建数据主题切换可以全部通过applyOptions完成。价格线、标记这些附加元素的颜色也要一起改所以我在项目里维护了一个当前主题的上下文所有颜色都从一个色板对象里取切换时统一替换色板并 applyOptions。实测切换耗时在十几毫秒用户完全感知不到。第六条如果你的页面里有两个以上图表要联动注意它们在数据量上的差异。主图三万根副图三百根同步可视范围的时候副图的横轴在放大到很小的时间跨度时会因为没有数据而显示空白区域看起来像 bug。我的处理是让副图的时间范围跟随主图但数据量保持一致副图也用同样的时间粒度代价是数据冗余好处是绝对不会出现范围不一致的问题。第七条也是我踩得最惨的一条不要在update之后立刻调用fitContent。fitContent会把可视范围重置到包含全部数据用户如果正在手动缩放到某个历史区间看细节新数据一来视口就被弹回最新体验非常糟糕。正确的做法是只在数据量变化超过一定幅度、或者用户主动点了回到最新按钮时才调。默认的实时跟随效果交给rightOffset和滚动位置自然处理就好。