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';
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 [rightTab, setRightTab] = useState<'outline' | 'info'>('outline');
const [view, setView] = useState<'editor' | 'graph'>('editor');
@@ -5,7 +5,15 @@ import { IPC } from '@hunter/shared';
import type { TreeNode } from '@hunter/shared';
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 [linkInput, setLinkInput] = useState('');
@@ -10,7 +10,9 @@ function saveLabel(s: SaveState): string {
}
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) {
return (
<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';
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);
if (!currentPath) return null;
// FR-X1:复制全文——成功/失败均提示
@@ -40,7 +40,8 @@ const Img = Image.extend({
});
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 editorRef = useRef<Editor | null>(null);
@@ -61,7 +62,7 @@ export function TiptapEditor() {
MathBlock,
InlineMath,
],
content: body,
content: useStore.getState().body,
editorProps: {
// FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径
handlePaste: (_view, event) => {
@@ -104,6 +105,7 @@ export function TiptapEditor() {
return;
}
useEditorShared.getState().setEditor(editor);
const body = useStore.getState().body;
skipUpdate.current = true;
editor.commands.setContent(body || '', { emitUpdate: false });
skipUpdate.current = false;
@@ -7,7 +7,13 @@ import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
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 [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null);
@@ -53,7 +53,8 @@ const MARKMAP_OPTIONS = {
function MindmapTree() {
const svgRef = useRef<SVGSVGElement>(null);
const mmRef = useRef<Markmap | null>(null);
const { tree, openDoc } = useStore();
const tree = useStore(s => s.tree);
const openDoc = useStore(s => s.openDoc);
useEffect(() => {
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 }) {
const svgRef = useRef<SVGSVGElement>(null);
const { openDoc } = useStore();
const openDoc = useStore(s => s.openDoc);
const [data, setData] = useState<GraphData | null>(null);
const [err, setErr] = useState<string | 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 listRef = useRef<HTMLDivElement>(null);
// 收集标题——currentPath 变化时重新收集(setContent 用 emitUpdate:false 不触发 update 事件)
// 收集标题——debounce 300ms,避免每次按键都遍历文档树
useEffect(() => {
if (!editor) return;
const doCollect = () => {
@@ -24,11 +24,11 @@ export function OutlinePanel() {
});
setHeadings(items);
};
// rAF 延迟到下一帧,确保 TiptapEditor 的 setContent 已完成
const raf = requestAnimationFrame(doCollect);
// 编辑时实时跟随
editor.on('update', doCollect);
return () => { editor.off('update', doCollect); cancelAnimationFrame(raf); };
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = () => { if (timer) clearTimeout(timer); timer = setTimeout(doCollect, 300); };
editor.on('update', debounced);
return () => { editor.off('update', debounced); cancelAnimationFrame(raf); if (timer) clearTimeout(timer); };
}, [editor, currentPath]);
// FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲
@@ -43,24 +43,30 @@ export function OutlinePanel() {
}
if (!scrollEl) return;
// rAF 节流——滚动事件高频触发,coordsAtPos 计算开销大
let ticking = false;
const onScroll = () => {
const containerTop = scrollEl!.getBoundingClientRect().top;
let current = 0; // 默认第一个标题
for (let i = 0; i < headings.length; i++) {
const coords = editor!.view.coordsAtPos(headings[i].pos + 1);
if (coords.top - containerTop <= 100) current = i;
else break;
}
if (current !== activeRef.current) {
activeRef.current = current;
setActiveIdx(current);
// 大纲自身滚动到当前项
const el = listRef.current?.querySelector(`[data-idx="${current}"]`) as HTMLElement | null;
el?.scrollIntoView({ block: 'nearest' });
}
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const containerTop = scrollEl!.getBoundingClientRect().top;
let current = 0;
for (let i = 0; i < headings.length; i++) {
const coords = editor!.view.coordsAtPos(headings[i].pos + 1);
if (coords.top - containerTop <= 100) current = i;
else break;
}
if (current !== activeRef.current) {
activeRef.current = current;
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 });
onScroll(); // 初始计算
onScroll();
return () => { scrollEl!.removeEventListener('scroll', onScroll); };
}, [editor, headings]);
@@ -5,7 +5,8 @@ import type { SearchHit } from '@hunter/shared';
import { useStore } from '../../store';
export function SearchBox() {
const { openDoc, loadTree } = useStore();
const openDoc = useStore(s => s.openDoc);
const loadTree = useStore(s => s.loadTree);
const [q, setQ] = useState('');
const [hits, setHits] = useState<SearchHit[]>([]);
const [searching, setSearching] = useState(false);
@@ -4,7 +4,7 @@ import { IPC, type TagStat } from '@hunter/shared';
import { useStore } from '../../store';
export function TagFilter() {
const { tree } = useStore();
const tree = useStore(s => s.tree);
const [tags, setTags] = useState<TagStat[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [collapsed, setCollapsed] = useState(false);
@@ -103,7 +103,9 @@ function dot(on: boolean): React.CSSProperties { return { position: 'absolute',
// FR-V5: 默认库路径设置页——查看当前库、浏览选择、立即切换
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 [msg, setMsg] = useState('');
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;
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);
useEffect(() => { invoke(IPC.winIsMaximized, undefined as never).then(setMaximized).catch(() => {}); }, []);
return (
@@ -4,11 +4,12 @@ import { useEffect, useState } from 'react';
export function TabBar() {
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);
// 当 activeId 变化,加载对应标签的文档;标签全部关闭时清空编辑器
const resetEditor = useStore(s => s.resetEditor);
useEffect(() => {
const tab = tabs.find(t => t.id === activeId);
if (tab && tab.path !== currentPath) openDoc(tab.path);