一款解放 vue 项目生产力的 SDK

前言

距离上次分享 一款 vue 应用 SDK 已经两个月了,经过这段时间的沉淀和完善,vue-app-sdk 补充了更多常见功能的 SDK,并完善了示例文档,致力于帮助 Vuer 进行敏捷应用开发,增多学习时间,一起来看看吧。

完整插件清单

Page - 前后端标准化页面数据管理

该插件致力于抹平前后端开发时页面数据处理不一致问题,以标准化的数据结构来满足不同的应用,同时区分了移动端和 PC 端使用场景,提供了常用的辅助函数。

安装

ts 复制代码
// sdk.ts
import { createPage, createSDK } from 'vue-app-sdk'

// 导入视图模块
const modules = import.meta.glob([
  '@/views/**/*.vue',
  // 排除 components 下的 vue 文件
  '!**/components/**/*.vue',
])

// 由于区分了 `PC` 端和移动端数据,所以需单独暴露 Page 实例
export const Page = createPage({
  // 当前应用模式,移动端时可设为 `mobile`
  mode: 'pc',
  // 获取真实组件,用于将页面元数据转为路由数据
  resolveComponent: (file) => modules[`/src/views/${file}.vue`],

  // 一般配置上述两个配置即可,其他配置项可视情况自行更改,均带有详细注释
  // ...
})

export const SDK = createSDK({
  plugins: [Page],
})

使用步骤

推荐搭配 pinia 使用!

1. 创建静态页面元数据列表

若无需后端接口介入可自行创建前端静态元数据列表,用于统一数据格式!

ts 复制代码
// assets/data/pages.ts
// 使用 unplugin-macros 时可以通过 assert { type: 'macro' } 减少运行时开销
// 由于宏编译参数必须是字面量值,可以在填写时移除 ` 符号,填写完成后再添加
// import { defineStaticPages } from 'vue-app-sdk/macros/page' assert { type: 'macro' }

// export default defineStaticPages(`[]`)

// 使用 defineStaticPages 可以为代码编辑器提供智能辅助提示
// 该函数将自动补充父子页面关联关系(追加 id、parentId),用于统一前后端页面元数据格式
// 普通使用
import { defineStaticPages } from 'vue-app-sdk/macros/page'

export default defineStaticPages([
  // 子级菜单配置
  {
    // 路由 name,需保持唯一
    name: 'home',
    // 路由 path
    path: '/home',
    // 视图文件地址,搭配 `resolveComponent` 使用,若无时可不设置
    file: 'Home/index',
    // 页面标题
    title: '首页',
    // 多语言时可设置为对象形式,通过 localeText(title) 处理
    // title: { 'zh-cn': '首页', en: 'Home', },
    // 需要挂载菜单时设为 true,可不设置
    isMenu: true,
    // PC 模式时用于固定到标签页上,可不设置
    isAffix: true,
    // PC 模式时用于判断页面是否需要保活,可不设置
    isKeepAlive: true,
    // 静态的允许访问的角色编码列表,不需要时参考以下两种方式:
    // 1. 设置为 '*',允许任意用户访问
    // 2. createPage({ strictRole: false }),之后可不设置 roleList
    roleList: '*',
    // 菜单图标
    icon: 'MenuIcon',
    // 子级页面列表
    children: [
      {
        name: 'home-detail',
        path: 'detail/:time',
        file: 'Home/Detail/index',
        title: '首页详情',
        isKeepAlive: true,
        roleList: '*',
        // 子级非菜单页面时需要激活的菜单 name
        activeMenu: 'home',
      },
    ],
  },

  // 链接菜单配置
  {
    name: 'link',
    path: '/link',
    title: '外部链接',
    isMenu: true,
    roleList: '*',
    // 自身作为父级菜单时没有页面文件,可设置激活时重定向至子级菜单 name
    redirect: 'bing',
    children: [
      {
        name: 'bing',
        path: 'bing',
        // 设置链接地址,存在时将跳转至目标页面
        link: '//www.bing.com/',
        // 存在 link 时若存在 file 或 redirect 将会优先跳转至对应组件,
        // 组件内部可通过 route.meta.link 属性获取链接地址进行处理
        file: 'IFrame/index',
        title: 'Bing 内嵌',
        isMenu: true,
        roleList: '*',
      },
      {
        name: 'baidu',
        path: 'baidu',
        title: '百度外链',
        // 若仅设置了 link 而不存在 file 和 redirect 时将打开新的浏览器页面
        link: '//www.baidu.com/',
        isMenu: true,
        isKeepAlive: true,
        roleList: '*',
      },
    ],
  },

  // 非布局菜单配置
  {
    path: '/data-screen',
    name: 'data-screen',
    file: 'DataScreen/index',
    title: '数据大屏',
    isMenu: true,
    // 一般大屏页面需脱离管理应用的基础布局组件,可设置 isFull 进行标识
    isFull: true,
    roleList: '*',
  },
])

