返回博客

级联填充的「双重严格」陷阱:源行查找与目标投影必须用不同宽严度

某后台管理平台有一个元数据驱动的级联字段组:用户在下拉选一个分类名称后,系统会自动把对应的「名称」和「编码」分别填入两个兄弟字段。功能跑了很久没问题,直到我们收到一个 bug 报告:选了父字段,「编码」字段一直保持空白。

更诡异的是:这个 bug 是在我刚修完另一个别名漂移问题(防止 label/code 互串)之后出现的。


现象

  • 用户选择父级枚举选项后,titleSeries(存名称)正常填入,reviewSeries(存编码)保持空白
  • 枚举选项 API 返回的数据结构是 { value: 'majors-engineering', label: '工程技术', code: 'ENGINEERING' }
  • 日志里 matched 变量是 undefined,级联投影根本没有触发

根因

级联兄弟填充本质上有两个独立的匹配点

  1. 源行查找(Source-row lookup):从下拉选项列表中找到当前选中值对应的那一行 option 对象
  2. 目标投影(Target projection):对每个兄弟字段,从找到的那行 option 里取出对应的属性值写入表单

之前为了修 ISSUE-050(防止不同字段共用一个枚举源时,label/code 互相「别名」填充),我把源行查找也一并收紧成严格模式:

// 修改前(宽松):
records.find(r => r[sourceVf] === selectedValue || r.value === selectedValue || r.label === selectedValue)
 
// 修改后(收紧,引入 bug):
records.find(r => r[sourceVf] === selectedValue)  // sourceVf = 'name'

但这里有一个关键事实被忽略了:枚举选项接口会对响应做归一化处理,把选中字段塞进 option.value,而原始的物理字段(如 name)有可能不出现在返回对象里

所以用 record.name === selectedValue 查找时,record.nameundefined,永远匹配不到,matchedundefined,投影直接跳过。

坑:把两个性质不同的匹配点当成同一个问题,用同一套严格度来修

源行查找的「宽松」是必要的——因为 API 归一化会抹掉物理字段,你只能靠 record.valuerecord.label 回退找到那行记录。

目标投影的「严格」也是必要的——每个兄弟字段只能取自己配置的 valueField,不能有任何跨字段回退,否则 label/code 就会互相别名。

这两者是对立需求,不是同一个问题,不能用同一套逻辑解决。


正解

对两个匹配点施以相反的宽严度

源行查找——宽松,允许 API 归一化后的回退:

function getSourceRecordValue(
  record: Record<string, string>,
  sourceValueField: string
): string {
  // 枚举接口会将选中值归一化到 option.value,
  // 物理字段(如 name)可能不存在于返回对象中,需要回退。
  return record[sourceValueField] || record.value || record.label || ''
}
 
const matched = records.find(
  (record) => getSourceRecordValue(record, sourceVf) === selectedValue
)

目标投影——严格,每个目标字段只取自己的 valueField,绝不跨字段回退:

export function projectCascadeValue(
  field: Pick<EffectiveField, 'cascade'>,
  sourceRow: Record<string, unknown>
): unknown {
  const valueField = field.cascade?.valueField
  if (!valueField) return undefined
  return sourceRow[valueField] ?? null  // 严格:不 fallback 到 label/value
}
正解:源行查找宽松 + 目标投影严格,两点分开各自处理
  • 找行时:record[sourceVf] || record.value || record.label,兜住 API 归一化的情况
  • 填字段时:sourceRow[sib.valueField],每个兄弟字段只取自己配置的属性,不串
防腐建议:在代码里显式标注这个不对称性

未来的维护者看到「一边宽松一边严格」很容易手痒想「统一一下」。在两处逻辑旁边各加一行注释,说明为什么它们不对称,是防止下一次同款 bug 的最便宜的保险。


一句话外卖

级联填充的「找行」和「取值」是两个性质不同的操作,API 归一化决定了前者必须宽松,别名防漂决定了后者必须严格——把它们混为一谈,修了一个 bug 会引入另一个。