跳到主要内容

8 篇博文 含有标签「React」

查看所有标签

接口 422、参数 undefined?前后端契约对不上的四个坑

· 阅读需 7 分钟

前后端联调的三种经典场面:POST 请求返回 422;路由页面参数永远是 undefined;SSE 请求状态码 200,消息却静默无响应。症状各不相同,挖到底是同一类根因——前后端对接口的理解不一致。

在为客户构建 AI Agent SaaS 平台时遇到此问题,前后端分别开发,四个坑全踩了一遍,记录定位方法与预防纪律。

TL;DR​

契约不一致有四种形态,对应三类症状:

坑不一致点症状
字段名前端发 name,后端要 label422
字段类型前端发对象数组,后端收字符串数组422
路由参数路由定义 :id,组件取 agentIdundefined,功能静默失效
事件字段后端推 {"content": ...},前端检查 token200,界面无响应

预防只有一个纪律:动手写代码前,先对齐 Schema——字段名、类型、路由参数名,全部以后端 Schema(或双方共同确认的接口文档)为唯一来源。

坑一:字段名不一致 → 422​

最原始的坑。前端 TypeScript 接口定义创建 API Key 的入参用 name + key,后端 Pydantic Schema 期望的是 label + api_key:

// 前端以为的
interface CreateApiKeyInput {
name: string
key: string
}

# 后端定义的
class ApiKeyCreate(BaseModel):
label: str
api_key: str
POST /api/api-keys → 422 Unprocessable Entity

定位靠 422 响应体,别瞎猜。 FastAPI 的 422 会带 detail 数组,逐条写明缺哪个字段、为什么:

[
{ "loc": ["body", "label"], "msg": "Field required", "type": "missing" },
{ "loc": ["body", "api_key"], "msg": "Field required", "type": "missing" }
]

Field required = 请求体里根本没有这个字段 = 字段名对不上。浏览器 Network 面板展开响应,十秒钟定位。

坑二:字段类型不一致 → 422​

字段名对上了,类型也能埋雷。后端 Schema 声明 mcp_tools 是字符串数组,前端却发对象数组(每个对象带 tool_id 和 token_id,业务上确实需要绑定 OAuth Token):

// 前端发的
{ mcp_tools: [{ tool_id: "t1", token_id: "k1" }] }

# 后端收的
mcp_tools: List[str]
PATCH /api/agents/{id} → 422 Unprocessable Entity

这次 detail 里报的是 Input should be a valid string——类型不符。修法在后端 Schema 兼容两种形态(前端数据结构有业务理由,不该硬砍):

from typing import List, Union

class McpToolConfig(BaseModel):
tool_id: str
token_id: str | None = None

mcp_tools: List[Union[str, McpToolConfig]]

Union 让 Schema 同时接受字符串(只引用工具 ID)和对象(带绑定配置),Pydantic 按顺序尝试匹配。这个模式对「接口演进、前端先行」的场景通用:Schema 迁就真实业务形态,而不是反过来逼调用方削足适履。

坑三:useParams 参数名不一致 → undefined​

路由定义和组件取参各写各的,参数名差一个词:

// 路由定义
<Route path="/agents/:id/memory" element={<MemoryPage />} />

// 组件里
const { agentId } = useParams<{ agentId: string }>()
// agentId 永远是 undefined —— 路由里的参数名叫 id,不叫 agentId

页面不报错、请求状态码正常,就是功能静默失效——agentId 是 undefined,后续 API 调用全带上 undefined,或者干脆没发出去。

两条修法:

// 1. 参数名对齐路由定义
const { id } = useParams<{ id: string }>()

// 2. 想用别的变量名,解构重命名
const { id: agentId } = useParams<{ id: string }>()

这个坑的隐蔽性在 useParams<{ agentId: string }> 的泛型参数——TypeScript 不会校验泛型里的键名是否真的存在于路由定义,类型标注给了假的安心。

