Vben Admin 源码学习:状态管理-多标签页

2年前 (2022) 程序员胖胖胖虎阿
399 0 0

前言

本文将对 Vue-Vben-Admin 多标签页的状态管理进行源码解读,耐心读完,相信您一定会有所收获!

更多系列文章详见专栏 👉 📚 Vben Admin 项目分析&实践 。

multipleTab.ts 系统锁屏

文件 src\store\modules\multipleTab.ts 声明导出一个store实例 useMultipleTabStore 、一个方法 useMultipleTabWithOutStore()用于没有使用 setup 组件时使用。

// 多标签页信息存储
export const useMultipleTabStore = defineStore({
  id: 'app-multiple-tab',
  state: { /*...*/ },
  getters: { /*...*/ }
  actions:{ /*...*/ }   
});

export function useMultipleTabWithOutStore() {
  return useMultipleTabStore(store);
}

State/Getter

状态对象定义了标签页路由列表、缓存标签页名称以及最后一次拖动标签的索引。同时提供了对应getter用于获取该状态值。

// 多标签页状态
export interface MultipleTabState { 
  cacheTabList: Set<string>;  // 缓存标签页路由名称 
  // 标签页路由列表   RouteLocationNormalized  标准化的路由地址
  tabList: RouteLocationNormalized[];  
  lastDragEndIndex: number; // 最后一次拖动标签的索引
} 

state: (): MultipleTabState => ({ 
  cacheTabList: new Set(), 
  tabList: cacheTab ? Persistent.getLocal(MULTIPLE_TABS_KEY) || [] : [],  // 优先加载缓存/本地存储内容
  lastDragEndIndex: 0,
}),
getters: {
  // 获取标签页路由列表
  getTabList(): RouteLocationNormalized[] {
    return this.tabList;
  },
  // 获取缓存标签页路由名称列表
  getCachedTabList(): string[] {
    return Array.from(this.cacheTabList);
  },
  // 获取最后一次拖动标签的索引
  getLastDragEndIndex(): number {
    return this.lastDragEndIndex;
  },
}, 

标签页组件

标签页功能通过自定义组件实现,项目中路径为 src\layouts\default\tabs\。标签页提供了添加、关闭、刷新、标题设置操作。

Vben Admin 源码学习:状态管理-多标签页

标签页组件封装了 antdv 的 Tabs 组件,通过getTabList获取标签页路由列表状态,遍历渲染成选项卡(标签页)。在 TabPane 组件具名插槽tab中,使用自定义组件TabContent重新渲染选项卡头显示文字。

// src\layouts\default\tabs\index.vue
<template>
  <div :class="getWrapClass">
    <Tabs>
      <!-- 标签页 -->
      <template v-for="item in getTabsState" :key="item.query ? item.fullPath : item.path">
        <TabPane :closable="!(item && item.meta && item.meta.affix)">
          <template #tab>
            <TabContent :tabItem="item" />
          </template>
        </TabPane>
      </template>
      <!-- 右侧标签页快捷操作 -->
      <template #rightExtra>
        // 刷新/折叠 ...
      </template>
    </Tabs>
  </div>
</template>

<script lang="ts">
// ...
// 多标签页信息存储
const tabStore = useMultipleTabStore(); 
// 获取标签页路由列表状态 并过滤状态
const getTabsState = computed(() => {
  return tabStore.getTabList.filter((item) => !item.meta?.hideTab);
});
</script>

TabContent组件中,根据传入打开页面路由记录,获取路由元信息(meta属性)。计算属性getTitle根据meta属性设置标签页的显示文字。

// src\layouts\default\tabs\components\TabContent.vue 
<template>
  <Dropdown :dropMenuList="getDropMenuList" :trigger="getTrigger" @menu-event="handleMenuEvent">
    <div :class="`${prefixCls}__info`" @contextmenu="handleContext" v-if="getIsTabs">
      <span class="ml-1">{{ getTitle }}</span>
    </div> 
  </Dropdown>
</template>

