搜索文章

输入关键词开始搜索

更安全的解析function字符串

程序设计#JavaScript#ECharts#数据可视化#Agent#AI#架构

内容基于开发过程中和 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 动态配置失效的问题。这个方案的核心思想是:

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

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