更安全的解析function字符串

内容基于开发过程中和 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
// 问题代码:API 路由中的序列化
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));
// 输出:{"itemStyle":{"color":{}}}
// 函数被转换为空对象

解决方案

核心思路

既然 JSON.stringify 会丢失函数,我们就不在传输过程中序列化包含函数的配置,而是传递原始的 JavaScript 代码字符串,让前端来解析。

技术架构

1
AI 返回 JS 字符串 → 后端保留字符串 → API 传输字符串 → 前端解析 JS 对象 → ECharts 渲染

实现步骤

1. 后端修改:保留原始配置字符串

1
2
3
4
5
6
7
8
9
10
11
12
// 修改 DeepSeekProvider 返回结果
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
/**
* 解析可能包含函数的配置字符串
* 处理AI返回的包含JavaScript函数的ECharts配置
* 使用eval来正确解析包含函数的JavaScript对象
*/
private parseConfigWithFunctions(jsonStr: string): any {
try {
// 首先尝试标准JSON解析
return JSON.parse(jsonStr);
} catch (error) {
console.log(
'标准JSON解析失败,检测到可能包含函数,尝试JavaScript对象解析'
);

try {
// 如果包含function关键字,说明这是一个JavaScript对象而不是JSON
if (jsonStr.includes('function')) {
console.log('检测到函数定义,使用JavaScript解析');

// 清理可能的格式问题
let jsCode = jsonStr.trim();

// 确保这是一个有效的JavaScript对象字面量
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);
}
}

// 使用【Function构造器】安全地解析JavaScript对象
// 这比eval更安全,因为它在独立的作用域中执行
try {
const func = new Function('return (' + jsCode + ')');
const result = func();
console.log('JavaScript对象解析成功');
return result;
} catch (evalError) {
console.warn('Function构造器解析失败,尝试其他方法:', evalError);

// 备选方案:尝试修复常见的JavaScript语法问题
let fixedJs = jsCode;

// 修复可能的语法问题
// 1. 移除注释
fixedJs = fixedJs.replace(/\/\/.*$/gm, '');
fixedJs = fixedJs.replace(/\/\*[\s\S]*?\*\//g, '');

// 2. 确保属性名都有引号(JSON要求)
fixedJs = fixedJs.replace(
/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g,
'$1"$2":'
);

// 3. 但是保留function关键字不加引号
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 : '未知错误'
}`
);
}
}
}

// 如果不包含function但JSON解析失败,可能是其他格式问题
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
// 修改 API 路由传输逻辑
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
// 创建 config-parser.ts
export function parseConfigString(configStr: string): any {
try {
return JSON.parse(configStr); // 优先尝试 JSON
} catch (error) {
// 包含函数时使用 Function 构造器
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
// ❌ eval:不安全,共享作用域
const config = eval('(' + configStr + ')');

// ✅ Function:更安全,独立作用域
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); // 标准 JSON
} catch (error) {
try {
const func = new Function('return (' + configStr + ')');
return func(); // JavaScript 对象
} 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); // "function"
console.log(config.series[0].itemStyle.color({ dataIndex: 0 })); // "#FF6B6B"

实践总结

1. 设计原则

  • 函数保留:在整个数据流中保持 JavaScript 函数的完整性
  • 安全执行:使用 Function 构造器而非 eval
  • 向后兼容:同时支持 JSON 和 JavaScript 对象

2. 性能考虑

  • 延迟解析:只在前端需要时才解析函数
  • 缓存结果:避免重复解析相同配置
  • 错误边界:完善的错误处理和降级机制

3. 安全性

  • 作用域隔离:使用 Function 构造器创建独立作用域
  • 输入验证:验证配置字符串的有效性
  • 白名单机制:只允许特定的函数模式

总结

通过避免在传输过程中序列化函数,成功解决了 ECharts 动态配置失效的问题。这个方案的核心思想是:

  1. 保持原始性:不破坏 AI 返回的 JavaScript 代码
  2. 延迟解析:在最终使用时才执行函数解析
  3. 安全第一:使用安全的执行方式

这种方法适用于所有需要传输可执行代码的场景。