<script lang="ts">
props: {
  tabItem: {
    type: Object as PropType<RouteLocationNormalized>,
    default: null,
  }, 
},
 
// 从页面路由对象中获取标题信息
const getTitle = computed(() => {
  const { tabItem: { meta } = {} } = props;
  return meta && t(meta.title as string);
});
</script>

Actions

接下来将结合组件功能去阐述下 Actions中提供的方法作用,组件的其他特性(拖拽、右键快捷等交互)分析不在此文内容中。

打开标签页

方法 addTab 用于打开标签页(重复打开内部为更新操作)。

  1. 判断当前打开是否特殊页面(错误处理/登录/重定向),退出方法。
  2. 若存在已经打开路径相同的标签页,更新其标签页路由记录,否则添加新页面路由记录,状态tabList新增值后,组件会渲染出新的标签页。
  3. 更新需要缓存的标签页路由名称,使用本地存储持久化。
// 打开标签页
async addTab(route: RouteLocationNormalized) {
  // 路由基本属性
  const { path, name, fullPath, params, query, meta } = getRawRoute(route);
  // 错误处理页面 登录 重定向 等页面
  if (
    path === PageEnum.ERROR_PAGE ||
    path === PageEnum.BASE_LOGIN ||
    !name ||
    [REDIRECT_ROUTE.name, PAGE_NOT_FOUND_ROUTE.name].includes(name as string)
  ) {
    return;
  }

  let updateIndex = -1;
  // 标签页已经存在,不在重复添加标签
  const tabHasExits = this.tabList.some((tab, index) => {
    updateIndex = index;
    return (tab.fullPath || tab.path) === (fullPath || path);
  });

  // 标签已经存在,执行更新操作
  if (tabHasExits) {
    const curTab = toRaw(this.tabList)[updateIndex]; // 获取当前标签页路由记录
    if (!curTab) {
      return;
    }
    curTab.params = params || curTab.params; // 从 path 中提取的已解码参数字典
    curTab.query = query || curTab.query; // 从 URL 的 search 部分提取的已解码查询参数的字典。
    curTab.fullPath = fullPath || curTab.fullPath; // URL 编码与路由地址有关。包括 path、 query 和 hash。
    this.tabList.splice(updateIndex, 1, curTab); // 替换原有的标签页路由记录
  } else {
    // 添加标签页
    // 获取动态路由打开数,超过 0 即代表需要控制打开数
    const dynamicLevel = meta?.dynamicLevel ?? -1;
    if (dynamicLevel > 0) {
      // 如果设置大于 0 了,那么就要限制该路由的打开数限制了
      // 首先获取到真实的路由,使用配置方式减少计算开销.
      // const realName: string = path.match(/(\S*)\//)![1];
      const realPath = meta?.realPath ?? '';
      // 获取到已经打开的动态路由数, 判断是否大于某一个值
      if (
        this.tabList.filter((e) => e.meta?.realPath ?? '' === realPath).length >= dynamicLevel
      ) {
        // 关闭第一个
        const index = this.tabList.findIndex((item) => item.meta.realPath === realPath);
        index !== -1 && this.tabList.splice(index, 1);
      }
    }
    this.tabList.push(route); // 添加至路由列表中
  }
  this.updateCacheTab();
  // 使用本地存储持久化
  cacheTab && Persistent.setLocal(MULTIPLE_TABS_KEY, this.tabList);
},

缓存列表

方法updateCacheTab用于更新需要缓存的标签页路由名称,返回一个 Set 集合。若路由中meta中设置ignoreKeepAlivetrue,该标签页不会被缓存。

// 根据当前打开的标签更新缓存
async updateCacheTab() {
  // Set 集合存储
  const cacheMap: Set<string> = new Set();

  for (const tab of this.tabList) {
    const item = getRawRoute(tab);
    // 若忽略KeepAlive缓存 不缓存
    const needCache = !item.meta?.ignoreKeepAlive;
    if (!needCache) {
      continue;
    }
    const name = item.name as string;
    cacheMap.add(name);
  }
  this.cacheTabList = cacheMap; // 存储路由记录名称的 Set 集合
},

