一款解放 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)
})

相关链接

相关推荐
浮华似水21 分钟前
Javascirpt时区——脱坑指南
前端
王二端茶倒水24 分钟前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
_oP_i29 分钟前
Web 与 Unity 之间的交互
前端·unity·交互
钢铁小狗侠31 分钟前
前端(1)——快速入门HTML
前端·html
凹凸曼打不赢小怪兽1 小时前
react 受控组件和非受控组件
前端·javascript·react.js
狂奔solar1 小时前
分享个好玩的,在k8s上部署web版macos
前端·macos·kubernetes
qiyi.sky1 小时前
JavaWeb——Web入门(8/9)- Tomcat:基本使用(下载与安装、目录结构介绍、启动与关闭、可能出现的问题及解决方案、总结)
java·前端·笔记·学习·tomcat
清云随笔2 小时前
axios 实现 无感刷新方案
前端
鑫宝Code2 小时前
【React】状态管理之Redux
前端·react.js·前端框架
忠实米线2 小时前
使用pdf-lib.js实现pdf添加自定义水印功能
前端·javascript·pdf