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