From 4b69f71ddbc32843d6c74376d97e8bd8fd986b22 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 27 Apr 2026 15:14:32 +0800 Subject: [PATCH] refactor: optimize TOTP and vault components with useMemo for performance improvements --- webapp/src/components/TotpCodesPage.tsx | 28 ++-- webapp/src/components/VaultPage.tsx | 121 +++++++++++++----- .../src/components/vault/VaultListPanel.tsx | 89 ++++++++----- .../components/vault/vault-page-helpers.tsx | 5 +- 4 files changed, 167 insertions(+), 76 deletions(-) diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index bd4e9ca..de3cd6d 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -70,8 +70,7 @@ function hostFromUri(uri: string): string { } function TotpListIcon({ cipher }: { cipher: Cipher }) { - const uri = firstCipherUri(cipher); - const host = hostFromUri(uri); + const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [loaded, setLoaded] = useState(false); const markIconError = () => { @@ -226,16 +225,21 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') }); } + const nameCollator = useMemo( + () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }), + [] + ); + const baseTotpItems = useMemo( () => props.ciphers .filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp) .sort((a, b) => { - const nameA = (a.decName || a.name || '').trim().toLowerCase(); - const nameB = (b.decName || b.name || '').trim().toLowerCase(); - return nameA.localeCompare(nameB); + const nameA = (a.decName || a.name || '').trim(); + const nameB = (b.decName || b.name || '').trim(); + return nameCollator.compare(nameA, nameB); }), - [props.ciphers] + [props.ciphers, nameCollator] ); const totpItems = useMemo(() => { @@ -247,11 +251,13 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { if (orderA != null && orderB != null) return orderA - orderB; if (orderA != null) return -1; if (orderB != null) return 1; - const nameA = (a.decName || a.name || '').trim().toLowerCase(); - const nameB = (b.decName || b.name || '').trim().toLowerCase(); - return nameA.localeCompare(nameB); + const nameA = (a.decName || a.name || '').trim(); + const nameB = (b.decName || b.name || '').trim(); + return nameCollator.compare(nameA, nameB); }); - }, [baseTotpItems, orderedIds]); + }, [baseTotpItems, orderedIds, nameCollator]); + + const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]); useEffect(() => { if (!baseTotpItems.length) return; @@ -361,7 +367,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { > {!totpItems.length && !props.loading &&
{t('txt_no_verification_codes')}
} - cipher.id)} strategy={rectSortingStrategy}> + {totpItems.map((cipher) => ( { + const cipherMetaById = useMemo(() => { + const meta = new Map(); + for (const cipher of props.ciphers) { + const name = String(cipher.decName || cipher.name || ''); + const username = String(cipher.login?.decUsername || ''); + const uri = firstCipherUri(cipher); + meta.set(cipher.id, { + name, + searchText: `${name}\n${username}\n${uri}`.toLowerCase(), + firstUri: uri, + typeKey: cipherTypeKey(Number(cipher.type || 1)), + sortTime: sortTimeValue(cipher), + creationTime: creationTimeValue(cipher), + }); + } + return meta; + }, [props.ciphers]); + + const cipherById = useMemo(() => { + const map = new Map(); + for (const cipher of props.ciphers) map.set(cipher.id, cipher); + return map; + }, [props.ciphers]); + + const folderById = useMemo(() => { + const map = new Map(); + for (const folder of props.folders) map.set(folder.id, folder); + return map; + }, [props.folders]); + + const nameCollator = useMemo( + () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }), + [] + ); + + const duplicateSignatureInfo = useMemo(() => { + if (sidebarFilter.kind !== 'duplicates') return null; + const byId = new Map(); const counts = new Map(); for (const cipher of props.ciphers) { if (!isCipherVisibleInNormalVault(cipher)) continue; const signature = buildCipherDuplicateSignature(cipher); + byId.set(cipher.id, signature); counts.set(signature, (counts.get(signature) || 0) + 1); } - return counts; - }, [props.ciphers]); + return { byId, counts }; + }, [props.ciphers, sidebarFilter.kind]); const filteredCiphers = useMemo(() => { const next = props.ciphers.filter((cipher) => { + const meta = cipherMetaById.get(cipher.id); if (sidebarFilter.kind === 'trash') { if (!isCipherVisibleInTrash(cipher)) return false; } else if (sidebarFilter.kind === 'archive') { if (!isCipherVisibleInArchive(cipher)) return false; } else { if (!isCipherVisibleInNormalVault(cipher)) return false; - if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { + if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) { return false; } if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; - if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false; + if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'folder') { if (sidebarFilter.folderId === null) { if (cipher.folderId) return false; @@ -358,15 +405,14 @@ export default function VaultPage(props: VaultPageProps) { } } if (!searchQuery) return true; - const name = (cipher.decName || '').toLowerCase(); - const username = (cipher.login?.decUsername || '').toLowerCase(); - const uri = firstCipherUri(cipher).toLowerCase(); - return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery); + return !!meta?.searchText.includes(searchQuery); }); - const orderMap = new Map(vaultOrderedIds.map((id, index) => [id, index])); + const orderMap = sortMode === 'manual' ? new Map(vaultOrderedIds.map((id, index) => [id, index])) : null; next.sort((a, b) => { - if (sortMode === 'manual') { + const metaA = cipherMetaById.get(a.id); + const metaB = cipherMetaById.get(b.id); + if (sortMode === 'manual' && orderMap) { const orderA = orderMap.get(a.id); const orderB = orderMap.get(b.id); if (orderA != null && orderB != null) { @@ -376,16 +422,13 @@ export default function VaultPage(props: VaultPageProps) { if (orderA != null) return -1; if (orderB != null) return 1; } else if (sortMode === 'edited') { - const diff = sortTimeValue(b) - sortTimeValue(a); + const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0); if (diff !== 0) return diff; } else if (sortMode === 'created') { - const diff = creationTimeValue(b) - creationTimeValue(a); + const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0); if (diff !== 0) return diff; } else { - const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, { - sensitivity: 'base', - numeric: true, - }); + const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || ''); if (nameDiff !== 0) return nameDiff; } @@ -393,7 +436,13 @@ export default function VaultPage(props: VaultPageProps) { }); return next; - }, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts, vaultOrderedIds]); + }, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, vaultOrderedIds, nameCollator]); + + const filteredCipherIds = useMemo(() => { + const ids = new Set(); + for (const cipher of filteredCiphers) ids.add(cipher.id); + return ids; + }, [filteredCiphers]); const sidebarFilterKey = useMemo(() => { if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; @@ -407,6 +456,7 @@ export default function VaultPage(props: VaultPageProps) { return; } setListScrollTop(0); + listScrollBucketRef.current = 0; listPanelRef.current?.scrollTo({ top: 0 }); }, [searchQuery, sortMode, sidebarFilterKey]); @@ -456,15 +506,12 @@ export default function VaultPage(props: VaultPageProps) { if (selectedCipherId) setSelectedCipherId(''); return; } - if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) { + if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) { setSelectedCipherId(filteredCiphers[0].id); } - }, [filteredCiphers, selectedCipherId, isCreating]); + }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]); - const selectedCipher = useMemo( - () => props.ciphers.find((x) => x.id === selectedCipherId) || null, - [props.ciphers, selectedCipherId] - ); + const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]); const virtualRange = useMemo(() => { if (!filteredCiphers.length) { return { start: 0, end: 0, padTop: 0, padBottom: 0 }; @@ -530,17 +577,24 @@ export default function VaultPage(props: VaultPageProps) { function folderName(id: string | null | undefined): string { if (!id) return t('txt_no_folder'); - const folder = props.folders.find((x) => x.id === id); + const folder = folderById.get(id); return folder?.decName || folder?.name || id; } function listSubtitle(cipher: Cipher): string { if (Number(cipher.type || 1) === 1) { - return cipher.login?.decUsername || firstCipherUri(cipher) || ''; + return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || ''; } return cipherTypeLabel(Number(cipher.type || 1)); } + function handleListScroll(top: number): void { + const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT); + if (bucket === listScrollBucketRef.current) return; + listScrollBucketRef.current = bucket; + setListScrollTop(top); + } + function startCreate(type: number): void { setDraft(createEmptyDraft(type)); setIsCreating(true); @@ -1024,7 +1078,7 @@ function folderName(id: string | null | undefined): string { const map: Record = {}; const seen = new Set(); for (const cipher of filteredCiphers) { - const signature = buildCipherDuplicateSignature(cipher); + const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); if (seen.has(signature)) { map[cipher.id] = true; continue; @@ -1049,12 +1103,15 @@ function folderName(id: string | null | undefined): string { }} onClearSelection={() => setSelectedMap({})} onReorderCipher={handleReorderVaultCipher} - onScroll={setListScrollTop} + onScroll={handleListScroll} onToggleSelected={(cipherId, checked) => - setSelectedMap((prev) => ({ - ...prev, - [cipherId]: checked, - })) + setSelectedMap((prev) => { + if (checked) return { ...prev, [cipherId]: true }; + if (!prev[cipherId]) return prev; + const next = { ...prev }; + delete next[cipherId]; + return next; + }) } onSelectCipher={(cipherId) => { if (isEditing || isCreating) { diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index cbd976c..02b2f34 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -1,6 +1,6 @@ import type { JSX, RefObject } from 'preact'; import { createPortal } from 'preact/compat'; -import { useState } from 'preact/hooks'; +import { useMemo, useState } from 'preact/hooks'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; import { closestCenter, @@ -186,6 +186,28 @@ function SortableCipherListItem(props: SortableCipherListItemProps) { ); } +function PlainCipherListItem(props: SortableCipherListItemProps) { + return ( +
{ + const target = event.target as HTMLElement; + if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return; + props.onSelectCipher(props.cipher.id); + }} + > + +
+ ); +} + export default function VaultListPanel(props: VaultListPanelProps) { const [activeDragId, setActiveDragId] = useState(''); const [activeDragWidth, setActiveDragWidth] = useState(null); @@ -203,7 +225,7 @@ export default function VaultListPanel(props: VaultListPanelProps) { }) ); - const sortableItems = props.filteredCiphers.map((cipher) => cipher.id); + const sortableItems = useMemo(() => props.visibleCiphers.map((cipher) => cipher.id), [props.visibleCiphers]); const renderedCiphers = props.visibleCiphers; const activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null; @@ -250,6 +272,22 @@ export default function VaultListPanel(props: VaultListPanelProps) { ); + const listItems = renderedCiphers.map((cipher) => { + const ItemComponent = props.canReorder ? SortableCipherListItem : PlainCipherListItem; + return ( + + ); + }); + return (
@@ -357,34 +395,25 @@ export default function VaultListPanel(props: VaultListPanelProps) {
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> {!!props.filteredCiphers.length && (
- - - {renderedCiphers.map((cipher) => ( - - ))} - - - {activeDragCipher ? ( -
- -
- ) : null} -
-
+ {props.canReorder ? ( + + + {listItems} + + + {activeDragCipher ? ( +
+ +
+ ) : null} +
+
+ ) : listItems}
)} {!props.filteredCiphers.length &&
{t('txt_no_items')}
} diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 23896b5..67ccf13 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'preact/hooks'; +import { useEffect, useMemo, useState } from 'preact/hooks'; import { CreditCard, FileKey2, @@ -438,8 +438,7 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null { const failedIconHosts = new Set(); export function VaultListIcon({ cipher }: { cipher: Cipher }) { - const uri = firstCipherUri(cipher); - const host = hostFromUri(uri); + const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [loaded, setLoaded] = useState(false); const markIconError = () => {