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