perf: 优化管理端生产加载体验
- 图标与重型组件按需加载,优化菜单预取和页面请求链路 - 开启 gzip 与静态缓存并修复子路由 KeepAlive 冲突
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isLocalIcon, loadIconCollection } from './index';
|
||||
|
||||
describe('offline icon collections', () => {
|
||||
it('keeps common icons in the startup registry', () => {
|
||||
expect(isLocalIcon('mdi:github')).toBe(true);
|
||||
expect(isLocalIcon('ant-design:appstore-outlined')).toBe(true);
|
||||
});
|
||||
|
||||
it('loads uncommon icons from a bundled collection on demand', async () => {
|
||||
expect(isLocalIcon('mdi:ab-testing')).toBe(false);
|
||||
|
||||
await loadIconCollection('mdi');
|
||||
|
||||
expect(isLocalIcon('mdi:ab-testing')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,40 @@
|
||||
import antDesign from '@iconify/json/json/ant-design.json';
|
||||
import ep from '@iconify/json/json/ep.json';
|
||||
import mdi from '@iconify/json/json/mdi.json';
|
||||
import {
|
||||
addCollection as addOfflineCollection,
|
||||
addIcon as addOfflineIcon,
|
||||
Icon as IconifyIcon,
|
||||
} from '@iconify/vue/offline';
|
||||
import type {
|
||||
IconifyIcon as IconifyIconData,
|
||||
IconifyJSON,
|
||||
} from '@iconify/vue/offline';
|
||||
|
||||
import {
|
||||
addCollection as addOfflineCollection,
|
||||
addIcon as addOfflineIcon,
|
||||
Icon as IconifyIcon,
|
||||
} from '@iconify/vue/offline';
|
||||
|
||||
import { LOCAL_ICON_DATA } from './local-icons';
|
||||
import { STARTUP_ICON_DATA } from './startup-icons';
|
||||
|
||||
type LazyCollectionPrefix = 'ant-design' | 'ep' | 'mdi';
|
||||
|
||||
const LOCAL_ICON_NAMES = new Set<string>();
|
||||
const COLLECTION_LOADERS: Record<
|
||||
LazyCollectionPrefix,
|
||||
() => Promise<IconifyJSON>
|
||||
> = {
|
||||
'ant-design': async () => {
|
||||
const collection = await import('@iconify/json/json/ant-design.json');
|
||||
return collection.default;
|
||||
},
|
||||
ep: async () => {
|
||||
const collection = await import('@iconify/json/json/ep.json');
|
||||
return collection.default;
|
||||
},
|
||||
mdi: async () => {
|
||||
const collection = await import('@iconify/json/json/mdi.json');
|
||||
return collection.default;
|
||||
},
|
||||
};
|
||||
const COLLECTION_LOAD_PROMISES = new Map<LazyCollectionPrefix, Promise<void>>();
|
||||
|
||||
function getCollectionPrefix(data: IconifyJSON, prefix?: string | boolean) {
|
||||
function getCollectionPrefix(data: IconifyJSON, prefix?: boolean | string) {
|
||||
if (prefix === false) {
|
||||
return '';
|
||||
}
|
||||
@@ -25,7 +44,7 @@ function getCollectionPrefix(data: IconifyJSON, prefix?: string | boolean) {
|
||||
/**
|
||||
* Register an icon collection in the offline Iconify store and local name index.
|
||||
*/
|
||||
function registerCollection(data: IconifyJSON, prefix?: string | boolean) {
|
||||
function registerCollection(data: IconifyJSON, prefix?: boolean | string) {
|
||||
addOfflineCollection(data, prefix);
|
||||
const iconPrefix = getCollectionPrefix(data, prefix);
|
||||
Object.keys(data.icons).forEach((name) => {
|
||||
@@ -62,12 +81,66 @@ function isLocalIcon(name: string) {
|
||||
return LOCAL_ICON_NAMES.has(name);
|
||||
}
|
||||
|
||||
registerCollection(antDesign);
|
||||
registerCollection(ep);
|
||||
registerCollection(mdi);
|
||||
Object.entries(LOCAL_ICON_DATA).forEach(([name, data]) => {
|
||||
registerIcon(name, data);
|
||||
});
|
||||
function isLazyCollectionPrefix(
|
||||
prefix: string,
|
||||
): prefix is LazyCollectionPrefix {
|
||||
return Object.prototype.hasOwnProperty.call(COLLECTION_LOADERS, prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a bundled offline icon collection once, without using a remote API.
|
||||
*/
|
||||
async function loadIconCollection(prefix: string) {
|
||||
if (!isLazyCollectionPrefix(prefix)) {
|
||||
return listIcons('', prefix);
|
||||
}
|
||||
|
||||
let pending = COLLECTION_LOAD_PROMISES.get(prefix);
|
||||
if (!pending) {
|
||||
pending = COLLECTION_LOADERS[prefix]()
|
||||
.then((data) => {
|
||||
registerCollection(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
COLLECTION_LOAD_PROMISES.delete(prefix);
|
||||
throw error;
|
||||
});
|
||||
COLLECTION_LOAD_PROMISES.set(prefix, pending);
|
||||
}
|
||||
await pending;
|
||||
return listIcons('', prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve historical menu icons by loading only the collections they need.
|
||||
*/
|
||||
async function loadMissingIconCollections(iconNames: string[]) {
|
||||
const prefixes = new Set<LazyCollectionPrefix>();
|
||||
iconNames.forEach((name) => {
|
||||
if (isLocalIcon(name)) {
|
||||
return;
|
||||
}
|
||||
const prefix = name.split(':', 1)[0] ?? '';
|
||||
if (isLazyCollectionPrefix(prefix)) {
|
||||
prefixes.add(prefix);
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
[...prefixes].map(async (prefix) => {
|
||||
try {
|
||||
await loadIconCollection(prefix);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load menu icon collection: ${prefix}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Object.entries({ ...LOCAL_ICON_DATA, ...STARTUP_ICON_DATA }).forEach(
|
||||
([name, data]) => {
|
||||
registerIcon(name, data);
|
||||
},
|
||||
);
|
||||
|
||||
export * from './create-icon';
|
||||
|
||||
@@ -75,9 +148,11 @@ export * from './lucide';
|
||||
|
||||
export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue/offline';
|
||||
export {
|
||||
registerCollection as addCollection,
|
||||
registerIcon as addIcon,
|
||||
IconifyIcon,
|
||||
isLocalIcon,
|
||||
listIcons,
|
||||
registerCollection as addCollection,
|
||||
registerIcon as addIcon,
|
||||
loadIconCollection,
|
||||
loadMissingIconCollections,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { IconifyIcon } from '@iconify/vue/offline';
|
||||
|
||||
/**
|
||||
* Icons referenced directly by application source or default persisted menus.
|
||||
* Full icon collections are loaded lazily when an uncommon icon is requested.
|
||||
*/
|
||||
export const STARTUP_ICON_DATA: Record<string, IconifyIcon> = {
|
||||
'ant-design:apartment-outlined': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M908 640H804V488c0-4.4-3.6-8-8-8H548v-96h108c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16H368c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h108v96H228c-4.4 0-8 3.6-8 8v152H116c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16H292v-88h440v88H620c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16m-564 76v168H176V716zm84-408V140h168v168zm420 576H680V716h168z"/>',
|
||||
},
|
||||
'ant-design:appstore-outlined': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16m-52 268H212V212h200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16m-52 268H612V212h200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16m-52 268H212V612h200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16m-52 268H612V612h200z"/>',
|
||||
},
|
||||
'ep:arrow-right': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512L340.864 831.872a30.59 30.59 0 0 0 0 42.752a29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"/>',
|
||||
},
|
||||
'ep:expand': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M128 192h768v128H128zm0 256h512v128H128zm0 256h768v128H128zm576-352l192 160l-192 128z"/>',
|
||||
},
|
||||
'ep:fold': {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
body: '<path fill="currentColor" d="M896 192H128v128h768zm0 256H384v128h512zm0 256H128v128h768zM320 384L128 512l192 128z"/>',
|
||||
},
|
||||
'mdi:chat-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M12 3C6.5 3 2 6.58 2 11a7.22 7.22 0 0 0 2.75 5.5c0 .6-.42 2.17-2.75 4.5c2.37-.11 4.64-1 6.47-2.5c1.14.33 2.34.5 3.53.5c5.5 0 10-3.58 10-8s-4.5-8-10-8m0 14c-4.42 0-8-2.69-8-6s3.58-6 8-6s8 2.69 8 6s-3.58 6-8 6"/>',
|
||||
},
|
||||
'mdi:clock-time-five-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M12 20c4.4 0 8-3.6 8-8s-3.6-8-8-8s-8 3.6-8 8s3.6 8 8 8m0-18c5.5 0 10 4.5 10 10s-4.5 10-10 10S2 17.5 2 12S6.5 2 12 2m3.3 14.2L14 17l-3-5.2V7h1.5v4.4z"/>',
|
||||
},
|
||||
'mdi:github': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"/>',
|
||||
},
|
||||
'mdi:hammer': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M2 19.63L13.43 8.2l-.71-.7l1.42-1.43L12 3.89c1.2-1.19 3.09-1.19 4.27 0l3.6 3.61l-1.42 1.41h2.84l.71.71l-3.55 3.59l-.71-.71V9.62l-1.47 1.42l-.71-.71L4.13 21.76z"/>',
|
||||
},
|
||||
'mdi:home-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="m12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81zM12 3L2 12h3v8h6v-6h2v6h6v-8h3"/>',
|
||||
},
|
||||
'mdi:image-outline': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z"/>',
|
||||
},
|
||||
'mdi:keyboard-esc': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1zm10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2"/>',
|
||||
},
|
||||
};
|
||||
@@ -91,6 +91,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:data-menu-path="path"
|
||||
:class="[
|
||||
rootMenu.theme,
|
||||
b(),
|
||||
|
||||
@@ -50,6 +50,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string, string[]];
|
||||
enter: [string];
|
||||
open: [string, string[]];
|
||||
select: [string, string[]];
|
||||
}>();
|
||||
@@ -257,6 +258,25 @@ function handleMenuItemClick(data: MenuItemClicked) {
|
||||
emit('select', path, parentPaths);
|
||||
}
|
||||
|
||||
let enteredPath = '';
|
||||
|
||||
function handleMenuItemEnter(event: Event) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const menuItem = target.closest<HTMLElement>('[data-menu-path]');
|
||||
if (!menuItem || !menu.value?.contains(menuItem)) {
|
||||
return;
|
||||
}
|
||||
const path = menuItem.dataset.menuPath;
|
||||
if (!path || path === enteredPath) {
|
||||
return;
|
||||
}
|
||||
enteredPath = path;
|
||||
emit('enter', path);
|
||||
}
|
||||
|
||||
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
|
||||
const isOpened = openedMenus.value.includes(path);
|
||||
|
||||
@@ -349,6 +369,9 @@ function getActivePaths() {
|
||||
]"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@focusin="handleMenuItemEnter"
|
||||
@mouseleave="enteredPath = ''"
|
||||
@mouseover="handleMenuItemEnter"
|
||||
>
|
||||
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
|
||||
<template v-for="item in getSlot.slotDefault" :key="item.key">
|
||||
|
||||
Reference in New Issue
Block a user