前端小白别慌!3天搞定绘图图表:从Canvas到ECharts实战避坑指南
前端小白别慌!3天搞定绘图图表:从Canvas到ECharts实战避坑指南
开场先唠两句
说实话,我刚入行那会儿看到"数据可视化"四个字就头大。什么D3.js、WebGL、SVG,听着跟天书似的,感觉没个三五年功力根本碰不得。结果呢?入职第一周老板就扔过来个需求:“把这个销售数据做个大屏展示,要炫酷,要能动,明天给初版看看”。
我当时内心是崩溃的,但脸上还得保持微笑。
后来摸爬滚打这几年算是悟了——可视化这玩意儿,说白了就是拿代码画画。老板觉得没图就没数据,没数据就显得咱们工作不饱和,这话虽然扎心,但确实是现状。你辛辛苦苦写了几千行逻辑,不如一张花花绿绿的折线图来得直观。产品经理拍板的时候,左边是密密麻麻的表格,右边是带动画的柱状图,傻子都知道选哪个。
所以今天咱们不整那些虚头巴脑的概念,就聊聊怎么把手里的数据变成能让产品经理闭嘴、让老板点头的漂亮图表。别被那些高大上的名词吓住,什么SVG、WebGL、Canvas,剥了皮都是画线条填颜色。我踩过的坑你不用再踩,我熬过的夜你不用再熬,咱们直接上干货。
这几种画图路子到底啥区别
刚开始学的时候我也懵,网上一搜"前端图表方案",哗啦啦出来十几种技术栈,看得人选择困难症都犯了。其实说白了就三大类:像素级操控的Canvas、矢量描述的SVG、以及那些封装好的图表库。咱们一个个掰扯清楚。
Canvas就是块大画布
Canvas这货本质上就是给你一块像素矩阵,你爱咋折腾咋折腾。想画条线?自己算起点终点然后stroke。想画个圆?用arc方法或者用贝塞尔曲线硬怼。自由度极高,但这也意味着所有事情都得你自己干。
它最适合什么场景呢?动效贼多、数据量巨大、或者需要像素级控制的地方。比如那种监控大屏,实时刷新几千个数据点,还得有各种粒子效果、流光动画,这时候Canvas的性能优势就体现出来了。因为它直接操作像素,没有DOM那一套复杂的布局计算,帧率能稳得住。
但缺点也很明显——交互麻烦。你想给某个数据点加hover效果?得自己算鼠标位置是不是在那个点的范围内,自己维护状态,自己重绘。代码量蹭蹭往上涨,头发蹭蹭往下掉。
SVG是矢量图的温柔乡
SVG走的是另一条路子。它是用XML描述图形,画个矩形就是<rect>,画个圆就是<circle>,路径用<path>的d属性定义。因为是矢量,放大缩小永远不会失真,而且每个元素都是DOM节点,天然支持CSS样式和事件监听。
这意味着什么?做交互式图表首选SVG。鼠标悬停显示提示框?直接给元素绑mouseenter事件。点击高亮?改个fill属性就行。想要响应式?SVG的viewBox属性让你缩放无忧。
但SVG也有软肋。当数据量特别大的时候,比如几万个点,DOM节点数量爆炸,浏览器渲染压力山大,卡顿是难免的。而且SVG的动画性能不如Canvas,复杂的路径计算也会消耗CPU。
图表库:香是真香,坑也是真坑
ECharts、Chart.js、AntV…这些库把底层封装得严严实实,配置几个option就能出图,确实爽。但问题也来了:想搞点非标定制就得头秃。
ECharts的文档我翻烂了,有些配置项藏得比宝藏还深,想改个坐标轴的刻度线长度,可能得在github issue里翻半天。而且体积问题不容忽视,为了画个简单的饼图引入几百KB的库,打包的时候看着bundle分析图,心都在滴血。
我的建议是:常规需求用库,特殊需求自己造。别动不动就想着从零写个图表库,那是自虐;但也别完全依赖库,关键时刻得知道底层原理,不然遇到bug只能干瞪眼。
WebGL?别急着堆高科技
现在一说高性能可视化就有人喊WebGL,仿佛不用GPU加速就落伍了。但说实话,大部分项目根本用不上这玩意儿。几千个点的数据量,Canvas完全能扛得住;几万个点,优化一下绘制策略也能跑。WebGL的学习成本高得吓人,着色器语法、缓冲区管理、矩阵变换…没有图形学基础真的劝退。
什么时候才该上WebGL?真正的海量数据,比如地理信息可视化里几十万个点,或者需要复杂的三维效果。否则就是杀鸡用牛刀,为了炫技把用户浏览器跑崩了,得不偿失。
手把手教你撸代码
光说不练假把式,咱们直接上手写代码。先从最基础的Canvas开始,理解底层原理后再用库,心里才有底。
从零开始用Canvas画个折线图
别急着调库,咱们先徒手撸一个。理解坐标系转换是关键,数据值怎么映射到像素坐标,这是所有图表的基石。
// 先搭个架子,获取canvas上下文
const canvas = document.getElementById('myChart');
const ctx = canvas.getContext('2d');
// 设置画布尺寸,记得处理高清屏模糊问题
function setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
// 实际渲染尺寸 = 显示尺寸 × 像素比,防止模糊
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// 用CSS控制显示尺寸,canvas内部用缩放适配
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
// 所有绘制操作都按像素比缩放,保证线条清晰
ctx.scale(dpr, dpr);
return { width: rect.width, height: rect.height };
}
const { width, height } = setupCanvas(canvas);
// 模拟数据:一周的销售额
const data = [120, 200, 150, 80, 70, 110, 130];
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 图表配置,留边距给坐标轴
const padding = { top: 40, right: 30, bottom: 40, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 核心:数据值映射到Y轴像素坐标
function mapY(value, maxValue) {
// 数据值越大,Y坐标越小(因为canvas原点在左上角)
return padding.top + chartHeight - (value / maxValue) * chartHeight;
}
// X轴均匀分布
function mapX(index, total) {
return padding.left + (index / (total - 1)) * chartWidth;
}
// 开画!
function drawLineChart() {
const maxValue = Math.max(...data) * 1.2; // 留20%顶部空间
// 清空画布,别留下一帧的残影
ctx.clearRect(0, 0, width, height);
// 画网格线,让图表不那么光秃秃
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
ctx.beginPath();
// 横向网格线,分5档
for (let i = 0; i <= 5; i++) {
const y = padding.top + (chartHeight / 5) * i;
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
// 顺便把Y轴刻度值写上
const value = Math.round(maxValue - (maxValue / 5) * i);
ctx.fillStyle = '#666';
ctx.font = '12px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(value, padding.left - 10, y + 4);
}
ctx.stroke();
// 画折线,这是重头戏
ctx.strokeStyle = '#5470c6';
ctx.lineWidth = 3;
ctx.lineCap = 'round'; // 线端圆润点,别那么生硬
ctx.lineJoin = 'round'; // 拐角也圆润
ctx.beginPath();
data.forEach((value, index) => {
const x = mapX(index, data.length);
const y = mapY(value, maxValue);
if (index === 0) {
ctx.moveTo(x, y);
} else {
// 加点平滑曲线效果,用贝塞尔曲线连接,别直愣愣的折线
const prevX = mapX(index - 1, data.length);
const prevY = mapY(data[index - 1], maxValue);
const cp1x = prevX + (x - prevX) / 2;
const cp1y = prevY;
const cp2x = prevX + (x - prevX) / 2;
const cp2y = y;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
}
});
ctx.stroke();
// 画数据点,让用户知道这里有数据
data.forEach((value, index) => {
const x = mapX(index, data.length);
const y = mapY(value, maxValue);
// 外圈白边,内圈主题色,这样不管背景啥颜色都看得清
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#5470c6';
ctx.lineWidth = 2;
ctx.stroke();
// X轴标签
ctx.fillStyle = '#666';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(labels[index], x, height - padding.bottom + 20);
});
}
drawLineChart();
看到没?就这么百来行代码,一个像模像样的折线图就出来了。关键点在于坐标映射:你的数据是0-200的业务值,但canvas要的是0-400的像素值,这个转换公式得写对。还有那个devicePixelRatio的处理,很多新手忽略这个,结果在MacBook上看着线条发虚,怎么调都调不清晰。
SVG里动态生成path路径
SVG的<path>元素那个d属性,看着像一堆乱码:M10 10 L90 90,其实全是套路。M是moveto(移动画笔),L是lineto(画直线),Q是二次贝塞尔曲线,C是三次贝塞尔曲线。掌握了这几个,你就能画出任意形状。
// 用同样的数据,这次用SVG画
function createSVGChart() {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
// 设置viewBox实现响应式,这才是SVG的精髓
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.overflow = 'visible'; // 让标签可以超出画布一点
const maxValue = Math.max(...data) * 1.2;
// 生成折线路径的d属性
let pathD = '';
data.forEach((value, index) => {
const x = mapX(index, data.length);
const y = mapY(value, maxValue);
if (index === 0) {
pathD += `M ${x} ${y}`;
} else {
// 同样用贝塞尔曲线做平滑处理
const prevX = mapX(index - 1, data.length);
const prevY = mapY(data[index - 1], maxValue);
const cp1x = prevX + (x - prevX) / 2;
const cp1y = prevY;
const cp2x = prevX + (x - prevX) / 2;
const cp2y = y;
pathD += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x} ${y}`;
}
});
// 创建路径元素,加点样式
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', pathD);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', '#5470c6');
path.setAttribute('stroke-width', '3');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
// 加个动画效果,让线条像画画一样长出来
const length = Math.ceil(path.getTotalLength ? path.getTotalLength() : 1000);
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;
path.style.animation = 'drawLine 2s ease-out forwards';
// 插入CSS动画
const style = document.createElement('style');
style.textContent = `
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
`;
document.head.appendChild(style);
svg.appendChild(path);
// 数据点用circle元素,每个都是独立的DOM节点
data.forEach((value, index) => {
const x = mapX(index, data.length);
const y = mapY(value, maxValue);
const group = document.createElementNS(svgNS, 'g');
group.style.cursor = 'pointer'; // 鼠标放上去变手型,提示可交互
// 外圈
const outerCircle = document.createElementNS(svgNS, 'circle');
outerCircle.setAttribute('cx', x);
outerCircle.setAttribute('cy', y);
outerCircle.setAttribute('r', 8);
outerCircle.setAttribute('fill', 'white');
outerCircle.setAttribute('stroke', '#5470c6');
outerCircle.setAttribute('stroke-width', '2');
// 内圈实心点
const innerCircle = document.createElementNS(svgNS, 'circle');
innerCircle.setAttribute('cx', x);
innerCircle.setAttribute('cy', y);
innerCircle.setAttribute('r', 3);
innerCircle.setAttribute('fill', '#5470c6');
// 交互:鼠标悬停放大
group.addEventListener('mouseenter', () => {
outerCircle.setAttribute('r', 12);
outerCircle.setAttribute('stroke-width', '3');
showTooltip(x, y, `${labels[index]}: ${value}`);
});
group.addEventListener('mouseleave', () => {
outerCircle.setAttribute('r', 8);
outerCircle.setAttribute('stroke-width', '2');
hideTooltip();
});
group.appendChild(outerCircle);
group.appendChild(innerCircle);
svg.appendChild(group);
// X轴标签
const text = document.createElementNS(svgNS, 'text');
text.setAttribute('x', x);
text.setAttribute('y', height - padding.bottom + 20);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('fill', '#666');
text.setAttribute('font-size', '12');
text.textContent = labels[index];
svg.appendChild(text);
});
return svg;
}
// 简单的提示框实现,实际项目中可以用绝对定位的div
function showTooltip(x, y, content) {
let tooltip = document.getElementById('chart-tooltip');
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'chart-tooltip';
tooltip.style.cssText = `
position: fixed;
background: rgba(0,0,0,0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
transition: opacity 0.2s;
`;
document.body.appendChild(tooltip);
}
tooltip.textContent = content;
tooltip.style.opacity = '1';
tooltip.style.left = (canvas.getBoundingClientRect().left + x) + 'px';
tooltip.style.top = (canvas.getBoundingClientRect().top + y - 40) + 'px';
}
function hideTooltip() {
const tooltip = document.getElementById('chart-tooltip');
if (tooltip) tooltip.style.opacity = '0';
}
document.getElementById('chart-container').appendChild(createSVGChart());
SVG的优势在这里体现得淋漓尽致:每个数据点都是独立的DOM元素,绑事件、改样式、加动画都极其方便。那个getTotalLength()方法配合stroke-dasharray动画,能让折线像画画一样逐渐显示,效果非常细腻,用Canvas实现同样的效果得写一堆状态管理代码。
给图表加交互,这才是体现功力的地方
静态图表只能叫"图",有交互的才叫"表"。鼠标悬停显示详情、点击筛选、拖拽缩放…这些细节才是区分菜鸟和老手的地方。
// 基于上面的Canvas折线图,加上完整的交互系统
class InteractiveLineChart {
constructor(canvas, data, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.data = data;
this.options = {
padding: { top: 40, right: 30, bottom: 40, left: 50 },
pointRadius: 6,
hoverRadius: 10,
...options
};
this.hoveredIndex = -1; // 当前悬停的数据点索引
this.animationId = null;
this.init();
}
init() {
this.setupCanvas();
this.bindEvents();
this.animate();
}
setupCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
this.ctx.scale(dpr, dpr);
this.width = rect.width;
this.height = rect.height;
this.chartWidth = this.width - this.options.padding.left - this.options.padding.right;
this.chartHeight = this.height - this.options.padding.top - this.options.padding.bottom;
this.maxValue = Math.max(...this.data.map(d => d.value)) * 1.2;
}
// 坐标映射方法
mapX(index) {
return this.options.padding.left + (index / (this.data.length - 1)) * this.chartWidth;
}
mapY(value) {
return this.options.padding.top + this.chartHeight - (value / this.maxValue) * this.chartHeight;
}
bindEvents() {
// 鼠标移动检测悬停
this.canvas.addEventListener('mousemove', (e) => {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 找到最近的数据点,距离小于阈值才算悬停
let minDistance = Infinity;
let nearestIndex = -1;
this.data.forEach((d, i) => {
const px = this.mapX(i);
const py = this.mapY(d.value);
const dist = Math.sqrt((x - px) ** 2 + (y - py) ** 2);
if (dist < minDistance && dist < 30) { // 30px的感应范围
minDistance = dist;
nearestIndex = i;
}
});
if (nearestIndex !== this.hoveredIndex) {
this.hoveredIndex = nearestIndex;
this.canvas.style.cursor = nearestIndex >= 0 ? 'pointer' : 'default';
}
});
// 鼠标离开画布清除悬停
this.canvas.addEventListener('mouseleave', () => {
this.hoveredIndex = -1;
});
// 点击事件,可以触发外部回调
this.canvas.addEventListener('click', () => {
if (this.hoveredIndex >= 0 && this.options.onPointClick) {
this.options.onPointClick(this.data[this.hoveredIndex], this.hoveredIndex);
}
});
}
draw() {
const { ctx, width, height } = this;
const { padding } = this.options;
ctx.clearRect(0, 0, width, height);
// 画网格和坐标轴(略,同前面的代码)
// 画折线
ctx.beginPath();
ctx.strokeStyle = '#5470c6';
ctx.lineWidth = 3;
this.data.forEach((d, i) => {
const x = this.mapX(i);
const y = this.mapY(d.value);
if (i === 0) ctx.moveTo(x, y);
else {
// 平滑曲线
const prevX = this.mapX(i - 1);
const prevY = this.mapY(this.data[i - 1].value);
const cp1x = prevX + (x - prevX) / 2;
const cp2x = prevX + (x - prevX) / 2;
ctx.bezierCurveTo(cp1x, prevY, cp2x, y, x, y);
}
});
ctx.stroke();
// 画数据点,悬停的点要突出显示
this.data.forEach((d, i) => {
const x = this.mapX(i);
const y = this.mapY(d.value);
const isHovered = i === this.hoveredIndex;
const radius = isHovered ? this.options.hoverRadius : this.options.pointRadius;
// 阴影效果,让点有立体感
if (isHovered) {
ctx.beginPath();
ctx.arc(x, y, radius + 4, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(84, 112, 198, 0.3)';
ctx.fill();
}
// 外圈
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = isHovered ? '#ff6b6b' : '#5470c6'; // 悬停变红色
ctx.lineWidth = isHovered ? 3 : 2;
ctx.stroke();
// 内圈
ctx.beginPath();
ctx.arc(x, y, radius * 0.4, 0, Math.PI * 2);
ctx.fillStyle = isHovered ? '#ff6b6b' : '#5470c6';
ctx.fill();
// 悬停时显示数值标签
if (isHovered) {
ctx.fillStyle = '#333';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(d.value, x, y - radius - 10);
// 画个指示线到X轴
ctx.beginPath();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]); // 虚线
ctx.moveTo(x, y + radius);
ctx.lineTo(x, height - padding.bottom);
ctx.stroke();
ctx.setLineDash([]); // 恢复实线
}
});
}
animate() {
this.draw();
this.animationId = requestAnimationFrame(() => this.animate());
}
// 销毁方法,防止内存泄漏
destroy() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
// 移除事件监听...
}
}
// 使用
const chart = new InteractiveLineChart(
document.getElementById('chart'),
[
{ label: '周一', value: 120 },
{ label: '周二', value: 200 },
// ...
],
{
onPointClick: (data, index) => {
console.log('点击了', data.label, '数值:', data.value);
// 这里可以跳转详情页或者弹窗
}
}
);
看到这段代码的精髓了吗?用requestAnimationFrame做渲染循环,而不是每次交互都强制重绘。这样动画流畅,性能也好。还有那个距离计算,用勾股定理算鼠标位置和每个数据点的距离,找到最近的,比单纯判断X坐标范围更准确。
响应式布局别搞崩了
窗口缩放时图表变形是最尴尬的,柱子被压扁、文字重叠、坐标轴溢出…这些问题都得防着。防抖节流得用上,不然resize事件能把你内存吃光。
class ResponsiveChart {
constructor() {
this.resizeTimer = null;
this.observer = null;
this.initResizeListener();
this.initResizeObserver(); // 现代浏览器推荐用这个
}
initResizeListener() {
// 防抖处理,别每次像素变化都重绘
window.addEventListener('resize', () => {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.handleResize();
}, 250); // 250ms内只执行一次
});
}
// ResizeObserver是更好的方案,能监听元素本身尺寸变化
initResizeObserver() {
if ('ResizeObserver' in window) {
this.observer = new ResizeObserver(entries => {
// 同样防抖
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
for (let entry of entries) {
const { width, height } = entry.contentRect;
this.redraw(width, height);
}
}, 100);
});
this.observer.observe(this.canvas.parentElement);
}
}
handleResize() {
// 重新计算尺寸并重绘
const rect = this.canvas.parentElement.getBoundingClientRect();
this.redraw(rect.width, rect.height);
}
redraw(width, height) {
// 更新canvas尺寸
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.canvas.style.width = width + 'px';
this.canvas.style.height = height + 'px';
this.ctx.scale(dpr, dpr);
// 重新计算布局参数
this.width = width;
this.height = height;
this.chartWidth = width - this.padding.left - this.padding.right;
this.chartHeight = height - this.padding.top - this.padding.bottom;
// 立即重绘
this.draw();
}
// 清理工作别忘了
destroy() {
clearTimeout(this.resizeTimer);
if (this.observer) {
this.observer.disconnect();
}
window.removeEventListener('resize', this.handleResize);
}
}
ResizeObserver比window.resize好用多了,前者监听容器变化,后者只能监听窗口。比如在flex布局里,侧边栏收缩导致图表区域变化,window.resize是感知不到的,但ResizeObserver能捕获到。
别光听好的,这些坑你得先踩为敬
我这些年掉进的坑,能填平一个太平洋。挑几个最痛的跟你们说道说道。
性能陷阱:数据一多页面卡成PPT
当年做智慧城市项目,要展示全市的出租车实时位置,两万多辆车,每5秒更新一次。我直接用Canvas画点,结果页面卡成幻灯片,风扇狂转,笔记本烫得能煎鸡蛋。
后来怎么解决的?分层渲染+脏矩形重绘。
class PerformanceChart {
constructor() {
// 创建两个canvas,一个画静态背景(网格、坐标轴),一个画动态数据
this.bgCanvas = document.createElement('canvas');
this.dataCanvas = document.createElement('canvas');
// 背景层只需要画一次,除非尺寸变化
this.bgDirty = true;
// 数据层只重绘变化区域
this.dirtyRegions = [];
}
draw() {
// 背景层
if (this.bgDirty) {
this.drawBackground(this.bgCanvas.getContext('2d'));
this.bgDirty = false;
}
// 数据层:不清空整个画布,只擦除需要更新的区域
const ctx = this.dataCanvas.getContext('2d');
// 用脏矩形局部清除,而不是clearRect(0,0,width,height)
this.dirtyRegions.forEach(rect => {
ctx.clearRect(rect.x - 2, rect.y - 2, rect.w + 4, rect.h + 4);
});
// 只重绘变化的数据点
this.changedDataPoints.forEach(point => {
this.drawPoint(ctx, point);
});
this.dirtyRegions = [];
this.changedDataPoints = [];
}
// 大数据量时还要做数据分层,只渲染视口内的
getVisibleData() {
const { startIndex, endIndex } = this.calculateVisibleRange();
return this.allData.slice(startIndex, endIndex);
}
// 虚拟滚动,DOM太多的时候用
updateVirtualScroll() {
const itemHeight = 30;
const scrollTop = this.container.scrollTop;
const viewportHeight = this.container.clientHeight;
const startIdx = Math.floor(scrollTop / itemHeight);
const endIdx = Math.min(
startIdx + Math.ceil(viewportHeight / itemHeight) + 1,
this.data.length
);
// 只渲染可见区域的DOM或Canvas元素
this.renderVisibleRange(startIdx, endIdx);
}
}
核心思想是:别重绘不需要重绘的东西。背景层一次画完缓存起来,数据层只更新变化的部分。几万个点不可能同时出现在屏幕上,做个视口裁剪,只画能看见的。
还有Web Worker也得用上,数据解析、坐标计算这些放后台线程,别阻塞主线程的渲染。
兼容性噩梦:安卓机的奇葩行为
某些老旧安卓机(说的就是某为的某款经典机型)上的Canvas,字体渲染能丑到让你怀疑人生。默认字体发虚,自定义字体加载失败,文字偏移几个像素…
// 字体加载检测,没加载完别急着画图
document.fonts.ready.then(() => {
this.draw(); // 确保字体到位了再渲染
});
// 或者保守点,用图片fallback
function drawTextWithFallback(ctx, text, x, y, options) {
try {
ctx.font = options.font || '14px sans-serif';
ctx.fillStyle = options.color || '#333';
ctx.fillText(text, x, y);
} catch (e) {
// 某些奇葩浏览器会抛异常,用DOM覆盖层兜底
this.createTextOverlay(text, x, y, options);
}
}
// 高清屏下文字模糊问题,除了devicePixelRatio,还要注意textBaseline
ctx.textBaseline = 'middle'; // 比默认的alphabetic更可控
ctx.textAlign = 'center';
还有Canvas的最大尺寸限制,某些浏览器单张Canvas不能超过4096×4096像素,超大屏拼接方案得提前规划好。
图表库的体积问题
ECharts完整版几百KB,AntV G2也是大胖子。为了画个简单的饼图引入全家桶,打包的时候心都在滴血。
// ECharts按需引入,别import * as echarts from 'echarts'
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts'; // 只引入需要的
import {
GridComponent,
TooltipComponent,
LegendComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
// 注册必须的组件
echarts.use([
BarChart, LineChart,
GridComponent, TooltipComponent, LegendComponent,
CanvasRenderer
]);
// 这样打包出来只有几十KB
或者用动态导入,图表不在首屏的话,别阻塞主bundle加载。
const loadChartLib = async () => {
if (!window.echarts) {
// 需要时再加载
const module = await import('echarts');
window.echarts = module;
}
return window.echarts;
};
样式定制难如登天
ECharts默认配色丑得像上世纪90年代的PPT,想改个主题色发现API藏得比宝藏还深。有时候得扒源码才能找到配置项。
// 深拷贝默认配置,然后暴力覆盖
const customTheme = {
color: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57'],
backgroundColor: 'transparent',
textStyle: {
fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif'
},
title: {
textStyle: { fontSize: 18, fontWeight: 'normal' },
subtextStyle: { color: '#999' }
},
line: {
smooth: true,
symbol: 'circle',
symbolSize: 8
},
// 坐标轴样式得一层层剥
categoryAxis: {
axisLine: { show: true, lineStyle: { color: '#eee' } },
axisTick: { show: false },
axisLabel: { color: '#666', fontSize: 12 },
splitLine: { show: true, lineStyle: { color: '#f5f5f5' } }
},
// 提示框自定义
tooltip: {
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#eee',
borderWidth: 1,
padding: [10, 15],
textStyle: { color: '#333' },
extraCssText: 'box-shadow: 0 4px 12px rgba(0,0,0,0.1); border-radius: 4px;'
}
};
// 注册主题
echarts.registerTheme('myTheme', customTheme);
const chart = echarts.init(dom, 'myTheme');
有时候官方文档没说清楚的配置,得去github的issue里翻,或者看源码里的defaultOption定义,那里才是真相。
真实项目里是怎么落地的
理论讲了一堆,看看实际项目中这些技术怎么组合使用。
后台管理系统的复杂混合图表
那个要同时展示柱状图(销售额)和折线图(增长率)的需求,怎么让它们不打架?
// ECharts双Y轴配置,左边是金额,右边是百分比
const mixChartOption = {
legend: {
data: ['销售额', '增长率'],
bottom: 0
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }, // 十字准星,方便对齐数据
formatter: function(params) {
// 自定义提示框,左边金额右边百分比,别混在一起
let result = `<div style="font-weight:bold;margin-bottom:5px;">${params[0].axisValue}</div>`;
params.forEach(item => {
if (item.seriesName === '销售额') {
result += `<div>${item.marker} ${item.seriesName}: <b>¥${item.value.toLocaleString()}</b></div>`;
} else {
result += `<div>${item.marker} ${item.seriesName}: <b>${item.value}%</b></div>`;
}
});
return result;
}
},
xAxis: {
type: 'category',
data: ['1月', '2月', '3月', '4月', '5月'],
axisPointer: { type: 'shadow' } // 阴影指示器
},
yAxis: [
{
type: 'value',
name: '销售额(万元)',
position: 'left',
axisLabel: { formatter: '{value}' },
splitLine: { lineStyle: { type: 'dashed' } }
},
{
type: 'value',
name: '增长率',
position: 'right',
axisLabel: { formatter: '{value}%' },
splitLine: { show: false }, // 右边Y轴不画网格线,避免混乱
min: -20, // 增长率可能有负值
max: 50
}
],
series: [
{
name: '销售额',
type: 'bar',
data: [120, 200, 150, 80, 70],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
]),
borderRadius: [4, 4, 0, 0] // 柱子上圆角,看着柔和点
},
barWidth: '40%' // 控制柱子宽度,别太挤
},
{
name: '增长率',
type: 'line',
yAxisIndex: 1, // 关键:指定用右边Y轴
data: [10, 25, -5, -15, 10],
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 3,
color: '#ff6b6b',
shadowColor: 'rgba(255, 107, 107, 0.3)',
shadowBlur: 10,
shadowOffsetY: 5
},
itemStyle: {
color: '#ff6b6b',
borderWidth: 2,
borderColor: '#fff'
}
}
],
// 动画配置,让图表加载时有个生长过程
animationDuration: 1000,
animationEasing: 'cubicOut'
};
关键点在于yAxisIndex,折线图指定用第二个Y轴,这样两种不同量纲的数据才能和平共处。还有那个axisPointer,十字准星让用户能准确对应两个维度的数值。
移动端H5的触摸优化
手指滑动查看数据趋势,怎么优化体验?触摸反馈要即时,但数据加载要防抖。
class MobileChart {
constructor() {
this.touchStartX = 0;
this.touchStartTime = 0;
this.isScrolling = false;
this.currentIndex = 0; // 当前显示的数据索引
}
bindTouchEvents() {
const canvas = this.canvas;
canvas.addEventListener('touchstart', (e) => {
this.touchStartX = e.touches[0].clientX;
this.touchStartTime = Date.now();
this.isScrolling = false;
}, { passive: true }); // passive提升滚动性能
canvas.addEventListener('touchmove', (e) => {
if (e.touches.length > 1) return; // 多点触控不管
const deltaX = e.touches[0].clientX - this.touchStartX;
// 移动距离超过阈值才算滑动,防误触
if (Math.abs(deltaX) > 10) {
this.isScrolling = true;
// 实时反馈:高亮即将选中的数据点,但不立即加载详情
const direction = deltaX > 0 ? -1 : 1;
const previewIndex = this.currentIndex + direction;
if (previewIndex >= 0 && previewIndex < this.data.length) {
this.highlightPreview(previewIndex); // 轻量级视觉反馈
}
}
}, { passive: true });
canvas.addEventListener('touchend', (e) => {
const deltaTime = Date.now() - this.touchStartTime;
const deltaX = e.changedTouches[0].clientX - this.touchStartX;
// 快速滑动(轻扫)或者慢速滑动(拖拽)都要处理
if (this.isScrolling) {
const direction = deltaX > 0 ? -1 : 1;
const newIndex = this.currentIndex + direction;
// 边界检查
if (newIndex >= 0 && newIndex < this.data.length) {
this.currentIndex = newIndex;
this.animateToIndex(newIndex); // 平滑动画过渡到新数据点
}
} else if (deltaTime < 300 && Math.abs(deltaX) < 10) {
// 点击事件,不是滑动
this.handleTap();
}
});
}
// 惯性滚动效果,松手后还能滑一段
applyInertia(velocity) {
const deceleration = 0.95; // 减速系数
let currentVelocity = velocity;
const animate = () => {
if (Math.abs(currentVelocity) < 1) return;
this.scrollOffset += currentVelocity;
currentVelocity *= deceleration;
this.draw();
requestAnimationFrame(animate);
};
animate();
}
// 虚拟滚动条,手指粗也能精准定位
renderScrollIndicator() {
const total = this.data.length;
const visible = 5; // 一屏显示5个
const ratio = visible / total;
const position = this.currentIndex / total;
// 画个迷你滚动条在底部
this.ctx.fillStyle = 'rgba(0,0,0,0.1)';
this.ctx.fillRect(50, this.height - 20, this.width - 100, 4);
this.ctx.fillStyle = 'rgba(84, 112, 198, 0.6)';
const thumbWidth = (this.width - 100) * ratio;
const thumbX = 50 + (this.width - 100) * position;
this.ctx.fillRect(thumbX, this.height - 22, thumbWidth, 8);
}
}
移动端passive事件监听器一定要加,不然滚动卡顿。还有touch-action: pan-x的CSS属性,告诉浏览器别阻止默认的横向滚动,但要处理好边界冲突。
大数据量实时刷新不闪屏
股票K线图那种每秒几十个点的更新,怎么做到不闪屏不卡顿?增量更新+双缓冲。
class RealtimeChart {
constructor() {
this.dataBuffer = []; // 数据缓冲区
this.isDrawing = false;
this.pendingUpdate = false;
// 双缓冲canvas
this.offscreenCanvas = document.createElement('canvas');
this.offscreenCtx = this.offscreenCanvas.getContext('2d');
}
pushData(newPoint) {
this.dataBuffer.push(newPoint);
// 限制数据长度,别让内存无限增长
if (this.dataBuffer.length > 1000) {
this.dataBuffer.shift(); // 移除最老的数据
this.fullRedrawNeeded = true; // 标记需要全量重绘
}
// 节流:最多30fps刷新,别跟着数据频率跑
if (!this.isDrawing) {
this.isDrawing = true;
requestAnimationFrame(() => {
this.update();
this.isDrawing = false;
if (this.pendingUpdate) {
this.pendingUpdate = false;
this.pushData(); // 处理积压的更新
}
});
} else {
this.pendingUpdate = true;
}
}
update() {
if (this.fullRedrawNeeded) {
this.drawFull();
this.fullRedrawNeeded = false;
} else {
this.drawIncremental(); // 只画新增的点
}
}
drawIncremental() {
const lastPoint = this.dataBuffer[this.dataBuffer.length - 1];
const prevPoint = this.dataBuffer[this.dataBuffer.length - 2];
if (!prevPoint) return;
// 在离屏canvas上画,画完一次性拷贝到主canvas
const ctx = this.offscreenCtx;
// 只画新线段,不清空画布
ctx.beginPath();
ctx.moveTo(this.mapX(prevPoint.time), this.mapY(prevPoint.value));
ctx.lineTo(this.mapX(lastPoint.time), this.mapY(lastPoint.value));
ctx.stroke();
// 画新数据点
ctx.beginPath();
ctx.arc(
this.mapX(lastPoint.time),
this.mapY(lastPoint.value),
3, 0, Math.PI * 2
);
ctx.fill();
// 一次性位图拷贝,避免闪烁
this.mainCtx.drawImage(this.offscreenCanvas, 0, 0);
}
// 自动缩放Y轴,适应数据波动
autoScale() {
const recentData = this.dataBuffer.slice(-100); // 看最近100个点
const min = Math.min(...recentData.map(d => d.value));
const max = Math.max(...recentData.map(d => d.value));
const padding = (max - min) * 0.1;
this.yMin = min - padding;
this.yMax = max + padding;
// Y轴变化了才需要全量重绘
if (this.yMin !== this.lastYMin || this.yMax !== this.lastYMax) {
this.fullRedrawNeeded = true;
this.lastYMin = this.yMin;
this.lastYMax = this.yMax;
}
}
}
双缓冲技术是关键:所有绘制先在离屏canvas完成,画完一次性drawImage到主canvas,用户永远看不到绘制过程中的半成品,也就不会闪屏。
导出图片功能那些坑
用户想把图表存下来发朋友圈,canvas.toDataURL()看似简单,实则坑多。
function exportChart(canvas, options = {}) {
// 坑1:跨域图片导致污染画布,toDataURL会报错
// 解决:所有图片资源加crossOrigin="anonymous"
// 坑2:高清屏下导出的图片尺寸不对
// 解决:手动创建临时canvas,按期望尺寸绘制
const exportWidth = options.width || 1200;
const exportHeight = options.height || 800;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = exportWidth;
tempCanvas.height = exportHeight;
const ctx = tempCanvas.getContext('2d');
// 白色背景,别透明(用户打印时需要)
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportWidth, exportHeight);
// 缩放绘制,保持比例
const scaleX = exportWidth / canvas.width;
const scaleY = exportHeight / canvas.height;
const scale = Math.min(scaleX, scaleY) * (window.devicePixelRatio || 1);
ctx.scale(scale, scale);
ctx.drawImage(canvas, 0, 0);
// 高DPI导出
const dataURL = tempCanvas.toDataURL('image/png', 1.0);
// 下载
const link = document.createElement('a');
link.download = `chart-${Date.now()}.png`;
link.href = dataURL;
link.click();
// 清理
tempCanvas.remove();
}
// 如果图表用了SVG,导出方式不同
function exportSVG(svgElement) {
// 克隆SVG,把样式都内联化,不然导出后样式丢失
const clone = svgElement.cloneNode(true);
// 遍历所有元素,把computedStyle写进style属性
const allElements = clone.querySelectorAll('*');
allElements.forEach(el => {
const computed = window.getComputedStyle(el);
let styleText = '';
for (let prop of computed) {
styleText += `${prop}: ${computed.getPropertyValue(prop)}; `;
}
el.setAttribute('style', styleText);
});
const svgData = new XMLSerializer().serializeToString(clone);
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
// 如果需要转PNG,可以画到canvas上
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0);
canvas.toDataURL('image/png');
URL.revokeObjectURL(url);
};
img.src = url;
}
坑点总结:跨域资源污染、高清屏尺寸错乱、SVG样式外联丢失、移动端下载行为诡异…每个都得单独处理。
出问题了别慌,按这个思路查
图表不显示?数据对了图不对?别急着砸键盘,按这个流程排查。
先看容器有没有宽高
别笑,这真的是新手最容易犯的错。canvas和SVG都需要明确的尺寸,如果父容器是display: none或者高度为0,图表肯定出不来。
// 调试技巧:给容器加边框,一眼看出尺寸
document.getElementById('chart-container').style.border = '2px solid red';
// 检查尺寸
console.log('Container size:', {
width: container.clientWidth,
height: container.clientHeight,
offsetWidth: container.offsetWidth
});
数据格式检查
ECharts这类库对数据格式很敏感,少个字段、类型不对都会静默失败。
// 防御性编程,画图前验证数据
function validateData(data) {
if (!Array.isArray(data)) {
console.error('数据必须是数组');
return false;
}
const requiredFields = ['name', 'value'];
const invalidItems = data.filter(item => {
return requiredFields.some(field => !(field in item));
});
if (invalidItems.length > 0) {
console.error('数据项缺少必要字段:', invalidItems);
return false;
}
// 检查数值有效性
const nanValues = data.filter(item => isNaN(item.value));
if (nanValues.length > 0) {
console.warn('包含非数值:', nanValues);
}
return true;
}
// 在图表库初始化前调用
if (!validateData(myData)) {
// 显示错误占位图,别白屏
showErrorPlaceholder('数据格式错误,请检查控制台');
} else {
initChart(myData);
}
内存泄漏排查
单页应用切路由后图表还在后台跑定时器,得记得销毁实例。
class ChartComponent {
constructor() {
this.chartInstance = null;
this.timer = null;
this.eventListeners = [];
}
init() {
this.chartInstance = echarts.init(this.dom);
// 绑定的事件都记录下来,方便销毁
const resizeHandler = () => this.chartInstance.resize();
window.addEventListener('resize', resizeHandler);
this.eventListeners.push({ target: window, type: 'resize', handler: resizeHandler });
// 定时器也要能清理
this.timer = setInterval(() => this.fetchData(), 5000);
}
// 组件销毁时调用,Vue的beforeUnmount、React的useEffect返回函数里调用
destroy() {
// 清定时器
if (this.timer) clearInterval(this.timer);
// 解绑事件
this.eventListeners.forEach(({ target, type, handler }) => {
target.removeEventListener(type, handler);
});
// 销毁图表实例,释放WebGL上下文等
if (this.chartInstance) {
this.chartInstance.dispose();
this.chartInstance = null;
}
console.log('Chart destroyed, memory cleaned');
}
}
// React Hook封装示例
function useChart(containerRef, options) {
const chartRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
chartRef.current = echarts.init(containerRef.current);
chartRef.current.setOption(options);
return () => {
// 清理函数,组件卸载时执行
chartRef.current?.dispose();
};
}, [options]);
return chartRef;
}
控制台报错看不懂
学会用断点调试,看看到底是数据格式不对还是配置项写歪了。
// 给图表配置加断点,一步步看哪里出问题
function debugChartOption(option) {
console.group('Chart Option Debug');
console.log('Full option:', JSON.stringify(option, null, 2));
// 检查series数据
if (option.series) {
option.series.forEach((s, i) => {
console.log(`Series ${i}:`, {
type: s.type,
dataLength: s.data?.length,
sampleData: s.data?.[0]
});
});
}
// 检查坐标轴
if (option.xAxis) {
console.log('X-axis:', option.xAxis);
}
console.groupEnd();
// 在setOption前打断点
debugger;
return option;
}
几个让同事直呼内行的骚操作
掌握了基础,来几招进阶技巧,让代码质量和视觉效果都上个档次。
自定义Tooltip,别用默认的白底黑字
ECharts默认的提示框太朴素,搞点渐变阴影甚至塞个迷你图进去。
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#eee',
borderWidth: 1,
padding: 0, // 自己控制padding
extraCssText: 'box-shadow: 0 8px 24px rgba(0,0,0,0.12); border-radius: 8px; overflow: hidden;',
formatter: function(params) {
// 用HTML字符串构建复杂布局
let html = `<div style="width: 280px; font-family: system-ui, -apple-system, sans-serif;">`;
// 头部带颜色标识
html += `
<div style="padding: 12px 16px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 600; font-size: 14px; color: #333;">${params[0].axisValue}</span>
<span style="font-size: 12px; color: #999;">${new Date().toLocaleDateString()}</span>
</div>
`;
// 内容区,每项带迷你趋势图
html += `<div style="padding: 12px 16px;">`;
params.forEach(item => {
// 生成简单的SVG迷你图
const sparkline = generateSparkline(item.data.trendData);
html += `
<div style="display: flex; align-items: center; margin-bottom: 10px; last-child: { margin-bottom: 0 }">
<span style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: ${item.color}; margin-right: 10px;"></span>
<div style="flex: 1;">
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="font-size: 13px; color: #666;">${item.seriesName}</span>
<span style="font-size: 14px; font-weight: 600; color: #333;">${item.value}</span>
</div>
<div style="height: 20px; background: #f5f5f5; border-radius: 2px; overflow: hidden;">
${sparkline}
</div>
</div>
</div>
`;
});
html += `</div>`;
// 底部操作按钮
html += `
<div style="padding: 10px 16px; background: #fafafa; border-top: 1px solid #f0f0f0; display: flex; gap: 8px;">
<button style="flex: 1; padding: 6px; border: 1px solid #ddd; background: white; border-radius: 4px; font-size: 12px; cursor: pointer;">查看详情</button>
<button style="flex: 1; padding: 6px; border: none; background: #5470c6; color: white; border-radius: 4px; font-size: 12px; cursor: pointer;">导出数据</button>
</div>
`;
html += `</div>`;
return html;
}
}
// 生成SVG迷你图函数
function generateSparkline(data) {
if (!data || data.length < 2) return '';
const width = 100;
const height = 20;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
let path = `M 0 ${height - ((data[0] - min) / range) * height}`;
data.forEach((val, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((val - min) / range) * height;
path += ` L ${x} ${y}`;
});
return `
<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<path d="${path}" fill="none" stroke="#5470c6" stroke-width="2" vector-effect="non-scaling-stroke"/>
<path d="${path} L ${width} ${height} L 0 ${height} Z" fill="url(#gradient)" opacity="0.3"/>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5470c6"/>
<stop offset="100%" stop-color="transparent"/>
</linearGradient>
</defs>
</svg>
`;
}
这种自定义tooltip虽然写起来麻烦,但用户体验直接拉满,同事看了绝对会问"这怎么实现的"。
动画缓动函数自己写
别让图表生硬地弹出来,加点Q弹的效果显得高级。
// 自定义缓动函数,比内置的更有弹性
const easing = {
// 弹性效果,像果冻一样
elastic: function(t) {
return Math.pow(2, -10 * t) * Math.sin((t - 0.1) * 5 * Math.PI) + 1;
},
// 回弹效果,冲过头再回来
back: function(t) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
// 平滑开始和结束
smooth: function(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
};
// 用在Canvas动画里
function animateValue(start, end, duration, onUpdate, easingFn = easing.smooth) {
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easingFn(progress);
const current = start + (end - start) * easedProgress;
onUpdate(current);
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
// 使用:让柱子从0长到目标高度,带弹性效果
animateValue(0, targetHeight, 1000, (currentHeight) => {
this.drawBar(index, currentHeight);
}, easing.elastic);
利用Shader搞点炫酷背景
虽然有点超纲,但做出来的效果绝对能让老板眼前一亮。WebGL的着色器能给图表加动态背景、流光效果。
// Three.js + Shader做粒子背景,上面叠2D图表
function createParticleBackground() {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// 着色器材质,做流动效果
const geometry = new THREE.PlaneGeometry(10, 10, 32, 32);
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor1: { value: new THREE.Color('#4ecdc4') },
uColor2: { value: new THREE.Color('#44a3aa') }
},
vertexShader: `
varying vec2 vUv;
uniform float uTime;
void main() {
vUv = uv;
vec3 pos = position;
// 顶点动画,波浪效果
pos.z += sin(pos.x * 2.0 + uTime) * 0.2;
pos.z += cos(pos.y * 2.0 + uTime * 0.5) * 0.2;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform float uTime;
void main() {
// 混合两种颜色,随时间流动
float mixFactor = sin(vUv.x * 3.14159 + uTime) * 0.5 + 0.5;
vec3 color = mix(uColor1, uColor2, mixFactor);
// 加些噪点纹理
float noise = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453);
color += noise * 0.02;
gl_FragColor = vec4(color, 0.3); // 半透明,让图表能看清
}
`,
transparent: true
});
const plane = new THREE.Mesh(geometry, material);
scene.add(plane);
camera.position.z = 5;
// 动画循环
function animate() {
material.uniforms.uTime.value += 0.01;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
// 在上面叠加Canvas 2D图表,用绝对定位
const chartCanvas = document.createElement('canvas');
chartCanvas.style.position = 'absolute';
chartCanvas.style.top = '0';
chartCanvas.style.left = '0';
chartCanvas.style.pointerEvents = 'auto'; // 让图表能交互
container.appendChild(chartCanvas);
return { scene, renderer, chartCanvas };
}
这种WebGL背景+Canvas图表的混合方案,既保证了性能,又有视觉效果。但注意别过度使用,低端设备上WebGL可能直接黑屏。
封装通用组件,摸鱼时间更多了
别每个页面都复制粘贴代码,抽离成hooks或者高阶组件。
// React Hook封装,一行代码出图
function useSmartChart(containerRef, type, dataSource, options) {
const chartRef = useRef(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 自动请求数据
useEffect(() => {
let isMounted = true;
async function loadData() {
setLoading(true);
setError(null);
try {
const data = typeof dataSource === 'function'
? await dataSource()
: dataSource;
if (!isMounted) return;
if (!chartRef.current) {
chartRef.current = echarts.init(containerRef.current);
}
const baseOption = getPresetOption(type); // 根据类型拿预设配置
const finalOption = merge(baseOption, {
dataset: { source: data },
...options
});
chartRef.current.setOption(finalOption, true);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
loadData();
// 自动刷新
let timer;
if (options.autoRefresh) {
timer = setInterval(loadData, options.autoRefresh);
}
return () => {
isMounted = false;
clearInterval(timer);
};
}, [dataSource, options.autoRefresh]);
// 响应式
useEffect(() => {
const handleResize = debounce(() => {
chartRef.current?.resize();
}, 200);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 清理
useEffect(() => {
return () => {
chartRef.current?.dispose();
};
}, []);
return { chart: chartRef.current, loading, error, retry: () => loadData() };
}
// 使用示例,简洁到离谱
function SalesChart() {
const containerRef = useRef(null);
const { loading, error } = useSmartChart(
containerRef,
'line', // 类型:line、bar、pie等
() => fetch('/api/sales').then(r => r.json()), // 数据源
{
autoRefresh: 30000, // 30秒自动刷新
smooth: true, // 平滑曲线
areaStyle: true // 面积图
}
);
return (
<div ref={containerRef} style={{ height: 400 }}>
{loading && <Spin />}
{error && <Alert message={error} type="error" />}
</div>
);
}
这种封装把数据获取、图表初始化、响应式、自动刷新、错误处理全包进去了,业务页面只需要关心要什么类型的图、数据从哪来,其他都是黑盒。
最后扯两句心里话
画图这事儿,入门容易精通难。我刚学那会儿,觉得能画个柱状图就挺牛了,后来接了个定制地图可视化的需求,才发现自己连门都没入。各种坐标系转换、投影算法、地理数据处理,熬了三个大夜才搞出来。
所以别指望一天成为大神,多抄多看多练才是正道。看到好看的图表,F12打开开发者工具研究人家怎么实现的;遇到奇葩需求别硬刚,有时候跟产品经理商量换个展现形式,比你熬夜写代码管用。我就见过为了画一个"3D旋转饼图"折腾一周的兄弟,最后产品经理说"其实2D的也行",当场崩溃。
还有最重要的一点:图表是为人服务的。别为了炫技搞一堆花里胡哨让人看不懂的东西,什么3D爆炸饼图、过度动画、闪瞎眼的配色,那都是本末倒置。好的可视化是让用户一眼看懂数据规律,而不是惊叹"这效果真牛"。
记住,能把复杂数据用简单图表讲清楚,才是真本事。那些特效、动画、黑科技,都是锦上添花,不是雪中送炭。
行了,絮叨了这么多,希望能帮你在可视化这条路上少走点弯路。代码这玩意儿,光看没用,得动手敲。找个数据集,今天就开始画吧!

1622

被折叠的 条评论
为什么被折叠?