坑四:事件字段不一致 → 200 但静默失败​

SSE 流式响应里,后端推的事件数据是 {"content": "..."},前端判断逻辑检查的却是 token:

// 前端的事件判断
const isTokenEvent = (d: any) => 'token' in d // 永远 false

// 后端实际推的
data: {"content": "你好"}

网络面板里请求 200、数据流在动,界面上却一个字都不出现——判断条件永远不成立,事件被静默丢弃。这类坑比 422 更难排查:没有报错,只有「功能没发生」。

修法是事件判断对齐后端的实际数据结构:

const isTokenEvent = (d: any) => 'content' in d && !('type' in d)

SSE / WebSocket 这类「一个连接多种事件」的接口,建议在事件数据里带显式的 type 字段做分发,靠「有没有某字段」推断事件类型的写法,契约变更时就是静默失败的温床。

预防:一条纪律​

四个坑的共同起因:前后端各自凭想象写了对接口的那一半。预防不需要工具,就一条纪律——

新接口动手前,先对齐 Schema:字段名、类型、路由参数名、事件数据结构,逐项过。 后端 Pydantic Schema(或 OpenAPI 文档)是唯一来源,前端 TypeScript 接口照着它写;谁要改契约,先改 Schema、双方确认,再动代码。

联调时遇到症状,按这个顺序筛:422 → 展开响应体 detail 看 loc;undefined → 核对路由定义的 :param 名;200 但功能没发生 → 抓一份真实响应,逐字段对前端判断逻辑。

流式接口的服务端另有伴生坑(客户端断开导致的异常与资源泄漏),已在 FastAPI SSE 客户端断开报 CancelledError?生成器必须捕获并 re-raise 一文展开。

注意事项

前端 TypeScript 的接口类型(interface XxxInput)只是编译期的纸面约束,as any、第三方请求封装都能绕过它——类型对得上不代表字段对得上。契约校验的最终依据永远是与后端 Schema 的比对,不是前端类型不报红。

常见问题​

FastAPI 返回 422 Unprocessable Entity 怎么排查?​

422 的响应体里带 detail 数组,逐条写明哪个字段、什么原因——先用浏览器 Network 面板或 curl 展开它,九成的 422 是字段名与后端 Schema 不一致或字段类型不符。请求体里发 name 而后端 Schema 要 label,就是典型的字段名坑。

React Router 的 useParams 为什么取不到值?​

useParams 返回的键名由路由定义里的 :param 决定:路由是 /agents/:id,就只能用 useParams().id 取。解构时想换变量名用重命名语法 const { id: agentId } = useParams()。参数名对不上不会报错,只是安静地给你 undefined。

前后端字段名不一致怎么预防?​

把后端 Schema(Pydantic/Zod 都行)当成契约的唯一来源:新接口动手前先对齐字段名和类型,前端 TypeScript 接口从 Schema 生成或逐字段核对。接口评审时多问一句「请求体长什么样」,比联调时抓包省一个下午。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

移动端 vaul 抽屉无法下拉关闭?根挂 overflow 拦截拖拽手势

· 阅读需 7 分钟

在手机上打开筛选抽屉时,内容看得到但滚不动,下拉也关不掉——桌面浏览器里一切正常,只有真机出问题。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,筛选和分类管理都走底部抽屉交互。这个坑我们修了两次:第一次修复后,另一次页面重写凭记忆重排了抽屉 JSX,同一个坑几小时内就回来了。

TL;DR​

  • 根因:DrawerContent 的根元素是 vaul 的拖拽层(负责下拉关闭手势)。根上挂 overflow-y-auto(连同 max-h-[80vh] 这类限高重排)后,浏览器把触摸手势判定为「滚动容器滚动」,vaul 的拖拽监听收不到事件——抽屉既不可滚(视觉上被 max-h 截断)也无法下拉关闭。
  • 解法:滚动下沉内层——根上只留固定区(标题栏),内容包进 flex-1 min-h-0 overflow-y-auto 的内层容器。
  • 元教训:修过的结构性坑,重写页面时必须从模板出发核对,不能凭旧代码记忆重排 JSX。