2. 创建 auth.ts 储存授权页面信息

ts 复制代码
// stores/auth.ts
import { Page } from '@/plugins/sdk'
import staticPages from '@/assets/data/pages'

const store = defineStore('auth', () => {
  // 源页面元数据列表
  const pages = ref([])
  // 模拟接口请求获取元数据列表
  const getAuthPages = async () => {
    pages.value = await Promise.resolve(staticPages)
  }

  // 根据 pages 使用 createStates 快速创建不同场景下的页面元数据状态
  const {
    // 扁平化的元数据列表
    flattenPages,
    // 树形元数据列表
    treePages,

    // 过滤权限后的扁平化元数据列表
    authPages,
    // 过滤权限后的树形元数据列表
    authTreePages,
    // 过滤权限后的元数据列表映射 { [id]: page }
    authPageMap,
    // 过滤权限后的树形元数据链路映射
    authTreeLinkMap,

    // 过滤权限后的树形菜单
    menus,
    // 当前激活的菜单
    activeMenu,
  } = Page.createStates(pages, {
    // 标明源数据格式,可选值:tree、list
    format: 'tree',

    // 子级列表属性名
    childrenKey: 'children',

    // 过滤权限时是否子级优先
    // - `true`: 子级存在权限时父级必定存在
    // - `false`: 父级无权限时子级必定不存在
    childrenFirst: true,

    // 转换 `page.path` 为绝对路径,用于扁平化元数据列表时让子级的 `path` 保持完整
    convertPathToAbsolute: true,

    // 当前登录角色权限列表,用来配合 `page.roleList` 过滤页面列表
    roleList: () => '*',

    // 根据当前路由获取激活菜单标识
    resolveActiveMenu: (route) => route.meta.activeMenu || route.meta.name || ''
  })

  /** 默认页面 name */
  const defaultPage = computed(() => {
    return menus.value[0]?.name || 'home'
  })

  // ...

  return {
    // 暴露可能会用到的数据
    /** 扁平化的所有页面元数据列表 */
    flattenPages,
    /** 登录角色权限的页面元数据列表 */
    authPages,
    /** 登陆角色权限的页面元数据映射 */
    authPageMap,
    /** 登陆角色权限的树形元数据链路映射 */
    authTreeLinkMap,
    /** 登录角色权限的树形菜单列表 */
    menus,
    /** 激活的菜单标识 */
    activeMenu,
    /** 默认页 name */
    defaultPage,

    // 获取源授权数据列表
    getAuthPages,

    // ...
  }
})

export function useAuthStore() {
  // https://pinia.web3doc.top/core-concepts/outside-component-usage.html
  return store(pinia)
}

3. 动态注册路由数据

ts 复制代码
// router/index.ts
export const router = createRouter({
  routes: [
    // 注册静态布局页
    {
      path: '/layout',
      name: 'layout',
      component: () => import('@/layout/index.vue'),
      // 子级设置为空数组,后续动态挂载路由列表
      children: [],
    },
  ]
})

