极致灵活:如何用一个输入框,满足后台千变万化的需求

作者:张世萌(汽车之家:APP 架构前端工程师)

一个输入框,满足后台千变万化的需求

在后台管理系统中,我们经常遇到各种复杂多变的业务需求。传统的配置方式往往难以应对这种复杂要求。本文介绍一种灵活的解决方案:通过一个函数输入框,结合参数插值和沙箱执行机制,让用户能够在输入框中编写 JavaScript 代码来满足各种复杂的业务场景。

为什么需要直接在输入框写函数?

传统表单输入的局限性

传统的后台配置通常采用表单填写的方式,用户通过下拉框、输入框、复选框等组件来配置参数。这种方式在处理简单场景时效果良好,但面对以下情况时就显得捉襟见肘:

  • 动态逻辑处理:需要根据不同条件生成不同的内容
  • 复杂数据转换:需要对输入数据进行复杂的计算和转换
  • 条件分支:需要根据参数值执行不同的逻辑分支
  • 数据联动:多个参数之间存在复杂的依赖关系

输入框函数的优势

通过让用户直接编写函数,我们可以获得以下优势:

  1. 无限的灵活性:JavaScript 的表达能力几乎没有限制
  2. 动态执行:可以根据运行时参数动态生成结果
  3. 逻辑复用:可以将复杂逻辑封装成可复用的函数
  4. 易于调试:可以通过 console.log 等方式进行调试

如何定义一个输入框函数

如下图所示,我们定义了一个名为 output 的函数输入框,用户可以在其中编写 JavaScript 代码,在后台执行后,生成动态 html 标签保存。

界面展示

自定义组件内容输出

组件内容 :一个异步的 JS 函数
函数名称output
函数参数:(自动注入)

  • config:通用参数集合对象
  • option:可定制参数集合对象

函数返回:对象数组,每一个对象用来描述一个 HTML 标签(一个组件可以由多个标签组成)

  • type:标签类型( script / style / link / meta 等)
  • name:组件名称,用于说明
  • attrs:标签属性集合
  • content:标签内容

基础示例

在我们的系统中,主要应用场景是动态生成 Web 组件(由 script、style、link、meta 等一个或多个标签组成)。用户通过编写函数来定义组件的结构、样式和行为:

javascript 复制代码
async function output(config, option, query) {
  const content = await fetchData({
    url: config.url,
  });

  return [
    {
      type: 'script',
      name: 'example-component',
      attrs: [{ name: 'type', value: 'text/javascript' }],
      content: content,
    },
  ];
}

安全性:沙箱隔离

沙箱环境设计

为了确保用户代码的安全执行,我们使用 Node.js 的 vm 模块创建了一个沙箱环境:

javascript 复制代码
import vm from 'node:vm';

// 创建受限的沙箱上下文
const sandbox = {
  _, // 允许使用 lodash
  console, // 允许使用 console 输出
  fetchData, // 允许使用数据请求方法
  transformCustomVariables, // 允许使用定制参数转换方法
  URL,
};

// 创建沙盒上下文
const context = vm.createContext(sandbox);

安全措施

  1. 受限的全局对象:只暴露必要的 API,避免访问敏感的系统资源
  2. 代码执行隔离:用户代码在独立的上下文中执行,无法影响主进程
  3. 错误捕获:完善的错误处理机制,防止恶意代码导致系统崩溃
  4. 资源限制:可以设置执行时间和内存限制,防止无限循环等问题

执行输入框函数

  • userFunctionString 后台读取到的输入框中的函数字符串
javascript 复制代码
const handleTemplate = async (userFunctionString, config, option) => {
  try {
    // 1. 参数插值处理
    const replacedUserFunctionString = replaceInterpolate(userFunctionString, {
      config,
      option,
    });

    // 2. 在沙盒环境中执行用户代码
    const userFunction = vm.runInContext(
      `(${replacedUserFunctionString})`,
      context,
    );

    // 3. 执行函数并获取结果
    const result = await userFunction(config, option);

    // 4. 后处理:压缩代码等
    const processedResult = result.map((item) => ({
      ...item,
      content: uglifyCode(item.content),
    }));

    return processedResult;
  } catch (error) {
    throw new Error(`函数执行错误: ${error.message}`);
  }
};

对外提供的对象和方法参考

  • lodash (_)

lodash 对象,提供丰富的工具函数

  • fetchData

通用的 HTTP 请求方法,封装了 axios 请求并支持重试、缓存、超时等功能:

灵活性:参数插值

两种参数类型