现象:桌面正常,真机抽屉「钉死」​

抽屉用的是 shadcn/ui 的 Drawer 组件(底层是 vaul ^1.1.2)。故障表现:桌面浏览器里抽屉能滚能关,一切正常;真机上内容区滚不动、下拉关闭也无效,抽屉像钉死了一样。而且「滚不动」和「关不掉」总是同时出现。

这个组合症状指向的不是样式问题,而是手势问题——滚动和拖拽在移动端是同一根手指的同一段触摸动作,归浏览器还是归组件,要看手势落在哪个元素上。

根因:滚动容器和拖拽层抢同一个手势​

vaul 的下拉关闭依赖根元素上的触摸手势监听:手指按住任意位置往下拖,根元素持续收到 touchmove,拖动距离超过阈值就关闭抽屉。

当根元素同时是滚动容器时,移动端浏览器的手势判定会先介入:手指移动被解释为「滚动这个容器」,浏览器消费掉这组触摸事件,vaul 的拖拽监听收不到足够的 touchmove——拖拽判定永远不成立。这就是「滚不动 + 关不掉」成对出现的原因:滚动容器赢了手势,vaul 输了,而内容又因 max-h 被截断无法完整展示。

出问题的结构(回归版本,抽自真实 diff):

{/* 错误:根是拖拽层,却挂了滚动 + 限高 */}
<DrawerContent className="max-h-[80vh] overflow-y-auto px-4 pb-6 text-left">
<DrawerHeader>
<DrawerTitle>筛选</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-4">{/* 内容 */}</div>
<DrawerFooter>{/* 操作按钮 */}</DrawerFooter>
</DrawerContent>

这个写法在桌面浏览器完全正常——鼠标滚轮直接驱动滚动容器,vaul 的下拉关闭靠把手(handle)仍可用。所以问题极易在开发阶段漏掉,直到真机走查才暴露。

修复:滚动下沉内层,根只留固定区​

正确结构是把滚动职责从根上摘掉,交给内层容器;标题栏留在根上固定:

{/* 正确:根无 overflow,滚动下沉内层 */}
<DrawerContent className="text-left">
<DrawerHeader>
<DrawerTitle>筛选</DrawerTitle>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-6">
{/* 内容 + Footer 都在这里滚 */}
</div>
</DrawerContent>

三个关键点:

  1. 根上零 overflow、零 max-h——拖拽手势畅通,任意位置下拉都能关闭。
  2. 内层 flex-1 min-h-0——flex 子项默认 min-height:auto,不压到零的话内容会把抽屉撑开、内层根本不产生滚动条;min-h-0 是滚动真正生效的前提。
  3. 标题栏留在根上——视觉上标题固定、内容滚动,交互上把手和标题区的下拉是「关闭」,内容区的滑动是「滚动」,两个手势各归其主。

修复后用 Playwright 手势验证了三件事:根元素 overflowY === 'visible'、内层 scrollTop 可独立滚动、模拟拖拽下拉能关闭抽屉。三条全过,桌面回归无影响。

为什么会回归:凭记忆重排 JSX​

更值得写下来的是这个坑的回归过程。第一次修复(commit a182098,2026-09-23 08:55)解决了「移动端不可滚」;之后一次页面重写,写的时候没有对照修复记录,凭对旧代码的记忆重排了抽屉 JSX——max-h 和 overflow-y-auto 又回到了根上。同日 12:10,第二次修复(f1097b2)落地,和第一次相隔不到 4 小时。

