创建表单为什么会发出 PATCH /undefined

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

这是一个看起来很矛盾的问题:用户明明第一次发布,客户端发出的却是修改请求。

Request URL: /resources/undefined
Request Method: PATCH
Status Code: 404

服务端返回“资源未找到”并没有错。真正奇怪的是客户端为什么选择了 PATCH,以及路由里的 ID 为什么会变成 undefined

先相信请求,而不是页面上的说法

排查时,我一开始把注意力放到了旧客户端缓存、误入编辑页和服务端版本不一致上。这些情况确实可能发生,但当 Network 面板已经记录到 PATCH /resources/undefined 时,它们就不该排在前面。

这条请求至少说明了两件事:

  • 客户端进入了编辑分支,因为创建接口应当使用 POST /resources
  • 编辑分支拿到的对象没有有效 ID,String(item.id) 最终生成了 "undefined"

请求载荷还能继续佐证。如果载荷只包含编辑时允许修改的字段,却缺少创建专用的 typeisAgreed 等字段,就可以排除“URL 被网关改写”这类猜测。分支在客户端内部已经选错了。

问题出在对象真假值

旧表单同时承担创建和编辑,属性大致如下:

interface ResourceFormProps {
  show: boolean
  item: Resource | null
  onClose: () => void
}

提交逻辑直接使用 item 的真假值判断模式:

if (item) {
  await client.resources[':id'].$patch({
    param: { id: String(item.id) },
    json: content,
  })
} else {
  await client.resources.$post({ json: content })
}

这段代码默认了一个并不存在的保证:只要 item 是对象,它就一定是加载完成、字段完整、可以编辑的 Resource

JavaScript 并不知道这条约定。事件对象、局部对象、经过组件边界传递的包装对象,甚至一个空对象,都是 truthy。只要其中任何一个值进入 item,代码就会执行 PATCH;它们没有 id 时,请求地址自然会变成 /resources/undefined

TypeScript 类型也不能证明运行时一定安全。只要调用链中出现 any、类型断言、跨运行时序列化或组件属性错配,静态声明就可能和真实值脱节。这里真正危险的不是“ID 没校验”,而是一个复杂对象同时承担了两种职责:它既保存编辑数据,又决定表单模式。

用可辨识联合表达组件模式

修复不需要全局请求拦截,也不需要每次使用 ID 都写一串判断。应该先让组件属性表达真实状态:创建模式没有编辑对象,编辑模式必须有完整对象。

interface ResourceFormCommonProps {
  show: boolean
  onClose: () => void
  onSubmitSuccess: () => void
}

type ResourceFormProps = ResourceFormCommonProps & (
  | { mode: 'create' }
  | { mode: 'edit'; item: Resource }
)

创建入口只传模式:

<ResourceForm
  mode="create"
  show={showForm}
  onClose={handleClose}
  onSubmitSuccess={refreshList}
/>

编辑入口只有拿到实体后才挂载:

{editingItem && (
  <ResourceForm
    mode="edit"
    item={editingItem}
    show={showEditForm}
    onClose={handleClose}
    onSubmitSuccess={refreshList}
  />
)}

提交分支也只看 mode

if (props.mode === 'edit') {
  await client.resources[':id'].$patch({
    param: { id: String(props.item.id) },
    json: content,
  })
} else {
  await client.resources.$post({ json: content })
}

这样处理以后,mode="create" 时从类型上就不能再传 itemmode="edit" 时又不能省略它。模式和实体不再靠真假值维持一条隐含约定。

如果提交前还要上传图片或视频,可以在副作用开始前检查一次编辑状态不变量:

if (
  props.mode === 'edit' &&
  (!Number.isInteger(props.item.id) || props.item.id <= 0)
) {
  showError('页面数据异常')
  return
}

这不是替服务端校验用户输入,也不是权限判断。它只负责阻止客户端在自身状态已经损坏时继续上传文件,并拼出一个确定无效的地址。服务端仍然负责资源是否存在、当前用户能否编辑以及提交内容是否合法。

不要把组件问题推广到所有页面

第一次修复之后,我又扫描了其他创建和编辑页面,并试图把它们全部改成显式 mode。这一步做过头了。

页面自身从 URL 读取简单参数时,逻辑可能本来就很清楚:

const { id } = useRouter().params
const isEdit = Boolean(id)

useEffect(() => {
  if (id) void fetchResource(id)
}, [id])

这里的 id 不是父组件传入的复杂实体,也没有同时携带表单数据。参数异常、资源不存在或没有访问权限时,详情请求会失败;写接口仍应在服务端做最终鉴权。这和“任意 truthy 对象让子组件误入编辑分支”不是同一种问题。

如果为了统一形式,把每个页面都改成在跳转时额外传递 mode=create|edit,再增加通用 URL 解析、重复判断和全局请求拦截,只会制造更多状态来源。原本一个 URL 参数能够说明的事情,现在需要多个值保持一致,出错面反而扩大了。

合理的规则应该限定适用范围:

可复用子组件同时承载创建和编辑时,使用显式判别属性决定提交分支,不通过父组件传入的复杂实体对象真假值推断模式。页面自身依据简单路由参数加载资源,不属于这条规则。

怎样扫描同类问题

“检查整个客户端”不等于给所有请求加防线。更有效的扫描方式是寻找同一种因果结构:

  1. 组件是否同时提供创建和编辑;
  2. 同一个提交函数是否在 POSTPUTPATCH 之间选择;
  3. 选择条件是否来自 itemrecorddata 等复杂属性的真假值;
  4. 创建入口是否也能接收这个复杂属性;
  5. 属性类型是否经过 any、断言或多层回调而失去约束。

只有同时命中这些条件,才属于这次故障的同类风险。一个只负责展示对象的抽屉、明确由按钮触发删除的组件,以及使用简单 URL ID 的独立页面,都不该因为名字相似而被改造。

最后的判断

PATCH /resources/undefined 并不是一个普通的“缺少 ID”错误。请求方法已经暴露了更早发生的状态错误:创建表单被当成了编辑表单。

修复的落点应当是组件边界。让创建和编辑成为类型系统可以区分的两种状态,再保留一次贴近副作用的状态检查,已经足够。继续向路由、请求层和其他页面扩张,解决的就不再是原来的问题了。