我们的系统定义了两种不同类型的参数,每种都有其特定的用途:

1. config(通用参数)
  • 来源:前端参数输入框,用户手动配置
  • 特点:自增的,可以动态添加新的配置项
  • 用途:存储通用的配置信息,如项目 ID、环境变量等
2. option(定制参数)
  • 来源:前端参数输入框,用户手动配置
  • 特点:自增的,针对特定业务场景的参数
  • 用途:存储业务相关的定制化配置

参数类型支持

参数输入框支持多种 JavaScript 数据类型,可以像写 JS 代码一样输入,后台能正确处理它们的插值:

基础类型
  • 数字类型

    input 输入框:42

  • 字符串类型:

    input 输入框:这是一个描述

  • 布尔类型:

    checkbox 输入框:选中/未选中

复合类型
  • 数组类型 input 输入框:['前端', '后端', '全栈', /\d+/]
  • 对象类型 input 输入框:{api: {baseUrl: '<https://api.example.com>', timeout: 5000, headers: {'Content-Type': 'application/json'}}, features: \['feature1', 'feature2']}
  • 函数类型 input 输入框:function (value) { return value.toString().padStart(2, '0'); }

插值机制实现

插值语法

我们使用 {{}} 作为插值标识符,支持 configoption 参数的插值:

javascript 复制代码
// 在用户函数中使用插值
function output(config, option) {
  // 这些插值会在执行前被替换
  const theme = '{{config.theme}}';
  const buttonColor = '{{option.buttonColor}}';
  const features = {{option.features}};

  return [{
    type: 'style',
    name: 'dynamic-style',
    attrs: [{ name: 'type', value: 'text/css' }],
    content: `
      .container {
        theme: ${theme};
        --button-color: ${buttonColor};
      }
      .features::after {
        content: '${features.join(', ')}';
      }
    `
  }];
}
插值处理逻辑
  1. 先将参数输入框中的值,按照类型转换为对应的 JS 值,确保输入是合法的
javascript 复制代码
/**
 * 将 { 参数名一: { type: xx,value: xx,desc: xx}, ...} 中的指定类型的 value 做 JSON.parse
 * @param {*} obj
 * @returns
 */
function parseValueField(obj: IUnParsedObject): IParsedObject {
  return Object.entries(obj).reduce(
    (acc: IParsedObject, [key, valueObj]: [string, IValueObject]) => {
      // 只处理 type 为 'object' 或 'array' 的项(新增了 arrayByItems 类型,值也是数组,但是前端处理方式不同)
      if (
        valueObj.type === 'object' ||
        valueObj.type === 'array' ||
        valueObj.type === 'arrayByItems' ||
        valueObj.type === 'boolean' ||
        valueObj.type === 'function' ||
        valueObj.type === 'number'
      ) {
        try {
          // 解析值为 js 对象
          valueObj.value = evalStrToObj(valueObj.value);

          // 解析成功后才添加到 acc
          acc[key] = valueObj;
        } catch (e) {
          console.error(`解析 ${key} 的值失败: `, e);
        }
      } else {
        acc[key] = valueObj;
      }

      return acc;
    },
    {},
  );
}

/**
 * 转换字符串为 js 表达式
 * @param str string
 * @returns
 */
const evalStrToObj = (str: string) => new Function(`return (${str})`)();
  1. 将具体的参数值再转换成字符串,替换到函数模板字符串中,确保输入函数能够在后台正确执行
javascript 复制代码
/**
 * 替换模板中的插值参数
 */
function replaceInterpolate(template, { config, option }) {
  const reg = /\{\{\s*(config\.[^}]+|option\.[^}]+)\s*\}\}/g;
  // 如果模板不存在插值字符串,直接返回函数字符串
  if (!reg.test(template)) {
    return template;
  }

  // 使用 lodash.template 进行替换
  return _.template(template, { interpolate: reg })({
    config: formatParams(config),
    option: formatParams(option),
  });
}

/**
 * 递归格式化对象
 * @param {object} obj
 * @returns {object}
 */
function formatParams(obj: any) {
  if (typeof obj !== 'object' || obj === null) return obj; // 非对象直接返回
  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => [
      key,
      stringifyValueAsExpression(value),
    ]),
  );
}

/**
 * 将 JavaScript 值转换为合法的 JavaScript 表达式字符串
 */
