yup 使用 3 - 利用 meta 实现表单字段与表格列的统一结构配置(适配 React Table)

发布于:2025-05-14 ⋅ 阅读:(12) ⋅ 点赞:(0)

yup 使用 3 - 利用 meta 实现表单字段与表格列的统一结构配置(适配 React Table)

Categories: Tools
Last edited time: May 11, 2025 7:45 PM
Status: Done
Tags: form validation, schema design, yup

本文介绍如何通过 Yup 的 meta() 字段,在 React 项目中实现表单字段与表格列的统一配置,适配多区域字段展示与结构共用需求。

前情提要:

之前在写完 yup 这两个系列的时候,我其实就有在考虑用 yup 处理一个问题——结合 yup 的 meta 属性,然后合并管理表单/表格的定义逻辑,这样可以更好地集中处理现有项目中,关于每一个属性的定义及实现

在经过了一系列反复测试和修改后,算是拿出了一个还可以的解决方案,故此记录一下

问题 - 重复的定义

我做的 B2B 和 B2C 的很多项目都是重表单的项目,因此从很久以前我就已经在想怎么要处理表单和结构的问题,总体来说也是经历了这么几种情况:

  • 手写验证+自己抽离 UI 逻辑
    这个是最初开始接触 React 时,在写了几个项目需求的表单后,自己琢磨出来的一套,在当时的项目里可以使用的功能。因为当时项目的需求要求 bundle 要小,需要 minimal dependency,所以很多东西都必须自己实现,也就自己造了些轮子
    大体的逻辑是,每个 state 抽出来这样的逻辑:
    const form = [
      {
        type: "str",
        header: "Label for this field",
        key: "attributeName",
        validations: [minLength(3), maxLength(5)],
      },
    ];
    
    作为一个基础的实现,这样可以比较快的遍历整个 state,获取当前这个 field 的 type,attribute name,displayed name,完成表单的渲染,同时 validations 中也提供了一部分的验证,可以完成一些比较基础的,包括 min/max,nullable,isNotEmpty 之类的验证
    这个时候的实现其实已经可以看到一些工具的影子了,只不过当时确实没有想这么多就是了
  • 手写验证+使用工具生成表单
    这已经是不同的项目了,不过因为 tech debt,整体的项目处在一种又老又新的状态。老是因为确实不少的工具——比如说 redux form,有些年头了,虽然当时用起来比较舒服,不过对于日新月异的更新来说,还是稍显疲软。新则是因为当时确实也在从 class based components 转向 functional components,并且大量的资源放在了 React 方面的重写,对于之前存在工具和验证还是采取了保留的状态
    当然,我没有在这个项目停留很久,只是作为 supportive resource 帮助完成了 React 方面的 refactor 就跑路了,不过我开始慢慢意识到了,验证和结构的分离,似乎在很多项目里都存在,也是不少项目都在头疼的地方
  • 忽略验证+手写 structure
    这也是最近这些年在工作的项目,忽略验证的原因是因为所有的验证 backend 都有 cover,当时是没有考虑做前端的验证,也是属于 tech debt。之所以单独把这项目拎出来的原因,则是因为手写 structure 的部分——structure 做起来其实还是有点麻烦的,因为它需要同时支持 table 和 form 两个 level 的显示
    除此之外,这个项目因为要做 multi-region support,因此每个 region 都会有对应的 structure。这也就意味着,当一个页面逻辑比较复杂的时候,它的管理难度就会直线上升:
    • 对于同一个属性来说,它在 table/form 层的逻辑可能不一样
      如,在 table 层,它可能是 dropdown,但是在 form 层它可以打开一个 modal
      或者在 table 层要做最简渲染,因此 table 层不会显示该属性,但是在 form 层有足够的空间显示完整的数据,因此需要显示
    • 对于不同 region 来说,显示的 attribute 也不一样
      针对这种情况,我其实已经做了一部分的 refactor,将类似 手写验证+自己抽离 UI 逻辑 中的逻辑,从 array based 换成 object based,使用下来大体如下:
    const regionA = { attributeA, attributeB, attributeC };
    const regionB = { attributeA, attributeC };
    
    在 component 中,则通过获取属性名称的方法进行重写,这样解决了一部分的问题——属性可以统一管理,修改显示名之类的只需要修改 base object,不需要重复 cv 多个地方,在 react 组件中的重写也比较方便,比起版本 1 来说是一个有效的提升了
    但是本质上来说,重复的 declaration 还是没有办法解决,尤其是在看特定属性,在不同 region 中的表现——是否存在、是否可编辑——也非常的困难
    尤其在使用 yup 后,就会发现,schema 和 structure 出现了很大的重复,这也是我现在主要想解决的问题

