在UniApp H5中,实现路由栈的持久化

在UniApp H5中,刷新页面会清空路由栈是因为浏览器的刷新行为会重新加载整个应用。要实现路由栈的持久化,需要手动保存和恢复路由状态。以下是几种解决方案:

方案1:使用 localStorage 保存路由栈(推荐)

1.1 创建路由管理工具

typescript 复制代码
// utils/routerManager.ts
import { ref } from 'vue'

interface RouteInfo {
  url: string
  query?: Record<string, any>
  meta?: Record<string, any>
  timestamp: number
}

class RouterManager {
  private readonly STORAGE_KEY = 'uni_router_stack'
  private readonly MAX_STACK_SIZE = 10
  private stack: RouteInfo[] = []
  
  constructor() {
    this.loadFromStorage()
    this.setupListeners()
  }
  
  // 监听路由变化
  private setupListeners() {
    // 监听页面加载
    uni.onAppRoute((res) => {
      this.addToStack({
        url: res.path,
        query: res.query,
        timestamp: Date.now()
      })
    })
    
    // 监听页面卸载(返回)
    window.addEventListener('popstate', () => {
      this.removeLastFromStack()
    })
  }
  
  // 添加到路由栈
  addToStack(route: RouteInfo) {
    // 避免重复添加相同路由
    const lastRoute = this.stack[this.stack.length - 1]
    if (lastRoute && lastRoute.url === route.url) {
      return
    }
    
    this.stack.push(route)
    
    // 限制栈大小
    if (this.stack.length > this.MAX_STACK_SIZE) {
      this.stack.shift()
    }
    
    this.saveToStorage()
  }
  
  // 从栈中移除最后一个
  removeLastFromStack() {
    if (this.stack.length > 1) {
      this.stack.pop()
      this.saveToStorage()
    }
  }
  
  // 清空路由栈(保留首页)
  clearStack() {
    if (this.stack.length > 0) {
      const homePage = this.stack[0]
      this.stack = [homePage]
      this.saveToStorage()
    }
  }
  
  // 获取路由栈
  getStack() {
    return [...this.stack]
  }
  
  // 获取上一个路由
  getPreviousRoute() {
    if (this.stack.length > 1) {
      return this.stack[this.stack.length - 2]
    }
    return null
  }
  
  // 保存到本地存储
  private saveToStorage() {
    try {
      localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.stack))
    } catch (e) {
      console.error('保存路由栈失败:', e)
    }
  }
  
  // 从本地存储加载
  private loadFromStorage() {
    try {
      const data = localStorage.getItem(this.STORAGE_KEY)
      if (data) {
        this.stack = JSON.parse(data)
      }
    } catch (e) {
      console.error('加载路由栈失败:', e)
      this.stack = []
    }
  }
  
  // 恢复路由栈
  async restoreStack() {
    if (this.stack.length <= 1) return
    
    // 获取当前页面
    const currentPages = getCurrentPages()
    if (currentPages.length === 0) return
    
    const currentRoute = currentPages[currentPages.length - 1]
    const currentPath = currentRoute.route
    
    // 如果不是首页,且路由栈中有历史记录
    if (this.stack.length > 1 && currentPath !== this.stack[0].url) {
      // 逐页跳转(从第二个开始,跳过首页)
      for (let i = 1; i < this.stack.length; i++) {
        const route = this.stack[i]
        if (route.url !== currentPath) {
          await this.navigateToRoute(route)
        }
      }
    }
  }
  
  // 跳转到指定路由
  private navigateToRoute(route: RouteInfo): Promise<void> {
    return new Promise((resolve) => {
      setTimeout(() => {
        uni.navigateTo({
          url: this.buildUrl(route.url, route.query),
          success: resolve,
          fail: resolve
        })
      }, 50) // 添加延迟避免跳转冲突
    })
  }
  
  // 构建 URL
  private buildUrl(path: string, query?: Record<string, any>): string {
    if (!query) return `/${path}`
    
    const queryStr = Object.entries(query)
      .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
      .join('&')
    
    return `/${path}?${queryStr}`
  }
}

export const routerManager = new RouterManager()

1.2 在 App.vue 中初始化

vue 复制代码
<script setup lang="ts">
import { onLaunch, onShow } from '@dcloudio/uni-app'
import { routerManager } from '@/utils/routerManager'

