Vue3刷新页面怎么做不闪屏?3种常用场景全解析+实用代码
开发Vue3项目时,刷新页面的需求其实挺常见的,比如权限变更后需要重渲染整个应用清除旧数据、表单提交成功后重置全局状态+刷新当前列表、用户手动切换语言后重新加载组件资源包……但直接用原生的location.reload()太粗暴了,不仅页面会白屏/闪屏,还会丢失临时数据、中断用户当前操作,体验差到没朋友,那到底有哪些温和又高效的方式?下面结合真实开发场景,给大家拆解3种最实用的方法,附可以直接复制改改就能用的代码。
局部刷新当前路由页面,保留用户当前的输入状态、滚动位置
最理想的刷新状态,看起来页面没动,但所有数据和组件都重新加载了”,比如电商后台的订单筛选页面,提交筛选条件后不想重置滚动条和已输入的搜索框补充项,只刷新订单列表和相关统计数据;或者在线编辑文档页面,点击“同步”后更新所有服务器上的内容,但保留当前编辑的光标位置,这种场景下,用Vue Router自带的导航守卫或者组件自身的刷新机制?不对,其实更简单的是利用Vue3的
首先说加唯一key的原理:Vue3的虚拟DOM diff算法,会对比新旧节点的key值,如果key不同,就会直接销毁旧节点、创建新节点,而不是复用,这样就相当于实现了“组件级别的全刷新”,但外层的App.vue、布局组件这些不会动,所以完全不会闪屏,滚动条也会因为外层容器没变而保留下来,那怎么生成这个唯一key呢?可以用时间戳,也可以用路由的query或者params拼接随机数,但拼接时间戳最稳定,不会影响页面的URL(除非你的业务需要URL里有刷新标记,但一般不需要)。
具体实现步骤:
- 在App.vue或者你的布局组件里,给
加一个动态的key,这个key可以从pinia/vuex的全局状态里拿,也可以在App.vue里直接定义一个响应式变量,为了全局管理更方便,推荐用pinia。 - 写一个pinia store,专门用来管理这个刷新的key值,比如叫appStore.js(TS的话就叫app.ts),里面有个refreshKey变量,还有一个changeRefreshKey的方法,每次调用就给refreshKey加1或者生成新的时间戳。
- 当需要局部刷新当前路由时,就调用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>
这里有个小细节:如果你的页面里有
全局刷新但不闪屏,保留用户当前的登录状态、主题设置等持久化数据
有时候我们需要全局刷新整个应用,比如更新了静态资源的版本(比如CSS、JS的hash值变了,不刷新的话可能会加载旧资源报错)、权限列表完全变更了(用户从“普通员工”升到了“管理员”,需要重新加载所有菜单、权限指令)、用户点击了“退出登录后重新登录”(退出登录后直接用新账号登录,不想清空所有缓存的话可以用),这种场景下,用location.reload(true)(强制从服务器加载资源)的话肯定会闪屏,但可以用Vue3的“provide/inject + 重写$mount”的方法,或者“重定向到一个空白中间页再跳回来”的方法——后者兼容性更好,不用操作底层的Vue实例,但需要注意中间页的处理。
先讲“重定向到空白中间页再跳回来”的方法,这个方法最容易理解,代码也最少:
- 在router/index.js里新增一个空白的中间路由,比如叫/refresh,component可以是一个只有的空组件,也可以用() => import('@/views/Refresh.vue')异步加载,避免增加首屏加载体积。
- 在中间页的onMounted钩子函数里,立即获取之前保存的目标路由(也就是刷新前的路由),然后用router.replace()跳回去。
- 需要全局刷新的时候,先把当前的路由信息(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原理有一定了解的开发者。
具体实现步骤:
- 在main.js里,把createApp返回的app实例保存到一个变量里,然后用provide把这个app实例和一个重写的refreshApp方法提供给全局。
- refreshApp方法的逻辑是:先卸载当前的app实例(app.unmount('#app')),然后重新创建一个app实例,重新挂载到#app上,重新注册pinia、vue-router、element-plus这些插件。
- 需要全局刷新的时候,用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)”来实现:
- 在需要强制刷新的地方,先把loading显示出来(可以用pinia管理一个全局的loading状态),然后把当前的路由存到sessionStorage里,然后跳转到中间页。
- 在中间页的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前端网发表,如需转载,请注明页面地址。
code前端网