以上是已经有 tech debt 中的项目会遇到的问题,如果没有的话,我个人建议还是优先考虑使用 react hook form 这种已经可以原生支持 yup/zod 等 schema validators 的工具。如果确实有一个 schema/structure 需要同时支持 form 和 table 的需求,再往下看

dummy data

这里放一下页面中要渲染的数据:

const demoStudentCourseRecords = [
  {
    studentName: "Alice Johnson",
    studentId: "S1001",
    courseLevel: "beginner",
    timeSlot: "morning",
    courseTaken: ["ml"],
  },
  {
    studentName: "Bob Smith",
    studentId: "S1002",
    courseLevel: "intermediate",
    timeSlot: "afternoon",
    courseTaken: ["db", "stats"],
  },
  {
    studentName: "Charlie Lee",
    studentId: "S1003",
    courseLevel: "advanced",
    timeSlot: "evening",
    courseTaken: ["ds"],
  },
  {
    studentName: "Diana Wang",
    studentId: "S1004",
    courseLevel: "beginner",
    timeSlot: "morning",
    courseTaken: ["stats"],
  },
  {
    studentName: "Ethan Brown",
    studentId: "S1005",
    courseLevel: "intermediate",
    timeSlot: "evening",
    courseTaken: ["ml", "ds", "stats"],
  },
];

这里所有的 courseTaken 能和具体的课程有显著的不同,其他的都是大小写的区别,这个是缩写和全称的区别。也就是说,完整渲染的表格上,如果不能显示完整的名称,那就证明下拉框的实现出现问题了

meta 基础定义

这里是一些基础的 meta data,验证部分暂时略过:

export function createStudentCourseRecordSchema(
  courseLevels: string[],
  timeSlots: string[],
  courseList: string[],
  courseLevelOptions: { id: string; label: string }[],
  timeSlotOptions: { id: string; label: string }[],
  courseOptions: { id: string; label: string }[]
): yup.AnySchema {
  return yup.object().shape({
    studentName: studentNameField,
    studentId: studentIdField,
    courseLevel: yup
      .string()
      .required(`${FIELD_LABELS.courseLevel} is required`)
      .oneOf(courseLevels, `Invalid ${FIELD_LABELS.courseLevel.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.courseLevel,
        table: { header: FIELD_LABELS.courseLevel, accessorKey: "courseLevel" },
        type: "enum",
        options: courseLevelOptions,
      }),
    timeSlot: yup
      .string()
      .required(`${FIELD_LABELS.timeSlot} is required`)
      .oneOf(timeSlots, `Invalid ${FIELD_LABELS.timeSlot.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.timeSlot,
        table: { header: FIELD_LABELS.timeSlot, accessorKey: "timeSlot" },
        type: "enum",
        options: timeSlotOptions,
      }),
    courseTaken: yup
      .array()
      .of(yup.string().oneOf(courseList, `Invalid course taken`))
      .min(
        1,
        `At least one ${FIELD_LABELS.courseTaken.toLowerCase()} must be selected`
      )
      .required(`${FIELD_LABELS.courseTaken} is required`)
      .meta({
        label: FIELD_LABELS.courseTaken,
        table: { header: FIELD_LABELS.courseTaken, accessorKey: "courseTaken" },
        type: "multi-enum",
        options: courseOptions,
      }),
  });
}

这里注意一下 typetype: "enum"type: "multi-enum",分别对应的是单选和多选,这个在后面转化成 react table 时需要用到

