From 4d490ef45d825241d20395181aa09aac14454009 Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 17 Jul 2026 23:46:22 +0800 Subject: [PATCH] =?UTF-8?q?perf(render):=20memo=20=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E5=8C=96=20+=20Context=20useMemo=20+=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=B9=20memo=EF=BC=8C=E6=B6=88=E9=99=A4=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E9=87=8D=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2 — 运行时渲染 ToastContext/ChannelContext: - value 加 useMemo(参照 GlobalTaskContext 已有模式),避免每次 Provider render 新建 value 导致所有 useToast/useChannels 消费者重渲染 Papers.tsx(消除勾选/toast 时 20 卡全量重渲染): - PaperListItem/PaperGridItem props 改稳定 callback:onSelect/onFavorite/onReject/onNavigate 接 id/path 由子组件内部用 paper.id 调用,消除每 render 内联箭头新引用击穿 memo Agent.tsx(长会话每键不再重渲染 N ChatBlock): - onOpenArtifact 提为 useCallback handleOpenArtifact(setCanvas 是稳定 useState setter) - EmptyState onSelect={(p)=>handleSend(p)} 改直接传 handleSend(已是 useCallback) Collect.tsx: - SearchResultCard 加 memo + props 稳定化:onToggle 改 handleResultToggle useCallback 接 index, onNavigate 直接传 navigate,消除内联箭头击穿 memo Statistics.tsx: - TopicCard 加 memo(props 仅 stat,浅比较有效) Pipelines.tsx: - 抽 RunRow memo 组件,navigate 稳定引用传入,消除筛选切换时全量行重渲染 (table 虚拟化作为后续,当前规模行 memo 已消除主要开销) --- frontend/src/contexts/ChannelContext.tsx | 28 +++++---- frontend/src/contexts/ToastContext.tsx | 7 ++- frontend/src/pages/Agent.tsx | 13 ++-- frontend/src/pages/Collect.tsx | 32 ++++++---- frontend/src/pages/Papers.tsx | 49 ++++++++-------- frontend/src/pages/Pipelines.tsx | 75 ++++++++++++------------ frontend/src/pages/Statistics.tsx | 6 +- 7 files changed, 115 insertions(+), 95 deletions(-) diff --git a/frontend/src/contexts/ChannelContext.tsx b/frontend/src/contexts/ChannelContext.tsx index a507e93..1429b0a 100644 --- a/frontend/src/contexts/ChannelContext.tsx +++ b/frontend/src/contexts/ChannelContext.tsx @@ -5,7 +5,7 @@ * @author Color2333 */ -import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; +import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useMemo } from 'react'; import { resolveApiBase } from '@/services/api'; export interface Channel { @@ -79,19 +79,21 @@ export function ChannelProvider({ children }: { children: ReactNode }) { setDefaultChannels(ids); }, []); + // value useMemo:各 callback 已稳定,仅依赖数据变化时重建 value,避免每次 Provider render + // 新建 value 对象导致所有 useChannels 消费者重渲染 + const value = useMemo(() => ({ + channels, + defaultChannels, + loading, + error, + getChannel, + updateChannelStatus, + setDefaultChannels: setDefault, + refreshChannels: fetchChannels, + }), [channels, defaultChannels, loading, error, getChannel, updateChannelStatus, setDefault, fetchChannels]); + return ( - + {children} ); diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx index 5da05e2..eb71680 100644 --- a/frontend/src/contexts/ToastContext.tsx +++ b/frontend/src/contexts/ToastContext.tsx @@ -2,7 +2,7 @@ * 全局 Toast 通知上下文 * @author Color2333 */ -import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -34,8 +34,11 @@ export function ToastProvider({ children }: { children: ReactNode }) { setTimeout(() => dismiss(id), 3500); }, [dismiss]); + // value useMemo:toast/dismiss 已是 useCallback(稳定),仅 toasts 变化时重建 value, + // 避免每次 Provider render 都新建 value 对象导致所有 useToast 消费者重渲染 + const value = useMemo(() => ({ toasts, toast, dismiss }), [toasts, toast, dismiss]); return ( - + {children} ); diff --git a/frontend/src/pages/Agent.tsx b/frontend/src/pages/Agent.tsx index 17aeb51..549e9be 100644 --- a/frontend/src/pages/Agent.tsx +++ b/frontend/src/pages/Agent.tsx @@ -239,6 +239,13 @@ export default function Agent() { [handleConfirm] ); + // useCallback 稳定化:setCanvas 是 useState setter(稳定),避免每次 render 新建函数击穿 ChatBlock memo + const handleOpenArtifact = useCallback( + (title: string, content: string, isHtml?: boolean) => + setCanvas({ title, markdown: content, isHtml }), + [setCanvas], + ); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -261,7 +268,7 @@ export default function Agent() { className="relative flex-1 overflow-y-auto" > {items.length === 0 ? ( - handleSend(p)} /> + ) : (
{items.map((item, idx) => { @@ -284,9 +291,7 @@ export default function Agent() { isConfirming={item.actionId ? confirmingActions.has(item.actionId) : false} onConfirm={handleConfirmAction} onReject={handleReject} - onOpenArtifact={(title, content, isHtml) => - setCanvas({ title, markdown: content, isHtml }) - } + onOpenArtifact={handleOpenArtifact} onRetry={retryFn} /> ); diff --git a/frontend/src/pages/Collect.tsx b/frontend/src/pages/Collect.tsx index dd79e26..519e672 100644 --- a/frontend/src/pages/Collect.tsx +++ b/frontend/src/pages/Collect.tsx @@ -2,7 +2,7 @@ * 论文收集与订阅管理(重构版:手动抓取 + 丰富结果展示) * @author Color2333 */ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, memo } from "react"; import { useNavigate } from "react-router-dom"; import { Button, Empty, Spinner } from "@/components/ui"; import { @@ -162,6 +162,13 @@ export default function Collect() { } }, [multiSuggestions]); + // 稳定 callback:避免 SearchResultCard memo 被内联箭头击穿(每项 expanded 切换只重渲染该项) + const handleResultToggle = useCallback((index: number) => { + setResults((prev) => + prev.map((x, j) => (j === index ? { ...x, expanded: !x.expanded } : x)) + ); + }, []); + // ========== 订阅管理 ========== const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); @@ -577,12 +584,9 @@ export default function Collect() { - setResults((prev) => - prev.map((x, j) => (j === i ? { ...x, expanded: !x.expanded } : x)) - ) - } - onNavigate={(paperId) => navigate(`/papers/${paperId}`)} + index={i} + onToggle={handleResultToggle} + onNavigate={navigate} /> ))}
@@ -1121,19 +1125,21 @@ function TopicCard({ /* ================================================================ * 即时搜索结果卡片 * ================================================================ */ -function SearchResultCard({ +const SearchResultCard = memo(function SearchResultCard({ result: r, + index, onToggle, onNavigate, }: { result: SearchResult; - onToggle: () => void; - onNavigate: (id: string) => void; + index: number; + onToggle: (index: number) => void; + onNavigate: (path: string) => void; }) { return (
{/* 头部:摘要信息 */} -
); -} +}); /* ================================================================ * 通用表单字段 diff --git a/frontend/src/pages/Papers.tsx b/frontend/src/pages/Papers.tsx index 503914a..32e677d 100644 --- a/frontend/src/pages/Papers.tsx +++ b/frontend/src/pages/Papers.tsx @@ -1111,10 +1111,10 @@ export default function Papers() { key={paper.id} paper={paper} selected={selected.has(paper.id)} - onSelect={() => toggleSelect(paper.id)} - onFavorite={(e) => handleToggleFavorite(e, paper.id)} - onReject={(e) => handleToggleRejected(e, paper.id)} - onClick={() => navigate(`/papers/${paper.id}`)} + onSelect={toggleSelect} + onFavorite={handleToggleFavorite} + onReject={handleToggleRejected} + onNavigate={navigate} /> ))} @@ -1124,9 +1124,9 @@ export default function Papers() { handleToggleFavorite(e, paper.id)} - onReject={(e) => handleToggleRejected(e, paper.id)} - onClick={() => navigate(`/papers/${paper.id}`)} + onFavorite={handleToggleFavorite} + onReject={handleToggleRejected} + onNavigate={navigate} /> ))} @@ -1255,14 +1255,14 @@ const PaperListItem = memo(function PaperListItem({ onSelect, onFavorite, onReject, - onClick, + onNavigate, }: { paper: Paper; selected: boolean; - onSelect: () => void; - onFavorite: (e: React.MouseEvent) => void; - onReject: (e: React.MouseEvent) => void; - onClick: () => void; + onSelect: (id: string) => void; + onFavorite: (e: React.MouseEvent, id: string) => void; + onReject: (e: React.MouseEvent, id: string) => void; + onNavigate: (path: string) => void; }) { const sc = statusBadge[paper.read_status] || statusBadge.unread; return ( @@ -1275,11 +1275,11 @@ const PaperListItem = memo(function PaperListItem({ onSelect(paper.id)} onClick={(e) => e.stopPropagation()} className="border-border text-primary focus:ring-primary/30 mt-1 h-3.5 w-3.5 shrink-0 rounded" /> - + ) : ( + + )} + + + {run.decision_note ? ( + {run.decision_note} + ) : run.error_message ? ( + {run.error_message} + ) : ( + + )} + + + {run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : "—"} + + {timeAgo(run.created_at)} + + ); +}); + const STATUS_FILTERS = [ { key: "all", label: "全部" }, { key: "succeeded", label: "成功" }, @@ -144,41 +181,7 @@ export default function Pipelines() { {filtered.map((run) => ( - - - - - {run.pipeline_name} - - {run.paper_id ? ( - - ) : ( - - )} - - - {run.decision_note ? ( - - {run.decision_note} - - ) : run.error_message ? ( - {run.error_message} - ) : ( - - )} - - - {run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : "—"} - - - {timeAgo(run.created_at)} - - + ))} diff --git a/frontend/src/pages/Statistics.tsx b/frontend/src/pages/Statistics.tsx index aa1d7d1..188b7e7 100644 --- a/frontend/src/pages/Statistics.tsx +++ b/frontend/src/pages/Statistics.tsx @@ -2,7 +2,7 @@ * Statistics - 主题统计分析 * @author Color2333 */ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, memo } from "react"; import { topicApi } from "@/services/api"; import type { TopicStats, TopicStatsResponse, PaperDistributionResponse } from "@/types"; import { @@ -99,7 +99,7 @@ function StatCard({ ); } -function TopicCard({ stat }: { stat: TopicStats }) { +const TopicCard = memo(function TopicCard({ stat }: { stat: TopicStats }) { const total = stat.status_dist.unread + stat.status_dist.skimmed + stat.status_dist.deep_read; const readRate = total > 0 @@ -176,7 +176,7 @@ function TopicCard({ stat }: { stat: TopicStats }) { ); -} +}); function CitationBar({ stat, max, index }: { stat: TopicStats; max: number; index: number }) { const colors = [