perf(创建产品): 物料手风琴虚拟滚动 + memo 化,并修掉打开时的重复请求

反馈是展开物料列表卡。实测后端并不慢:/materials/groups 10~18ms,
生产配件 684 条 / 107KB 的 items 只要 16ms,MOM 侧纯 SQL 1.43ms,
category 上还有 idx_base_category 索引 —— 瓶颈全部在前端渲染。

1. Table 既不分页也没虚拟化,LICA/生产配件 的 684 条要一次性建出近 700 个
   表格行。改为 virtual + scroll={y:240, x:600},只渲染可视区那十几行。
   (antd 的 virtual 要求 scroll.x/y 都是数字,列宽因此显式指定。)
2. collapseItems 每次重渲染都重建全部 Table 元素,而每个分组在「开始加载」
   和「加载完成」各触发一次 forceRefresh —— 点「全部展开」就是十几次全量
   重建,这才是卡顿主因。改用 useMemo;tick 必须进依赖,因为 groupCache /
   groupLoadingMap 都是 ref。
3. columns 与 handleSelect 一并 memo 化,否则第 2 条 memo 每次都会失效。
4. 打开对话框时 useEffect([open]) 与 useEffect([keyword]) 会各请求一次
   /materials/groups。用 lastSearchedRef 记录「上次已搜索的词」,跳过
   setKeyword("") 重置造成的那次重复请求。