方法clearCacheTabs用于清除缓存列表。

// 清除缓存列表
clearCacheTabs(): void {
  this.cacheTabList = new Set();
},

设置tab标题

方法setTabTitle使用meta属性,将最新标题内容附加到路由上,在组件TabContent中就会获取该路由的标题设置,然后渲染更新。

// 设置标签标题
async setTabTitle(title: string, route: RouteLocationNormalized) {
  const findTab = this.getTabList.find((item) => item === route);
  if (findTab) {
    findTab.meta.title = title; // meta实现 设置每个页面的title标题
    await this.updateCacheTab();
  }
},

关闭操作

组件提供了很多关闭方法(所有、当前、左侧、右侧等)。上文介绍了标签页渲染是由状态tabList控制,关闭操作本质上就是将对应标签页路由信息从tabList中删除。

closeAllTab

方法 closeAllTab 关闭所有非 affix 的 tab,并跳转到首页PageEnum.BASE_HOME

// 关闭所有非 affix 的 tab,并跳转到首页`PageEnum.BASE_HOME`
async closeAllTab(router: Router) {
  this.tabList = this.tabList.filter((item) => item?.meta?.affix ?? false); // 没有固定的标签页
  this.clearCacheTabs();  // 清除缓存列表
  this.goToPage(router);  // 跳转首页
},
goToPage(router: Router) {
  // ...
  const { path } = unref(router.currentRoute); 
  let toPath: PageEnum | string = PageEnum.BASE_HOME; 
  // ... 
  path !== toPath && go(toPath as PageEnum, true);
},

// src\enums\pageEnum.ts 首页设置
export enum PageEnum { 
  BASE_HOME = '/dashboard', 
}

closeLeft

方法 closeLeft 关闭指定路由左侧()标签页。

// 关闭右侧标签页并跳转
async closeLeftTabs(route: RouteLocationNormalized, router: Router) {
  // 根据指定路由获取标签页索引顺序
  const index = this.tabList.findIndex((item) => item.path === route.path);
  
  // 获取指定路由左侧非固定的标签页的fullPath列表
  if (index > 0) {
    const leftTabs = this.tabList.slice(0, index);
    const pathList: string[] = [];
    for (const item of leftTabs) {
      const affix = item?.meta?.affix ?? false;
      if (!affix) {
        pathList.push(item.fullPath);
      }
    } 
    this.bulkCloseTabs(pathList); // 批量关闭列表路由
  }
  this.updateCacheTab();
  handleGotoPage(router); // 路由页面跳转
},
// 批量关闭标签页 
async bulkCloseTabs(pathList: string[]) {
  this.tabList = this.tabList.filter((item) => !pathList.includes(item.fullPath));
},


// 路由页面跳转
function handleGotoPage(router: Router) {
  const go = useGo(router);
  go(unref(router.currentRoute).path, true);
}

closeRight

方法 closeRightcloseLeft逻辑相似,关闭指定路由右侧(非固定)标签页。

async closeRightTabs(route: RouteLocationNormalized, router: Router) {
  const index = this.tabList.findIndex((item) => item.fullPath === route.fullPath);
  // 非最后一个
  if (index >= 0 && index < this.tabList.length - 1) { 
    const rightTabs = this.tabList.slice(index + 1, this.tabList.length);

    const pathList: string[] = [];
    for (const item of rightTabs) {
      const affix = item?.meta?.affix ?? false;
      if (!affix) {
        pathList.push(item.fullPath);
      }
    }
    this.bulkCloseTabs(pathList);
  }
  this.updateCacheTab();
  handleGotoPage(router);
},

closeOther

方法closeOther 关闭指定路由之外的其他(非固定)标签页。

