前言
距离上次分享 一款 vue 应用 SDK 已经两个月了,经过这段时间的沉淀和完善,vue-app-sdk 补充了更多常见功能的
SDK
,并完善了示例文档,致力于帮助Vuer
进行敏捷应用开发,增多学习时间,一起来看看吧。
完整插件清单
- Animation - 转场动画管理
- KeepAlive - 路由页面缓存管理
- BetterScroller - 路由滚动位置管理
- FeatureAuth - 应用功能权限
- Page - 前后端标准化页面数据管理
- SSO - 单点登录管理
- Tabs - 标签页列表管理
- Token - 应用令牌信息管理
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
展示如何渲染菜单和跳转。
- 先创建递归子菜单组件
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)],
})
路由滚动器管理
该插件用于更好的管理路由跳转后的滚动位置。
该插件源于
antfu
的 vue-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)
})