perf(sidebar): 优化侧边栏搜索匹配性能

This commit is contained in:
t8y2
2026-05-09 17:36:09 +08:00
parent de6fecb18b
commit 1fd1cb10eb
3 changed files with 122 additions and 20 deletions
+49 -20
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
import { useConnectionStore } from "@/stores/connectionStore";
import type { TreeNode } from "@/types/database";
import { matchSidebarLabel } from "@/lib/sidebarSearch";
import TreeItem from "./TreeItem.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import {
@@ -18,7 +19,27 @@ import {
const { t } = useI18n();
const store = useConnectionStore();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const selectedTypes = ref<string[]>([]);
let searchTimer: number | undefined;
watch(
searchQuery,
(value) => {
const normalized = value.trim().toLowerCase();
window.clearTimeout(searchTimer);
if (!normalized) {
deferredSearchQuery.value = "";
return;
}
searchTimer = window.setTimeout(() => {
deferredSearchQuery.value = normalized;
}, 120);
},
{ flush: "sync" },
);
const isFiltering = computed(() => !!searchQuery.value.trim() || hasTypeFilter.value);
@@ -55,28 +76,36 @@ function clearTypeFilter() {
selectedTypes.value = [];
}
function fuzzyMatch(text: string, query: string): boolean {
let j = 0;
for (let i = 0; i < text.length && j < query.length; i++) {
if (text[i] === query[j]) j++;
}
return j === query.length;
}
const normalizedLabelCache = new WeakMap<TreeNode, { label: string; normalized: string }>();
function matchesQuery(node: TreeNode, q: string): boolean {
const label = node.label.toLowerCase();
if (label.includes(q) || fuzzyMatch(label, q)) return true;
return node.children?.some((child) => matchesQuery(child, q)) ?? false;
function normalizedLabel(node: TreeNode): string {
const cached = normalizedLabelCache.get(node);
if (cached?.label === node.label) return cached.normalized;
const normalized = node.label.toLowerCase();
normalizedLabelCache.set(node, { label: node.label, normalized });
return normalized;
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
return nodes
.filter((node) => matchesQuery(node, q))
.map((node) => {
if (!node.children) return node;
const filtered = filterTree(node.children, q);
return { ...node, children: filtered, isExpanded: filtered.length > 0 };
});
const filteredNodes: TreeNode[] = [];
for (const node of nodes) {
const label = normalizedLabel(node);
const selfMatches = !!matchSidebarLabel(label, q);
const filteredChildren = node.children ? filterTree(node.children, q) : undefined;
if (selfMatches || (filteredChildren && filteredChildren.length > 0)) {
if (!node.children) {
filteredNodes.push(node);
} else {
const children = filteredChildren ?? [];
filteredNodes.push({ ...node, children, isExpanded: children.length > 0 });
}
}
}
return filteredNodes;
}
function matchesType(node: TreeNode): boolean {
@@ -102,7 +131,7 @@ const filteredNodes = computed(() => {
});
}
const q = searchQuery.value.trim().toLowerCase();
const q = deferredSearchQuery.value;
if (q) {
nodes = filterTree(nodes, q);
}
+50
View File
@@ -0,0 +1,50 @@
export type SidebarSearchMatchKind = "exact" | "prefix" | "word-prefix" | "substring" | "abbreviation" | "fuzzy";
export interface SidebarSearchMatch {
kind: SidebarSearchMatchKind;
score: number;
}
function isWordBoundary(text: string, index: number): boolean {
if (index === 0) return true;
const prev = text[index - 1];
return prev === "_" || prev === "-" || prev === "." || prev === " " || prev === "/" || prev === "\\";
}
function matchesWordPrefix(text: string, query: string): boolean {
for (let i = 0; i < text.length; i++) {
if (isWordBoundary(text, i) && text.startsWith(query, i)) return true;
}
return false;
}
function matchesAbbreviation(text: string, query: string): boolean {
let j = 0;
for (let i = 0; i < text.length && j < query.length; i++) {
if (isWordBoundary(text, i) && text[i] === query[j]) j++;
}
return j === query.length;
}
function matchesSubsequence(text: string, query: string): boolean {
if (query.length < 2 || query.length > text.length) return false;
let j = 0;
for (let i = 0; i < text.length && j < query.length; i++) {
if (text[i] === query[j]) j++;
}
return j === query.length;
}
export function matchSidebarLabel(label: string, query: string): SidebarSearchMatch | null {
if (!query) return null;
if (label === query) return { kind: "exact", score: 100 };
if (label.startsWith(query)) return { kind: "prefix", score: 90 };
if (matchesWordPrefix(label, query)) return { kind: "word-prefix", score: 80 };
if (label.includes(query)) return { kind: "substring", score: 70 };
if (query.length >= 2 && matchesAbbreviation(label, query)) return { kind: "abbreviation", score: 60 };
if (matchesSubsequence(label, query)) return { kind: "fuzzy", score: 40 };
return null;
}