Code前端首页关于Code前端联系我们

Vue3刷新页面怎么做不闪屏?3种常用场景全解析+实用代码

terry 1小时前 阅读数 28 #Vue

开发Vue3项目时,刷新页面的需求其实挺常见的,比如权限变更后需要重渲染整个应用清除旧数据、表单提交成功后重置全局状态+刷新当前列表、用户手动切换语言后重新加载组件资源包……但直接用原生的location.reload()太粗暴了,不仅页面会白屏/闪屏,还会丢失临时数据、中断用户当前操作,体验差到没朋友,那到底有哪些温和又高效的方式?下面结合真实开发场景,给大家拆解3种最实用的方法,附可以直接复制改改就能用的代码。

局部刷新当前路由页面,保留用户当前的输入状态、滚动位置

最理想的刷新状态,看起来页面没动,但所有数据和组件都重新加载了”,比如电商后台的订单筛选页面,提交筛选条件后不想重置滚动条和已输入的搜索框补充项,只刷新订单列表和相关统计数据;或者在线编辑文档页面,点击“同步”后更新所有服务器上的内容,但保留当前编辑的光标位置,这种场景下,用Vue Router自带的导航守卫或者组件自身的刷新机制?不对,其实更简单的是利用Vue3的配合“重置路由参数”的技巧,或者给需要局部刷新的页面加个唯一的key值——后者因为通用性更强、实现成本更低,是开发中用得最多的。

首先说加唯一key的原理:Vue3的虚拟DOM diff算法,会对比新旧节点的key值,如果key不同,就会直接销毁旧节点、创建新节点,而不是复用,这样就相当于实现了“组件级别的全刷新”,但外层的App.vue、布局组件这些不会动,所以完全不会闪屏,滚动条也会因为外层容器没变而保留下来,那怎么生成这个唯一key呢?可以用时间戳,也可以用路由的query或者params拼接随机数,但拼接时间戳最稳定,不会影响页面的URL(除非你的业务需要URL里有刷新标记,但一般不需要)。

具体实现步骤:

  1. 在App.vue或者你的布局组件里,给加一个动态的key,这个key可以从pinia/vuex的全局状态里拿,也可以在App.vue里直接定义一个响应式变量,为了全局管理更方便,推荐用pinia。
  2. 写一个pinia store,专门用来管理这个刷新的key值,比如叫appStore.js(TS的话就叫app.ts),里面有个refreshKey变量,还有一个changeRefreshKey的方法,每次调用就给refreshKey加1或者生成新的时间戳。
  3. 当需要局部刷新当前路由时,就调用changeRefreshKey方法,的key变了,对应的页面组件就会重新渲染。

代码示例(pinia+setup语法糖版本): 先写appStore.js:

import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
  // 用ref存响应式的时间戳,每次刷新就生成新的
  const refreshKey = ref(Date.now())
  // 生成新时间戳的方法
  const changeRefreshKey = () => {
    refreshKey.value = Date.now()
  }
  return {
    refreshKey,
    changeRefreshKey
  }
})

然后在布局组件(比如Layout.vue)里用:

<template>
  <div class="layout-container">
    <!-- 侧边栏、顶部导航这些不变的布局 -->
    <aside></aside>
    <header></header>
    <!-- 给router-view加动态key -->
    <main>
      <router-view :key="appStore.refreshKey"></router-view>
    </main>
  </div>
</template>
<script setup>
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
</script>

最后在需要刷新的页面(比如OrderList.vue)里触发:

<template>
  <div class="order-list">
    <!-- 筛选表单 -->
    <form @submit.prevent="handleFilter">
      <input v-model="form.keyword" placeholder="输入订单号/客户名" />
      <select v-model="form.status">
        <option value="">全部状态</option>
        <option value="pending">待付款</option>
        <option value="paid">已付款</option>
      </select>
      <button type="submit">筛选</button>
      <!-- 或者单独加个刷新按钮 -->
      <button type="button" @click="handleRefresh">刷新</button>
    </form>
    <!-- 订单列表 -->
    <ul></ul>
  </div>