// 重置路由
export function resetRouter() {
  const authStore = useAuthStore()
  Page.resetRouter(authStore.authPages)
}

router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore()

  // 动态更改页面标题
  if (to.meta.title) document.title = to.meta.title

  // 没有授权页面时请求数据并挂载动态路由
  if (!authStore.authPages.length) {
    await authStore.getAuthPages()

    if (authStore.authPages.length === 0) {
      alert('当前账号无任何页面权限,请联系管理员!')
      return Promise.reject(new Error('No Permission!'))
    }

    authStore.authPages.forEach((page) => {
      // 如果 isFull 为 true 则不挂在在布局页面下
      if (page.isFull)
        router.addRoute(Page.pageToRoute(page, { props: true }))
      else
        // 挂载在布局页面下
        router.addRoute('layout', Page.pageToRoute(page, { props: true }))
    })

    // 替换目标路由
    return next({ ...to, replace: true })
  }

  if (to.path === '/' || ['layout'].includes(to.name as string)) {
    // 重定向至默认页
    return next({
      name: authStore.defaultPage,
      query: to.query,
      params: to.params,
      replace: true,
    })
  }

  // 正常跳转
  return next()
})

4. 渲染菜单

这里使用 ElementPlus 展示如何渲染菜单和跳转。

  1. 先创建递归子菜单组件
html 复制代码
<!-- layout/components/ReMenuItem.vue -->
<script setup lang="ts">
import { Page } from '@/plugins/sdk'
import type { MetadataWithChildren } from 'vue-app-sdk'

// 可以考虑在全局类型定义中定义别名
type PageMetadataWithChildren = MetadataWithChildren<'pc'>

defineProps<{
  // 由于 `ElementPlus` 的 `ElMenu` 组件会获取插槽虚拟节点列表,
  // 在水平时处理宽度溢出问题,所以这里仅接收单个菜单配置进行渲染
  menu: PageMetadataWithChildren
}>()

function handleMenuClick(menu: PageMetadataWithChildren) {
  // 直接使用提供的函数处理点击事件
  Page.handleMenuClick(menu)
}
</script>

<template>
  <!-- 若存在子级列表时使用 `ElSubMenu` 渲染父级,之后递归渲染子级 -->
  <template v-if="menu.children">
    <ElSubMenu
      :index="menu.name"
      class="re-sub-menu"
    >
      <template #title>
        <div>{{ menu.title }}</div>
      </template>
      <ReMenuItem
        v-for="item in menu.children"
        :key="item.id"
        :menu="item"
      />
    </ElSubMenu>
  </template>
  <template v-else>
    <!-- 渲染子级菜单 -->
    <ElMenuItem
      class="re-menu-item"
      :index="menu.name"
      @click="handleMenuClick(menu)"
    >
      <template #title>
        <div>{{ menu.title }}</div>
      </template>
    </ElMenuItem>
  </template>
</template>
html 复制代码
<!-- layout/components/Menu.vue -->
<script setup lang="ts">
import ReMenuItem from './ReMenuItem.vue'
import { useAuthStore } from '@/stores/auth'

const authStore = useAuthStore()
</script>

<template>
  <ElMenu
    mode="vertical"
    :router="false"
    :default-active="authStore.activeMenu"
  >
    <!-- 遍历 menus 渲染递归子菜单组件 -->
    <ReMenuItem
      v-for="item in authStore.menus"
      :key="item.id"
      :menu="item"
    />
  </ElMenu>
</template>

5. 渲染面包屑导航

这里使用 ElementPlus 展示如何渲染面包屑导航和跳转。

html 复制代码
<!-- layout/components/Breadcrumb.vue -->
<script setup lang="ts">
import { Page } from '@/plugins/sdk'

const authStore = useAuthStore()

