用Schema驱动前端开发提升效率与可维护性

轩辕佳沫 框架 阅读 2,974
赞 20 收藏
二维码
手机扫码查看
反馈

先写个表单,看看Schema怎么玩

上周在搞一个用户注册页,字段特别多,邮箱、手机号、密码强度校验、验证码倒计时、协议勾选……最烦的是产品经理隔两个小时就改一次字段规则。第一次我手写校验逻辑,改到第三次直接心态崩了,干脆上Schema驱动。

用Schema驱动前端开发提升效率与可维护性

简单说,就是把整个表单结构和规则定义成一个 JSON 对象,然后用统一的渲染器去生成 UI 和处理逻辑。改需求?不用动组件代码,改配置就行。

const userFormSchema = {
  fields: [
    {
      name: 'email',
      label: '邮箱',
      type: 'text',
      placeholder: '请输入邮箱',
      rules: [
        { required: true, message: '邮箱必填' },
        { pattern: /^[^s@]+@[^s@]+.[^s@]+$/, message: '邮箱格式不正确' }
      ]
    },
    {
      name: 'phone',
      label: '手机号',
      type: 'tel',
      placeholder: '请输入手机号',
      rules: [
        { required: true, message: '手机号必填' },
        { pattern: /^1[3-9]d{9}$/, message: '手机号格式错误' }
      ]
    },
    {
      name: 'password',
      label: '密码',
      type: 'password',
      placeholder: '至少8位,含大小写字母和数字',
      rules: [
        { required: true, message: '密码不能为空' },
        { min: 8, message: '密码至少8位' },
        { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*d)/, message: '需包含大小写字母和数字' }
      ]
    },
    {
      name: 'agree',
      label: '我已阅读并同意用户协议',
      type: 'checkbox',
      rules: [{ required: true, message: '请阅读并同意协议' }]
    }
  ],
  submitText: '立即注册'
};

有了这个 schema,你只需要写一个通用的 FormRenderer 组件:

function FormRenderer({ schema, onSubmit }) {
  const [form, setForm] = useState({});
  const [errors, setErrors] = useState({});

  const handleChange = (name, value) => {
    setForm(prev => ({ ...prev, [name]: value }));
    if (errors[name]) validateField(name, value);
  };

  const validateField = (name, value) => {
    const field = schema.fields.find(f => f.name === name);
    const fieldErrors = [];

    for (const rule of field.rules) {
      if (rule.required && !value) {
        fieldErrors.push(rule.message);
        break;
      }
      if (rule.pattern && value && !rule.pattern.test(value)) {
        fieldErrors.push(rule.message);
        break;
      }
      if (rule.min && value && value.length < rule.min) {
        fieldErrors.push(rule.message);
        break;
      }
    }

    setErrors(prev => ({
      ...prev,
      [name]: fieldErrors.length ? fieldErrors[0] : null
    }));

    return fieldErrors.length === 0;
  };

  const handleSubmit = () => {
    let valid = true;
    const newErrors = {};

    schema.fields.forEach(field => {
      const value = form[field.name];
      const fieldValid = validateField(field.name, value);
      if (!fieldValid) {
        valid = false;
      }
    });

    if (valid) {
      onSubmit(form);
    }
  };

  return (
    <div>
      {schema.fields.map(field => (
        <div key={field.name} className="mb-4">
          <label className="block mb-1">{field.label}</label>
          {field.type === 'checkbox' ? (
            <div className="flex items-center">
              <input
                type="checkbox"
                checked={!!form[field.name]}
                onChange={e => handleChange(field.name, e.target.checked)}
              />
              <span className="ml-2 text-sm">我已阅读并同意用户协议</span>
            </div>
          ) : (
            <input
              type={field.type}
              placeholder={field.placeholder}
              value={form[field.name] || ''}
              onChange={e => handleChange(field.name, e.target.value)}
              className={w-full p-2 border ${errors[field.name] ? &#039;border-red-500&#039; : &#039;border-gray-300&#039;}}
            />
          )}
          {errors[field.name] && <p className="text-red-500 text-sm mt-1">{errors[field.name]}</p>}
        </div>
      ))}
      <button
        onClick={handleSubmit}
        className="bg-blue-600 text-white px-4 py-2 rounded"
      >
        {schema.submitText}
      </button>
    </div>
  );
}

亲测有效,产品经理改了五次字段,我只改了两次 schema 配置,中间还抽空喝了一杯咖啡。

动态字段?条件显示也不难

后来加了个新需求:如果用户选择“企业注册”,要多出公司名称、税号两个字段。这种条件逻辑最容易让代码变脏,但我用了 schema 的 conditional 字段控制显示。

{
  name: 'userType',
  label: '注册类型',
  type: 'radio',
  options: [
    { label: '个人', value: 'personal' },
    { label: '企业', value: 'company' }
  ]
},
{
  name: 'companyName',
  label: '公司名称',
  type: 'text',
  placeholder: '请输入公司全称',
  condition: (form) => form.userType === 'company'
},
{
  name: 'taxId',
  label: '统一社会信用代码',
  type: 'text',
  placeholder: '请输入税号',
  condition: (form) => form.userType === 'company'
}

然后在 FormRenderer 里判断 condition 是否为真再渲染:

{schema.fields.map(field => {
  // condition 存在且返回 false 则跳过
  if (field.condition && !field.condition(form)) {
    return null;
  }
  return renderField(field); // 上面那段渲染逻辑封装一下就行
})}

这里注意下,我踩过坑:一开始用 useEffect 去监听 form.userType 变化后手动清空 company 字段值,结果发现用户切回来时原来输入的内容没了。后来改成只要字段不渲染,就在提交前自动从 form 数据中剔除:

const cleanFormData = (formData, schemaFields) => {
  const cleaned = {};
  Object.keys(formData).forEach(key => {
    const field = schemaFields.find(f => f.name === key);
    if (!field || !field.condition || field.condition(formData)) {
      cleaned[key] = formData[key];
    }
  });
  return cleaned;
};

接口联动?远程数据也得管

还有个场景是省市区三级联动。城市列表是从接口拿的,总不能写死在 schema 里吧?我的做法是在 schema 中支持 asyncSource:

{
  name: 'province',
  label: '省份',
  type: 'select',
  source: '/api/provinces'  // 普通静态接口
},
{
  name: 'city',
  label: '城市',
  type: 'select',
  source: '/api/cities?provinceId={province}',  // 动态参数
  dependsOn: ['province']
}

dependsOn 表示这个字段依赖于其他字段的值。实现时监听依赖字段变化,拼接 URL 发请求。

useEffect(() => {
  if (field.dependsOn) {
    const depsChanged = field.dependsOn.some(dep => dep in form && form[dep] !== prevFormRef.current[dep]);
    if (depsChanged && field.source.includes('{')) {
      const url = field.source.replace(/{(w+)}/g, (_, key) => form[key] || '');
      fetch(url)
        .then(res => res.json())
        .then(data => setOptions(data));
    }
  }
}, [form]);

这里有个隐藏坑点:接口可能失败或者返回空数组,这时候你得给个兜底提示,不然用户看着下拉框是空的会以为卡了。建议加上 loading 状态和 error 提示。

踩坑提醒:这三点一定注意

  • 不要在 schema 里写函数传给组件。比如我把校验规则写成函数而不是对象,结果序列化时报错。记住:schema 最好是纯 JSON 可序列化的结构,函数、正则这些放外面处理。
  • 初始值问题。checkbox 默认 false,但有时候后端返回 null 或 undefined,会导致受控组件警告。建议在 handleChange 时统一处理非空值:!!value 强转布尔。
  • 性能别忽视。字段一多,每次输入都全量校验会卡。可以优化成只校验当前字段,或者用防抖。别学我一开始图省事全校验,结果输入框明显延迟。

高级技巧:可扩展的 widget 类型

上面用的都是 input、select 这些基础控件。实际项目中肯定有更复杂的,比如日期范围选择、图片上传、富文本编辑器。这时候可以在 schema 里加 widget 字段:

{
  name: 'dateRange',
  label: '活动时间',
  widget: 'DateRangePicker',
  rules: [{ required: true }]
}

然后在 renderField 里判断:

switch(field.widget) {
  case 'DateRangePicker':
    return <DateRangePicker value={form[field.name]} onChange={val => handleChange(field.name, val)} />;
  case 'ImageUpload':
    return <ImageUploader maxCount={3} value={form[field.name]} onChange={...} />;
  default:
    return <DefaultInput {...field} value={form[field.name]} onChange={handleChange} />;
}

这样业务组件还是解耦的,schema 控制流程,具体实现由 widget 自己决定。后期替换组件也不影响整体结构。

还能干啥?别只盯着表单

其实 schema 驱动不止能做表单。我最近用它做了个页面布局生成器——把页面区块(轮播图、商品列表、图文区)都写成 schema,后台拖拽排序后生成 JSON,前端直接解析渲染。

[
  {
    "type": "carousel",
    "data": {
      "images": ["url1", "url2"]
    }
  },
  {
    "type": "productList",
    "api": "https://jztheme.com/api/products?category=hot"
  }
]

配合微前端,不同团队维护各自的 widget 实现,主应用只负责加载 schema 并调度渲染,简直不要太爽。

最后说两句

Schema 驱动不是银弹,复杂交互还是得写代码。但它特别适合那些“变来变去”“配置化”的场景。尤其是中后台系统、CMS、运营活动页这类地方,用好了能少掉一半头发。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 TypeScript 做类型推导、支持 i18n 多语言、做可视化编辑器导出 schema……后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论