This commit is contained in:
2026-09-21 16:24:36 +08:00
parent de930ff496
commit f04c7e0b99

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
import api from "../../services/api";
@ -52,9 +52,13 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
const [createdSn, setCreatedSn] = useState<string | null>(null);
// ---- 初始化 ----
// 打开时只加载一次分组摘要。下面的防抖 effect 靠 lastSearchedRef 跳过这次,
// 否则同一个 /materials/groups 请求会被发两遍(打开时要多等一个来回)。
const lastSearchedRef = useRef<string>("");
useEffect(() => {
if (open) {
setKeyword("");
lastSearchedRef.current = "";
setActiveKeys([]);
setSelected(null);
setExternalSerial("");
@ -90,13 +94,19 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
loadSummary(keyword.trim() || undefined);
}
// 防抖搜索输入即搜300ms 无键入后自动触发
// 防抖搜索输入即搜300ms 无键入后自动触发
// keyword 与「上次已搜索的词」相同则跳过,覆盖两种会误触发的情况:
// 1) 组件挂载后的首次运行;
// 2) 打开对话框时 setKeyword("") 造成的重置(此时 loadSummary 已经跑过)。
useEffect(() => {
if (!open) return;
if (keyword === lastSearchedRef.current) return;
const timer = setTimeout(() => {
lastSearchedRef.current = keyword;
handleSearch();
}, 300);
return () => clearTimeout(timer);
}, [keyword]);
}, [keyword, open]);
/** 懒加载分组内物料 */
async function loadGroupItems(category: string) {
@ -121,7 +131,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
}
/** 强制刷新 — 因为 useRef 不会触发重渲染,借用 state 刷新 */
const [, setTick] = useState(0);
const [tick, setTick] = useState(0);
function forceRefresh() {
setTick((t) => t + 1);
}
@ -153,7 +163,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
// 选择物料
// ============================================================
function handleSelect(item: MaterialItem) {
const handleSelect = useCallback((item: MaterialItem) => {
setSelected({
material_id: String(item.id),
material_name: item.name,
@ -161,7 +171,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
category: item.category,
material_type: item.type,
});
}
}, []);
// ============================================================
// 提交创建
@ -196,94 +206,116 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
// 表格列定义
// ============================================================
const columns = [
{
title: "名称",
dataIndex: "name",
key: "name",
ellipsis: true,
render: (v: string) => <span className="font-medium text-gray-800">{v}</span>,
},
{
title: "规格",
dataIndex: "spec",
key: "spec",
ellipsis: true,
},
{
title: "类型",
dataIndex: "type",
key: "type",
width: 80,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: "单位",
dataIndex: "unit",
key: "unit",
width: 60,
},
{
title: "操作",
key: "action",
width: 80,
render: (_: unknown, record: MaterialItem) => (
<Button
type="link"
size="small"
onClick={(e) => {
e.stopPropagation();
handleSelect(record);
}}
>
</Button>
),
},
];
// useMemo让 columns 引用保持稳定,否则 collapseItems 的 memo 每次都会失效。
// 每列都显式给 width —— 虚拟滚动要靠它算可视区间(总宽 600与 scroll.x 对应)。
const columns = useMemo(
() => [
{
title: "名称",
dataIndex: "name",
key: "name",
width: 200,
ellipsis: true,
render: (v: string) => <span className="font-medium text-gray-800">{v}</span>,
},
{
title: "规格",
dataIndex: "spec",
key: "spec",
width: 180,
ellipsis: true,
},
{
title: "类型",
dataIndex: "type",
key: "type",
width: 80,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: "单位",
dataIndex: "unit",
key: "unit",
width: 60,
},
{
title: "操作",
key: "action",
width: 80,
render: (_: unknown, record: MaterialItem) => (
<Button
type="link"
size="small"
onClick={(e) => {
e.stopPropagation();
handleSelect(record);
}}
>
</Button>
),
},
],
[handleSelect]
);
// ============================================================
// Collapse items 生成
// ============================================================
const collapseItems = summary.map((group) => {
const items = groupCache.current.get(group.category);
const isLoading = groupLoadingMap.current.get(group.category) === true;
// useMemo不缓存的话每次重渲染都会重建全部 Table 元素。而每个分组在
// 「开始加载」和「加载完成」各触发一次 forceRefresh点「全部展开」就是
// 十几次全量重建 —— 这才是展开大分组时卡顿的主因(后端只要 16ms
const collapseItems = useMemo(
() =>
summary.map((group) => {
const items = groupCache.current.get(group.category);
const isLoading = groupLoadingMap.current.get(group.category) === true;
return {
key: group.category,
label: (
<div className="flex items-center justify-between pr-2">
<span className="text-sm font-medium text-gray-700">{group.category}</span>
<Tag className="ml-2">{group.count}</Tag>
</div>
),
children: isLoading ? (
<div className="flex items-center justify-center py-8">
<Spin />
</div>
) : items && items.length > 0 ? (
<Table
columns={columns}
dataSource={items}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 240 }}
onRow={(record) => ({
className:
selected?.material_id === String(record.id) ? "bg-blue-50" : "",
})}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该分类下暂无物料"
className="py-6"
/>
),
};
});
return {
key: group.category,
label: (
<div className="flex items-center justify-between pr-2">
<span className="text-sm font-medium text-gray-700">{group.category}</span>
<Tag className="ml-2">{group.count}</Tag>
</div>
),
children: isLoading ? (
<div className="flex items-center justify-center py-8">
<Spin />
</div>
) : items && items.length > 0 ? (
<Table
columns={columns}
dataSource={items}
rowKey="id"
size="small"
pagination={false}
// virtual + 固定的 x/y只渲染可视区那十几行。
// LICA/生产配件 有 684 条,不分页又不虚拟化时一次性要建近 700 个
// 表格行,展开和「全部展开」都会明显卡住。
// 注意 antd 的 virtual 要求 scroll.x 和 scroll.y 都是数字。
scroll={{ y: 240, x: 600 }}
virtual
onRow={(record) => ({
className:
selected?.material_id === String(record.id) ? "bg-blue-50" : "",
})}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该分类下暂无物料"
className="py-6"
/>
),
};
}),
// tick 必须进依赖groupCache / groupLoadingMap 都是 ref数据到位后
// 依赖 forceRefresh 改变 tick 来触发重算。
// eslint-disable-next-line react-hooks/exhaustive-deps
[summary, tick, selected, columns]
);
// ============================================================
// 打印标签