Bläddra i källkod

feat: add the attribute routeCached to route to control cache the DOM corresponding to the route

Jin Mao 3 månader sedan
förälder
incheckning
bd22793ceb

+ 7 - 0
docs/src/guide/essentials/route.md

@@ -599,6 +599,13 @@ _注意:_ 排序仅针对一级菜单有效,二级菜单的排序需要在对
 
 用于配置当前路由不使用基础布局,仅在顶级时生效。默认情况下,所有的路由都会被包裹在基础布局中(包含顶部以及侧边等导航部件),如果你的页面不需要这些部件,可以设置 `noBasicLayout` 为 `true`。
 
+### domCached
+
+- 类型:`boolean`
+- 默认值:`false`
+
+用于配置当前路由是否要将route对应dom元素缓存起来。对于一些复杂页面切换tab浏览器回流/重绘会导致卡顿, `domCached` 设为 `true`可解决该问题,但是也有代价:1、内存占用升高 2、vue的部分生命周期不会触发
+
 ## 路由刷新
 
 路由刷新方式如下:

+ 4 - 0
packages/@core/base/typings/src/vue-router.d.ts

@@ -43,6 +43,10 @@ interface RouteMeta {
     | 'success'
     | 'warning'
     | string;
+  /**
+   * 路由对应dom是否缓存起来
+   */
+  domCached?: boolean;
   /**
    * 路由的完整路径作为key(默认true)
    */

+ 21 - 81
packages/effects/layouts/src/basic/content/content.vue

@@ -1,17 +1,15 @@
 <script lang="ts" setup>
-import type { VNode } from 'vue';
-import type {
-  RouteLocationNormalizedLoaded,
-  RouteLocationNormalizedLoadedGeneric,
-} from 'vue-router';
+import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
 
-import { computed } from 'vue';
+import { unref } from 'vue';
 import { RouterView } from 'vue-router';
 
-import { preferences, usePreferences } from '@vben/preferences';
+import { usePreferences } from '@vben/preferences';
 import { getTabKey, storeToRefs, useTabbarStore } from '@vben/stores';
 
+import { transformComponent, useLayoutHook } from '../../hooks';
 import { IFrameRouterView } from '../../iframe';
+import { RouteCachedPage, RouteCachedView } from '../../route-cached';
 
 defineOptions({ name: 'LayoutContent' });
 
@@ -21,85 +19,27 @@ const { keepAlive } = usePreferences();
 const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
   storeToRefs(tabbarStore);
 
-/**
- * 是否使用动画
- */
-const getEnabledTransition = computed(() => {
-  const { transition } = preferences;
-  const transitionName = transition.name;
-  return transitionName && transition.enable;
-});
-
-// 页面切换动画
-function getTransitionName(_route: RouteLocationNormalizedLoaded) {
-  // 如果偏好设置未设置,则不使用动画
-  const { tabbar, transition } = preferences;
-  const transitionName = transition.name;
-  if (!transitionName || !transition.enable) {
-    return;
-  }
-
-  // 标签页未启用或者未开启缓存,则使用全局配置动画
-  if (!tabbar.enable || !keepAlive) {
-    return transitionName;
-  }
-
-  // 如果页面已经加载过,则不使用动画
-  // if (route.meta.loaded) {
-  //   return;
-  // }
-  // 已经打开且已经加载过的页面不使用动画
-  // const inTabs = getCachedTabs.value.includes(route.name as string);
-
-  // return inTabs && route.meta.loaded ? undefined : transitionName;
-  return transitionName;
-}
+const { getEnabledTransition, getTransitionName } = useLayoutHook();
 
 /**
- * 转换组件,自动添加 name
- * @param component
+ * 是否显示component
+ * @param route
  */
-function transformComponent(
-  component: VNode,
-  route: RouteLocationNormalizedLoadedGeneric,
-) {
-  // 组件视图未找到,如果有设置后备视图,则返回后备视图,如果没有,则抛出错误
-  if (!component) {
-    console.error(
-      'Component view not found,please check the route configuration',
-    );
-    return undefined;
-  }
-
-  const routeName = route.name as string;
-  // 如果组件没有 name,则直接返回
-  if (!routeName) {
-    return component;
-  }
-  const componentName = (component?.type as any)?.name;
-
-  // 已经设置过 name,则直接返回
-  if (componentName) {
-    return component;
-  }
-
-  // componentName 与 routeName 一致,则直接返回
-  if (componentName === routeName) {
-    return component;
-  }
-
-  // 设置 name
-  component.type ||= {};
-  (component.type as any).name = routeName;
-
-  return component;
-}
+const showComponent = (route: RouteLocationNormalizedLoadedGeneric) => {
+  return !route.meta.domCached && unref(renderRouteView);
+};
 </script>
 
 <template>
   <div class="relative h-full">
     <IFrameRouterView />
+    <RouteCachedView />
     <RouterView v-slot="{ Component, route }">
+      <RouteCachedPage
+        :component="Component"
+        :route="route"
+        v-if="route.meta.domCached"
+      />
       <Transition
         v-if="getEnabledTransition"
         :name="getTransitionName(route)"
@@ -113,14 +53,14 @@ function transformComponent(
         >
           <component
             :is="transformComponent(Component, route)"
-            v-if="renderRouteView"
+            v-if="showComponent(route)"
             v-show="!route.meta.iframeSrc"
             :key="getTabKey(route)"
           />
         </KeepAlive>
         <component
           :is="Component"
-          v-else-if="renderRouteView"
+          v-else-if="showComponent(route)"
           :key="getTabKey(route)"
         />
       </Transition>
@@ -132,14 +72,14 @@ function transformComponent(
         >
           <component
             :is="transformComponent(Component, route)"
-            v-if="renderRouteView"
+            v-if="showComponent(route)"
             v-show="!route.meta.iframeSrc"
             :key="getTabKey(route)"
           />
         </KeepAlive>
         <component
           :is="Component"
-          v-else-if="renderRouteView"
+          v-else-if="showComponent(route)"
           :key="getTabKey(route)"
         />
       </template>

+ 98 - 0
packages/effects/layouts/src/hooks/index.ts

@@ -0,0 +1,98 @@
+import type { VNode } from 'vue';
+import type {
+  RouteLocationNormalizedLoaded,
+  RouteLocationNormalizedLoadedGeneric,
+} from 'vue-router';
+
+import { computed } from 'vue';
+
+import { preferences, usePreferences } from '@vben/preferences';
+
+/**
+ * 转换组件,自动添加 name
+ * @param component
+ * @param route
+ */
+export function transformComponent(
+  component: VNode,
+  route: RouteLocationNormalizedLoadedGeneric,
+) {
+  // 组件视图未找到,如果有设置后备视图,则返回后备视图,如果没有,则抛出错误
+  if (!component) {
+    console.error(
+      'Component view not found,please check the route configuration',
+    );
+    return undefined;
+  }
+
+  const routeName = route.name as string;
+  // 如果组件没有 name,则直接返回
+  if (!routeName) {
+    return component;
+  }
+  const componentName = (component?.type as any)?.name;
+
+  // 已经设置过 name,则直接返回
+  if (componentName) {
+    return component;
+  }
+
+  // componentName 与 routeName 一致,则直接返回
+  if (componentName === routeName) {
+    return component;
+  }
+
+  // 设置 name
+  component.type ||= {};
+  (component.type as any).name = routeName;
+
+  return component;
+}
+
+/**
+ * Layout相关hook
+ */
+export function useLayoutHook() {
+  const { keepAlive } = usePreferences();
+  /**
+   * 是否使用动画
+   */
+  const getEnabledTransition = computed(() => {
+    const { transition } = preferences;
+    const transitionName = transition.name;
+    return transitionName && transition.enable;
+  });
+
+  /**
+   * 获取路由过渡动画
+   * @param _route
+   */
+  function getTransitionName(_route: RouteLocationNormalizedLoaded) {
+    // 如果偏好设置未设置,则不使用动画
+    const { tabbar, transition } = preferences;
+    const transitionName = transition.name;
+    if (!transitionName || !transition.enable) {
+      return;
+    }
+
+    // 标签页未启用或者未开启缓存,则使用全局配置动画
+    if (!tabbar.enable || !keepAlive) {
+      return transitionName;
+    }
+
+    // 如果页面已经加载过,则不使用动画
+    // if (route.meta.loaded) {
+    //   return;
+    // }
+    // 已经打开且已经加载过的页面不使用动画
+    // const inTabs = getCachedTabs.value.includes(route.name as string);
+
+    // return inTabs && route.meta.loaded ? undefined : transitionName;
+    return transitionName;
+  }
+
+  return {
+    getEnabledTransition,
+    getTransitionName,
+  };
+}

+ 2 - 0
packages/effects/layouts/src/route-cached/index.ts

@@ -0,0 +1,2 @@
+export { default as RouteCachedPage } from './route-cached-page.vue';
+export { default as RouteCachedView } from './route-cached-view.vue';

+ 36 - 0
packages/effects/layouts/src/route-cached/route-cached-page.vue

@@ -0,0 +1,36 @@
+<!-- 本组件用于获取缓存的route并保存到pinia -->
+<script setup lang="ts">
+import type { VNode } from 'vue';
+import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
+
+import { watch } from 'vue';
+
+import { useTabbarStore } from '@vben/stores';
+
+interface Props {
+  component?: VNode;
+  route: RouteLocationNormalizedLoadedGeneric;
+}
+
+/**
+ * 这是页面缓存组件,不做任何的的实际渲染
+ */
+defineOptions({
+  render() {
+    return null;
+  },
+});
+const props = defineProps<Props>();
+
+const { addCachedRoute } = useTabbarStore();
+
+watch(
+  () => props.route,
+  () => {
+    if (props.component && props.route.meta.domCached) {
+      addCachedRoute(props.component, props.route);
+    }
+  },
+  { immediate: true },
+);
+</script>

+ 98 - 0
packages/effects/layouts/src/route-cached/route-cached-view.vue

@@ -0,0 +1,98 @@
+<script setup lang="ts">
+import { computed, unref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { preferences } from '@vben/preferences';
+import { getTabKey, storeToRefs, useTabbarStore } from '@vben/stores';
+
+import { transformComponent, useLayoutHook } from '../hooks';
+
+const route = useRoute();
+
+const tabbarStore = useTabbarStore();
+
+const { getTabs, getCachedRoutes, getExcludeCachedTabs } =
+  storeToRefs(tabbarStore);
+const { removeCachedRoute } = tabbarStore;
+
+const { getEnabledTransition, getTransitionName } = useLayoutHook();
+
+/**
+ * 是否启用tab
+ */
+const enableTabbar = computed(() => preferences.tabbar.enable);
+
+const computedCachedRouteKeys = computed(() => {
+  if (!unref(enableTabbar)) {
+    return [];
+  }
+  return unref(getTabs)
+    .filter((item) => item.meta.domCached)
+    .map((item) => getTabKey(item));
+});
+
+/**
+ * 监听缓存路由变化,删除不存在的缓存路由
+ */
+watch(computedCachedRouteKeys, (keys) => {
+  unref(getCachedRoutes).forEach((item) => {
+    if (!keys.includes(item.key)) {
+      removeCachedRoute(item.key);
+    }
+  });
+});
+
+/**
+ * 所有缓存的route
+ */
+const computedCachedRoutes = computed(() => {
+  if (!unref(enableTabbar)) {
+    return [];
+  }
+  // 刷新路由可刷新缓存
+  const excludeCachedTabKeys = unref(getExcludeCachedTabs);
+  return [...unref(getCachedRoutes).values()].filter((item) => {
+    const componentType: any = item.component.type || {};
+    let componentName = componentType.name;
+    if (!componentName) {
+      componentName = item.route.name;
+    }
+    return !excludeCachedTabKeys.includes(componentName);
+  });
+});
+
+/**
+ * 是否显示
+ */
+const computedShowView = computed(() => unref(computedCachedRoutes).length > 0);
+
+const computedCurrentRouteKey = computed(() => {
+  return getTabKey(route);
+});
+</script>
+
+<template>
+  <template v-if="computedShowView">
+    <template v-for="item in computedCachedRoutes" :key="item.key">
+      <Transition
+        v-if="getEnabledTransition"
+        appear
+        mode="out-in"
+        :name="getTransitionName(item.route)"
+      >
+        <component
+          v-show="item.key === computedCurrentRouteKey"
+          :is="transformComponent(item.component, item.route)"
+        />
+      </Transition>
+      <template v-else>
+        <component
+          v-show="item.key === computedCurrentRouteKey"
+          :is="transformComponent(item.component, item.route)"
+        />
+      </template>
+    </template>
+  </template>
+</template>
+
+<style scoped></style>

+ 34 - 2
packages/stores/src/modules/tabbar.ts

@@ -1,13 +1,15 @@
-import type { ComputedRef } from 'vue';
+import type { ComputedRef, VNode } from 'vue';
 import type {
   RouteLocationNormalized,
+  RouteLocationNormalizedLoaded,
+  RouteLocationNormalizedLoadedGeneric,
   Router,
   RouteRecordNormalized,
 } from 'vue-router';
 
 import type { TabDefinition } from '@vben-core/typings';
 
-import { toRaw } from 'vue';
+import { markRaw, toRaw } from 'vue';
 
 import { preferences } from '@vben-core/preferences';
 import {
@@ -20,7 +22,14 @@ import {
 
 import { acceptHMRUpdate, defineStore } from 'pinia';
 
+interface RouteCached {
+  component: VNode;
+  key: string;
+  route: RouteLocationNormalizedLoadedGeneric;
+}
+
 interface TabbarState {
+  cachedRoutes: Map<string, RouteCached>;
   /**
    * @zh_CN 当前打开的标签页列表缓存
    */
@@ -553,6 +562,25 @@ export const useTabbarStore = defineStore('core-tabbar', {
       }
       this.cachedTabs = cacheMap;
     },
+    /**
+     * 添加缓存的route
+     * @param component
+     * @param route
+     */
+    addCachedRoute(component: VNode, route: RouteLocationNormalizedLoaded) {
+      const key = getTabKey(route);
+      if (this.cachedRoutes.has(key)) {
+        return;
+      }
+      this.cachedRoutes.set(key, {
+        key,
+        component: markRaw(component),
+        route: markRaw(route),
+      });
+    },
+    removeCachedRoute(key: string) {
+      this.cachedRoutes.delete(key);
+    },
   },
   getters: {
     affixTabs(): TabDefinition[] {
@@ -577,6 +605,9 @@ export const useTabbarStore = defineStore('core-tabbar', {
       const normalTabs = this.tabs.filter((tab) => !isAffixTab(tab));
       return [...this.affixTabs, ...normalTabs].filter(Boolean);
     },
+    getCachedRoutes(): Map<string, RouteCached> {
+      return this.cachedRoutes;
+    },
   },
   persist: [
     // tabs不需要保存在localStorage
@@ -604,6 +635,7 @@ export const useTabbarStore = defineStore('core-tabbar', {
   ],
   state: (): TabbarState => ({
     visitHistory: createStack<string>(true, MAX_VISIT_HISTORY),
+    cachedRoutes: new Map<string, RouteCached>(),
     cachedTabs: new Set(),
     dragEndIndex: 0,
     excludeCachedTabs: new Set(),

+ 1 - 0
playground/src/router/routes/modules/demos.ts

@@ -30,6 +30,7 @@ const routes: RouteRecordRaw[] = [
             meta: {
               icon: 'mdi:page-previous-outline',
               title: $t('demos.access.pageAccess'),
+              domCached: true,
             },
           },
           {