</template>
<script setup>
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const handleFilter = () => {
  // 先提交筛选条件到服务器(这里假装已经提交成功)
  console.log('筛选条件已提交')
  // 触发刷新
  appStore.changeRefreshKey()
}
const handleRefresh = () => {
  appStore.changeRefreshKey()
}
</script>

这里有个小细节:如果你的页面里有包裹的子组件,需要刷新的话,也可以给这个子组件单独加个动态key,不用刷新整个路由页面,更节省性能,比如OrderList里有个订单统计的子组件,只需要刷新这个组件的话,就给它加key="statsRefreshKey",然后在store里再加个对应的changeStatsRefreshKey方法就行。

全局刷新但不闪屏,保留用户当前的登录状态、主题设置等持久化数据

有时候我们需要全局刷新整个应用,比如更新了静态资源的版本(比如CSS、JS的hash值变了,不刷新的话可能会加载旧资源报错)、权限列表完全变更了(用户从“普通员工”升到了“管理员”,需要重新加载所有菜单、权限指令)、用户点击了“退出登录后重新登录”(退出登录后直接用新账号登录,不想清空所有缓存的话可以用),这种场景下,用location.reload(true)(强制从服务器加载资源)的话肯定会闪屏,但可以用Vue3的“provide/inject + 重写$mount”的方法,或者“重定向到一个空白中间页再跳回来”的方法——后者兼容性更好,不用操作底层的Vue实例,但需要注意中间页的处理。

先讲“重定向到空白中间页再跳回来”的方法,这个方法最容易理解,代码也最少:

  1. 在router/index.js里新增一个空白的中间路由,比如叫/refresh,component可以是一个只有的空组件,也可以用() => import('@/views/Refresh.vue')异步加载,避免增加首屏加载体积。
  2. 在中间页的onMounted钩子函数里,立即获取之前保存的目标路由(也就是刷新前的路由),然后用router.replace()跳回去。
  3. 需要全局刷新的时候,先把当前的路由信息(fullPath)存到sessionStorage里(sessionStorage关闭标签页就会清空,比localStorage安全,不会影响下次打开应用),然后用router.replace('/refresh')跳转到中间页。

为什么要用replace而不是push?因为push会把中间页加到浏览器的历史记录里,用户点击后退按钮的话会回到中间页,体验不好,用replace的话,中间页不会留在历史记录里,用户后退会直接回到刷新前的上一个页面。

代码示例(router/index.js):

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Refresh from '@/views/Refresh.vue'
const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  // 新增空白中间路由
  {
    path: '/refresh',
    name: 'Refresh',
    component: Refresh,
    meta: {
      // 不需要登录验证(如果你的应用有登录验证的话)
      requiresAuth: false,
      // 不需要显示侧边栏、顶部导航(如果你的中间页有布局的话)
      hideLayout: true
    }
  }
  // 其他路由
]
const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})
export default router

Refresh.vue的代码:

<template>
  <!-- 可以加个loading提示,让用户知道正在刷新,正在更新数据,请稍候...” -->
  <div class="refresh-container">
    <div class="loading-spinner"></div>
    <p>正在同步数据,请稍候...</p>
  </div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
onMounted(() => {
  // 从sessionStorage里获取刷新前的路由
  const targetPath = sessionStorage.getItem('refreshTargetPath') || '/'
  // 清除sessionStorage里的标记,避免下次误操作
  sessionStorage.removeItem('refreshTargetPath')
  // 用replace跳回去
  router.replace(targetPath)
})
</script>
<style scoped>
.refresh-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vw;
  background-color: #fff;
}
.loading-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

然后在需要全局刷新的地方触发:

<template>
  <button @click="handleGlobalRefresh">全局更新</button>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const handleGlobalRefresh = () => {
  // 把当前的fullPath存到sessionStorage里
  sessionStorage.setItem('refreshTargetPath', route.fullPath)
  // 跳转到中间页
  router.replace('/refresh')
}
</script>

这个方法唯一的缺点是,如果刷新前的页面URL里有很长的query或者params,sessionStorage存起来也没问题,但跳回去的时候URL是完整的,不会丢失。

再讲一下“provide/inject + 重写$mount”的方法,这个方法性能更好,因为不需要跳转路由,直接在当前页面重新挂载整个Vue应用,但需要操作底层的Vue实例,适合对Vue3原理有一定了解的开发者。