这里也是一个比较初期的想法,因此在选择创建 createStudentCourseRecordSchema 的时候采取了传值的方法。现在的想法是,之后抽空回到 zustand 的实现,然后通过 pub/sub 的方法,在获取数据后自动 publish 相应的变化,然后让 yup 通过 subscribe 去自动更新

生成 table column

方法如下:

export function generateTableColumnsFromSchema(
  schema: yup.AnySchema
): ColumnDef<any, any>[] {
  console.log(schema);

  if (!schema) return [];

  const description = schema.describe() as ObjectSchemaDescription;

  if (description.type !== "object" || !description.fields) {
    throw new Error("Schema must be a Yup object schema.");
  }

  const fields = description.fields;

  const columns: ColumnDef<any, any>[] = [];

  for (const [_, fieldDesc] of Object.entries(fields)) {
    const meta = fieldDesc.meta as any;

    const { header, accessorKey } = meta.table;

    const type = meta?.type;
    const options = meta?.options;

    let cell;

    if (type === "enum" && options) {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return optionMap.get(rawValue) ?? rawValue;
      };
    } else if (type === "multi-enum" && options) {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValues = getValue() as string[];
        if (!Array.isArray(rawValues)) return null;
        return rawValues.map((val) => optionMap.get(val) ?? val).join(", ");
      };
    } else {
      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return rawValue;
      };
    }

    columns.push({
      header,
      accessorKey,
      cell,
    });
  }

  return columns;
}

这里对于 cell 的处理就是依据不同的类型做的,具体是要通过 key 去获得 value。

渲染效果如下:

可以看到,所有的 course 都从缩写变成了完整的课程,这也可以证明数据获取和渲染都是没有问题的

拓展 - 动态支持

拓展部分会用到一个 package:sift,sift 实现了一种类似 mongodb 的语法,我在用的过程中发现,至少对大多数的项目来说,一个简单的 $or 用法就够了

schema 部分的拓展

代码如下:

// 更新一下 props 定义,如果要写多个 util 方法的话,可以让 AnySchema 拓展下面的 meta 部分
// 这样可以获取更好的支持
export type Region = "us" | "apac" | "eu";
export type Env = "dev" | "uat" | "prod";
export type FieldVisibility = "form" | "table" | "both";
export type Context = {
  env?: Env;
  region?: Region;
  fieldVisibility?: FieldVisibility;
}[];

export interface IFieldMeta {
  label?: string;
  table?: {
    header?: string;
    accessorKey?: string;
  };
  type?: string;
  options?: { id: string; label: string }[];
  isAvailable?: boolean | Context;
}

// ========================================

export function createStudentCourseRecordSchema(
  courseLevels: string[],
  timeSlots: string[],
  courseList: string[],
  courseLevelOptions: { id: string; label: string }[],
  timeSlotOptions: { id: string; label: string }[],
  courseOptions: { id: string; label: string }[]
): yup.AnySchema {
  return yup.object().shape({
    studentName: studentNameField,
    studentId: studentIdField,
    courseLevel: yup
      .string()
      .required(`${FIELD_LABELS.courseLevel} is required`)
      .oneOf(courseLevels, `Invalid ${FIELD_LABELS.courseLevel.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.courseLevel,
        table: { header: FIELD_LABELS.courseLevel, accessorKey: "courseLevel" },
        type: "enum",
        options: courseLevelOptions,
        isAvailable: [{ fieldVisibility: "form" }],
      }),
    timeSlot: yup
      .string()
      .required(`${FIELD_LABELS.timeSlot} is required`)
      .oneOf(timeSlots, `Invalid ${FIELD_LABELS.timeSlot.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.timeSlot,
        table: { header: FIELD_LABELS.timeSlot, accessorKey: "timeSlot" },
        type: "enum",
        options: timeSlotOptions,
        isAvailable: [{ fieldVisibility: "both" }],
      }),
    courseTaken: yup
      .array()
      .of(yup.string().oneOf(courseList, `Invalid course taken`))
      .min(
        1,
        `At least one ${FIELD_LABELS.courseTaken.toLowerCase()} must be selected`
      )
      .required(`${FIELD_LABELS.courseTaken} is required`)
      .meta({
        label: FIELD_LABELS.courseTaken,
        table: { header: FIELD_LABELS.courseTaken, accessorKey: "courseTaken" },
        type: "multi-enum",
        options: courseOptions,
      }),
  });
}