function stringifyValueAsExpression(value) {
  if (value instanceof RegExp) {
    return value.toString(); // /pattern/flags
  } else if (Array.isArray(value)) {
    return `[${value.map(stringifyValueAsExpression).join(',')}]`;
  } else if (typeof value === 'object' && value !== null) {
    return `{${Object.entries(value)
      .map(([key, val]) => `${key}:${stringifyValueAsExpression(val)}`)
      .join(',')}}`;
  } else if (typeof value === 'string') {
    return `'${value.replace(/'/g, "\\'")}'`; // 转义单引号
  } else if (value == null) {
    return 'null';
  }
  return String(value);
}
插值示例

假设我们有以下参数:

javascript 复制代码
// option 参数
const option = {
  buttonColor: 'blue',
  features: ['login', 'register'],
  config: {
    timeout: 5000,
    retries: 3,
  },
};

用户编写的函数:

javascript 复制代码
function output(config, option) {
  const color = '{{option.buttonColor}}';
  const features = {{option.features}};
  const timeout = {{option.config.timeout}};

  return [{
    type: 'script',
    content: `
      console.log('颜色:${color}');
      console.log('功能:', ${JSON.stringify(features)});
      console.log('超时:${timeout}ms');
    `
  }];
}

经过插值替换处理后:

javascript 复制代码
function output(config, option, query) {
  const color = 'blue';
  const features = ['login', 'register'];
  const timeout = 5000;

  return [
    {
      type: 'script',
      name: 'debug-info',
      attrs: [{ name: 'type', value: 'text/javascript' }],
      content: `
      console.log('颜色:blue');
      console.log('功能:', ["login","register"]);
      console.log('超时:5000ms');
    `,
    },
  ];
}

实际应用案例

案例一:条件式字体管理组件

这个案例展示了如何根据用户配置动态加载字体资源,并生成对应的 CSS 变量和工具类。该组件会根据 option 参数中的字体开关来决定加载哪些字体文件:

javascript 复制代码
function output(config, option) {
  const {
    'custom-regular': enableRegular,
    'custom-medium': enableMedium,
    'custom-bold': enableBold,
  } = option;

  // 字体配置列表
  const fontList = [
    {
      name: 'custom-regular',
      content:
        '@font-face { font-family: custom-regular; src: local("CustomFont"), local("CustomFont_Regular"), url("https://cdn.example.com/fonts/CustomFont_Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; }',
    },
    {
      name: 'custom-medium',
      content:
        '@font-face { font-family: custom-medium; src: local("CustomFont_Medium"), url("https://cdn.example.com/fonts/CustomFont_Medium.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; }',
    },
    {
      name: 'custom-bold',
      content:
        '@font-face { font-family: custom-bold; src: local("CustomFont_Bold"), url("https://cdn.example.com/fonts/CustomFont_Bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; }',
    },
  ];

  // 根据 option 参数筛选启用的字体
  const activeKeys = Object.keys(option).filter((item) => option[item]);
  const filterList = fontList.filter((item) => activeKeys.includes(item.name));

  // 使用 lodash 模板生成 CSS 内容
  const content = _.template(
    `  
    <% _.forEach(list, function(item) { %>
      {{ item.content }}
    <% }); %>

    :root {
      <% _.forEach(list, function(item) { var cleanItem = item.name.replace('custom-', '');%>
        --font-{{ cleanItem }}: custom-{{ cleanItem }};
      <% }); %>
    }

    <% _.forEach(list, function(item) { var cleanItem = item.name.replace('custom-', '');%>
      .f-{{ cleanItem.split('-').map(word => word[0]).join('') }} {
        font-family: var(--font-{{ cleanItem }});
      }
    <% }); %>
  `,
  )({ list: filterList });

  return [
    {
      type: 'style',
      name: '字体管理 CSS 变量版',
      attrs: [{ name: 'type', value: 'text/css' }],
      content: content,
    },
  ];
}

功能说明:

  1. 条件加载 :只有当 option 中对应的字体开关为 true 时,才会加载该字体
  2. CSS 变量 :为每个启用的字体生成 CSS 变量,如 --font-regular--font-medium
  3. 工具类 :自动生成简化的 CSS 类名,如 .f-r(custom-regular)、.f-m(custom-medium)、.f-b(custom-bold)
  4. 字体文件:使用 CDN 上的自定义字体文件,支持现代 woff2 格式

生成的 CSS 示例:

css 复制代码
/* 当启用 custom-regular 时 */
@font-face {
  font-family: custom-regular;
  src:
    local('CustomFont'),
    local('CustomFont_Regular'),
    url('https://cdn.example.com/fonts/CustomFont_Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  --font-regular: custom-regular;
}

.f-r {
  font-family: var(--font-regular);
}

案例二:网页置灰组件

javascript 复制代码
async function output(config, option, query) {
  const customCss = option.customCss || '';
  return [
    {
      type: 'script',
      name: '网页置灰',
      attrs: [],
      content: `
          (function () {
            // 启用灰度效果的函数
            let grayscaleStyleElement = null;
            let intervalId = null; // 用于保存定时器的标识
    
            // 启用灰度效果(除视频播放外全部置灰)
            function enableGrayscale() {
              if (grayscaleStyleElement) return; // 避免重复添加 style 元素

              const style = document.createElement('style');
              
              style.innerHTML = \`html {filter: grayscale(0.95);-webkit-filter: grayscale(0.95);}${customCss}\`;
              document.documentElement.insertBefore(style, document.head);
              grayscaleStyleElement = style; // 保存引用,方便之后移除
            }
    
            // 禁用灰度效果
            function disableGrayscale() {
              if (grayscaleStyleElement) {
                document.documentElement.removeChild(grayscaleStyleElement); // 移除添加的 style 元素
                grayscaleStyleElement = null; // 清空引用
              }
            }
    
            // 获取毫秒数
            function getMillTime(d) {
              return d ? new Date(d).getTime() : Date.now();
            }
    
            function handleGrayscale(startTime, endTime) {
              const currentTime = getMillTime();
              console.log('开始检测');
    
              if (currentTime >= startTime && currentTime <= endTime) {
                enableGrayscale();
              } else {
                disableGrayscale();
              }
    
              // 超过结束时间后清除定时器
              if (currentTime > endTime && !grayscaleStyleElement) {
                clearInterval(intervalId); // 清除定时器,避免重复执行检测逻辑
                intervalId = null;
              }
            }
    
            // 定时上线和下线灰度效果
            function scheduleGrayscaleEffect() {
              const startTime = getMillTime({{option.startDate}});
              const endTime = getMillTime({{option.endDate}});
              
              handleGrayscale(startTime, endTime);
    
              // 每分钟检查一次时间,确保灰度效果按时开启/关闭
              intervalId = setInterval(() => {
                handleGrayscale(startTime, endTime);
              }, 10000); // 每30秒执行一次
            }
    
            // 启动定时任务
            scheduleGrayscaleEffect();
          })();
      `,
    },
  ];
}

总结

通过函数输入框的方式,我们成功地解决了后台配置系统在面对复杂业务需求时的灵活性问题。这种方案具有以下优势:

  1. 极高的灵活性:用户可以编写任意复杂的逻辑
  2. 强大的表达能力:支持条件判断、循环、函数调用等所有 JavaScript 特性
  3. 安全的执行环境:通过沙箱隔离确保系统安全
  4. 丰富的参数支持:支持多种数据类型和插值机制
  5. 良好的扩展性:可以轻松添加新的对外暴露的 API 和功能

当然,这种方案也有一定的学习成本,需要用户具备基本的 JavaScript 编程能力。但对于需要处理复杂业务逻辑的场景来说,这种投入是非常值得的。

未来,我们还可以考虑添加以下功能来进一步提升用户体验:

  • 模板库:预置常用的代码模板供用户参考
  • 可视化调试:提供更直观的调试工具,查看输入函数的输出结果
  • 版本管理:支持函数代码的版本控制和回滚

通过不断的优化和完善,这个函数输入框系统将能够更好地满足各种复杂的业务需求,真正做到"一个输入框,满足后台千变万化的需求"。

相关推荐
用户11481867894842 小时前
Rollup构建JavaScript核验库,并发布到NPM
前端
肥晨2 小时前
前端私有化变量还只会加前缀嘛?保姆级教程教你4种私有化变量方法
前端·javascript
小高0072 小时前
前端 Class 不是花架子!3 个大厂常用场景,告诉你它有多实用
前端·javascript·面试
不喝奶茶哦喝奶茶长胖2 小时前
CSS 文本换行控制:text-wrap、white-space 和 word-break 详解
前端
傅里叶2 小时前
Flutter用户体验之01-避免在 build() 或 initState() 内直接做耗时 blocking
前端·flutter
namehu2 小时前
搞定 iOS App 测试包分发,也就这么简单!😎
前端·ios·app
code_YuJun2 小时前
1. 使用VueCli编译生产环境代码以及创建不同模式
前端
MrGaoGang3 小时前
耗时1年,终于我也拥有属于自己的AI工作流
前端·agent·ai编程
没有鸡汤吃不下饭3 小时前
前端【数据类型】 No.1 Javascript的数据类型与区别
前端·javascript·面试