From f04c7e0b9922bd0a73e0ddc24aa9d65a13416c96 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Mon, 21 Sep 2026 16:24:36 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E5=88=9B=E5=BB=BA=E4=BA=A7=E5=93=81):=20?= =?UTF-8?q?=E7=89=A9=E6=96=99=E6=89=8B=E9=A3=8E=E7=90=B4=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=20+=20memo=20=E5=8C=96=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E6=89=93=E5=BC=80=E6=97=B6=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 反馈是展开物料列表卡。实测后端并不慢:/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("") 重置造成的那次重复请求。 --- .../src/pages/admin/CreateProductDialog.tsx | 208 ++++++++++-------- 1 file changed, 120 insertions(+), 88 deletions(-) diff --git a/frontend/src/pages/admin/CreateProductDialog.tsx b/frontend/src/pages/admin/CreateProductDialog.tsx index dbc0396..5037f5e 100644 --- a/frontend/src/pages/admin/CreateProductDialog.tsx +++ b/frontend/src/pages/admin/CreateProductDialog.tsx @@ -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(null); // ---- 初始化 ---- + // 打开时只加载一次分组摘要。下面的防抖 effect 靠 lastSearchedRef 跳过这次, + // 否则同一个 /materials/groups 请求会被发两遍(打开时要多等一个来回)。 + const lastSearchedRef = useRef(""); 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) => {v}, - }, - { - title: "规格", - dataIndex: "spec", - key: "spec", - ellipsis: true, - }, - { - title: "类型", - dataIndex: "type", - key: "type", - width: 80, - render: (v: string) => {v}, - }, - { - title: "单位", - dataIndex: "unit", - key: "unit", - width: 60, - }, - { - title: "操作", - key: "action", - width: 80, - render: (_: unknown, record: MaterialItem) => ( - - ), - }, - ]; + // useMemo:让 columns 引用保持稳定,否则 collapseItems 的 memo 每次都会失效。 + // 每列都显式给 width —— 虚拟滚动要靠它算可视区间(总宽 600,与 scroll.x 对应)。 + const columns = useMemo( + () => [ + { + title: "名称", + dataIndex: "name", + key: "name", + width: 200, + ellipsis: true, + render: (v: string) => {v}, + }, + { + title: "规格", + dataIndex: "spec", + key: "spec", + width: 180, + ellipsis: true, + }, + { + title: "类型", + dataIndex: "type", + key: "type", + width: 80, + render: (v: string) => {v}, + }, + { + title: "单位", + dataIndex: "unit", + key: "unit", + width: 60, + }, + { + title: "操作", + key: "action", + width: 80, + render: (_: unknown, record: MaterialItem) => ( + + ), + }, + ], + [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: ( -
- {group.category} - {group.count} -
- ), - children: isLoading ? ( -
- -
- ) : items && items.length > 0 ? ( - ({ - className: - selected?.material_id === String(record.id) ? "bg-blue-50" : "", - })} - /> - ) : ( - - ), - }; - }); + return { + key: group.category, + label: ( +
+ {group.category} + {group.count} +
+ ), + children: isLoading ? ( +
+ +
+ ) : items && items.length > 0 ? ( +
({ + className: + selected?.material_id === String(record.id) ? "bg-blue-50" : "", + })} + /> + ) : ( + + ), + }; + }), + // tick 必须进依赖:groupCache / groupLoadingMap 都是 ref,数据到位后 + // 依赖 forceRefresh 改变 tick 来触发重算。 + // eslint-disable-next-line react-hooks/exhaustive-deps + [summary, tick, selected, columns] + ); // ============================================================ // 打印标签