具体实现步骤:

  1. 在main.js里,把createApp返回的app实例保存到一个变量里,然后用provide把这个app实例和一个重写的refreshApp方法提供给全局。
  2. refreshApp方法的逻辑是:先卸载当前的app实例(app.unmount('#app')),然后重新创建一个app实例,重新挂载到#app上,重新注册pinia、vue-router、element-plus这些插件。
  3. 需要全局刷新的时候,用inject拿到refreshApp方法,调用即可。

这个方法的优点是性能快,不用跳转路由,缺点是如果你的应用里有全局的定时器、事件监听器没有清理的话,可能会导致内存泄漏,所以在调用unmount之前,最好先清理一下这些资源。

代码示例(main.js):

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 定义一个函数,用来创建和挂载app
const createAndMountApp = () => {
  const app = createApp(App)
  const pinia = createPinia()
  app.use(pinia)
  app.use(router)
  app.use(ElementPlus)
  app.mount('#app')
  return app
}
// 第一次创建和挂载
let currentApp = createAndMountApp()
// 提供refreshApp方法给全局
currentApp.provide('refreshApp', () => {
  // 先卸载当前app
  currentApp.unmount('#app')
  // 可以在这里清理全局的定时器、事件监听器
  // clearInterval(globalTimer)
  // window.removeEventListener('resize', handleResize)
  // 重新创建和挂载
  currentApp = createAndMountApp()
})

然后在需要全局刷新的地方用:

<template>
  <button @click="handleGlobalRefresh">全局更新</button>
</template>
<script setup>
import { inject } from 'vue'
// 用inject拿到refreshApp方法
const refreshApp = inject('refreshApp')
const handleGlobalRefresh = () => {
  if (refreshApp) {
    refreshApp()
  }
}
</script>

强制从服务器加载所有资源,解决静态资源缓存问题

有时候我们发布了新版本的应用,但是用户的浏览器缓存了旧的CSS、JS文件,导致页面样式错乱或者功能报错,这时候就需要强制从服务器加载所有资源,也就是原生location.reload(true)的效果,但能不能稍微优化一下体验?比如在加载新资源之前,先显示一个“正在更新应用,请稍候...”的全屏loading,避免用户看到旧的错乱页面。

可以结合“重定向到空白中间页”和“location.reload(true)”来实现:

  1. 在需要强制刷新的地方,先把loading显示出来(可以用pinia管理一个全局的loading状态),然后把当前的路由存到sessionStorage里,然后跳转到中间页。
  2. 在中间页的onMounted钩子函数里,先从sessionStorage里拿到目标路由,然后清除sessionStorage,再设置localStorage里的一个版本标记(比如appVersion,值为当前的版本号),然后用location.replace(targetPath + '?v=' + appVersion)跳回去,跳回去之后自动调用location.reload(true)。

或者更简单的,在中间页的onMounted里直接调用location.reload(true),但之前要先设置版本标记,避免下次再强制刷新。

不过这里有个问题:版本号怎么获取?可以在打包的时候,用webpack或者vite的环境变量注入进去,比如用vite的话,可以在.env.production文件里设置VITE_APP_VERSION=1.0.0,然后在代码里用import.meta.env.VITE_APP_VERSION获取,每次发布新版本的时候,手动修改这个版本号,或者用git的commit hash自动生成版本号。

用git commit hash自动生成vite版本号的方法: 在vite.config.js里,引入child_process模块,执行git rev-parse --short HEAD命令获取短commit hash,然后设置到define里:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { execSync } from 'child_process'
// 获取短commit hash
const commitHash = execSync('git rev-parse --short HEAD').toString().trim()
export default defineConfig({
  plugins: [vue()],
  define: {
    // 注入到全局变量里
    __COMMIT_HASH__: JSON.stringify(commitHash)
  }
})

然后在代码里用__COMMIT_HASH__获取版本号。

代码示例(结合vite的环境变量和中间页的强制刷新): .env.production文件:

VITE_APP_VERSION=1.0.0

或者用上面的commit hash,直接用__COMMIT_HASH__就行。

router/index.js里的中间路由和之前一样。

Refresh.vue的代码修改一下:

<template>
  <div class="refresh-container">
    <div class="loading-spinner"></div>
    <p>正在更新应用,请稍候...</p>
  </div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
onMounted(() => {
  // 从sessionStorage里获取刷新前的路由
  const targetPath = sessionStorage.getItem('refreshTargetPath') || '/'
  // 清除sessionStorage里的标记
  sessionStorage.removeItem('refreshTargetPath')
  // 获取当前的版本号
  const currentVersion = import.meta.env.VITE_APP_VERSION || __COMMIT_HASH__ || '1.0.0'
  // 获取localStorage里的旧版本号
  const oldVersion = localStorage.getItem('appVersion')
  // 如果版本号不一样,就强制从服务器加载资源
  if (currentVersion !== oldVersion) {
    localStorage.setItem('appVersion', currentVersion)
    // 跳回去,然后强制刷新
    window.location.href = targetPath
    window.location.reload(true)
  } else {
    // 版本号一样,就直接跳回去,不用强制刷新
    router.replace(targetPath)
  }
})
</script>

然后在发布新版本的时候,需要修改VITE_APP_VERSION或者确保有新的git commit,这样用户打开应用的时候,第一次点击需要刷新的地方(或者在App.vue的onMounted里检查版本号),就会触发强制刷新,加载新的资源。

总结一下三种方法的适用场景和优缺点

方法 适用场景 优点 缺点
给router-view加唯一key 局部刷新当前路由页面,保留滚动位置、输入状态 实现简单,通用性强,性能好,完全不闪屏 只能刷新组件,不能刷新静态资源
重定向到空白中间页再跳回来 全局刷新但不闪屏,保留持久化数据 兼容性好,不用操作底层Vue实例 需要跳转路由,性能比provide/inject稍差
重定向到空白中间页+强制刷新 强制从服务器加载所有资源,解决静态资源缓存问题 可以解决缓存问题,体验比直接location.reload(true)好 会有一瞬间的跳转,性能一般

开发的时候,根据自己的实际需求选择合适的方法就行,局部刷新用加key的方法最多,全局刷新用中间页的方法最多,强制刷新用最后一种方法。

不管用哪种方法,都要注意数据的持久化,比如用户的登录状态、主题设置、表单的临时输入状态(如果需要的话),最好存到localStorage或者sessionStorage里,避免刷新后丢失,比如表单的临时输入状态,可以在页面组件的onBeforeUnmount钩子函数里存到sessionStorage里,然后在onMounted钩子函数里取出来赋值给form。

还有一个小技巧:如果你的应用里有多个地方需要刷新,可以把这些方法封装成一个 composable,比如叫useRefresh.js,然后在需要的页面里引入使用,这样代码更简洁,更容易维护。

useRefresh.js的代码示例:

import { useAppStore } from '@/stores/app'
import { useRouter, useRoute } from 'vue-router'
export const useRefresh = () => {
  const appStore = useAppStore()
  const router = useRouter()
  const route = useRoute()
  // 局部刷新当前路由
  const refreshCurrentRoute = () => {
    appStore.changeRefreshKey()
  }
  // 全局刷新但不闪屏
  const refreshGlobal = () => {
    sessionStorage.setItem('refreshTargetPath', route.fullPath)
    router.replace('/refresh')
  }
  // 强制刷新所有资源
  const refreshForce = () => {
    sessionStorage.setItem('refreshTargetPath', route.fullPath)
    router.replace('/refresh')
  }
  return {
    refreshCurrentRoute,
    refreshGlobal,
    refreshForce
  }
}

然后在需要的页面里引入:

<script setup>
import { useRefresh } from '@/composables/useRefresh'
const { refreshCurrentRoute, refreshGlobal, refreshForce } = useRefresh()
// 调用对应的方法
</script>

这样就不用每次都重复写引入store、router、route的代码了,非常方便。

最后再提醒一句:刷新页面是不得已而为之的操作,能通过重新请求数据来更新页面的,就尽量不要刷新,因为刷新会影响用户体验,也会增加服务器的压力,比如订单列表页面,提交筛选条件后,直接重新调用getOrderList的接口就行,不用刷新整个组件或者路由页面,除非有其他特殊需求。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门