// 关闭指定路由之外的其他标签页
async closeOtherTabs(route: RouteLocationNormalized, router: Router) {
  // 所有打开页面路由路径列表
  const closePathList = this.tabList.map((item) => item.fullPath); 
  const pathList: string[] = [];
  for (const path of closePathList) {
    // 指定路由之外(非固定)标签页都会被删除
    if (path !== route.fullPath) {
      const closeItem = this.tabList.find((item) => item.path === path);
      if (!closeItem) {
        continue;
      }
      const affix = closeItem?.meta?.affix ?? false;
      if (!affix) {
        pathList.push(closeItem.fullPath);
      }
    }
  }
  this.bulkCloseTabs(pathList);
  this.updateCacheTab();
  handleGotoPage(router);
},

closeTab

方法closeTab 关闭指定标签页。

  1. 关闭不是当前激活的标签页,就直接关闭没有跳转处理。
  2. 关闭为当前激活标签页,跳转处理如下:

    • 标签页为最左侧,只有一个标签页时,会自动跳转路由默认页面userStore.getUserInfo.homePath PageEnum.BASE_HOME
    • 否则关闭后右侧标签显示激活状态。
    • 标签页不是最左侧,关闭后默认将其左侧标签页激活。
    • 最后使用 replace 导航后不会留下历史记录。
// tab 关闭标签页的路由   router 当前激活标签页路由
async closeTab(tab: RouteLocationNormalized, router: Router) {
  // 内部方法 关闭指定路由(非固定)标签页
  const close = (route: RouteLocationNormalized) => {
    const { fullPath, meta: { affix } = {} } = route;
    if (affix) { return; }
    const index = this.tabList.findIndex((item) => item.fullPath === fullPath);
    index !== -1 && this.tabList.splice(index, 1);
  };

  const { currentRoute, replace } = router; 
  const { path } = unref(currentRoute);
  // 判断关闭的标签页是不是当前激活状态
  if (path !== tab.path) {
    // 不是激活状态,直接关闭后退出方法
    close(tab); // 内部方法
    return;
  }

  // 关闭的标签页是当前激活状态
  let toTarget: RouteLocationRaw = {}; 
  const index = this.tabList.findIndex((item) => item.path === path);

  // 关闭的标签页最左侧的标签
  if (index === 0) {
    // 只有一个标签,那么就跳到主页,否则就跳到右侧的标签。
    if (this.tabList.length === 1) {
      const userStore = useUserStore();
      toTarget = userStore.getUserInfo.homePath || PageEnum.BASE_HOME;
    } else {
      //  跳到右边的标签
      const page = this.tabList[index + 1];
      toTarget = getToTarget(page);
    }
  } else {
    // 非最左侧标签 关闭后跳转到左侧标签页
    const page = this.tabList[index - 1];
    toTarget = getToTarget(page);
  }
  close(currentRoute.value);
  await replace(toTarget);  // 导航后不会留下历史记录
},

// 路由地址格式化处理
const getToTarget = (tabItem: RouteLocationNormalized) => {
  // params 从 path 中提取的已解码参数字典。
  // path 编码 URL 的 pathname 部分,与路由地址有关。
  // query 从 URL 的 search 部分提取的已解码查询参数的字典。
  const { params, path, query } = tabItem;
  return {
    params: params || {},
    path,
    query: query || {},
  };
};

刷新

方法 refreshPage 刷新标签页。

// 刷新标签页
async refreshPage(router: Router) {
  const { currentRoute } = router;
  const route = unref(currentRoute);
  const name = route.name; 
  
  // Remove the tab from the cache  从缓存中找到标签页并删除
  const findTab = this.getCachedTabList.find((item) => item === name);
  if (findTab) {
    this.cacheTabList.delete(findTab);
  }
  const redo = useRedo(router);
  await redo();
},

📚参考&关联阅读

"routelocationnormalized",vue-router
"Meta 配置说明",vvbin.cn

关注专栏

如果本文对您有所帮助请关注➕、 点赞👍、 收藏⭐!您的认可就是对我的最大支持!

版权声明:程序员胖胖胖虎阿 发表于 2022年9月13日 下午12:16。
转载请注明:Vben Admin 源码学习:状态管理-多标签页 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...