实战指南
本章汇集真实业务场景的完整方案,每个方案都可直接用于项目。结合 Uni Router 的特性和 uni-app 的限制,提供最佳实践。
登录认证
最常见的需求:未登录用户访问受保护页面时跳转登录页,登录后返回原页面。
路由配置
ts
// router/routes.ts
import type { RouteConfig } from '@meng-xi/uni-router'
export const routes: RouteConfig[] = [
{ path: 'pages/index/index', name: 'home', meta: { isTab: true, title: '首页' } },
{ path: 'pages/login/login', name: 'login', meta: { title: '登录' } },
{ path: 'pages/about/about', name: 'about', meta: { requireAuth: true, title: '关于' } },
{ path: 'pages/profile/profile', name: 'profile', meta: { requireAuth: true, title: '个人中心' } },
{ path: 'pages/user/user', name: 'user', meta: { isTab: true, title: '我的' } }
]守卫实现
ts
// router/index.ts
import { createRouter } from '@meng-xi/uni-router'
import { routes } from './routes'
const router = createRouter({
routes,
interceptUniApi: true // 拦截原生 API,防止绕过守卫
})
// 登录状态检查
function isLoggedIn(): boolean {
return !!uni.getStorageSync('token')
}
router.beforeEach((to, from, next) => {
// 1. 未登录访问受保护页面 → 跳登录页
if (to.meta.requireAuth && !isLoggedIn()) {
next(
{ name: 'login', query: { redirect: to.fullPath } },
{ mode: 'replace' } // replace 避免返回到受保护页的中间态
)
return
}
// 2. 已登录访问登录页 → 跳首页
if (to.name === 'login' && isLoggedIn()) {
next({ name: 'home' }, { mode: 'replace' })
return
}
// 3. 其他情况放行
next()
})
export default router登录页实现
ts
// pages/login/login.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from '@meng-xi/uni-router'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
async function handleLogin() {
loading.value = true
try {
const { token } = await loginApi(username.value, password.value)
uni.setStorageSync('token', token)
// 登录成功,返回原页面
const redirect = route.query.redirect as string
if (redirect) {
// 用 replace 回到原页面,避免登录页留在栈中
await router.replace(redirect)
} else {
// 无重定向目标,回首页
await router.relaunch({ name: 'home' })
}
} catch (err) {
uni.showToast({ title: '登录失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>关键点
mode: 'replace':登录页用 replace 跳转,避免用户返回到"未登录的中间态"- 携带 redirect:登录后能返回原目标页面
- 已登录拦截登录页:防止已登录用户误入登录页
interceptUniApi:确保uni.navigateTo也经过守卫
角色权限控制
基于角色的权限控制,不同角色访问不同页面。
扩展 RouteMeta
ts
// types/router.d.ts
import '@meng-xi/uni-router'
declare module '@meng-xi/uni-router' {
interface RouteMeta {
roles?: string[]
permissions?: string[]
}
}路由配置
ts
export const routes: RouteConfig[] = [
{
path: 'pages/admin/admin',
name: 'admin',
meta: {
roles: ['admin'], // 仅管理员
title: '管理后台'
}
},
{
path: 'pages/editor/editor',
name: 'editor',
meta: {
roles: ['admin', 'editor'], // 管理员或编辑
title: '编辑器'
}
},
{
path: 'pages/order/order',
name: 'order',
meta: {
permissions: ['order:view'], // 需要 order:view 权限
title: '订单'
}
}
]权限守卫
ts
// utils/auth.ts
interface UserInfo {
roles: string[]
permissions: string[]
}
let currentUser: UserInfo | null = null
export async function fetchUserInfo(): Promise<UserInfo> {
if (currentUser) return currentUser
const res = await getUserInfoApi()
currentUser = {
roles: res.roles || [],
permissions: res.permissions || []
}
return currentUser
}
export function hasRole(roles: string[]): boolean {
if (!currentUser) return false
return roles.some(r => currentUser!.roles.includes(r))
}
export function hasPermission(perms: string[]): boolean {
if (!currentUser) return false
return perms.some(p => currentUser!.permissions.includes(p))
}
export function clearUser() {
currentUser = null
}ts
// router/guards/permission.ts
import { fetchUserInfo, hasRole, hasPermission } from '@/utils/auth'
export function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
// 不需要权限的页面直接放行
if (!to.meta.roles && !to.meta.permissions) {
next()
return
}
// 未登录
if (!uni.getStorageSync('token')) {
next({ name: 'login', query: { redirect: to.fullPath } }, { mode: 'replace' })
return
}
// 获取用户信息(带缓存)
try {
await fetchUserInfo()
} catch {
// 获取失败,token 可能过期
uni.removeStorageSync('token')
clearUser()
next({ name: 'login' }, { mode: 'relaunch' })
return
}
// 角色检查
if (to.meta.roles && !hasRole(to.meta.roles)) {
uni.showToast({ title: '无权访问', icon: 'none' })
next({ name: 'home' }, { mode: 'relaunch' })
return
}
// 权限检查
if (to.meta.permissions && !hasPermission(to.meta.permissions)) {
uni.showToast({ title: '权限不足', icon: 'none' })
next({ name: 'home' }, { mode: 'relaunch' })
return
}
next()
})
}按钮级权限
ts
// composables/use-permission.ts
import { hasRole, hasPermission } from '@/utils/auth'
export function usePermission() {
const checkRole = (roles: string[]) => hasRole(roles)
const checkPermission = (perms: string[]) => hasPermission(perms)
return { checkRole, checkPermission }
}vue
<!-- 页面中使用 -->
<template>
<button v-if="checkRole(['admin'])" @click="handleDelete">删除</button>
<button v-if="checkPermission(['order:export'])" @click="handleExport">导出</button>
</template>
<script setup>
import { usePermission } from '@/composables/use-permission'
const { checkRole, checkPermission } = usePermission()
</script>页面间通信
两种方案:EventChannel(仅 push)和全局状态(全导航方式)。
方案 1:EventChannel(推荐,仅 push)
适用场景:列表页 → 详情页,详情页修改后通知列表页刷新。
ts
// pages/list/list.vue(发起页)
<script setup lang="ts">
import { useRouter } from '@meng-xi/uni-router'
import { ref } from 'vue'
const router = useRouter()
const list = ref<Item[]>([])
async function goDetail(id: string) {
const { eventChannel } = await router.push({
path: 'pages/detail/detail',
query: { id },
events: {
// 监听详情页发来的更新事件
updated: () => {
// 刷新列表
fetchList()
},
deleted: (deletedId: string) => {
// 从列表中移除
list.value = list.value.filter(item => item.id !== deletedId)
}
}
})
// 向详情页发送初始化数据
eventChannel?.emit('init', { timestamp: Date.now() })
}
</script>ts
// pages/detail/detail.vue(目标页)
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
let eventChannel: any
onLoad(() => {
// 获取 EventChannel
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
eventChannel = currentPage.getOpenerEventChannel()
// 监听发起页的事件
eventChannel.on('init', (data: any) => {
console.log('收到初始化:', data)
})
})
async function handleUpdate() {
await updateApi(/* ... */)
// 通知发起页已更新
eventChannel?.emit('updated')
}
async function handleDelete() {
await deleteApi(/* ... */)
// 通知发起页已删除
eventChannel?.emit('deleted', route.query.id)
router.back()
}
</script>方案 2:全局状态(全导航方式)
适用场景:replace / relaunch 后需要通信,或跨多页面通信。
ts
// stores/communication.ts
import { defineStore } from 'pinia'
export const useCommStore = defineStore('communication', () => {
const pendingData = ref<any>(null)
const refreshFlags = ref<Record<string, boolean>>({})
function setData(data: any) {
pendingData.value = data
}
function consumeData() {
const data = pendingData.value
pendingData.value = null
return data
}
function markRefresh(key: string) {
refreshFlags.value[key] = true
}
function consumeRefresh(key: string): boolean {
const flag = refreshFlags.value[key]
delete refreshFlags.value[key]
return flag
}
return { pendingData, setData, consumeData, markRefresh, consumeRefresh }
})ts
// 发起页
const commStore = useCommStore()
commStore.markRefresh('list') // 标记列表需要刷新
await router.replace({ name: 'detail' })
// 目标页 onShow
onShow(() => {
if (commStore.consumeRefresh('list')) {
fetchList()
}
})TabBar 页面数据传递
由于 switchTab 不支持 query,TabBar 页面间传参需用全局状态。
方案:Pinia + onShow
ts
// stores/tab.ts
import { defineStore } from 'pinia'
export const useTabStore = defineStore('tab', () => {
const activeTab = ref('home') // 当前激活的 Tab
const tabParams = ref<Record<string, any>>({}) // 各 Tab 的参数
function setTabParam(tab: string, param: any) {
tabParams.value[tab] = param
}
function getTabParam(tab: string): any {
return tabParams.value[tab]
}
function clearTabParam(tab: string) {
delete tabParams.value[tab]
}
return { activeTab, tabParams, setTabParam, getTabParam, clearTabParam }
})ts
// 从普通页跳转到 TabBar 页面并传参
const tabStore = useTabStore()
tabStore.setTabParam('user', { scrollTarget: 'orders' })
await router.push({ name: 'user' }) // switchTab,无法用 queryts
// TabBar 页面 onShow 中读取参数
import { onShow } from '@dcloudio/uni-app'
const tabStore = useTabStore()
onShow(() => {
router.syncRoute() // 同步路由状态
const param = tabStore.getTabParam('user')
if (param?.scrollTarget) {
// 滚动到指定位置
scrollTo(param.scrollTarget)
tabStore.clearTabParam('user') // 消费后清理
}
})表单离开确认
防止用户误操作离开未保存的表单。
方案:守卫 + onBackPress
ts
// composables/use-dirty-guard.ts
import { onBackPress, onShow } from '@dcloudio/uni-app'
import { useRouter } from '@meng-xi/uni-router'
import { ref, type Ref } from 'vue'
export function useDirtyGuard(dirty: Ref<boolean>, onLeave?: () => void) {
const router = useRouter()
// 编程式导航的守卫(router.push / router.back 等)
const guard = router.beforeEach((to, from, next) => {
if (from.meta._dirty && dirty.value) {
uni.showModal({
title: '提示',
content: '有未保存的修改,确认离开?',
success: (res) => {
if (res.confirm) {
dirty.value = false
onLeave?.()
next()
} else {
next(false)
}
}
})
} else {
next()
}
})
// App 端:物理返回键
// #ifdef APP-PLUS
onBackPress(() => {
if (dirty.value) {
uni.showModal({
title: '提示',
content: '有未保存的修改,确认离开?',
success: (res) => {
if (res.confirm) {
dirty.value = false
onLeave?.()
router.back()
}
}
})
return true // 阻止默认返回
}
return false
})
// #endif
// 全平台:onShow 同步状态(物理返回后同步)
onShow(() => {
router.syncRoute()
})
// 返回卸载函数,离开页面时移除守卫
return guard
}使用
vue
<!-- pages/edit/edit.vue -->
<template>
<input v-model="form.name" @input="dirty = true" />
<button @click="handleSave">保存</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useDirtyGuard } from '@/composables/use-dirty-guard'
const form = ref({ name: '' })
const dirty = ref(false)
useDirtyGuard(dirty, () => {
// 离开时的清理逻辑
console.log('用户确认离开')
})
async function handleSave() {
await saveApi(form.value)
dirty.value = false
uni.showToast({ title: '保存成功' })
}
</script>物理返回键的限制
onBackPress 仅 App 端生效。H5 和小程序的物理返回无法拦截,只能通过 onShow + syncRoute 做事后处理。详见平台兼容性。
数据预取
在导航前预取数据,提升用户体验。
方案:beforeResolve
ts
// router/guards/preload.ts
import type { Router } from '@meng-xi/uni-router'
// 页面数据加载器映射
const preloaders: Record<string, (to: RouteLocation) => Promise<void>> = {
detail: async (to) => {
const store = useDetailStore()
await store.fetchDetail(to.query.id)
},
list: async (to) => {
const store = useListStore()
const page = to.queryInt('page', 1)
await store.fetchList(page)
}
}
export function setupPreloadGuard(router: Router) {
router.beforeResolve(async (to, from, next) => {
const preloader = preloaders[to.name as string]
if (preloader) {
try {
uni.showLoading({ title: '加载中...' })
await preloader(to)
next()
} catch (err) {
uni.showToast({ title: '加载失败', icon: 'none' })
next(false) // 数据加载失败,中止导航
} finally {
uni.hideLoading()
}
} else {
next()
}
})
}带缓存的预取
ts
const cache = new Map<string, { data: any; timestamp: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
export function setupCachedPreload(router: Router) {
router.beforeResolve(async (to, from, next) => {
const cacheKey = `${to.name}:${to.fullPath}`
const cached = cache.get(cacheKey)
// 缓存未过期,直接放行
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
next()
return
}
try {
const data = await fetchData(to)
cache.set(cacheKey, { data, timestamp: Date.now() })
next()
} catch {
next(false)
}
})
}页面标题自动设置
方案:afterEach
ts
// router/guards/title.ts
export function setupTitleGuard(router: Router) {
router.afterEach((to) => {
const title = to.meta.title as string | undefined
uni.setNavigationBarTitle({ title: title || '默认标题' })
})
}动态标题
ts
// 路由配置中不设 title,在页面中动态设置
router.afterEach((to) => {
// 根据路由参数动态生成标题
if (to.name === 'detail') {
const store = useDetailStore()
const title = store.detail?.title || '详情'
uni.setNavigationBarTitle({ title })
} else if (to.meta.title) {
uni.setNavigationBarTitle({ title: to.meta.title as string })
}
})埋点统计
方案:afterEach + onRouteChange
ts
// utils/analytics.ts
export function trackPageView(path: string, fromPath: string, synced: boolean) {
// 上报埋点
analytics.report({
event: 'page_view',
page_path: path,
from_path: fromPath,
is_synced: synced, // 是否为状态同步(物理返回等)
timestamp: Date.now()
})
}
// router/guards/analytics.ts
export function setupAnalyticsGuard(router: Router) {
// 完整导航
router.afterEach((to, from) => {
trackPageView(to.path, from.path, false)
})
// 状态同步(物理返回等)
router.onRouteChange((to, from) => {
if (to._synced) {
trackPageView(to.path, from.path, true)
}
})
}维护模式
应用维护时,所有导航重定向到维护页。
ts
// stores/app.ts
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', () => {
const maintenanceMode = ref(false)
async function checkMaintenance() {
try {
const res = await checkMaintenanceApi()
maintenanceMode.value = res.maintenance
} catch {
// 检查失败不阻断
}
}
return { maintenanceMode, checkMaintenance }
})ts
// router/guards/maintenance.ts
export function setupMaintenanceGuard(router: Router) {
const appStore = useAppStore()
router.beforeEach((to, from, next) => {
// 维护页本身放行
if (to.name === 'maintenance') {
next()
return
}
// 维护模式,重定向到维护页
if (appStore.maintenanceMode) {
next({ name: 'maintenance' }, { mode: 'relaunch' })
return
}
next()
})
// 定期检查维护状态
setInterval(() => {
appStore.checkMaintenance()
}, 60 * 1000) // 每分钟检查一次
}页面栈深度管理
防止小程序页面栈溢出(上限 10 层)。
封装安全导航
ts
// composables/use-safe-nav.ts
import { useRouter } from '@meng-xi/uni-router'
import type { RouteLocationRaw } from '@meng-xi/uni-router'
const STACK_WARNING_THRESHOLD = 8 // 预留 2 层缓冲
export function useSafeNav() {
const router = useRouter()
/**
* 安全 push:栈接近上限时自动改用 relaunch
*/
async function safePush(location: RouteLocationRaw) {
const pages = getCurrentPages()
if (pages.length >= STACK_WARNING_THRESHOLD) {
console.warn('[uni-router] Page stack near limit, using relaunch instead of push')
await router.relaunch(location)
} else {
await router.push(location)
}
}
/**
* 安全返回:栈不足时改用 relaunch 到首页
*/
async function safeBack(delta = 1, fallback?: RouteLocationRaw) {
const pages = getCurrentPages()
if (pages.length - delta < 1) {
// 栈不足,跳转到 fallback 或首页
await router.relaunch(fallback || { name: 'home' })
} else {
await router.back(delta)
}
}
return { safePush, safeBack }
}使用
ts
const { safePush, safeBack } = useSafeNav()
// 列表 → 详情 → 评论 → 回复(可能栈很深)
await safePush({ name: 'detail', query: { id: '1' } })
await safePush({ name: 'comment', query: { id: '1' } })
// 当栈接近上限时自动改用 relaunch
// 安全返回
await safeBack(2, { name: 'home' }) // 栈不足时回首页完整守卫组装
将所有守卫组合到一起:
ts
// router/index.ts
import { createRouter } from '@meng-xi/uni-router'
import { routes } from './routes'
import { setupAuthGuard } from './guards/auth'
import { setupPermissionGuard } from './guards/permission'
import { setupPreloadGuard } from './guards/preload'
import { setupTitleGuard } from './guards/title'
import { setupAnalyticsGuard } from './guards/analytics'
import { setupMaintenanceGuard } from './guards/maintenance'
const router = createRouter({
routes,
interceptUniApi: true,
guardTimeout: 15000 // 守卫中有网络请求,调大超时
})
// 按顺序注册守卫
setupMaintenanceGuard(router) // 1. 维护模式(最先检查)
setupAuthGuard(router) // 2. 登录认证
setupPermissionGuard(router) // 3. 权限控制
setupPreloadGuard(router) // 4. 数据预取(beforeResolve)
setupTitleGuard(router) // 5. 标题设置(afterEach)
setupAnalyticsGuard(router) // 6. 埋点(afterEach + onRouteChange)
export default router守卫注册顺序
守卫按注册顺序执行。建议:
- 维护模式(最早拦截)
- 登录认证
- 权限控制
- 数据预取(放 beforeResolve)
- 后置处理(afterEach)
路由模块化
大型项目将路由按模块拆分:
src/
├── router/
│ ├── index.ts # 路由器实例 + 守卫组装
│ ├── routes.ts # 汇总所有路由
│ └── guards/
│ ├── auth.ts
│ ├── permission.ts
│ └── ...
├── modules/
│ ├── user/
│ │ └── routes.ts # 用户模块路由
│ ├── order/
│ │ └── routes.ts # 订单模块路由
│ └── product/
│ └── routes.ts # 商品模块路由ts
// modules/user/routes.ts
import type { RouteConfig } from '@meng-xi/uni-router'
export const userRoutes: RouteConfig[] = [
{ path: 'pages/user/user', name: 'user', meta: { isTab: true, title: '我的' } },
{ path: 'pages/profile/profile', name: 'profile', meta: { requireAuth: true } },
{ path: 'pages/settings/settings', name: 'settings', meta: { requireAuth: true } }
]ts
// router/routes.ts
import { userRoutes } from '@/modules/user/routes'
import { orderRoutes } from '@/modules/order/routes'
import { productRoutes } from '@/modules/product/routes'
export const routes = [
{ path: 'pages/index/index', name: 'home', meta: { isTab: true, title: '首页' } },
{ path: 'pages/login/login', name: 'login' },
...userRoutes,
...orderRoutes,
...productRoutes
]