const route = useRoute()
const breadcrumbList = computed(() => {
  // 根据当前路由元数据的 id 获取授权页面树形节点链路
  return authStore.authTreeLinkMap[route.meta.id] || []
})

function onBreadcrumbClick(item: PageMetadata, index: number) {
  // 如果是末级节点则跳过点击
  if (index === breadcrumbList.value.length - 1) return 
  // 直接使用 Page 提供的函数处理点击
  Page.handleMenuClick(item)
}
</script>

<template>
  <ElBreadcrumb>
    <ElBreadcrumbItem
      v-for="(item, index) in breadcrumbList"
      :key="item.id"
    >
      <ElLink
        :underline="false"
        @click="onBreadcrumbClick(item, index)"
      >
        <ElText>{{ localeText(item.title) }}</ElText>
      </ElLink>
    </ElBreadcrumbItem>
  </ElBreadcrumb>
</template>

配合后端开发

配合后端开发时只需将 auth.ts 中的获取页面数据函数替换为真实接口,并将接口数据转换为标准化的页面元数据即可。

国际化使用

一般都会采用 vue-i18n 作为国际化的工具,这里仅提供该方案的示例代码,逻辑较为简单,也可自行实现。

ts 复制代码
// i18n.ts
import { createLocaleText } from 'vue-app-sdk'

export const i18n = createI18n({
  // ...
})

/**
 * 获取本地化文本,支持动态设置多语言文本
 *
 * @example
 * ```ts
 * // menu.ts
 * const menu = { title: '菜单' }
 * localeText(menu.title) // '菜单'
 *
 * const menu = { title: { 'zh-cn': '菜单', en: 'Menu' } }
 * // 根据 i18n.global.locale 获取当前语言文本
 * localeText(menu.title) // '菜单'
 *
 * // 获取指定语言文本
 * localeText(menu.title, 'en') // 'Menu'
 *
 * // alert.ts
 * alert(localeText({ 'zh-cn': '这是中文警告!', en: 'This is a warning in English!' }))
 *
 * // menu.vue
 * const menu = { title: { 'zh-cn': '菜单', en: 'Menu' } }
 * const menuTitle = computed(() => localeText(menu.title)) // 支持响应式动态变更
 * ```
 */
export const localeText = createLocaleText(i18n)

以子菜单渲染为例:

diff 复制代码
<template>
  <!-- 若存在子级列表时使用 `ElSubMenu` 渲染父级,之后递归渲染子级 -->
  <template v-if="menu.children">
    <ElSubMenu
      :index="menu.name"
      class="re-sub-menu"
    >
      <template #title>
-        <div>{{ menu.title }}</div>
+        <div>{{ localeText(menu.title) }}</div>
      </template>
      <ReMenuItem
        v-for="item in menu.children"
        :key="item.id"
        :menu="item"
      />
    </ElSubMenu>
  </template>
  <template v-else>
    <!-- 渲染子级菜单 -->
    <ElMenuItem
      class="re-menu-item"
      :index="menu.name"
      @click="handleMenuClick(menu)"
    >
      <template #title>
-        <div>{{ menu.title }}</div>
+        <div>{{ localeText(menu.title) }}</div>
      </template>
    </ElMenuItem>
  </template>
</template>

配合 KeepAlive 使用

创建 SDK 时将 Page.keepAliveOptions 传入 createKeepAlive() 中。

diff 复制代码
- import { createPage, createSDK } from 'vue-app-sdk'
+ import { createPage, createKeepAlive, createSDK } from 'vue-app-sdk'

// 由于区分了 `PC` 端和移动端数据,所以需单独暴露 Page 实例
export const Page = createPage({
  // 当前应用模式,移动端时可设为 `mobile`
  mode: 'pc',
  // 获取真实组件,用于将页面元数据转为路由数据
  resolveComponent: (file) => modules[`/src/views/${file}.vue`],

  // 一般配置上述两个配置即可,其他配置项可视情况自行更改,均带有详细注释
  // ...
})

