
做前端数据可视化的应该都跟 Highcharts 打过交道。这个老牌图表库从 IE6 时代一路活到现在靠的就是稳定和细节。但很多人用了一两年还只停留在chart new Highcharts.Chart(options)这个层面完全没意识到图表底层其实是一整套完整的 SVG 渲染系统。你要真把这套系统吃透了能玩出的花样比想象中多得多。比如客户突然说图表里那个 Logo 给我加上或者设计师想要把图导成 SVG 矢量文件扔进 VI 手册又或者你想给图表的柱子加一段独一无二的路径动画……这些需求都离不开 SVG 渲染器。这篇博客我就来把 Highcharts SVG 渲染器的底层逻辑、实操方法和坑位给你捋清楚不绕弯子都是可以直接拿去用的东西。1. 核心机制Highcharts 为什么离不开 SVG1.1 图表不是画出来的是写进 DOM 的很多新手会误以为图表是 Canvas 画布上的像素点。Highcharts 默认不是这么干的它是在页面里先创建了一个svg根节点然后通过调用自己的SVGRenderer对象不断往这个 SVG 容器里塞rect、path、text、circle这些元素。你可以打开控制台试一下在一个渲染了柱状图的页面上执行var chart Highcharts.chart(container, { series: [{ type: column, data: [1, 2, 3] }] }); console.log(chart.container.innerHTML);你会看到一长串 SVG 标签比如path classhighcharts-point代表柱子text classhighcharts-title代表标题。所谓 SVG 渲染器本质上就是一套帮你管理这些 SVG 标签的翻译层你把数据丢进去它负责把数据映射成坐标再把坐标换算成 SVG 的dM...L...Z路径指令同时处理动画、事件、图层顺序和坐标轴。这里还有个很多人没注意到的点SVG 是 DOM 的一部分所以图表里的每个柱子、每个点其实都是真实的页面元素。这意味着你能用 CSS 选择器去改它的颜色、用事件委托去监听它的点击甚至能直接用document.querySelectorAll(.highcharts-point)把它抓出来做二次处理。1.2 为什么 Highcharts 死磕 SVG 而不是 Canvas现在新出的图表库好多都选了 Canvas 或是 WebGLHighcharts 倒好主力渲染模式一直坚持 SVG。我接触过不少项目一开始也觉得 Canvas 性能更强但后来对比发现对于绝大多数后台管理系统、报表平台来说SVG 的交互优势是 Canvas 没法替代的。首先是事件绑定。SVG 元素天然支持 DOM 事件hover、点击、拖拽都非常好做。Highcharts 里那套 tooltip 跟随、点击高亮的效果在 Canvas 上你要自己计算命中区域非常麻烦。其次是矢量无损。由 SVG 渲染出来的图表放大到任意尺寸都不模糊打印输出、导出图片都非常友好。我之前在一个大屏项目里把 Highcharts 图表直接导出成 PDF 附件清晰度极高完全不需要额外处理。再有就是可访问性。SVG 里的text是真正的文字可以被屏幕阅读器读取可以选中复制。在一些对合规性有要求的政务系统、金融报表里这算得上刚需了。1.3 SVGRenderer 类的角色定位Highcharts 内部所有图形元素都是通过Highcharts.SVGRenderer创建的。这个类负责统一管理 SVG 命名空间、元素创建、样式设置和动画调度。不过你基本上不需要直接new SVGRenderer()因为每个图表实例在初始化之后就把自己的渲染器实例挂在chart.renderer上了。这其实给了我们一个很便利的口子。你可以随时通过chart.renderer往图表里塞自定义图形不需要动图表原有的配置。比如chart.renderer.circle(100, 100, 50) .attr({ fill: #f00 }) .add();这就在图表容器里画了一个实心圆。看着简单但它是后面所有自定义绘制的基础。2. 实操第一步搞懂 SVGRenderer 的常用接口2.1 渲染器的四大核心绘图方法chart.renderer上挂着一组绘图方法我在项目里最常用的有四个方法作用生成标签renderer.rect(x, y, width, height)绘制矩形rectrenderer.circle(x, y, r)绘制圆形circlerenderer.path(pathArray)绘制路径pathrenderer.text(str, x, y)绘制文本text这四个方法覆盖了 95% 的自定义图形需求。你调用它们之后会得到一个对应的 SVG 元素对象这个对象都有.attr()和.css()方法分别用于设置 SVG 属性和 CSS 样式。设置完之后调用.add()才会真正加到图表里。举个例子。我做过一个项目需要在柱状图的基础上把某个季度的目标值用虚线框标出来。直接画矩形太单调我用renderer.path画了一个带箭头的标注线var renderer chart.renderer; renderer.path([M, 150, 230, L, 150, 80, L, 160, 90, M, 150, 80, L, 140, 90]) .attr({ stroke: #ff9900, stroke-width: 2, fill: none }) .add();path方法的参数是一个数组格式和你手写 SVG 的d属性几乎是一一对应的M表示移动到某个点L表示从当前点画直线到目标点。这种代码看起来有点原始但胜在非常灵活几乎能画任何形状。2.2 坐标体系和 transform 变换使用renderer画东西之前一定要先搞清楚坐标体系。Highcharts 的 SVG 坐标系以图表容器左上角为原点x轴向右y轴向下。这里的坐标是针对整个 SVG 画布的不是图表绘图区内部。如果你想让自定义的元素跟随图表缩放、平移也就是和绘图区绑定在一起那就需要用chart.plotLeft和chart.plotTop这两个属性作为偏移基准。比如想在绘图区右上角加一个角标var x chart.plotLeft chart.plotWidth; var y chart.plotTop; renderer.text(内部数据, x - 50, y 20) .attr({ fill: #666, font-size: 12px }) .add();chart.plotWidth和chart.plotHeight分别是绘图区的宽和高。这种相对定位的方式比写死坐标要稳得多因为它在窗口缩放、数据变化后依然能保持位置合理。另外要说一句transform属性的使用。SVG 里的transform不像 CSS 那么人性化它是translate(x, y)、scale(k)、rotate(deg)这种函数式写法。当你想让自定义图形跟着某一个 series 点移动时可以直接拿到点的像素坐标然后设置transform。我当时做一个动态标签跟随的效果时就是监听chart.redraw事件然后不断更新 label 的transform属性实现起来很直接。2.3 动画是内置的但需要正确姿势SVG 元素本身是没有动画能力的但 Highcharts 在SVGElement上包了一层animate方法。你可以用它让自定义元素的属性平滑过渡var circle renderer.circle(0, 0, 5) .attr({ fill: red }) .add(); circle.animate({ cx: 300, cy: 200, r: 20 }, { duration: 800 });注意animate的动画属性是看 SVG 原生属性的比如cx、cy、r、x、y而不是 CSS 的left、top。你要是想在动画结束后执行点什么可以在配置里加complete回调。这个能力最典型的应用是数据更新时的过渡动画。Highcharts 在更新 series 数据时默认会触发柱状图高度、折线图路径的动画如果你想把自定义元素也一起带入动画节奏可以监听chart.redraw再在动画回调里同步更新自定义元素。3. 进阶玩法让 SVG 渲染器为你的业务场景服务3.1 把自定义图标和业务元素直接塞进图表传统做法里业务元素一般是通过renderer.image加一张图片或者用图表外层的 HTML 来布局。但有些场景下把这些元素画进 SVG 内部反而更统一比如给折线图的某个特殊点画一个五角星标记。五角星没有现成方法你用path又嫌麻烦其实可以通过renderer.symbol来解决。Highcharts 内部定义了一组 symbol 函数包括circle、diamond、triangle、square等。你还可以自己注册新 symbolHighcharts.SVGRenderer.prototype.symbols.star function(x, y, w, h) { // 这里返回一个路径数组画出一个五角星 return [ M, x, y h * 0.5, L, x w * 0.2, y w * 0.2, // ... ]; }; chart.renderer.symbol(star, 100, 100, 20, 20).add();我用这种方式给股票K线的特殊事件点做过带旗帜的标记效果比贴图片好很多因为矢量标记打印出来不会糊。3.2 获取整张图表的 SVG 字符串很多时候你不是想在浏览器里看而是想把图表导出来。Highcharts 自带导出模块但是如果你不想引入庞大的导出组件或者想自定义导出内容就需要自己动手拿 SVG。chart.getSVG()会返回当前图表的完整 SVG 字符串。这里面包含了图表的全部细节包括背景、坐标轴、数据列、图例。var svgString chart.getSVG(); // 你得到了纯文本的SVG内容得到字符串之后你既可以把它传给后端去生成 PDF也可以直接在前端把它变成可下载的.svg文件。我一般是这样做的var blob new Blob([svgString], { type: image/svgxml;charsetutf-8 }); var url URL.createObjectURL(blob); var link document.createElement(a); link.href url; link.download chart.svg; link.click(); URL.revokeObjectURL(url);这个方法屡试不爽。不过要注意一点chart.getSVG()返回的内容里CSS 样式是内联在style标签里的不会引用外部样式表所以导出的 SVG 文件单拿出去也能保持样式不会因为环境变了就裸奔。3.3 把 SVG 转成 PNG 或图片有些业务场景需要 PNG 格式比如要放进 Word 文档、PPT 里SVG 就不太方便了。把 Highcharts 的 SVG 转成 PNG核心思路是把 SVG 放到一个独立的img标签里再画到 Canvas 上输出。我这里直接给出一段封装好的实现用起来很省心function svgToPng(svgString, width, height) { return new Promise(function(resolve) { var img new Image(); var svgBlob new Blob([svgString], { type: image/svgxml;charsetutf-8 }); var url URL.createObjectURL(svgBlob); img.onload function() { var canvas document.createElement(canvas); canvas.width width; canvas.height height; var ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, width, height); URL.revokeObjectURL(url); resolve(canvas.toDataURL(image/png)); }; img.src url; }); }这里有个大坑要提醒你Image对象加载 SVG 的时候SVG 内部如果有外部引用的图片资源会因为跨域或路径问题加载失败最后画出来是一块空白。解决办法是提前把图片资源转成 base64 再嵌入 SVG。Highcharts 在导出时有个exporting.allowHTML之类的开关但如果你手动处理最好把图表内用到的图片先处理成内联 data URI。很多小工具比如 svg-crowbar 这个开源项目底层思路其实和我上面说的差不多。它的亮点是会额外去扫描页面 CSS把相关样式也塞进 SVG 里避免导出后样式丢失。你要是遇到样式对不上的问题可以往这个方向查。4. 项目实战用 SVG 渲染器做一张动态标注地图4.1 需求拆解和方案设计有一回接到一个销售大屏的需求要在中国地图上标几个重点城市每个城市旁边要显示销售额并且数字要有个上浮的动画效果。常规做法是用第三方地图库但当时项目里已经用了 Highcharts再引一套地图库有点重。我最后是用 Highcharts 的map系列配合 SVG 渲染器来做的。思路分成两步先用 Highcharts 的地图系列Highcharts Maps把区域画出来再用chart.renderer在对应城市坐标上画圆形标记和文本。坐标定位不需要手动算经纬度可以用chart.fromPointToLatLon方法把经纬度转换成图表的像素坐标。这个方案的优点很明显完全规避了和两个渲染引擎抢 canvas 层级的问题。地图归地图自定义元素归自定义元素都在同一个 SVG 节点里层次关系非常透明。4.2 代码实现与关键点说明核心代码如下// 假设 mapChart 是已经初始化好的地图实例 var renderer mapChart.renderer; // 城市的经纬度数组 var cities [ { name: 北京, lat: 39.9, lon: 116.4, value: 128 }, { name: 上海, lat: 31.2, lon: 121.5, value: 112 }, { name: 广州, lat: 23.1, lon: 113.3, value: 86 } ]; cities.forEach(function(city) { // 经纬度转像素坐标 var point mapChart.fromPointToLatLon({ lat: city.lat, lon: city.lon }); var x point.x; var y point.y; // 画一个圆点 renderer.circle(x, y, 6) .attr({ fill: #ff9900, stroke: #fff, stroke-width: 2 }) .add(); // 画文本标签 var label renderer.text(city.name city.value 万, x 10, y) .attr({ fill: #333, font-size: 12px }) .add(); // 让标签有一个上下浮动的动画 var offset 0; setInterval(function() { offset offset 0 ? -10 : 0; label.animate({ y: y offset }, { duration: 600 }); }, 800); });这里要注意fromPointToLatLon是 Highcharts Maps 提供的 API传统的柱状图、折线图里是没有的。如果不用地图纯用直角坐标系你可能会用chart.xAxis[0].toPixels(value)来转换注意别把两套体系搞混。我踩过一次坑拿toPixels的结果直接当成地图的坐标用结果标签全飞到图表外面去了。动画方面我这个示例用了简单的setInterval做循环浮动。实际项目里建议监听chart.redraw并且用requestAnimationFrame控制动画频率不然大屏上开久了 CPU 会上去。4.3 层级管理和事件穿透自定义 SVG 元素加到图表之后还有一个容易忽视的问题事件穿透。如果你画的圆形恰好覆盖在某个地图区域上方用户鼠标就没法触发地图区域本身的点击事件了。Highcharts 的 SVG 元素默认是可以响应指针事件的你需要在自定义元素上把pointer-events设置成nonerenderer.circle(x, y, 6) .attr({ fill: #ff9900, pointer-events: none }) .add();或者反过来如果你希望点击这个自定义圆形能触发自定义逻辑那就要给它绑定事件。SVG 元素不像 HTML 元素可以直接addEventListener但 Highcharts 的元素对象自己封装了一个on方法circle.on(click, function() { // 你的业务逻辑 });这个方法在 Highcharts 内部是用事件委托实现的比硬绑定在每个 DOM 上性能好也方便后面解绑。5. 性能优化与避坑指南5.1 渲染成千上万个点时也会卡SVG 虽好但它的性能上限比 Canvas 低这个事实得认。当你的 series 数据点超过几千甚至上万页面上的 DOM 节点数量就会爆炸图表交互会明显变卡。Highcharts 官方推荐的方案是引入boost模块它会自动把大数据量的散点图、折线图底层渲染切换到 Canvas。不过要提醒你一下boost 模块开启后图表内部的 SVG 节点就不会为每个点单独生成了你的自定义renderer绘制的逻辑也要跟着适配。比如你想给某个点加自定义标记boost 模式下这个点可能根本不存在对应的 SVG 标签。如果遇到这种场景我的建议是关闭 boost或者用series.enableMouseTracking关闭一部分点的鼠标追踪来省开销。5.2 中文乱码和字体问题导出 SVG 后最常见的坑是中文变方块。原因在于SVG 文件本身不包含字体它的字体渲染完全依赖打开它的环境。如果你的图表在页面上显示正常但导出后在别的电脑上打开中文变成方块十有八九是目标环境里没有你当前使用的字体。解决办法是在 Highcharts 配置里把字体设置成一个跨平台通用的字体族比如Highcharts.setOptions({ chart: { style: { fontFamily: PingFang SC, Microsoft YaHei, sans-serif } } });同时如果你在导出图片时把 SVG 画到了 Canvas还要注意 Canvas 环境下字体的加载时机。我在项目里就碰到过导出的时候字体还没加载完结果 Canvas 画出来是默认字体和页面有细微差别。解决的办法是用document.fonts.ready.then(...)确保字体加载完成再执行导出。5.3 SVG 字符串体积过大当图表数据点多、坐标轴刻度密时getSVG()拿到的字符串可能动辄几百 KB。这在传输、存储时都有点浪费。我建议在生成 SVG 后做一次简单的清理把连续空格和换行去掉把一些默认值属性删掉。例如var cleanSvg svgString .replace(/\s/g, ) .replace(/ /g, );这招配合后端做 PDF 报告时有奇效文件小、解析快。你要是还想进一步压缩可以引一个 SVGO 这样的工具库能帮你删掉大量冗余的坐标点不过我一般只在导出最终 PNG 的离线脚本里用前端实时导出用多了反而耗时。5.4 导出 SVG 后颜色与页面不一致明明页面上是深蓝色导出的 SVG 却变成深紫色这种玄学问题我也遇过。后来排查发现是页面里的全局 CSS 影响了 SVG 内部元素的继承样式。Highcharts 在导出时会把部分样式解析成内联样式但某些复杂选择器的样式覆盖它算不到。想彻底解决可以在导出前主动把目标元素的内联样式补齐比如// 导出前强制设置系列颜色为内联样式 chart.series.forEach(function(series) { series.points.forEach(function(point) { point.graphic.attr({ fill: point.color }); }); });这种方式比较暴力但确实有效。生产环境里我只在明确感知到颜色差异时才这么干平时不建议开这个逻辑因为逐个设置属性会加点渲染开销。6. 排查技巧SVG 渲染器出的问题怎么定位最快图表出问题别急着改代码先打开控制台执行一下chart.container.innerHTML看看 SVG 结构。这一步能筛掉 80% 的问题。如果连 SVG 结构都不对那问题在数据或配置如果结构对但显示不对那问题在样式或坐标计算。另外推荐一个排查 SVG 低效代码的习惯用浏览器开发者工具直接检查某个元素看看它在页面上对应的坐标盒和父级关系。SVG 元素如果没有width或height在控制台里可能一直显示为 0别慌先看它的图形属性是不是有d或r。还要提一个比较隐蔽的坑useHTML属性。Highcharts 允许你在某些组件里开启 HTML 渲染比如数据标签、tooltip这样它们会成为 SVG 内部的foreignObject。但注意导出的 SVG 字符串里如果有foreignObject很多图片预览工具压根不认打开只显示一个框框。如果导出是你的核心需求useHTML尽量别设成true或者导出前临时替换掉。我自己的习惯是整个 SVG 渲染器相关的功能写完后会专门做一个导出测试页把各种导出组合都过一遍直接.svg文件、转 PNG、放到 PDF 里确保最终产物样式一致。这比每次临时抱佛脚查问题要省心得多。随笔写到这里我再分享一个个人习惯我通常会在项目的全局样式文件里给.highcharts-container svg加一条overflow: visible。因为很多自定义的标注元素、数据标签会超出图表的可视区域不加这个就会被裁切掉。加了之后图表四周的越界元素就能正常显示导出时也更符合预期。这个改动很小但能省掉很多奇怪的问题排查时间。