onLaunch(() => {
  console.log('App Launch')
  
  // 如果是H5环境,恢复路由栈
  // #ifdef H5
  setTimeout(() => {
    routerManager.restoreStack()
  }, 1000) // 等待页面加载完成
  // #endif
})

onShow(() => {
  console.log('App Show')
})
</script>

1.3 在页面中记录路由

vue 复制代码
<script setup lang="ts">
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { routerManager } from '@/utils/routerManager'

onLoad((query) => {
  // 记录当前页面到路由栈
  const pages = getCurrentPages()
  const currentPage = pages[pages.length - 1]
  
  routerManager.addToStack({
    url: currentPage.route,
    query,
    timestamp: Date.now()
  })
})

// 监听页面返回
onUnload(() => {
  routerManager.removeLastFromStack()
})
</script>

方案2:使用 URL 参数保存路由历史(SEO友好)

2.1 创建路由中间件

typescript 复制代码
// utils/routeHistory.ts
export class RouteHistory {
  private readonly QUERY_KEY = 'history'
  private readonly MAX_HISTORY = 5
  
  // 编码路由历史
  encodeHistory(routes: Array<{path: string, query?: any}>): string {
    const history = routes.map(route => ({
      p: route.path,
      q: route.query || {}
    }))
    return btoa(encodeURIComponent(JSON.stringify(history)))
  }
  
  // 解码路由历史
  decodeHistory(encoded: string): Array<{path: string, query: any}> {
    try {
      const decoded = decodeURIComponent(atob(encoded))
      return JSON.parse(decoded)
    } catch {
      return []
    }
  }
  
  // 获取当前路由历史
  getCurrentHistory(): Array<{path: string, query: any}> {
    const pages = getCurrentPages()
    return pages.map(page => ({
      path: page.route,
      query: page.options || {}
    }))
  }
  
  // 更新 URL 中的历史参数
  updateUrlHistory() {
    // #ifdef H5
    const history = this.getCurrentHistory()
    if (history.length <= 1) return
    
    const encoded = this.encodeHistory(history)
    const currentUrl = new URL(window.location.href)
    
    currentUrl.searchParams.set(this.QUERY_KEY, encoded)
    
    // 使用 history.replaceState 更新 URL(不刷新页面)
    window.history.replaceState({}, '', currentUrl.toString())
    // #endif
  }
  
  // 从 URL 恢复路由历史
  async restoreFromUrl(): Promise<boolean> {
    // #ifdef H5
    const url = new URL(window.location.href)
    const encodedHistory = url.searchParams.get(this.QUERY_KEY)
    
    if (!encodedHistory) return false
    
    const history = this.decodeHistory(encodedHistory)
    if (history.length <= 1) return false
    
    // 获取当前页面
    const currentPages = getCurrentPages()
    if (currentPages.length === 0) return false
    
    const currentRoute = currentPages[currentPages.length - 1]
    
    // 逐页跳转(跳过当前页)
    for (let i = 1; i < history.length; i++) {
      const route = history[i]
      if (route.path !== currentRoute.route) {
        await this.navigateTo(route)
      }
    }
    
    // 清理 URL 参数
    url.searchParams.delete(this.QUERY_KEY)
    window.history.replaceState({}, '', url.toString())
    
    return true
    // #endif
    
    return false
  }
  
  private navigateTo(route: {path: string, query: any}): Promise<void> {
    return new Promise((resolve) => {
      setTimeout(() => {
        const queryStr = Object.entries(route.query)
          .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
          .join('&')
        
        const url = `/${route.path}${queryStr ? `?${queryStr}` : ''}`
        
        uni.navigateTo({
          url,
          success: resolve,
          fail: resolve
        })
      }, 50)
    })
  }
}

export const routeHistory = new RouteHistory()

2.2 封装路由跳转方法

typescript 复制代码
// utils/navigate.ts
import { routeHistory } from './routeHistory'

// 封装 navigateTo
export const navigateTo = (options: UniApp.NavigateToOptions) => {
  uni.navigateTo({
    ...options,
    success: () => {
      // 更新 URL 历史
      setTimeout(() => {
        routeHistory.updateUrlHistory()
      }, 100)
    }
  })
}

// 封装 redirectTo
export const redirectTo = (options: UniApp.RedirectToOptions) => {
  uni.redirectTo({
    ...options,
    success: () => {
      setTimeout(() => {
        routeHistory.updateUrlHistory()
      }, 100)
    }
  })
}

