图表库原理与大屏可视化性能优化
图表库面试就考两件事:数据怎么变成屏幕上的像素,以及大屏卡了从哪一层查。 文章把「模型 → 数据 → 坐标系 → 图元 → 渲染器」这条链路说清,给出 ECharts 采样 / 渐进渲染 / large 模式的真实默认值与生效条件, 再给一套能当场复述的分层定位清单。
一句话概括
图表库的本质是一条固定流水线:你写一份声明式的 option,它被解析成模型;模型里的数据经过坐标系和比例尺换算成坐标;坐标变成图元;图元被渲染器画到 Canvas 或 SVG 上。 这五步里任何一步做多了,大屏就卡。
面试为什么问这个?因为”用过 ECharts”和”知道 ECharts 在干什么”完全是两回事:
- 前者遇到卡顿只会加参数试——
large: true试一下、animation: false试一下,试不出来就没招了。 - 后者能一眼说出”你这是每帧全量
setOption,问题在调度层,不在渲染层”。
这篇分两半:前半讲这条流水线是怎么走的(图表库原理),后半讲大屏卡了怎么按层定位(性能优化)。
核心知识点
1. 五步链路:从 option 到像素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
你的 option
│
▼ ① 模型层:解析 option、合并默认值、建组件树
GlobalModel ── SeriesModel ── ComponentModel(xAxis / legend / tooltip …)
│
▼ ② 数据层:data / dataset → DataStore(维度归一化 + encode 编码)
SeriesData(列式存储,后续所有读写都在这一层)
│
▼ ③ 布局层:坐标系 + 比例尺 + 视觉编码
Cartesian2D / Polar / Geo / Matrix × Scale(定义域 → 像素值域)
│
▼ ④ 图元层:造出「可复用的图形对象」
zrender Element(Path / Text / Group),每个都带 normal / emphasis / blur / select 四套状态
│
▼ ⑤ 渲染层:Painter 在一帧里统一刷
Canvas 2D 或 SVG
这套分工和 ZRender 的模块划分是对得上的(它的源码就是 Storage.ts / PainterBase.ts / Handler.ts 三个文件撑起来的):
| 角色 | ECharts | ZRender(底层渲染引擎) |
|---|---|---|
| 模型(M) | GlobalModel / SeriesModel | Storage:存所有待绘图元 |
| 视图(V) | ChartView / ComponentView | Painter:Canvas 或 SVG 两套实现 |
| 控制(C) | echarts 实例 + Scheduler | Handler:把画布 DOM 事件代理成图元事件 |
| 动画 | — | Animation / Animator |
三个容易答漏的点:
- 渲染是按优先级分任务的,不是一步到底。 ECharts 的
Scheduler把工作切成有 stage 优先级的 task:数据处理(过滤/堆叠/统计)在最前,然后是布局和视觉编码,标签避让这类”依赖最终图形位置”的活放在最后(后布局)。这就是为什么labelLayout回调里能拿到每个标签的包围盒——那时候图形位置已经算完了。 Storage不只是个数组。 每帧绘制前它会按zlevel → z → z2 → 插入顺序做一次排序。所以同层内谁盖住谁由z决定,而不是你add的先后。- zlevel 换的是「物理图层」。 不同
zlevel的图元被画在不同的 canvas 元素上。所以”背景不动、上层高频更新”时把它们拆到不同 zlevel,就省掉一半重绘——代价是多一张 canvas 的内存,别滥用。
2. 坐标系和比例尺才是”图表库”的分界线
面试常问:”折线图里的点是怎么落到坐标上的?”答案就一个词:比例尺(scale)。
比例尺是一个纯函数:把数据的定义域(domain)映射到像素值域(range)。
1
2
3
4
5
6
7
// 手写一个最小线性比例尺,d3.scaleLinear 的骨架就是它
function linear([d0, d1], [r0, r1]) {
const k = (r1 - r0) / (d1 - d0) // 缩放系数
return (v) => r0 + (v - d0) * k
}
const x = linear([0, 100], [0, 400])
x(50) // 200
这里有个新手必踩的坑,也是面试官爱追问的地方:
1
2
3
4
5
6
7
8
// ❌ 默认定义域一定从 0 开始
const bad = (v, max, width) => (v / max) * width
// domain = [40, 60] 时,50 应该落在正中间 200,结果算出 333
bad(50, 60, 400)
// ✅ 永远用定义域两端去算
const good = (v, d0, d1, width) => ((v - d0) / (d1 - d0)) * width
good(50, 40, 60, 400) // 200
除了”连续映射”,图表里还有第二类比例尺:分类轴。它不映射数值,而是把画布宽度等分成 N 个”带”(band),每个带里居中放一根柱子——这就是柱状图为什么必须用类目轴,也是 bandwidth 这类 API 存在的理由。ECharts 里管这层叫 Scale(时间轴是 TimeScale,多做了一步”按年/月/日挑整点刻度”),6.0 重构过一次实现,但职责没变。
还有个小细节值得知道:数值轴的 min / max 不是原样照抄你的数据的,会先跑一次”取整”(nice)——把 [43.7, 96.2] 拉到 [40, 100],再把刻度步长凑成 5 或 10 的整数倍。ECharts 的 min / max / interval / splitNumber 就在干涉这一步。
3. 图元带「状态」,所以 hover 不需要你重画
一个图元不是”一个矩形”,而是一份几何 + 一份样式 + 几套状态。ECharts 的 emphasis / blur / select 就挂在图元上,鼠标划过时不是重新 setOption,而是图元自己在状态之间切换(默认 stateAnimation.duration 是 300ms)。
这条认知能直接解释一个现象:大屏上”鼠标划过图表就卡”通常不是数据处理慢,而是状态动画 + hover 层的开销。 解法是关掉或缩短 stateAnimation、给大面积的图元关掉 emphasis。
另外两个高频参数,先说结论:
useDirtyRect(脏矩形)默认是false。 官方文档原话就是 “Enable dirty rectangle rendering or not,falseby default”。它只对”大部分区域不变、每帧只改一小块”的场景有收益;整屏都在动时,合并脏矩形本身也是成本。devicePixelRatio默认取window.devicePixelRatio。 高分屏大屏上如果手动传了个偏小的值,图会糊;反之传太大,等于让 GPU 多画几倍的像素。
4. 大数据量四条路 + 真实默认值
这是真题最爱问的一段。先说清四条路各自解决什么:
| 思路 | 做什么 | 典型选项 |
|---|---|---|
| 少画点 | 把 100 万个点降成屏幕能画下的 N 个 | sampling: 'lttb' |
| 分帧画 | 一帧只画一批,剩下的丢给后续帧 | progressive + progressiveThreshold |
| 换画法 | 关掉逐点交互,改成批量描一条路径 | large: true + largeThreshold |
| 只画可见的 | 视窗外的数据根本不参与绘制 | dataZoom |
下面这些默认值来自 echarts@6.1.0 的源码和类型定义(不是文档抄来的),面试直接引用会很有说服力:
| 选项 | 含义 | 默认值 |
|---|---|---|
animationThreshold | 数据量超过它就自动关动画 | 2000 |
progressiveThreshold | 超过它才启用渐进渲染 | 3000 |
progressive | 每帧画多少条 | 全局 400;折线图是 0(关闭);柱状 / K 线是 3000 |
progressiveChunkMode | 分片方式 | 'sequential';K 线用 'mod' |
hoverLayerThreshold | 超过它就把 hover 效果单独画一层 | 3000 |
sampling | 采样算法 | 折线图 'none'(关闭) |
largeThreshold | 大数据模式的门槛 | 散点 2000 / 柱状 400 / K 线 600 / 路径线 2000 |
注意折线图那两行:progressive 默认 0、sampling 默认 'none'。也就是说折线图开箱状态是两条优化都没开,这也是”我明明开了 ECharts 怎么还是卡”的最常见原因。渐进渲染的生效条件还多一道:数据量必须 >= progressiveThreshold,只调 progressive 不调 progressiveThreshold 是没用的。
sampling 全部可选值:'none' | 'average' | 'min' | 'max' | 'minmax' | 'sum' | 'lttb',也可以传自定义函数。
1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 100 万点直接全量交给折线图:首屏白屏 + 主线程长任务
series: [{ type: 'line', data: millionPoints }]
// ✅ 采样 + 渐进渲染一起上,再用 dataZoom 兜住可见范围
series: [{
type: 'line',
data: millionPoints,
sampling: 'lttb', // 少画点:保留拐点的降采样
progressive: 5000, // 分帧画:一帧 5000 条
progressiveThreshold: 10000,
}],
dataZoom: [{ type: 'inside' }, { type: 'slider' }],
5. 采样的真实机制(这一节最值钱)
sampling 不是”渲染时少画几条”,它是在数据层真的把 series 的数据换掉了:源码里注册在数据处理阶段(”Down sample after filter”),做的事情是 seriesModel.setData(data.lttbDownSample(维度, 1 / rate))。
而且 rate 不是固定值,是算出来的:
1
2
rate = round(数据量 / (基准轴的像素长度 × devicePixelRatio))
只有 rate > 1 才采样
三个推论,讲出来就是加分项:
- 采样率自适应窗口。 图表变窄、DPR 变小,
rate变大(采得更狠);图表拉大、放大到只看一小段,rate就趋近 1,采样自动退出、细节自己回来了。这就是”看起来无损”的原因。 - 它是真的丢数据。 因为 data 被替换了,tooltip 能命中的点、
dataIndex对应的原始下标、getOption()里那份数据结构,都跟你的原始数组对不上了。需要”精确高亮第 5000 条”这类能力的图表,别用采样,宁可自己在前端降采样并保留原始 index。 average会削峰,lttb不会。lttb是 Largest-Triangle-Three-Buckets,按”构成最大三角形”挑点,保留波形拐点;average/sum会把一个尖峰平均掉,做监控告警图时可能因此漏掉那个毛刺。
另外它有两个生效前提:数据量 > 10,且坐标系必须是直角坐标系(cartesian2d)——极坐标、地图上用不了。
6. 大屏卡了:先分层定位,再动手
这一步的顺序比任何优化参数都值钱。 上来就调参数,等于凭感觉改 bug。
第 0 步:量。 打开 Performance 面板录 5 秒。看三件事——主线程有没有长任务(Long Task)、长任务里跑的是不是 setOption 相关、以及是 JS 计算慢还是渲染(rasterize / GPU)慢。
然后按下面五层往下排:
| 层 | 典型症状 | 手段 |
|---|---|---|
| 调度层 | 主线程被 setOption 占满 | 推送先聚合;只更新变化的 series,别重建整个 option |
| 渲染层 | JS 快但 FPS 低 | 第 4 节的四条路 + zlevel 分层 + useDirtyRect |
| 布局层 | 有 Layout / Recalculate Style | 滚动列表虚拟滚动;少在动画里读 offsetHeight、getBoundingClientRect() |
| 内存层 | 越开越卡、内存单调上涨 | 历史数据滑动窗口裁剪,别无限 push |
| 生命周期层 | 切走再回来更卡、三天后更卡 | 清定时器、dispose() 实例、解绑 resize 监听 |
调度层是 90% 的现场,因为它最容易写错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 每秒把一个全新的 option 丢进去,图例、坐标轴、tooltip 全跟着重解析一遍
setInterval(() => {
chart.setOption({ series: [{ type: 'line', data: fetchData() }] })
}, 1000)
// ✅ 推送先聚合,只更新数据
const buf = []
function onPush(point) {
buf.push(point)
}
setInterval(() => {
if (!buf.length) return
const data = buf.splice(0) // 双缓冲:取走快照,新数据继续往 buf 里进
chart.setOption({ series: [{ data }] }) // 默认合并语义:没提到的项保持原样
}, 200)
setOption 的默认行为是合并,这是设计好的:你只给变的那部分,它就在 series 级别做 diff。所以——
1
2
3
4
5
// ❌ notMerge: true 等于每次全量重建,前面的合并优化全废
chart.setOption(option, true)
// ✅ 确实想丢掉旧系列时,用 replaceMerge 精确指定要替换的组件类型
chart.setOption({ series: nextSeries }, { replaceMerge: 'series' })
收尾(生命周期层)一行都不能少:
1
2
3
4
5
6
let timer = setInterval(flush, 200)
onUnmounted(() => {
clearInterval(timer)
chart.dispose() // 不 dispose = canvas + 图元树 + rAF 循环全泄漏在内存里
})
还有 resize。大屏常常嵌在 iframe 里,iframe 自己的尺寸变化不会触发父窗口的 resize 事件,所以推荐用 ResizeObserver 观察容器;并且把 resize() 丢进 rAF 里,避免”回调里改尺寸导致再次触发回调”的循环告警:
1
2
3
4
5
6
let raf = 0
const ro = new ResizeObserver(() => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => chart.resize()) // 推迟到下一帧,断开回路
})
ro.observe(document.getElementById('box'))
7. 大屏的尺寸适配:为什么最后都改成 transform: scale
大屏和手机 H5 的适配诉求根本不同:
- 手机 H5:宽度差异小(320~430),高度可变,用户可以滚动 → 用
vw/rem做等比缩放最自然。 - 大屏:比例差异大(16:9 / 21:9 / 拼接超宽屏),要求”铺满、不出现滚动条、保持设计稿比例”,而且字体、间距、图表内部字号要按同一个系数一起缩。
transform: scale() 正好一次解决:
1
2
3
4
5
6
7
8
9
const DESIGN_W = 1920
const DESIGN_H = 1080
function fit() {
const el = document.getElementById('screen')
const scale = Math.min(innerWidth / DESIGN_W, innerHeight / DESIGN_H)
el.style.transformOrigin = '0 0'
el.style.transform = 'scale(' + scale + ')'
}
rem 方案在大屏上有两个具体麻烦:一是它只缩放”你自己写的那些 rem 值”,ECharts 内部的字号、tooltip、toolbox 是它自己算的,不跟着缩,你还得单独给图表配字号;二是根字号被放大后,1px 边框和 canvas 的高 DPI 缩放更容易出糊边。这也是很多大屏项目最后从 rem 换成 scale 的原因。
代价也要说清楚:非整数比例缩放会有一点模糊(文字最明显),以及缩放后四周会留白(用同色背景填充或按比例居中)。
其实你每天都在用
- 折线图 hover 才显示数值,那不是数据变了,是图元从 normal 切到了
emphasis。 - 大屏上 0.5 秒滚一次的公告栏,如果每次滚动都重新拉接口 +
setOption,它大概率就是这个页面越来越卡的元凶。 - 监控面板刷新时”图表缩放位置重置了一下”,通常是 series 数组被整体换了个新实例,甚至传了
notMerge。 - 手机上打开 ECharts 页面,双指放大后文字变糊,是 canvas 按
devicePixelRatio初始化好了、缩放后没有重绘。 - 列表里那个”迷你趋势图”,本质就是把 line series 塞进一个极小的 grid,不加坐标轴、不给交互。
- React 里用
ref挂图,卸载时只清空了 DOM 没dispose(),HMR 几次之后就明显变卡。 - 大屏上”每 3 秒动一次”的数字滚动,如果历史数据一直往数组里 push,跑一周内存就下不来。
- 一个页面上 30 个图表 = 30 个 canvas + 30 套独立的帧循环,再用
echarts.connect联动一下,开销是叠加的。
常见误解(FAQ)
❌ 误区1:”数据量大就把 progressive 调大。” 它只是”每帧画多少条”,不是总开关,而且折线图的 progressive 默认就是 0(关闭),柱状图和 K 线才默认 3000。另外它还有一道门槛:数据量必须 >= progressiveThreshold(默认 3000)才启用。所以折线图要同时给 progressive 和 progressiveThreshold,只调前者等于没开。
❌ 误区2:”开了 useDirtyRect 一定更快。” 官方默认值就是 false。它只在”静态内容多、每帧变动区域小”时才有收益;整屏都在刷新的时候,计算并合并脏矩形的成本可能超过省下的绘制成本。要开,先在 Performance 里确认瓶颈确实在绘制。
❌ 误区3:”采样只是少画点,数据没变,所以随便开。” 采样是在数据层真的把数据换掉了(seriesModel.setData(降采样结果)),所以 tooltip 可命中的点变少、dataIndex 不再对应原始数组下标、getOption() 里也是降采样后的数据。它只是”视觉上看起来没变”。需要精确定位某一条数据的图表(比如点击某行表格要高亮对应的点),要么关采样,要么自己降采样并保住原始 index。
❌ 误区4:”大屏卡,先让后端少返点数据。” 多数大屏的卡顿跟数据量无关。Performance 面板里主线程的长任务,90% 是”每秒全量 setOption + 重建 option 对象”。先按第 6 节那五层排一遍,最后才谈砍数据。
❌ 误区5:”setOption 应该一律加 notMerge: true,保证数据干净。” notMerge: true 会把整个 option 重置,等于每次全量重建,前面讲的合并优化全废。高频更新场景下默认的合并语义才是对的;真需要丢弃旧系列,用 replaceMerge 精确指定(setOption({ series }, { replaceMerge: 'series' })),别全局开 notMerge。
❌ 误区6:”组件卸载时把容器 DOM 清空,就等于把图表销毁了。” 不是。清空 innerHTML 只是摘掉了 canvas 节点,ECharts 实例、zrender 的图元树、事件代理、还在跑的帧循环都还挂在内存里。必须显式 chart.dispose(),并和 clearInterval 一起放进卸载钩子。
一句话总结
图表库就是一条流水线:option → 模型 → 数据 → 坐标系与比例尺 → 图元 → 渲染器;大屏优化就是先量出这条流水线哪一段最慢,再决定是少画点(采样)、分帧画(渐进)、换画法(large 模式)、还是干脆别画(dataZoom)。