perf: 懒加载表现优化

- 优化路由首进页面过渡策略,减少遮罩阻塞感

- 为菜单导航增加组件预取,降低首次点击等待

- 缩短页面 loading 遮罩过渡时长
This commit is contained in:
2026-02-24 16:45:14 +08:00
parent a9060ed2b9
commit 306b08d55a
5 changed files with 191 additions and 19 deletions

View File

@@ -1,49 +1,138 @@
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useRouter } from 'vue-router';
import { preferences } from '@easyflow/preferences';
interface NetworkConnectionLike {
addEventListener?: (event: 'change', listener: () => void) => void;
effectiveType?: string;
removeEventListener?: (event: 'change', listener: () => void) => void;
saveData?: boolean;
}
function getNetworkConnection() {
if (typeof navigator === 'undefined') {
return undefined;
}
const nav = navigator as Navigator & {
connection?: NetworkConnectionLike;
mozConnection?: NetworkConnectionLike;
webkitConnection?: NetworkConnectionLike;
};
return nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
}
function isSlowNetworkConnection() {
const connection = getNetworkConnection();
if (!connection) {
return false;
}
if (connection.saveData) {
return true;
}
return ['2g', '3g', 'slow-2g'].includes(connection.effectiveType ?? '');
}
function useContentSpinner() {
const spinning = ref(false);
const startTime = ref(0);
const router = useRouter();
const minShowTime = 500; // 最小显示时间
const showDelay = 120; // 超过该时长才展示loading避免快速切换闪烁
const minVisibleTime = 180; // 展示后最小可见时长,避免抖动
const enableLoading = computed(() => preferences.transition.loading);
const slowNetwork = ref(isSlowNetworkConnection());
const enableOverlayLoading = computed(
() => enableLoading.value && slowNetwork.value,
);
const showTimer = ref<ReturnType<typeof setTimeout>>();
const hideTimer = ref<ReturnType<typeof setTimeout>>();
const networkConnection = getNetworkConnection();
const handleNetworkChange = () => {
slowNetwork.value = isSlowNetworkConnection();
if (!slowNetwork.value) {
onEnd();
}
};
function clearShowTimer() {
if (!showTimer.value) {
return;
}
clearTimeout(showTimer.value);
showTimer.value = undefined;
}
function clearHideTimer() {
if (!hideTimer.value) {
return;
}
clearTimeout(hideTimer.value);
hideTimer.value = undefined;
}
// 结束加载动画
const onEnd = () => {
if (!enableLoading.value) {
clearShowTimer();
if (!enableOverlayLoading.value) {
clearHideTimer();
spinning.value = false;
return;
}
const processTime = performance.now() - startTime.value;
if (processTime < minShowTime) {
setTimeout(() => {
spinning.value = false;
}, minShowTime - processTime);
} else {
spinning.value = false;
if (!spinning.value) {
return;
}
const visibleTime = performance.now() - startTime.value;
const remainTime = Math.max(0, minVisibleTime - visibleTime);
clearHideTimer();
hideTimer.value = setTimeout(() => {
spinning.value = false;
hideTimer.value = undefined;
}, remainTime);
};
// 路由前置守卫
router.beforeEach((to) => {
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
if (to.meta.loaded || !enableOverlayLoading.value || to.meta.iframeSrc) {
onEnd();
return true;
}
startTime.value = performance.now();
spinning.value = true;
clearHideTimer();
if (spinning.value) {
return true;
}
clearShowTimer();
showTimer.value = setTimeout(() => {
startTime.value = performance.now();
spinning.value = true;
showTimer.value = undefined;
}, showDelay);
return true;
});
// 路由后置守卫
router.afterEach((to) => {
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
if (to.meta.loaded || !enableOverlayLoading.value || to.meta.iframeSrc) {
return true;
}
onEnd();
return true;
});
networkConnection?.addEventListener?.('change', handleNetworkChange);
onBeforeUnmount(() => {
networkConnection?.removeEventListener?.('change', handleNetworkChange);
clearShowTimer();
clearHideTimer();
});
return { spinning };
}

View File

@@ -13,7 +13,7 @@ import { useNavigation } from './use-navigation';
function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
const accessStore = useAccessStore();
const { navigation, willOpenedByWindow } = useNavigation();
const { navigation, prefetch, willOpenedByWindow } = useNavigation();
const menus = computed(() => useRootMenus?.value ?? accessStore.accessMenus);
@@ -87,6 +87,8 @@ function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
};
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
prefetch(menu.path);
if (!preferences.sidebar.expandOnHover) {
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
extraMenus.value = findMenu?.children ?? [];

View File

@@ -7,6 +7,8 @@ import { isHttpUrl, openRouteInNewWindow, openWindow } from '@easyflow/utils';
function useNavigation() {
const router = useRouter();
const routeMetaMap = new Map<string, RouteRecordNormalized>();
const prefetchedPaths = new Set<string>();
const prefetchingPaths = new Set<string>();
// 初始化路由映射
const initRouteMetaMap = () => {
@@ -37,6 +39,51 @@ function useNavigation() {
return router.resolve(path).href;
};
const prefetch = (path: string) => {
if (
isHttpUrl(path) ||
prefetchedPaths.has(path) ||
prefetchingPaths.has(path)
) {
return;
}
const { matched } = router.resolve(path);
if (matched.length === 0) {
return;
}
const componentLoadTasks: Array<Promise<unknown>> = [];
matched.forEach((route) => {
const component = route.components?.default;
if (typeof component !== 'function' || component.length > 0) {
return;
}
try {
const loaded = (component as () => unknown)();
if (loaded && typeof (loaded as Promise<unknown>).then === 'function') {
componentLoadTasks.push(loaded as Promise<unknown>);
}
} catch {
// 预取失败不影响正常导航
}
});
if (componentLoadTasks.length === 0) {
prefetchedPaths.add(path);
return;
}
prefetchingPaths.add(path);
void Promise.allSettled(componentLoadTasks)
.then(() => {
prefetchedPaths.add(path);
})
.finally(() => {
prefetchingPaths.delete(path);
});
};
const navigation = async (path: string) => {
try {
const route = routeMetaMap.get(path);
@@ -53,6 +100,7 @@ function useNavigation() {
} else if (openInNewWindow) {
openRouteInNewWindow(resolveHref(path));
} else {
prefetch(path);
await router.push({
path,
query,
@@ -68,7 +116,7 @@ function useNavigation() {
return shouldOpenInNewWindow(path);
};
return { navigation, willOpenedByWindow };
return { navigation, prefetch, willOpenedByWindow };
}
export { useNavigation };