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:
@ -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]
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 打印标签
|
||||
|
||||
Reference in New Issue
Block a user