// 封装 navigateBack
export const navigateBack = (options: UniApp.NavigateBackOptions = {}) => {
  uni.navigateBack(options)
}

2.3 在页面中使用

vue 复制代码
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { navigateTo } from '@/utils/navigate'
import { routeHistory } from '@/utils/routeHistory'

onLoad(() => {
  // 恢复路由历史
  // #ifdef H5
  routeHistory.restoreFromUrl()
  // #endif
})

const goToDetail = () => {
  navigateTo({
    url: '/pages/detail/detail?id=123'
  })
}
</script>

方案3:使用 Vuex/Pinia 配合 localStorage

3.1 Pinia Store

typescript 复制代码
// stores/routerStore.ts
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'

interface RouteItem {
  path: string
  fullPath: string
  query: Record<string, any>
  params?: Record<string, any>
  timestamp: number
}

export const useRouterStore = defineStore('router', () => {
  const stack = ref<RouteItem[]>([])
  const isRestoring = ref(false)
  
  // 从 localStorage 加载
  const loadFromStorage = () => {
    try {
      const saved = localStorage.getItem('uni_router_stack')
      if (saved) {
        stack.value = JSON.parse(saved)
      }
    } catch (e) {
      console.error('加载路由栈失败', e)
    }
  }
  
  // 保存到 localStorage
  const saveToStorage = () => {
    if (isRestoring.value) return
    
    try {
      localStorage.setItem('uni_router_stack', JSON.stringify(stack.value))
    } catch (e) {
      console.error('保存路由栈失败', e)
    }
  }
  
  // 添加路由
  const pushRoute = (route: Omit<RouteItem, 'timestamp'>) => {
    if (isRestoring.value) return
    
    const routeItem: RouteItem = {
      ...route,
      timestamp: Date.now()
    }
    
    // 去重
    const lastRoute = stack.value[stack.value.length - 1]
    if (!lastRoute || lastRoute.fullPath !== routeItem.fullPath) {
      stack.value.push(routeItem)
      
      // 限制栈大小
      if (stack.value.length > 10) {
        stack.value = stack.value.slice(-10)
      }
    }
  }
  
  // 移除最后一个路由
  const popRoute = () => {
    if (isRestoring.value || stack.value.length <= 1) return
    stack.value.pop()
  }
  
  // 清空路由栈(保留首页)
  const clearStack = () => {
    if (stack.value.length > 0) {
      const homeRoute = stack.value.find(route => 
        route.path === 'pages/index/index' || 
        route.path === 'pages/home/home'
      )
      stack.value = homeRoute ? [homeRoute] : []
    }
  }
  
  // 恢复路由栈
  const restoreStack = async () => {
    if (stack.value.length <= 1) return
    
    isRestoring.value = true
    
    const currentPages = getCurrentPages()
    if (currentPages.length === 0) {
      isRestoring.value = false
      return
    }
    
    const currentPage = currentPages[currentPages.length - 1]
    
    // 逐页跳转
    for (let i = 1; i < stack.value.length; i++) {
      const route = stack.value[i]
      
      // 跳过当前页
      if (route.path === currentPage.route) continue
      
      await new Promise<void>((resolve) => {
        uni.navigateTo({
          url: route.fullPath,
          success: resolve,
          fail: resolve
        })
        setTimeout(resolve, 100)
      })
    }
    
    setTimeout(() => {
      isRestoring.value = false
    }, 500)
  }
  
  // 自动保存到 localStorage
  watch(stack, saveToStorage, { deep: true })
  
  // 初始化
  loadFromStorage()
  
  return {
    stack,
    pushRoute,
    popRoute,
    clearStack,
    restoreStack
  }
})

3.2 路由守卫

typescript 复制代码
// utils/routerGuard.ts
import { useRouterStore } from '@/stores/routerStore'

export const setupRouterGuard = () => {
  // 监听页面加载
  uni.onAppRoute((res) => {
    const routerStore = useRouterStore()
    
    routerStore.pushRoute({
      path: res.path,
      fullPath: `${res.path}${res.query ? '?' + new URLSearchParams(res.query).toString() : ''}`,
      query: res.query || {}
    })
  })
  
  // 监听页面卸载(返回)
  const originalNavigateBack = uni.navigateBack
  uni.navigateBack = function(options) {
    const routerStore = useRouterStore()
    routerStore.popRoute()
    return originalNavigateBack(options)
  }
}

