ECharts 动态加载策略
📋 概述
在大型应用中,图表库的体积可能显著影响首屏加载性能。动态加载策略能够:
- ✅ 按需加载:只在需要时加载图表库
- ✅ 代码分割:将大文件拆分为小块
- ✅ 预加载优化:智能预测用户行为并提前加载
- ✅ 降级方案:网络失败时的备用策略
本文档详细介绍ECharts的各种动态加载方案和最佳实践。
🎯 核心概念
加载时机分类
| 策略 | 加载时机 | 优点 | 缺点 |
|---|---|---|---|
| 立即加载 | 页面初始化时 | 用户体验好 | 首屏慢 |
| 懒加载 | 滚动到可视区域 | 首屏快 | 首次交互有延迟 |
| 预加载 | 空闲时或预测后 | 平衡性能和体验 | 可能浪费带宽 |
| 按需加载 | 用户触发时 | 最省流量 | 明显延迟 |
ECharts模块化结构
typescript
// ECharts核心模块拆分
echarts/
├── core.js // 核心 (~30KB)
├── charts/ // 图表类型
│ ├── line.js // 折线图 (~15KB)
│ ├── bar.js // 柱状图 (~12KB)
│ ├── pie.js // 饼图 (~10KB)
│ └── ...
├── components/ // 组件
│ ├── grid.js // 网格 (~5KB)
│ ├── legend.js // 图例 (~8KB)
│ ├── tooltip.js // 提示框 (~10KB)
│ └── ...
└── renderers/ // 渲染器
├── canvas.js // Canvas渲染器
└── svg.js // SVG渲染器 (~5KB)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
🔧 动态加载实现
1. Webpack动态导入
基础用法
typescript
/**
* 动态加载ECharts核心
*/
async function loadEChartsCore(): Promise<typeof echarts> {
const echarts = await import(/* webpackChunkName: "echarts-core" */ 'echarts');
return echarts;
}
/**
* 动态加载特定图表类型
*/
async function loadChartType(type: 'line' | 'bar' | 'pie'): Promise<void> {
switch (type) {
case 'line':
await import(/* webpackChunkName: "echarts-line" */ 'echarts/charts/line');
break;
case 'bar':
await import(/* webpackChunkName: "echarts-bar" */ 'echarts/charts/bar');
break;
case 'pie':
await import(/* webpackChunkName: "echarts-pie" */ 'echarts/charts/pie');
break;
}
}
/**
* 动态加载组件
*/
async function loadComponent(name: 'tooltip' | 'legend' | 'grid'): Promise<void> {
await import(/* webpackChunkName: "echarts-[request]" */ `echarts/components/${name}`);
}
// 使用示例
async function initChart() {
// 并行加载
await Promise.all([
loadEChartsCore(),
loadChartType('line'),
loadComponent('tooltip'),
loadComponent('grid')
]);
// 初始化图表
const chart = echarts.init(container);
chart.setOption(option);
}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
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
高级封装
typescript
interface LoadOptions {
/** 图表类型 */
types: Array<'line' | 'bar' | 'pie' | 'scatter'>;
/** 组件 */
components?: Array<'tooltip' | 'legend' | 'grid' | 'dataZoom'>;
/** 渲染器 */
renderer?: 'canvas' | 'svg';
}
/**
* 智能加载器
*/
class EChartsLoader {
private static loadedTypes = new Set<string>();
private static loadedComponents = new Set<string>();
private static coreLoaded = false;
/**
* 加载指定配置
*/
static async load(options: LoadOptions): Promise<void> {
const tasks: Promise<any>[] = [];
// 加载核心(只加载一次)
if (!this.coreLoaded) {
tasks.push(this.loadCore());
}
// 加载图表类型
options.types.forEach(type => {
if (!this.loadedTypes.has(type)) {
tasks.push(this.loadChartType(type));
}
});
// 加载组件
options.components?.forEach(comp => {
if (!this.loadedComponents.has(comp)) {
tasks.push(this.loadComponent(comp));
}
});
// 等待所有加载完成
await Promise.all(tasks);
}
/**
* 加载核心
*/
private static async loadCore(): Promise<void> {
const echarts = await import('echarts/core');
window.echarts = echarts;
this.coreLoaded = true;
}
/**
* 加载图表类型
*/
private static async loadChartType(type: string): Promise<void> {
await import(`echarts/charts/${type}`);
this.loadedTypes.add(type);
}
/**
* 加载组件
*/
private static async loadComponent(comp: string): Promise<void> {
await import(`echarts/components/${comp}`);
this.loadedComponents.add(comp);
}
/**
* 检查是否已加载
*/
static isLoaded(options: LoadOptions): boolean {
return options.types.every(type => this.loadedTypes.has(type));
}
}
// 使用示例
await EChartsLoader.load({
types: ['line', 'bar'],
components: ['tooltip', 'legend', 'grid']
});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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2. Intersection Observer懒加载
可视区域检测
typescript
/**
* 图表懒加载Hook
*/
export function useLazyChart(
option: EChartsOption,
rootMargin = '200px'
): [React.RefObject<HTMLDivElement>, boolean] {
const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const element = containerRef.current;
if (!element || isVisible) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !isLoaded) {
setIsVisible(true);
setIsLoaded(true);
// 停止观察
observer.disconnect();
}
},
{ rootMargin }
);
observer.observe(element);
return () => observer.disconnect();
}, [isVisible, isLoaded, rootMargin]);
// 当可见时初始化图表
useEffect(() => {
if (isVisible && containerRef.current) {
initChart(containerRef.current, option);
}
}, [isVisible, option]);
return [containerRef, isVisible];
}
// 使用示例
function LazyChart({ option }: { option: EChartsOption }) {
const [ref, isVisible] = useLazyChart(option);
return (
<div ref={ref}>
{!isVisible && <div className="chart-placeholder">Loading...</div>}
</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
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
/**
* 预加载管理器
*/
class PreloadManager {
private readonly IDLE_TIMEOUT = 2000; // 空闲2秒后预加载
private idleTimer: number | null = null;
/**
* 监听用户活动,空闲时预加载
*/
startIdlePreload(): void {
let lastActivity = Date.now();
// 监听用户活动
const events = ['mousedown', 'mousemove', 'keydown', 'scroll'];
const updateActivity = () => {
lastActivity = Date.now();
// 清除之前的定时器
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
};
events.forEach(event => {
window.addEventListener(event, updateActivity);
});
// 定期检查空闲状态
setInterval(() => {
const idleTime = Date.now() - lastActivity;
if (idleTime > this.IDLE_TIMEOUT) {
this.preloadCommonCharts();
}
}, 1000);
}
/**
* 预加载常用图表
*/
private async preloadCommonCharts(): Promise<void> {
// 避免重复加载
if (EChartsLoader.isLoaded({ types: ['line', 'bar'] })) {
return;
}
console.log('Preloading common charts...');
try {
await EChartsLoader.load({
types: ['line', 'bar'],
components: ['tooltip', 'legend']
});
console.log('Preload completed');
} catch (error) {
console.warn('Preload failed:', error);
}
}
/**
* 基于路由预测预加载
*/
preloadByRoute(route: string): void {
const routeMap: Record<string, LoadOptions> = {
'/dashboard': {
types: ['line', 'bar'],
components: ['tooltip', 'legend', 'grid']
},
'/reports': {
types: ['pie', 'bar'],
components: ['legend', 'tooltip']
},
'/analytics': {
types: ['line', 'scatter', 'heatmap'],
components: ['dataZoom', 'tooltip', 'legend']
}
};
const options = routeMap[route];
if (options) {
EChartsLoader.load(options).catch(console.warn);
}
}
/**
* 预加载下一个可能访问的页面
*/
preloadNextPage(nextPages: string[]): void {
nextPages.forEach(page => {
this.preloadByRoute(page);
});
}
}
export const preloadManager = new PreloadManager();
// 使用示例
// 应用启动时开始空闲预加载
preloadManager.startIdlePreload();
// 路由变化时预测预加载
router.onRouteChange((route) => {
const nextPages = predictNextPages(route);
preloadManager.preloadNextPage(nextPages);
});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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
4. 进度反馈
加载进度追踪
typescript
interface LoadProgress {
stage: 'core' | 'charts' | 'components' | 'complete';
current: number;
total: number;
percentage: number;
}
/**
* 带进度的加载器
*/
class ProgressiveLoader {
private onProgress?: (progress: LoadProgress) => void;
constructor(onProgress?: (progress: LoadProgress) => void) {
this.onProgress = onProgress;
}
/**
* 加载并报告进度
*/
async loadWithProgress(options: LoadOptions): Promise<void> {
const total =
1 + // 核心
options.types.length +
(options.components?.length || 0);
let current = 0;
const updateProgress = (stage: LoadProgress['stage']) => {
current++;
this.onProgress?.({
stage,
current,
total,
percentage: Math.round((current / total) * 100)
});
};
// 加载核心
await EChartsLoader.loadCore();
updateProgress('core');
// 加载图表类型
for (const type of options.types) {
await EChartsLoader.loadChartType(type);
updateProgress('charts');
}
// 加载组件
for (const comp of options.components || []) {
await EChartsLoader.loadComponent(comp);
updateProgress('components');
}
updateProgress('complete');
}
}
// 使用示例
const loader = new ProgressiveLoader((progress) => {
console.log(`Loading: ${progress.percentage}% (${progress.current}/${progress.total})`);
});
await loader.loadWithProgress({
types: ['line', 'bar', 'pie'],
components: ['tooltip', 'legend', 'grid']
});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
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
5. 错误处理与降级
加载失败处理
typescript
/**
* 健壮的加载器
*/
class RobustLoader {
private readonly MAX_RETRIES = 3;
private readonly CDN_FALLBACKS = [
'https://cdn.jsdelivr.net/npm/echarts@5/dist/',
'https://unpkg.com/echarts@5/dist/',
'https://cdnjs.cloudflare.com/ajax/libs/echarts/5/'
];
/**
* 带重试和降级的加载
*/
async loadWithFallback(module: string): Promise<any> {
let lastError: Error | null = null;
// 尝试从主源加载
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
console.log(`Loading ${module} (attempt ${attempt})...`);
return await this.loadFromPrimary(module);
} catch (error) {
console.warn(`Attempt ${attempt} failed:`, error);
lastError = error as Error;
// 指数退避
if (attempt < this.MAX_RETRIES) {
await this.delay(Math.pow(2, attempt) * 1000);
}
}
}
// 主源失败,尝试CDN
console.warn('Primary source failed, trying CDN fallbacks...');
for (const cdn of this.CDN_FALLBACKS) {
try {
return await this.loadFromCDN(cdn, module);
} catch (error) {
console.warn(`CDN ${cdn} failed:`, error);
}
}
// 全部失败
throw new Error(`Failed to load ${module}: ${lastError?.message}`);
}
/**
* 从主源加载
*/
private async loadFromPrimary(module: string): Promise<any> {
return import(/* webpackIgnore: true */ `/modules/${module}.js`);
}
/**
* 从CDN加载
*/
private async loadFromCDN(cdn: string, module: string): Promise<any> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${cdn}${module}.min.js`;
script.onload = () => resolve(window.echarts);
script.onerror = () => reject(new Error(`CDN load failed: ${script.src}`));
document.head.appendChild(script);
});
}
/**
* 延迟
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// 使用示例
const robustLoader = new RobustLoader();
try {
await robustLoader.loadWithFallback('echarts-core');
console.log('ECharts loaded successfully');
} catch (error) {
console.error('All loading attempts failed:', error);
// 显示降级UI
showFallbackUI();
}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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
💡 实战案例
案例1:Vue Router路由级代码分割
typescript
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/dashboard',
component: () => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue'),
beforeEnter: async () => {
// 进入路由前预加载图表
await EChartsLoader.load({
types: ['line', 'bar'],
components: ['tooltip', 'legend', 'grid']
});
}
},
{
path: '/reports',
component: () => import(/* webpackChunkName: "reports" */ '@/views/Reports.vue')
}
]
});
export default router;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
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
案例2:React Suspense集成
typescript
import React, { Suspense, lazy } from 'react';
// 懒加载图表组件
const LazyLineChart = lazy(() =>
import('./LineChart').then(module => ({
default: module.LineChart
}))
);
const LazyBarChart = lazy(() =>
import('./BarChart').then(module => ({
default: module.BarChart
}))
);
function ChartGallery() {
return (
<div>
<Suspense fallback={<div>Loading line chart...</div>}>
<LazyLineChart data={lineData} />
</Suspense>
<Suspense fallback={<div>Loading bar chart...</div>}>
<LazyBarChart data={barData} />
</Suspense>
</div>
);
}
export default ChartGallery;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
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
案例3:移动端按需加载
typescript
/**
* 移动端优化的加载策略
*/
class MobileOptimizedLoader {
private isLowEndDevice: boolean;
private isSlowConnection: boolean;
constructor() {
// 检测低端设备
this.isLowEndDevice = navigator.hardwareConcurrency <= 2;
// 检测慢速连接
const connection = (navigator as any).connection;
this.isSlowConnection = connection &&
(connection.saveData || connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g');
}
/**
* 根据设备和网络条件选择加载策略
*/
getLoadStrategy(): LoadOptions {
if (this.isSlowConnection || this.isLowEndDevice) {
// 精简模式:只加载核心和最常用的图表
return {
types: ['line'],
components: ['tooltip'],
renderer: 'svg' // SVG在移动端更省内存
};
} else {
// 完整模式
return {
types: ['line', 'bar', 'pie'],
components: ['tooltip', 'legend', 'grid'],
renderer: 'canvas'
};
}
}
}
// 使用示例
const mobileLoader = new MobileOptimizedLoader();
const strategy = mobileLoader.getLoadStrategy();
await EChartsLoader.load(strategy);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
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
⚠️ 常见问题
问题1:加载顺序错误
症状:图表类型未注册错误
解决:确保先加载核心再加载图表类型
typescript
// ❌ 错误做法 - 并行加载可能导致时序问题
Promise.all([
import('echarts/core'),
import('echarts/charts/line')
]);
// ✅ 正确做法 - 串行加载
await import('echarts/core');
await import('echarts/charts/line');1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
问题2:重复加载
症状:同一个模块被多次加载
解决:使用缓存机制
typescript
class CachedLoader {
private cache = new Map<string, Promise<any>>();
async load(module: string): Promise<any> {
// 检查缓存
if (this.cache.has(module)) {
return this.cache.get(module);
}
// 创建加载任务并缓存
const loadTask = import(module);
this.cache.set(module, loadTask);
return loadTask;
}
}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
🎯 最佳实践
1. 加载策略矩阵
| 场景 | 推荐策略 | 说明 |
|---|---|---|
| 首页图表 | 立即加载 | 用户体验优先 |
| 二级页面 | 路由预加载 | 平衡性能和体验 |
| 弹窗图表 | 懒加载 | 按需加载 |
| 低频功能 | 按需加载 | 节省流量 |
2. Bundle大小预算
| 模块 | 目标大小 | 实际大小 |
|---|---|---|
| 核心 | < 50KB | ~30KB |
| 单个图表类型 | < 20KB | ~15KB |
| 单个组件 | < 10KB | ~8KB |
| 总计(常用组合) | < 150KB | ~100KB |
3. 监控指标
- 加载成功率:> 99%
- 平均加载时间:< 2秒(3G)
- 缓存命中率:> 80%
- 预加载准确率:> 60%
📊 性能对比
| 策略 | 首屏时间 | 总下载量 | 用户体验 |
|---|---|---|---|
| 全量加载 | 3.5s | 500KB | ⭐⭐⭐ |
| 按需加载 | 1.2s | 100KB | ⭐⭐⭐⭐ |
| 懒加载+预加载 | 0.8s | 150KB | ⭐⭐⭐⭐⭐ |
🔗 相关链接
💎 总结
动态加载策略是优化ECharts应用性能的关键。通过合理的代码分割、智能预加载和完善的降级方案,可以在保证用户体验的同时显著减少首屏加载时间。记住:最好的加载策略是根据用户行为和实际情况动态调整。