结构性的坑(DOM 层级、手势归属、滚动容器位置)和普通逻辑 bug 不同:它不是「记住结论」就能防住的,因为重写代码的人未必是踩过坑的人,记忆也未必是自己的。可靠的做法是把正确结构沉淀成可复制的模板,规则一句话——「DrawerContent 根是 vaul 拖拽层,禁挂 overflow/max-h,滚动下沉内层」——重写时照模板复制,写完跑一次手势断言。

注意事项

这套「滚动下沉内层」不只适用于 vaul:任何「根元素承载拖拽手势」的组件(各类 bottom sheet、可拖拽 Dialog)都遵循同一规律——手势层和滚动层必须分层。另外 min-h-0 在 flex 列布局里是滚动生效的隐性前提,漏掉它时症状是「内层不出现滚动条、抽屉被撑高」,和根挂 overflow 的症状不同,排查时先确认滚动容器本身能不能滚,再往前追手势归属。

常见问题​

移动端底部抽屉无法下拉关闭怎么办?​

先检查 DrawerContent 根元素是否挂了 overflow-y-auto 或 max-h——DrawerContent 的根是 vaul 的拖拽层,根上的滚动容器会把触摸手势判定为滚动,vaul 收不到拖拽事件。把滚动下沉到内层容器(flex-1 min-h-0 overflow-y-auto),标题栏留在根上固定。

底部弹窗能打开但内容滚不动,是怎么回事?​

「滚不动」和「拉不下来关闭」往往是同一个根因的两个症状:滚动容器放在了组件根上,触摸移动被滚动消费,vaul 的拖拽手势被拦截。滚动下沉内层后两个症状同时消失——内容区滑动是滚动,把手或标题区下拉是关闭。

vaul 的 DrawerContent 上为什么不能放 overflow-y-auto?​

DrawerContent 根元素绑定着 vaul 的拖拽手势监听,负责任意位置下拉关闭;根上放 overflow-y-auto 后,浏览器优先把手势判定为容器滚动,touchmove 事件到不了 vaul 的监听层。这不是 vaul 的 bug,是滚动容器和手势层抢同一个手势的归属。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Ant Design Table 点击一行却多行同时高亮?rowKey 不唯一

· 阅读需 5 分钟

在数据报表页面点击表格某一行查看详情时,被点的行和另外几行同时高亮,控制台还在不停刷 React duplicate key 警告。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。广告周报页面里有多张明细表(计划×关键词、计划×地域),每张表都要支持点击行高亮、联动查看该行的投放明细。上线后点任意一行,同一计划下的所有行会一起「点亮」。

TL;DR​

Ant Design 的 Table 用 rowKey 的返回值作为每行的 React key。当 rowKey 取的字段不是数据的真实业务键——列名凭猜测、或漏了参与唯一性的维度——多行会生成相同的 key:所有按 key 匹配的行交互(选中、高亮、展开)一次命中多行,React 还会抛出 duplicate key 警告。修法:先用 information_schema 查表的真实列,再选定真正唯一的列或复合列做 rowKey,并用 GROUP BY HAVING 验证。

问题现象​

下面是一个最小复现(Ant Design 5 + React 18):

import { Table } from 'antd';
import { useState } from 'react';

// 数据粒度:关键词 × 商品 —— 同一个关键词会按推广商品拆成多行
const data = [
{ keyword_id: 88, keyword: 'summer dress', offer_id: 101, clicks: 12 },
{ keyword_id: 88, keyword: 'summer dress', offer_id: 102, clicks: 7 },
{ keyword_id: 90, keyword: 'maxi skirt', offer_id: 103, clicks: 5 },
];

export default function WeeklyKeywords() {
const [selected, setSelected] = useState<string[]>([]);
return (
<Table
rowKey={(r) => String(r.keyword_id)} // 坑:keyword_id 在该粒度下不唯一
columns={[
{ title: '关键词', dataIndex: 'keyword' },
{ title: '商品', dataIndex: 'offer_id' },
{ title: '点击量', dataIndex: 'clicks' },
]}
dataSource={data}
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
onRow={(r) => ({ onClick: () => setSelected([String(r.keyword_id)]) })}
/>
);
}

