perf: 全局响应速度优化——1.核心: 所有组件 useStore() 改为窄选择器 useStore(s=>s.field), 之前每次按键 setBody 改变 store.body 导致订阅整个 store 的 14 个组件全部 re-render(TiptapEditor/Editor/FileTree/TabBar/DocInfoPanel/Toolbar/App/SearchBox/TagFilter/SettingsPanel/GraphView/LinkGraph/MoreActions/TitleBar); TiptapEditor 不再订阅 body(仅 currentPath/setBody), body 从 getState() 在 effect 内获取; 2.OutlinePanel collect debounce 300ms(之前每次按键遍历文档树); 3.OutlinePanel scroll handler rAF 节流(之前每像素滚动调 coordsAtPos); 4.修复 TabBar resetEditor 重复声明 + TiptapEditor content 引用

This commit is contained in:
hunter-dev
2026-08-26 22:34:16 +08:00
parent 7f48cef344
commit 3fe35ef873
14 changed files with 68 additions and 35 deletions
+3 -1
View File
@@ -29,7 +29,9 @@ import { OutlinePanel } from './features/outline/OutlinePanel';
import { DocInfoPanel } from './features/doc-info/DocInfoPanel'; import { DocInfoPanel } from './features/doc-info/DocInfoPanel';
export function App() { export function App() {
const { loadVault, loadTree, openDoc } = useStore(); const loadVault = useStore(s => s.loadVault);
const loadTree = useStore(s => s.loadTree);
const openDoc = useStore(s => s.openDoc);
const { restore, tabs, activeId } = useTabs(); const { restore, tabs, activeId } = useTabs();
const [rightTab, setRightTab] = useState<'outline' | 'info'>('outline'); const [rightTab, setRightTab] = useState<'outline' | 'info'>('outline');
const [view, setView] = useState<'editor' | 'graph'>('editor'); const [view, setView] = useState<'editor' | 'graph'>('editor');
@@ -5,7 +5,15 @@ import { IPC } from '@hunter/shared';
import type { TreeNode } from '@hunter/shared'; import type { TreeNode } from '@hunter/shared';
export function DocInfoPanel() { export function DocInfoPanel() {
const { currentPath, title, meta, tree, toggleStar, addTag, removeTag, addLink, removeLink } = useStore(); const currentPath = useStore(s => s.currentPath);
const title = useStore(s => s.title);
const meta = useStore(s => s.meta);
const tree = useStore(s => s.tree);
const toggleStar = useStore(s => s.toggleStar);
const addTag = useStore(s => s.addTag);
const removeTag = useStore(s => s.removeTag);
const addLink = useStore(s => s.addLink);
const removeLink = useStore(s => s.removeLink);
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
const [linkInput, setLinkInput] = useState(''); const [linkInput, setLinkInput] = useState('');
@@ -10,7 +10,9 @@ function saveLabel(s: SaveState): string {
} }
export function Editor() { export function Editor() {
const { currentPath, title, saveState } = useStore(); const currentPath = useStore(s => s.currentPath);
const title = useStore(s => s.title);
const saveState = useStore(s => s.saveState);
if (!currentPath) { if (!currentPath) {
return ( return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.4 }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.4 }}>
@@ -4,7 +4,9 @@ import { IPC } from '@hunter/shared';
import { useState } from 'react'; import { useState } from 'react';
export function MoreActions() { export function MoreActions() {
const { currentPath, body, openDoc } = useStore(); const currentPath = useStore(s => s.currentPath);
const body = useStore(s => s.body);
const openDoc = useStore(s => s.openDoc);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!currentPath) return null; if (!currentPath) return null;
// FR-X1:复制全文——成功/失败均提示 // FR-X1:复制全文——成功/失败均提示
@@ -40,7 +40,8 @@ const Img = Image.extend({
}); });
export function TiptapEditor() { export function TiptapEditor() {
const { currentPath, body, setBody } = useStore(); const currentPath = useStore(s => s.currentPath);
const setBody = useStore(s => s.setBody);
const skipUpdate = useRef(false); const skipUpdate = useRef(false);
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
@@ -61,7 +62,7 @@ export function TiptapEditor() {
MathBlock, MathBlock,
InlineMath, InlineMath,
], ],
content: body, content: useStore.getState().body,
editorProps: { editorProps: {
// FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径 // FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径
handlePaste: (_view, event) => { handlePaste: (_view, event) => {
@@ -104,6 +105,7 @@ export function TiptapEditor() {
return; return;
} }
useEditorShared.getState().setEditor(editor); useEditorShared.getState().setEditor(editor);
const body = useStore.getState().body;
skipUpdate.current = true; skipUpdate.current = true;
editor.commands.setContent(body || '', { emitUpdate: false }); editor.commands.setContent(body || '', { emitUpdate: false });
skipUpdate.current = false; skipUpdate.current = false;
@@ -7,7 +7,13 @@ import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store'; import { useTabs } from '../tabs/tab-store';
export function FileTree() { export function FileTree() {
const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath, tagFilter } = useStore(); const tree = useStore(s => s.tree);
const loadTree = useStore(s => s.loadTree);
const treeLoading = useStore(s => s.treeLoading);
const openDoc = useStore(s => s.openDoc);
const currentPath = useStore(s => s.currentPath);
const syncCurrentPath = useStore(s => s.syncCurrentPath);
const tagFilter = useStore(s => s.tagFilter);
const { showInput, showConfirm, showAlert } = useDialog(); const { showInput, showConfirm, showAlert } = useDialog();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null); const [renaming, setRenaming] = useState<string | null>(null);
@@ -53,7 +53,8 @@ const MARKMAP_OPTIONS = {
function MindmapTree() { function MindmapTree() {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const mmRef = useRef<Markmap | null>(null); const mmRef = useRef<Markmap | null>(null);
const { tree, openDoc } = useStore(); const tree = useStore(s => s.tree);
const openDoc = useStore(s => s.openDoc);
useEffect(() => { useEffect(() => {
const svg = svgRef.current; const svg = svgRef.current;
@@ -13,7 +13,7 @@ const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '
export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) { export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const { openDoc } = useStore(); const openDoc = useStore(s => s.openDoc);
const [data, setData] = useState<GraphData | null>(null); const [data, setData] = useState<GraphData | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const zoomRef = useRef<{ zoomBeh: any; svg: any; g: any } | null>(null); const zoomRef = useRef<{ zoomBeh: any; svg: any; g: any } | null>(null);
@@ -12,7 +12,7 @@ export function OutlinePanel() {
const [activeIdx, setActiveIdx] = useState(-1); const [activeIdx, setActiveIdx] = useState(-1);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
// 收集标题——currentPath 变化时重新收集(setContent 用 emitUpdate:false 不触发 update 事件) // 收集标题——debounce 300ms,避免每次按键都遍历文档树
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
const doCollect = () => { const doCollect = () => {
@@ -24,11 +24,11 @@ export function OutlinePanel() {
}); });
setHeadings(items); setHeadings(items);
}; };
// rAF 延迟到下一帧,确保 TiptapEditor 的 setContent 已完成
const raf = requestAnimationFrame(doCollect); const raf = requestAnimationFrame(doCollect);
// 编辑时实时跟随 let timer: ReturnType<typeof setTimeout> | null = null;
editor.on('update', doCollect); const debounced = () => { if (timer) clearTimeout(timer); timer = setTimeout(doCollect, 300); };
return () => { editor.off('update', doCollect); cancelAnimationFrame(raf); }; editor.on('update', debounced);
return () => { editor.off('update', debounced); cancelAnimationFrame(raf); if (timer) clearTimeout(timer); };
}, [editor, currentPath]); }, [editor, currentPath]);
// FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲 // FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲
@@ -43,24 +43,30 @@ export function OutlinePanel() {
} }
if (!scrollEl) return; if (!scrollEl) return;
// rAF 节流——滚动事件高频触发,coordsAtPos 计算开销大
let ticking = false;
const onScroll = () => { const onScroll = () => {
const containerTop = scrollEl!.getBoundingClientRect().top; if (ticking) return;
let current = 0; // 默认第一个标题 ticking = true;
for (let i = 0; i < headings.length; i++) { requestAnimationFrame(() => {
const coords = editor!.view.coordsAtPos(headings[i].pos + 1); const containerTop = scrollEl!.getBoundingClientRect().top;
if (coords.top - containerTop <= 100) current = i; let current = 0;
else break; for (let i = 0; i < headings.length; i++) {
} const coords = editor!.view.coordsAtPos(headings[i].pos + 1);
if (current !== activeRef.current) { if (coords.top - containerTop <= 100) current = i;
activeRef.current = current; else break;
setActiveIdx(current); }
// 大纲自身滚动到当前项 if (current !== activeRef.current) {
const el = listRef.current?.querySelector(`[data-idx="${current}"]`) as HTMLElement | null; activeRef.current = current;
el?.scrollIntoView({ block: 'nearest' }); setActiveIdx(current);
} const el = listRef.current?.querySelector(`[data-idx="${current}"]`) as HTMLElement | null;
el?.scrollIntoView({ block: 'nearest' });
}
ticking = false;
});
}; };
scrollEl.addEventListener('scroll', onScroll, { passive: true }); scrollEl.addEventListener('scroll', onScroll, { passive: true });
onScroll(); // 初始计算 onScroll();
return () => { scrollEl!.removeEventListener('scroll', onScroll); }; return () => { scrollEl!.removeEventListener('scroll', onScroll); };
}, [editor, headings]); }, [editor, headings]);
@@ -5,7 +5,8 @@ import type { SearchHit } from '@hunter/shared';
import { useStore } from '../../store'; import { useStore } from '../../store';
export function SearchBox() { export function SearchBox() {
const { openDoc, loadTree } = useStore(); const openDoc = useStore(s => s.openDoc);
const loadTree = useStore(s => s.loadTree);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [hits, setHits] = useState<SearchHit[]>([]); const [hits, setHits] = useState<SearchHit[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
@@ -4,7 +4,7 @@ import { IPC, type TagStat } from '@hunter/shared';
import { useStore } from '../../store'; import { useStore } from '../../store';
export function TagFilter() { export function TagFilter() {
const { tree } = useStore(); const tree = useStore(s => s.tree);
const [tags, setTags] = useState<TagStat[]>([]); const [tags, setTags] = useState<TagStat[]>([]);
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -103,7 +103,9 @@ function dot(on: boolean): React.CSSProperties { return { position: 'absolute',
// FR-V5: 默认库路径设置页——查看当前库、浏览选择、立即切换 // FR-V5: 默认库路径设置页——查看当前库、浏览选择、立即切换
function PathPage() { function PathPage() {
const { vault, loadVault, loadTree } = useStore(); const vault = useStore(s => s.vault);
const loadVault = useStore(s => s.loadVault);
const loadTree = useStore(s => s.loadTree);
const [val, setVal] = useState(vault?.currentRoot ?? ''); const [val, setVal] = useState(vault?.currentRoot ?? '');
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const browse = async () => { const p = await invoke(IPC.vaultBrowse, undefined as never); if (p) setVal(p); }; const browse = async () => { const p = await invoke(IPC.vaultBrowse, undefined as never); if (p) setVal(p); };
@@ -7,7 +7,7 @@ const drag = { WebkitAppRegion: 'drag', flex: 1, height: '100%' } as React.CSSPr
const noDrag = { WebkitAppRegion: 'no-drag' } as React.CSSProperties; const noDrag = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggleSidebar, onToggleMinimal, onToggleTabbar, minimal, tabbarVisible }: { onToggleView: () => void; view: 'editor' | 'graph'; onSettings: () => void; onAppearance: () => void; onToggleSidebar: () => void; onToggleMinimal: () => void; onToggleTabbar: () => void; minimal: boolean; tabbarVisible: boolean }) { export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggleSidebar, onToggleMinimal, onToggleTabbar, minimal, tabbarVisible }: { onToggleView: () => void; view: 'editor' | 'graph'; onSettings: () => void; onAppearance: () => void; onToggleSidebar: () => void; onToggleMinimal: () => void; onToggleTabbar: () => void; minimal: boolean; tabbarVisible: boolean }) {
const { loadTree } = useStore(); const loadTree = useStore(s => s.loadTree);
const [maximized, setMaximized] = useState(false); const [maximized, setMaximized] = useState(false);
useEffect(() => { invoke(IPC.winIsMaximized, undefined as never).then(setMaximized).catch(() => {}); }, []); useEffect(() => { invoke(IPC.winIsMaximized, undefined as never).then(setMaximized).catch(() => {}); }, []);
return ( return (
@@ -4,11 +4,12 @@ import { useEffect, useState } from 'react';
export function TabBar() { export function TabBar() {
const { tabs, activeId, setActive, closeTab, togglePin } = useTabs(); const { tabs, activeId, setActive, closeTab, togglePin } = useTabs();
const { openDoc, currentPath } = useStore(); const openDoc = useStore(s => s.openDoc);
const currentPath = useStore(s => s.currentPath);
const resetEditor = useStore(s => s.resetEditor);
const [hoveredTab, setHoveredTab] = useState<string | null>(null); const [hoveredTab, setHoveredTab] = useState<string | null>(null);
// 当 activeId 变化,加载对应标签的文档;标签全部关闭时清空编辑器 // 当 activeId 变化,加载对应标签的文档;标签全部关闭时清空编辑器
const resetEditor = useStore(s => s.resetEditor);
useEffect(() => { useEffect(() => {
const tab = tabs.find(t => t.id === activeId); const tab = tabs.find(t => t.id === activeId);
if (tab && tab.path !== currentPath) openDoc(tab.path); if (tab && tab.path !== currentPath) openDoc(tab.path);