ECharts 最佳实践与性能优化
📋 概述
基于大量生产环境经验总结的ECharts最佳实践,涵盖性能优化、代码组织、错误处理、可访问性等方面,帮助开发者构建高质量的数据可视化应用。
🎯 性能优化
1. 按需引入(减少Bundle体积)
❌ 错误做法 - 全量引入
typescript
// 导入整个ECharts库(850KB)
import * as echarts from 'echarts';1
2
2
✅ 正确做法 - 按需引入
typescript
// 只引入核心和需要的图表类型
import * as echarts from 'echarts/core';
import { LineChart, BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
// 注册必要的模块
echarts.use([
LineChart,
BarChart,
GridComponent,
TooltipComponent,
CanvasRenderer
]);1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
效果:Bundle从850KB降至150KB(↓82%)
2. 数据降采样
LTTB算法(保持视觉特征)
typescript
/**
* Largest-Triangle-Three-Buckets 降采样
* @param data 原始数据
* @param threshold 目标点数
*/
function lttbDownsample(
data: Array<{ x: number; y: number }>,
threshold: number
): Array<{ x: number; y: number }> {
if (threshold >= data.length || threshold === 0) {
return data;
}
const sampled: Array<{ x: number; y: number }> = [];
let sampledIndex = 0;
sampled[sampledIndex++] = data[0]; // 始终包含第一个点
const bucketSize = (data.length - 2) / (threshold - 2);
let prevAvgX = data[0].x;
let prevAvgY = data[0].y;
for (let i = 0; i < threshold - 2; i++) {
const bucketStart = Math.floor((i + 0) * bucketSize) + 1;
const bucketEnd = Math.floor((i + 1) * bucketSize) + 1;
const avgRangeStart = Math.floor((i + 1) * bucketSize) + 1;
const avgRangeEnd = Math.floor((i + 2) * bucketSize) + 1;
const avgRangeValidCount = Math.min(avgRangeEnd, data.length) - avgRangeStart;
let avgX = 0;
let avgY = 0;
for (let j = avgRangeStart; j < avgRangeStart + avgRangeValidCount; j++) {
avgX += data[j].x;
avgY += data[j].y;
}
avgX /= avgRangeValidCount;
avgY /= avgRangeValidCount;
let maxArea = -1;
let maxAreaIndex = bucketStart;
for (let k = bucketStart; k < bucketEnd; k++) {
const area = Math.abs(
(prevAvgX - data[k].x) * (avgY - data[k].y) -
(prevAvgX - data[k].x) * (avgY - prevAvgY)
) * 0.5;
if (area > maxArea) {
maxArea = area;
maxAreaIndex = k;
}
}
sampled[sampledIndex++] = data[maxAreaIndex];
prevAvgX = data[maxAreaIndex].x;
prevAvgY = data[maxAreaIndex].y;
}
sampled[sampledIndex] = data[data.length - 1]; // 始终包含最后一个点
return sampled;
}
// 使用示例
const rawData = generateData(10000);
const downsampled = lttbDownsample(rawData, 500);
// 从10000点降至500点,视觉差异极小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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
3. 防抖和节流
窗口resize防抖
typescript
import { debounce } from 'lodash-es';
class ChartManager {
private chart: echarts.ECharts;
constructor(container: HTMLElement) {
this.chart = echarts.init(container);
// 防抖处理resize
const resizeHandler = debounce(() => {
this.chart.resize();
}, 300);
window.addEventListener('resize', resizeHandler);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
数据更新节流
typescript
import { throttle } from 'lodash-es';
class RealtimeChart {
private updateChart = throttle((data: any) => {
this.chart.setOption({
series: [{ data }]
});
}, 1000); // 最多每秒更新一次
onNewData(data: any) {
this.updateChart(data);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
4. 虚拟滚动(大量图表)
tsx
// React Virtualized示例
import { List } from 'react-virtualized';
function ChartGallery({ charts }: { charts: ChartConfig[] }) {
const rowRenderer = ({ index, key, style }: any) => (
<div key={key} style={style}>
<DynamicChart config={charts[index]} />
</div>
);
return (
<List
width={1200}
height={800}
rowCount={charts.length}
rowHeight={400}
rowRenderer={rowRenderer}
/>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
效果:渲染100个图表时,内存占用从800MB降至150MB
5. 离屏渲染
typescript
// 使用Web Worker进行数据处理
const worker = new Worker('data-processor.js');
worker.onmessage = (event) => {
const processedData = event.data;
chart.setOption({
series: [{ data: processedData }]
});
};
// 发送原始数据到Worker
worker.postMessage(rawData);1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
💻 代码组织
1. 图表配置工厂模式
typescript
// chartFactory.ts
type ChartType = 'line' | 'bar' | 'pie' | 'scatter';
interface BaseChartConfig {
title?: string;
data: any[];
colors?: string[];
}
function createChartOption(type: ChartType, config: BaseChartConfig): EChartsOption {
const baseOption: EChartsOption = {
title: { text: config.title, left: 'center' },
color: config.colors || defaultColors
};
const creators: Record<ChartType, (config: BaseChartConfig) => EChartsOption> = {
line: createLineOption,
bar: createBarOption,
pie: createPieOption,
scatter: createScatterOption
};
return { ...baseOption, ...creatorstype };
}
function createLineOption(config: BaseChartConfig): EChartsOption {
return {
xAxis: { type: 'category', data: config.data.map(d => d.label) },
yAxis: { type: 'value' },
series: [{
type: 'line',
data: config.data.map(d => d.value),
smooth: true
}]
};
}
// 使用
const option = createChartOption('line', {
title: '销售趋势',
data: salesData
});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
42
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
42
2. 自定义Hook封装
typescript
// hooks/useECharts.ts
import { useEffect, useRef, useState } from 'react';
export function useECharts<T extends EChartsOption>(
option: T,
deps: any[] = []
) {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!chartRef.current) return;
try {
setLoading(true);
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
chartInstance.current.setOption(option);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
return () => {
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
}, deps);
return {
chartRef,
loading,
error,
resize: () => chartInstance.current?.resize()
};
}
// 使用
function MyChart() {
const { chartRef, loading, error } = useECharts(option, [data]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div ref={chartRef} />;
}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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
3. 主题管理
typescript
// themeManager.ts
class ThemeManager {
private currentTheme: string = 'light';
private listeners: Array<(theme: string) => void> = [];
/**
* 注册主题
*/
registerTheme(name: string, theme: object) {
echarts.registerTheme(name, theme);
}
/**
* 切换主题
*/
setTheme(theme: string) {
this.currentTheme = theme;
this.listeners.forEach(listener => listener(theme));
}
/**
* 订阅主题变化
*/
subscribe(listener: (theme: string) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
/**
* 获取当前主题
*/
getTheme(): string {
return this.currentTheme;
}
}
export const themeManager = new ThemeManager();
// 使用
function ThemedChart() {
const [theme, setTheme] = useState(themeManager.getTheme());
useEffect(() => {
return themeManager.subscribe(setTheme);
}, []);
return <Chart theme={theme} />;
}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
42
43
44
45
46
47
48
49
50
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
42
43
44
45
46
47
48
49
50
⚠️ 错误处理
1. 全局错误边界
tsx
// ErrorBoundary.tsx
class ChartErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// 上报错误
reportError({
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
});
}
render() {
if (this.state.hasError) {
return (
<div className="chart-error">
<h3>图表加载失败</h3>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>刷新页面</button>
</div>
);
}
return this.props.children;
}
}
// 使用
<ChartErrorBoundary>
<MyChart />
</ChartErrorBoundary>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
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
2. 数据验证
typescript
function validateChartData(data: any[]): ValidationResult {
const errors: string[] = [];
if (!Array.isArray(data)) {
errors.push('Data must be an array');
return { valid: false, errors };
}
if (data.length === 0) {
errors.push('Data array is empty');
}
data.forEach((value, index) => {
if (typeof value === 'number') {
if (isNaN(value)) {
errors.push(`Invalid value at index ${index}: NaN`);
} else if (!isFinite(value)) {
errors.push(`Infinite value at index ${index}`);
}
}
});
return {
valid: errors.length === 0,
errors
};
}
// 使用
const validation = validateChartData(rawData);
if (!validation.valid) {
console.error('Invalid chart data:', validation.errors);
// 使用默认数据或显示错误状态
}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
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
3. 降级方案
typescript
class ResilientChart {
async render() {
try {
// 尝试正常渲染
await this.renderWithCanvas();
} catch (error) {
console.warn('Canvas render failed, falling back to SVG:', error);
try {
// 降级到SVG渲染
await this.renderWithSVG();
} catch (svgError) {
console.error('SVG render also failed:', svgError);
// 显示静态图片或错误消息
this.showFallbackImage();
}
}
}
private async renderWithCanvas() {
this.chart = echarts.init(this.container, undefined, { renderer: 'canvas' });
this.chart.setOption(this.option);
}
private async renderWithSVG() {
this.chart = echarts.init(this.container, undefined, { renderer: 'svg' });
this.chart.setOption(this.option);
}
private showFallbackImage() {
this.container.innerHTML = '<img src="/static/chart-fallback.png" alt="Chart unavailable" />';
}
}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
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
♿ 可访问性(Accessibility)
1. 添加ARIA属性
tsx
function AccessibleChart({ title, description, option }: ChartProps) {
return (
<div
role="img"
aria-label={title}
aria-describedby={`chart-desc-${id}`}
>
<div id={`chart-desc-${id}`} className="sr-only">
{description}
</div>
<Chart option={option} />
</div>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
2. 提供数据表格替代
tsx
function ChartWithDataTable({ data, chartOption }: Props) {
const [showTable, setShowTable] = useState(false);
return (
<div>
<div className="chart-controls">
<button onClick={() => setShowTable(!showTable)}>
{showTable ? '显示图表' : '显示数据表格'}
</button>
</div>
{showTable ? (
<table className="data-table">
<thead>
<tr>
<th>类别</th>
<th>数值</th>
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.label}>
<td>{row.label}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
) : (
<Chart option={chartOption} />
)}
</div>
);
}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
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
3. 键盘导航
typescript
// 支持键盘控制图表交互
chart.on('keydown', (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowLeft':
focusPreviousDataPoint();
break;
case 'ArrowRight':
focusNextDataPoint();
break;
case 'Enter':
showDataDetails();
break;
}
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
📊 性能监控
1. 渲染时间监控
typescript
class PerformanceMonitor {
recordRenderTime(chartId: string, duration: number) {
if (duration > 1000) {
console.warn(`Slow render detected for ${chartId}: ${duration}ms`);
// 上报性能问题
reportMetric({
name: 'chart_render_time',
value: duration,
chartId,
threshold: 1000
});
}
}
}
// 使用
const startTime = performance.now();
chart.setOption(option);
const renderTime = performance.now() - startTime;
monitor.recordRenderTime('my-chart', renderTime);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2. 内存监控
typescript
function monitorMemory() {
if (performance.memory) {
const memoryMB = performance.memory.usedJSHeapSize / 1024 / 1024;
if (memoryMB > 500) {
console.warn(`High memory usage: ${memoryMB.toFixed(2)}MB`);
// 建议清理未使用的图表实例
suggestCleanup();
}
}
}
// 定期监控
setInterval(monitorMemory, 60000); // 每分钟检查1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
💎 总结
核心最佳实践
- 按需引入:减少82% Bundle体积
- 数据降采样:LTTB算法保持视觉特征
- 防抖节流:避免频繁更新
- 虚拟滚动:渲染大量图表
- 错误边界:优雅降级
- 可访问性:ARIA属性和数据表格
- 性能监控:及时发现瓶颈
性能检查清单
- [ ] 使用按需引入
- [ ] 启用Tree Shaking
- [ ] 大数据量使用降采样
- [ ] resize事件防抖
- [ ] 实时更新节流
- [ ] 及时dispose图表实例
- [ ] 使用production模式构建
- [ ] 启用Gzip压缩
遵循这些最佳实践,可以构建高性能、可维护的ECharts应用。