3.3 在 main.ts 中初始化

typescript 复制代码
// main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import { setupRouterGuard } from './utils/routerGuard'

export function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()
  
  app.use(pinia)
  
  // 设置路由守卫
  setupRouterGuard()
  
  return {
    app,
    pinia
  }
}

方案4:使用 sessionStorage 临时存储(刷新后失效)

4.1 简单实现

javascript 复制代码
// 在页面显示时保存
onShow(() => {
  const pages = getCurrentPages()
  const currentPage = pages[pages.length - 1]
  
  // 保存当前路由栈
  const stack = pages.map(page => ({
    route: page.route,
    options: page.options
  }))
  
  sessionStorage.setItem('current_route_stack', JSON.stringify(stack))
})

// 页面加载时恢复
onLoad(() => {
  // 检查是否需要恢复
  const shouldRestore = sessionStorage.getItem('need_restore')
  
  if (shouldRestore === 'true') {
    const savedStack = sessionStorage.getItem('current_route_stack')
    
    if (savedStack) {
      const stack = JSON.parse(savedStack)
      
      // 跳过首页,逐页跳转
      for (let i = 1; i < stack.length; i++) {
        const page = stack[i]
        const queryStr = page.options ? 
          Object.entries(page.options)
            .map(([key, value]) => `${key}=${value}`)
            .join('&') : ''
        
        uni.navigateTo({
          url: `/${page.route}${queryStr ? '?' + queryStr : ''}`,
          animationType: 'none' // 无动画
        })
      }
    }
    
    // 清除标记
    sessionStorage.removeItem('need_restore')
  }
})

// 监听页面刷新
// #ifdef H5
window.addEventListener('beforeunload', () => {
  sessionStorage.setItem('need_restore', 'true')
})
// #endif

最佳实践建议

  1. 选择方案1(localStorage)

    • 最稳定可靠
    • 支持页面完全关闭后恢复
    • 需要处理数据过期和清理
  2. 结合多种方案

typescript 复制代码
// 综合方案
class RouterPersistence {
  async saveStack() {
    // 同时保存到 localStorage 和 URL
    localStorage.setItem('router_stack', JSON.stringify(this.stack))
    this.updateUrlWithStack()
  }
  
  async restoreStack() {
    // 1. 尝试从 URL 恢复(最准确)
    const fromUrl = await this.restoreFromUrl()
    if (fromUrl) return true
    
    // 2. 尝试从 localStorage 恢复
    const fromStorage = await this.restoreFromStorage()
    if (fromStorage) return true
    
    // 3. 使用默认首页
    return false
  }
}
  1. 注意事项

    • 路由栈大小限制(建议5-10层)
    • 数据序列化和反序列化安全
    • 避免循环跳转
    • 处理特殊字符和编码
    • 考虑隐私模式(localStorage 不可用)
  2. 清理策略

typescript 复制代码
// 定期清理过期路由
setInterval(() => {
  const now = Date.now()
  const expiredTime = 30 * 60 * 1000 // 30分钟
  
  routerManager.stack = routerManager.stack.filter(
    route => now - route.timestamp < expiredTime
  )
}, 5 * 60 * 1000) // 每5分钟检查一次

推荐使用方案1,它提供了最完整的路由栈管理功能,适合大多数H5应用场景。

相关推荐
米柆17 小时前
CSS:clip-path 详解
前端·css
影子打怪17 小时前
uniapp通过plus.geolocation.watchPosition获取的坐标格式转换
uni-app
ProgramHan17 小时前
React 19 新特性深度解析:告别 useEffect 的时代
前端·react.js·前端框架
次元工程师!17 小时前
Sa-Token完成路由鉴权
java·服务器·前端
IT_陈寒17 小时前
Redis 7.0 实战:5个被低估但超实用的新特性,让你的QPS提升40%
前端·人工智能·后端
南玖i17 小时前
SuperMap iServer + vue3 实现点聚合 超简单!
javascript·vue.js·elementui
web守墓人17 小时前
【前端】ikun-pptx编辑器前瞻问题四:通过AI编写一个前端pptx编辑器
前端
泰勒疯狂展开17 小时前
Vue3研学-标签ref属性与TS接口泛型
前端·javascript·vue.js
小二·17 小时前
前端 DevOps 完全指南:从 Docker 容器化到 GitHub Actions 自动化部署(Vue 3 + Vite)
前端·docker·devops