内容基于开发过程中和 Claude 4.0 Sonnet 的对话整理而成。
背景
在开发基于 AI 的 ECharts 配置修改器时,我遇到一个问题:当 AI 返回包含 JavaScript 函数的图表配置时,这些函数在传输和解析过程中丢失,导致动态配置(如颜色函数、格式化器等)无法正常工作。
关联内容:[[流式输出]]
问题描述
典型场景
比如用户输入:”把柱状图改成每根柱子不同颜色”
AI 返回的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13
| { series: [ { type: 'bar', itemStyle: { color: function (params) { var colorList = ['#5470C6', '#91CC75', '#FAC858']; return colorList[params.dataIndex]; }, }, }, ]; }
|
问题现象
- AI 正确生成了包含函数的配置
- 但最终渲染的图表中函数失效,所有柱子颜色相同
问题分析
数据流追踪
追踪配置从 AI 到 ECharts 的完整流程:
1
| AI 返回 JS 对象 → 后端解析 → API 传输 → 前端接收 → ECharts 渲染
|
通过调试发现,问题出现在 API 传输 环节:
1 2 3 4 5 6 7 8 9
| controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'complete', config: result.config, // ❌ JSON.stringify 会丢失函数! })}\n\n` ) );
|
根本原因
JSON.stringify() 无法序列化 JavaScript 函数
1 2 3 4 5 6 7 8 9 10 11
| const configWithFunction = { itemStyle: { color: function (params) { return '#FF6B6B'; }, }, };
console.log(JSON.stringify(configWithFunction));
|
解决方案
核心思路
既然 JSON.stringify 会丢失函数,我们就不在传输过程中序列化包含函数的配置,而是传递原始的 JavaScript 代码字符串,让前端来解析。
技术架构
1
| AI 返回 JS 字符串 → 后端保留字符串 → API 传输字符串 → 前端解析 JS 对象 → ECharts 渲染
|
实现步骤
1. 后端修改:保留原始配置字符串
1 2 3 4 5 6 7 8 9 10 11 12
| const jsonMatch = fullContent.match(/\{[\s\S]*\}/); if (jsonMatch) { const originalConfigStr = jsonMatch[0]; const config = this.parseConfigWithFunctions(originalConfigStr); return { success: true, config, originalConfigStr, explanation: fullContent, }; }
|
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
|
private parseConfigWithFunctions(jsonStr: string): any { try { return JSON.parse(jsonStr); } catch (error) { console.log( '标准JSON解析失败,检测到可能包含函数,尝试JavaScript对象解析' );
try { if (jsonStr.includes('function')) { console.log('检测到函数定义,使用JavaScript解析');
let jsCode = jsonStr.trim();
if (!jsCode.startsWith('{')) { const startIndex = jsCode.indexOf('{'); if (startIndex !== -1) { jsCode = jsCode.substring(startIndex); } }
if (!jsCode.endsWith('}')) { const lastIndex = jsCode.lastIndexOf('}'); if (lastIndex !== -1) { jsCode = jsCode.substring(0, lastIndex + 1); } }
try { const func = new Function('return (' + jsCode + ')'); const result = func(); console.log('JavaScript对象解析成功'); return result; } catch (evalError) { console.warn('Function构造器解析失败,尝试其他方法:', evalError);
let fixedJs = jsCode;
fixedJs = fixedJs.replace(/\/\/.*$/gm, ''); fixedJs = fixedJs.replace(/\/\*[\s\S]*?\*\//g, '');
fixedJs = fixedJs.replace( /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":' );
fixedJs = fixedJs.replace(/"function"/g, 'function');
try { const func2 = new Function('return (' + fixedJs + ')'); const result2 = func2(); console.log('修复后的JavaScript对象解析成功'); return result2; } catch (finalError) { console.error('所有JavaScript解析方法都失败了:', finalError); throw new Error( `无法解析包含函数的配置对象: ${ finalError instanceof Error ? finalError.message : '未知错误' }` ); } } }
throw error; } catch (jsError) { console.error('JavaScript对象解析失败:', jsError); throw new Error( `配置解析失败: ${ jsError instanceof Error ? jsError.message : '未知错误' }` ); } } }
|
2. API 路由修改:传输字符串而非对象
1 2 3 4 5 6 7 8 9 10
| controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'complete', configStr: result.originalConfigStr, // ✅ 传输字符串 explanation: result.explanation, })}\n\n` ) );
|
3. 前端解析器:安全执行 JavaScript
1 2 3 4 5 6 7 8 9 10
| export function parseConfigString(configStr: string): any { try { return JSON.parse(configStr); } catch (error) { const func = new Function('return (' + configStr + ')'); return func(); } }
|
4. 前端组件修改:使用新解析器
1 2 3 4 5 6 7
| case 'complete': if (data.configStr) { const parsedConfig = parseConfigString(data.configStr); setChartConfig(parsedConfig); } break;
|
关键技术点
1. Function 构造器 vs eval
我们选择 Function 构造器而不是 eval:
1 2 3 4 5 6
| const config = eval('(' + configStr + ')');
const func = new Function('return (' + configStr + ')'); const config = func();
|
2. 错误处理与降级
1 2 3 4 5 6 7 8 9 10 11 12
| export function parseConfigString(configStr: string): any { try { return JSON.parse(configStr); } catch (error) { try { const func = new Function('return (' + configStr + ')'); return func(); } catch (jsError) { throw new Error(`配置解析失败: ${jsError.message}`); } } }
|
3. TypeScript 类型安全
1 2 3 4 5 6 7
| export interface StreamResponse { type: 'start' | 'chunk' | 'complete' | 'error'; config?: any; configStr?: string; explanation?: string; }
|
测试验证
测试用例
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const testConfig = `{ series: [{ itemStyle: { color: function(params) { var colors = ['#FF6B6B', '#4ECDC4', '#45B7D1']; return colors[params.dataIndex % colors.length]; } } }] }`;
const config = parseConfigString(testConfig); console.log(typeof config.series[0].itemStyle.color); console.log(config.series[0].itemStyle.color({ dataIndex: 0 }));
|
实践总结
1. 设计原则
- 函数保留:在整个数据流中保持 JavaScript 函数的完整性
- 安全执行:使用
Function 构造器而非 eval
- 向后兼容:同时支持 JSON 和 JavaScript 对象
2. 性能考虑
- 延迟解析:只在前端需要时才解析函数
- 缓存结果:避免重复解析相同配置
- 错误边界:完善的错误处理和降级机制
3. 安全性
- 作用域隔离:使用
Function 构造器创建独立作用域
- 输入验证:验证配置字符串的有效性
- 白名单机制:只允许特定的函数模式
总结
通过避免在传输过程中序列化函数,成功解决了 ECharts 动态配置失效的问题。这个方案的核心思想是:
- 保持原始性:不破坏 AI 返回的 JavaScript 代码
- 延迟解析:在最终使用时才执行函数解析
- 安全第一:使用安全的执行方式
这种方法适用于所有需要传输可执行代码的场景。