export const SDK = createSDK({
-  plugins: [Page],
+  plugins: [Page, createKeepAlive(Page.keepAliveOptions)],
})

路由滚动器管理

该插件用于更好的管理路由跳转后的滚动位置。

该插件源于 antfuvue-router-better-scroller,但由于其仅适合于移动端前进后退跳转,在 PC 端常见的中后台管理应用中无法正常使用,这里基于源码开放了额外配置来同时满足 PC 端和移动端。

安装

ts 复制代码
// sdk.ts
import { createSDK, createScroller } from 'vue-app-sdk'

export const SDK = createSDK({
  plugins: [
    createScroller({
      // 设置需要记录滚动位置的选择器
      selectors: {
        window: true,
        body: true,
        '.scrollable': true,
      },
    }),
  ],
})

使用

默认会自动注册路由后置守卫,在跳转后延迟更改记录的滚动位置,但若存在 Transition 动画时可能无法达到预期效果,需在页面动画进入时手动触发滚动!

html 复制代码
<!-- App.vue -->
<script setup>
import { nextTick } from 'vue'
import { useAppSDK } from 'vue-app-sdk'

const { routerScroller } = useAppSDK()
function handleRouterScroll() {
  // 动画进入时注入微任务触发滚动
  nextTick(() => {
    routerScroller.trigger()
  })
}
</script>

<template>
  <RouterView v-slot="{ Component, route }">
    <Transition
      appear
      name="fade-transform"
      mode="out-in"
      @before-enter="handleRouterScroll"
    >
      <Component :is="Component" />
    </Transition>
  </RouterView>
</template>

搭配 Tabs 插件使用

ts 复制代码
// Tabs.vue
import { useAppSDK } from 'vue-app-sdk'

const { tabs, routerScroller, hooks } = useAppSDK()

// 注册路由前进事件
const unhook = hooks.hook('sdk:router:forward', (to) => {
  const needRevertPos = tabs.pages.value.some(({ comparisonData }) => comparisonData.fullPath === to.fullPath)
  // 如果没有相同路径的标签页意味着新开页签,删除掉旧的位置记录
  if (!needRevertPos) routerScroller.positions.delete(to.fullPath)
})
// 组件卸载前关闭事件监听
onBeforeUnmount(() => {
  unhook()
})

若存在动态开启标签页功能时:

ts 复制代码
// Main.vue
import { ref, watch } from 'vue'
import { useAppSDK } from 'vue-app-sdk'

const { routerScroller } = useAppSDK()
const enableTabs = ref(true)
watch(enableTabs, (value) => {
  // 开启时启动滚动器的自动记录模式
  if (value) {
    routerScroller.enableAuto()
  }
  else {
    // 关闭时禁用滚动器自动记录模式,并清理掉所有的记录信息
    routerScroller.disableAuto()
    routerScroller.positions.clear()
  }
}, { immediate: true })

移动端使用方式

原始的 vue-router-better-scroller 会在前进页面时清空存在过的位置记录,后退时才会还原位置,由于修改了这一特性,若需使用可参考下面代码。

ts 复制代码
// sdk.ts
import { createSDK, createScroller } from 'vue-app-sdk'

export const sdk = createSDK({
  plugins: [
    createScroller({
      // 设置需要记录滚动位置的选择器
      selectors: {
        window: true,
        body: true,
        '.scrollable': true,
      },
    }),
  ],
})

// 插件默认注册了后退时删除来源页记录,这里仅注册前进时删除目标页记录
sdk.hook('sdk:router:forward', (to) => {
  sdk.routerScroller.positions.delete(to.fullPath)
})

相关链接

相关推荐
GISer_Jing1 小时前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲6 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5817 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter8 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry8 小时前
Jetpack Compose 中的状态
前端
dae bal9 小时前
关于RSA和AES加密
前端·vue.js
柳杉9 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化