症状有两个:点击第一行,前两行(keyword_id 都是 88)同时高亮;控制台反复出现:

Warning: Encountered two children with the same key, `88`.
Keys should be unique so that components maintain their identity across updates.

根因​

antd Table 的行身份就是 rowKey 的返回值。 它被直接用作该行 React 元素的 key。key 重复时,React 的 diff 会把多行视为同一个元素:渲染可能错乱,受控状态会在行间互相串。

所有按 key 匹配的行交互都会被放大。 rowSelection 的 selectedRowKeys、onRow 点击、expandedRowKeys 全部按 key 比较——key 重复时一次匹配命中多行,这就是「点一行亮一片」的直接原因。

键列选错往往发生在数据侧。 这次的实际根因:键列是凭命名猜测的——以为地域表有 region_id,表里实际的业务键列是 area_name;以为关键词表的粒度是关键词,实际是关键词×商品(offer_id 也参与唯一键)。键列漏配维度,同组所有行的 rowKey 就完全相同。

解决方案​

第一步:查表的真实列,别凭命名猜​

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'ad_weekly_keywords'
ORDER BY ordinal_position;

确认业务键到底是哪些列,表里是否真的存在你以为的那一列。

第二步:验证键(或键组合)唯一​

SELECT keyword, offer_id, COUNT(*)
FROM ad_weekly_keywords
GROUP BY keyword, offer_id
HAVING COUNT(*) > 1;
-- 返回 0 行 = 唯一;同时确认键列无 NULL

第三步:用复合键配置 rowKey​

<Table
rowKey={(r) => `${r.keyword}::${r.offer_id}`}
// 或者更稳:JSON.stringify([r.keyword, r.offer_id])
dataSource={data}
...
/>

拼接复合键时用一个字段值里不可能出现的分隔符(或直接 JSON.stringify 成数组),避免 a + b 与 ab 撞键。

改完后行点击只高亮一行,duplicate key 警告清零。这和React 列表 key 重复导致 DOM 报错是同一族问题——key 唯一,diff 与行交互才谈得上正确。

注意事项

定键列之前先查 information_schema 的实际列,别凭字段命名猜测:业务键列可能和直觉完全不同(表里只有 area_name 没有 region_id;关键词粒度实际是关键词×商品)。

duplicate key 警告不是「警告而已」:它意味着 React 调和出错,行状态互串、高亮错乱、更新不生效都可能发生,必须清零。

换完 rowKey 后用 GROUP BY ... HAVING COUNT(*) > 1 复核唯一性,并检查键列是否有 NULL——NULL 键同样会制造重复。

常见问题​

Ant Design Table 的 rowKey 应该怎么设置?​

设置为数据中唯一标识一行的字段或字段组合:单字段唯一就直接用,单字段不唯一就用多列拼复合键(分隔符防撞或 JSON.stringify)。别用不唯一的业务字段,也别图省事用数组 index——排序、筛选、分页后状态会串行。

React duplicate key 警告怎么解决?​

key 重复意味着 React 把多个节点当成同一个元素,渲染错乱、状态互串。先定位产生重复 key 的列表渲染处,换成真正唯一的 key,再从数据源侧用 GROUP BY HAVING 验证唯一性——只消警告不查数据,问题迟早换个形式复发。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

绕过 Supabase Auth 实现 Playwright E2E 测试免登录

· 阅读需 4 分钟

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR​

E2E 测试不应该依赖真实的 OAuth 登录流程。通过在 useAuth hook 中检测 localStorage 的测试标记,直接注入 mock 认证状态,跳过 Supabase 初始化。同时将 Zustand store 的 loading 默认值改为 false,避免 AuthGuard 卡在无限 spinner。