更安全的解析function字符串
内容基于开发过程中和 Claude 4.0 Sonnet 的对话整理而成。
背景
在开发基于 AI 的 ECharts 配置修改器时,我遇到一个问题:当 AI 返回包含 JavaScript 函数的图表配置时,这些函数在传输和解析过程中丢失,导致动态配置(如颜色函数、格式化器等)无法正常工作。 关联内容:[[流式输出]]
问题描述
典型场景
比如用户输入:“把柱状图改成每根柱子不同颜色”
AI 返回的配置:
{
series: [
{
type: 'bar',
itemStyle: {
color: function (params) {
var colorList = ['#5470C6', '#91CC75', '#FAC858'];
return colorList[params.dataIndex];
},
},
},
];
}
问题现象
- AI 正确生成了包含函数的配置
- 但最终渲染的图表中函数失效,所有柱子颜色相同
问题分析
数据流追踪
追踪配置从 AI 到 ECharts 的完整流程:
AI 返回 JS 对象 → 后端解析 → API 传输 → 前端接收 → ECharts 渲染
通过调试发现,问题出现在 API 传输 环节:
// 问题代码:API 路由中的序列化
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'complete',
config: result.config, // ❌ JSON.stringify 会丢失函数!
})}\n\n`
)
);
根本原因
JSON.stringify() 无法序列化 JavaScript 函数
const configWithFunction = {
itemStyle: {
color: function (params) {
return '#FF6B6B';
},
},
};
console.log(JSON.stringify(configWithFunction));
// 输出:{"itemStyle":{"color":{}}}
// 函数被转换为空对象
解决方案
核心思路
既然 JSON.stringify 会丢失函数,我们就不在传输过程中序列化包含函数的配置,而是传递原始的 JavaScript 代码字符串,让前端来解析。
技术架构
AI 返回 JS 字符串 → 后端保留字符串 → API 传输字符串 → 前端解析 JS 对象 → ECharts 渲染
实现步骤
1. 后端修改:保留原始配置字符串
// 修改 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,
};
}
/**
* 解析可能包含函数的配置字符串
* 处理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 路由修改:传输字符串而非对象
// 修改 API 路由传输逻辑
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'complete',
configStr: result.originalConfigStr, // ✅ 传输字符串
explanation: result.explanation,
})}\n\n`
)
);
3. 前端解析器:安全执行 JavaScript
// 创建 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. 前端组件修改:使用新解析器
// 修改页面组件处理逻辑
case 'complete':
if (data.configStr) {
const parsedConfig = parseConfigString(data.configStr);
setChartConfig(parsedConfig); // ✅ 包含完整函数的配置
}
break;
关键技术点
1. Function 构造器 vs eval
我们选择 Function 构造器而不是 eval:
// ❌ eval:不安全,共享作用域
const config = eval('(' + configStr + ')');
// ✅ Function:更安全,独立作用域
const func = new Function('return (' + configStr + ')');
const config = func();
2. 错误处理与降级
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 类型安全
// 扩展接口支持新字段
export interface StreamResponse {
type: 'start' | 'chunk' | 'complete' | 'error';
config?: any;
configStr?: string; // ✅ 新增字段
explanation?: string;
}
测试验证
测试用例
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 动态配置失效的问题。这个方案的核心思想是:
- 保持原始性:不破坏 AI 返回的 JavaScript 代码
- 延迟解析:在最终使用时才执行函数解析
- 安全第一:使用安全的执行方式
这种方法适用于所有需要传输可执行代码的场景。