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,11 +206,15 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
// 表格列定义 // 表格列定义
// ============================================================ // ============================================================
const columns = [ // useMemo:让 columns 引用保持稳定,否则 collapseItems 的 memo 每次都会失效。
// 每列都显式给 width —— 虚拟滚动要靠它算可视区间(总宽 600,与 scroll.x 对应)。
const columns = useMemo(
() => [
{ {
title: "名称", title: "名称",
dataIndex: "name", dataIndex: "name",
key: "name", key: "name",
width: 200,
ellipsis: true, ellipsis: true,
render: (v: string) => <span className="font-medium text-gray-800">{v}</span>, render: (v: string) => <span className="font-medium text-gray-800">{v}</span>,
}, },
@ -208,6 +222,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
title: "规格", title: "规格",
dataIndex: "spec", dataIndex: "spec",
key: "spec", key: "spec",
width: 180,
ellipsis: true, ellipsis: true,
}, },
{ {
@ -240,13 +255,20 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
</Button> </Button>
), ),
}, },
]; ],
[handleSelect]
);
// ============================================================ // ============================================================
// Collapse items 生成 // Collapse items 生成
// ============================================================ // ============================================================
const collapseItems = summary.map((group) => { // useMemo:不缓存的话,每次重渲染都会重建全部 Table 元素。而每个分组在
// 「开始加载」和「加载完成」各触发一次 forceRefresh,点「全部展开」就是
// 十几次全量重建 —— 这才是展开大分组时卡顿的主因(后端只要 16ms)。
const collapseItems = useMemo(
() =>
summary.map((group) => {
const items = groupCache.current.get(group.category); const items = groupCache.current.get(group.category);
const isLoading = groupLoadingMap.current.get(group.category) === true; const isLoading = groupLoadingMap.current.get(group.category) === true;
@ -269,7 +291,12 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
rowKey="id" rowKey="id"
size="small" size="small"
pagination={false} pagination={false}
scroll={{ y: 240 }} // virtual + 固定的 x/y:只渲染可视区那十几行。
// LICA/生产配件 有 684 条,不分页又不虚拟化时一次性要建近 700 个
// 表格行,展开和「全部展开」都会明显卡住。
// 注意 antd 的 virtual 要求 scroll.x 和 scroll.y 都是数字。
scroll={{ y: 240, x: 600 }}
virtual
onRow={(record) => ({ onRow={(record) => ({
className: className:
selected?.material_id === String(record.id) ? "bg-blue-50" : "", selected?.material_id === String(record.id) ? "bg-blue-50" : "",
@ -283,7 +310,12 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
/> />
), ),
}; };
}); }),
// tick 必须进依赖:groupCache / groupLoadingMap 都是 ref,数据到位后
// 依赖 forceRefresh 改变 tick 来触发重算。
// eslint-disable-next-line react-hooks/exhaustive-deps
[summary, tick, selected, columns]
);
// ============================================================ // ============================================================
// 打印标签 // 打印标签