这里主要更新的就是类型,以及 courseLeveltimeSlotisAvailable 部分,这一部分需要包含所有的选项,让 sift 可以去做 filter → 具体用法在下面

Context 单独抽出来是因为,这里可以不止可以用在 isAvailable 里,同样的逻辑也可以放在 isVisible, isEditable 等其他可拓展的逻辑中

这样就可以做一个非常直观且 fine grain 的控制:

  • 当前属性在 form/table/region 中是否包含
  • 当前属性在 form/table/region 中是否可见
  • 当前属性在 form/table/region 中是否可变

如果没有设置的话,则是默认 true ,同时也支持通过 boolean 的方式强制重写,这个优先权会高于 context 的设置

util 部分的拓展

代码如下:

export function generateTableColumnsFromSchema(
  schema: yup.AnySchema
): ColumnDef<any, any>[] {
  console.log(schema);

  if (!schema) return [];

  const description = schema.describe() as ObjectSchemaDescription;

  if (description.type !== "object" || !description.fields) {
    throw new Error("Schema must be a Yup object schema.");
  }

  const fields = description.fields;

  const columns: ColumnDef<any, any>[] = [];

  for (const [_, fieldDesc] of Object.entries(fields)) {
    const meta = fieldDesc.meta as any;

    const { header, accessorKey } = meta.table;

    const type = meta?.type;
    const options = meta?.options;

    let cell;

    if (type === "enum") {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return optionMap.get(rawValue) ?? rawValue;
      };
    } else if (type === "multi-enum") {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValues = getValue() as string[];
        if (!Array.isArray(rawValues)) return null;
        return rawValues.map((val) => optionMap.get(val) ?? val).join(", ");
      };
    } else {
      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return rawValue;
      };
    }

    let isColAvailable = true;
    // const currEnv = {
    //   region: 'us'
    // };
    const currEnv = {
      $or: [
        { env: "dev" },
        { region: "us" },
        { fieldVisibility: "table" },
        { fieldVisibility: "both" },
      ],
    };
    if (meta?.isAvailable) {
      // 添加 typeof 的检查,保证 boolean 的优先权
      isColAvailable = meta.isAvailable.filter(sift(currEnv)).length > 0;
    }

    if (!isColAvailable) continue;

    columns.push({
      header,
      accessorKey,
      cell,
    });
  }

  return columns;
}

这里为了展示一下做的稍微简化了一些,本来的做法是写三个方法的:

  • 第一个是 generateTableColumnsFromSchema
  • 第二个是 generateFormFieldsFromSchema

然后二者通过 sift 过滤一下 isColAvailable ,获取一个 IFieldMeta[] 结构的 columns,传到最后集中处理逻辑的 util function 里

这里核心的修改逻辑是这个:

let isColAvailable = true;
// const currEnv = {
//   region: 'us'
// };
const currEnv = {
  $or: [
    { env: "dev" },
    { region: "us" },
    { fieldVisibility: "table" },
    { fieldVisibility: "both" },
  ],
};
if (meta?.isAvailable) {
  // 添加 typeof 的检查,保证 boolean 的优先权
  isColAvailable = meta.isAvailable.filter(sift(currEnv)).length > 0;
}

if (!isColAvailable) continue;

这一段代码可以实现对当前 attribute 的控制,并且,我会建议把这个 context 再分一下,分成 global context 和 local context。global context,如 region,env 这种,在特定环境下是不会变化的,这个就完全可以抽出来放到其他的 constant 或是 util 中,等到要用的时候再 extend 即可

最终渲染效果如下:

Course Level 成了一个 form only 的选项,因此在 table view 中被忽略了,而 Time Slot 则是因为会出现在 form 和 table 中得以被保留,可以正常的在页面中渲染


网站公告

今日签到

点亮在社区的每一天
去签到