前端会话安全里有个特别经典的需求:用户一小时不操作,系统自动把登录态清掉。这个功能看着简单,真做起来坑不少——计时器怎么起、怎么重置、多个标签页怎么同步、路由切换算不算操作、刷新页面后计时器还在不在,每一个点都能让实现翻车。我在几个中后台系统里都落地过这套逻辑,从最早的纯定时器方案,到后来基于事件节流的方案,再到多标签页共享状态的方案,踩过的坑基本能写满一页纸。
这篇内容就是围绕“Vue 中实现 1 小时不操作则退出登录”这个具体需求展开的,把核心思路、关键参数、完整实现、常见问题全部拆开讲清楚。适合正在做后台管理系统、需要处理会话超时的前端同学参考,也适合刚接触 Vue 组合式 API、想找一个真实场景练手的朋友。不管你是用 Vue 2 还是 Vue 3,核心逻辑是相通的,我会以 Vue 3 组合式 API 为主线,同时把 Vue 2 的差异点标出来。
1. 需求拆解与整体方案设计
1.1 先把“不操作”这三个字定义清楚
很多人一上来就写setTimeout,结果发现用户明明在页面上点来点去,一小时到了照样被踢出去。问题就出在没定义清楚什么叫“操作”。我的经验是,把用户行为分成三类来对待:
- 有效操作:鼠标点击、键盘输入、页面滚动、触摸屏滑动。这些是真正代表“人还在”的信号。
- 无效操作:鼠标只是悬停移动、页面自动轮询请求、定时器触发的渲染。这些不能算,否则用户开着页面挂机,鼠标被猫碰一下都能续命。
- 边界操作:路由切换、标签页切换。路由切换我一般算作有效操作,因为用户确实在主动导航;标签页切走再切回来,这个要单独处理,后面会讲。
定义清楚之后,方案就清晰了:监听一组有效操作事件,每次触发就重置计时器,计时器满 1 小时就执行登出。听起来简单,但“重置计时器”这个动作如果每次事件都做,性能会很难看——用户快速滚动页面,一秒能触发几十次事件,每次都清定时器再建定时器,主线程会被拖累。
1.2 为什么选“节流 + 单定时器”而不是“每次重置”
我最早的做法是每次事件都clearTimeout再setTimeout,实测在滚动场景下确实有卡顿感。后来改成节流方案:事件触发时只记录一个时间戳,用一个固定间隔的定时器去检查“当前时间减去最后操作时间是否超过阈值”。这个方案的好处是:
- 事件处理函数极轻,只做一次赋值,几乎零开销。
- 定时器只有一个,检查频率可控(比如每 30 秒查一次),不会因为用户操作频繁而抖动。
- 时间判断逻辑集中在一处,调试和改阈值都方便。
代价是登出会有最多一个检查间隔的延迟。比如检查间隔 30 秒,用户停止操作后最长 1 小时 30 秒才登出。对于 1 小时这种量级的需求,30 秒的误差完全可以接受。如果你要求精确到秒,那就把检查间隔调小,比如 5 秒,开销依然很小。
1.3 整体架构:三层职责分离
我把这套功能拆成三层,各司其职,后面维护起来很清爽:
| 层级 | 职责 | 实现载体 |
|---|---|---|
| 事件采集层 | 监听用户操作,更新最后操作时间 | 组合式函数 / mixin |
| 计时判断层 | 定时检查是否超时,触发登出 | 定时器 + 时间比较 |
| 登出执行层 | 清理登录态、跳转登录页、提示用户 | 状态管理 + 路由 |
这样拆的好处是,事件采集层可以独立测试,计时判断层的阈值可以配置,登出执行层可以复用到“手动退出登录”的场景。很多团队把这坨逻辑全塞在一个App.vue的mounted里,后期加个“记住我”或者“超时前 5 分钟提醒”的需求,就得推倒重来。
2. 核心实现细节与关键参数
2.1 事件监听的选择与节流策略
要监听哪些事件,这里有个取舍。全量监听mousemove会让事件频率爆炸,我一般只监听这几个:
const activityEvents = [ 'mousedown', 'keydown', 'scroll', 'touchstart', 'click' ]注意我用的是mousedown而不是mousemove,用keydown而不是keyup。原因是按下动作更能代表用户意图,而且频率低得多。scroll事件本身频率也高,但现代浏览器对 scroll 有优化,配合节流问题不大。
节流的实现我不用第三方库,手写一个时间戳判断就够了:
let lastRecordTime = 0 const THROTTLE_MS = 1000 function recordActivity() { const now = Date.now() if (now - lastRecordTime < THROTTLE_MS) return lastRecordTime = now lastActivityTime = now }这里THROTTLE_MS设 1 秒,意思是每秒最多记录一次操作时间。对于 1 小时的阈值来说,1 秒的精度绰绰有余。这个参数不要设太小,否则节流形同虚设;也不要设太大,否则用户最后一次操作可能被漏记,导致提前登出。
2.2 定时检查的间隔怎么定
检查间隔CHECK_INTERVAL和超时阈值TIMEOUT是两个独立参数。我的经验公式是:
检查间隔 = 超时阈值 / 120,且不小于 5 秒
1 小时等于 3600 秒,除以 120 是 30 秒。这个比例下,最坏延迟是 30 秒,相对误差不到 1%,用户完全无感。如果你把检查间隔设成 1 秒,虽然精度高了,但每秒唤醒一次定时器,在低端设备上会有轻微的电量消耗,后台系统通常没必要。
const TIMEOUT_MS = 60 * 60 * 1000 // 1 小时 const CHECK_INTERVAL_MS = 30 * 1000 // 30 秒检查一次有个细节要注意:定时器用setInterval还是递归setTimeout?我倾向递归setTimeout,因为setInterval在页面被挂起(比如切到后台标签页)后,恢复时可能积压多次回调,导致连续触发检查。递归setTimeout每次执行完再排下一次,行为更可控。
2.3 时间基准的选择:Date.now 还是 performance.now
判断超时用Date.now()就够了,但有个坑:用户手动改系统时间,或者系统休眠唤醒后时间跳变,会导致判断异常。比如用户把系统时间往前调一小时,Date.now()突然变大,会误判超时。
更稳的做法是用performance.now(),它返回的是页面加载以来的毫秒数,不受系统时间调整影响。但performance.now()在页面刷新后会归零,所以需要配合sessionStorage存一个基准。我的折中方案是:用Date.now()做主判断,同时在页面可见性变化时做一次校正,具体在 2.5 节讲。
2.4 登出执行:清理什么、跳转到哪
登出不是简单跳个路由就完事,要清理的东西比想象中多:
function doLogout(reason) { // 1. 清理本地存储的令牌和用户信息 localStorage.removeItem('token') localStorage.removeItem('userInfo') sessionStorage.clear() // 2. 重置状态管理中的登录态 userStore.reset() // 3. 取消所有进行中的请求(可选但推荐) cancelPendingRequests() // 4. 跳转登录页,带上来源路径方便登录后回跳 router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath, reason } }) }reason参数很有用,登录页可以根据它显示不同提示:“登录已过期,请重新登录”和“您已长时间未操作,请重新登录”,用户体验差别很大。另外router.replace而不是push,避免用户点浏览器后退又回到需要登录的页面。
2.5 页面可见性变化的处理
这是最容易被忽略的一块。用户切到别的标签页,浏览器会节流定时器,setInterval可能被降到 1 分钟甚至更久才执行一次。等用户切回来,定时器才补执行,这时候判断超时是准的,但有个体验问题:用户切回来的一瞬间就被踢了,感觉很突兀。
我的处理是监听visibilitychange,页面重新可见时立即做一次超时检查,如果确实超时了,先弹一个提示,给用户 10 秒缓冲再登出。这个缓冲期不是必须的,但对后台系统来说很友好,避免用户正在填表单切出去查个资料,回来发现全没了。
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { checkTimeout(true) // 立即检查,带缓冲逻辑 } })3. 完整实操:从零落地这套功能
3.1 组合式函数的完整实现
我把整套逻辑封装成一个useIdleLogout组合式函数,Vue 3 项目直接引入即可。先看完整代码,再逐段解释:
import { onMounted, onUnmounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' export function useIdleLogout(options = {}) { const { timeout = 60 * 60 * 1000, checkInterval = 30 * 1000, throttle = 1000, events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'] } = options const router = useRouter() const userStore = useUserStore() let lastActivityTime = Date.now() let lastRecordTime = 0 let timerId = null let isRunning = false function recordActivity() { const now = Date.now() if (now - lastRecordTime < throttle) return lastRecordTime = now lastActivityTime = now } function checkTimeout() { const idle = Date.now() - lastActivityTime if (idle >= timeout) { stop() doLogout('idle') } } function doLogout(reason) { localStorage.removeItem('token') localStorage.removeItem('userInfo') sessionStorage.clear() userStore.reset() router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath, reason } }) } function scheduleCheck() { if (!isRunning) return timerId = setTimeout(() => { checkTimeout() scheduleCheck() }, checkInterval) } function start() { if (isRunning) return isRunning = true lastActivityTime = Date.now() events.forEach(e => window.addEventListener(e, recordActivity, { passive: true })) document.addEventListener('visibilitychange', onVisibilityChange) scheduleCheck() } function stop() { isRunning = false if (timerId) { clearTimeout(timerId) timerId = null } events.forEach(e => window.removeEventListener(e, recordActivity)) document.removeEventListener('visibilitychange', onVisibilityChange) } function onVisibilityChange() { if (document.visibilityState === 'visible') { checkTimeout() } } onMounted(start) onUnmounted(stop) return { start, stop, recordActivity } }几个关键点说明。passive: true是给滚动事件用的,告诉浏览器这个监听器不会调用preventDefault,浏览器可以放心优化滚动性能。isRunning标志位防止重复启动,因为onMounted在某些场景下可能被多次触发(比如keep-alive组件)。stop里把所有监听器和定时器都清干净,避免内存泄漏。
3.2 在根组件中挂载
这个函数要在应用级别生效,所以挂在根组件或者布局组件里:
<script setup> import { useIdleLogout } from '@/composables/useIdleLogout' useIdleLogout({ timeout: 60 * 60 * 1000, checkInterval: 30 * 1000 }) </script> <template> <router-view /> </template>注意不要挂在登录页本身,否则登录页也会启动计时器,用户还没登录就被“登出”了。我的做法是在布局组件(比如Layout.vue)里挂载,登录页走独立路由,不经过布局组件。
3.3 多标签页同步:一个容易翻车的点
用户开了三个标签页,在 A 标签页操作,B 和 C 标签页的计时器不知道,各自按自己的时间算,结果 A 还活着,B 先超时把用户踢了。这个体验很糟糕。
解决方案是用localStorage的storage事件做跨标签页通信。A 标签页记录操作时间时,写一个localStorage键;B 和 C 监听storage事件,收到变化就更新自己的lastActivityTime。
const ACTIVITY_KEY = '__last_activity__' function recordActivity() { const now = Date.now() if (now - lastRecordTime < throttle) return lastRecordTime = now lastActivityTime = now localStorage.setItem(ACTIVITY_KEY, String(now)) } window.addEventListener('storage', (e) => { if (e.key === ACTIVITY_KEY && e.newValue) { lastActivityTime = Number(e.newValue) } })这里有个细节:storage事件只在其他标签页触发,当前标签页不会收到自己写的事件,所以不会造成循环。另外写localStorage也有频率,配合节流每秒最多写一次,开销可接受。
3.4 刷新页面后的状态恢复
用户刷新页面,lastActivityTime归零重新算,相当于刷新就续命了。这算不算漏洞?看业务要求。如果要求严格,刷新后应该从localStorage读取上次操作时间:
function initLastActivity() { const stored = localStorage.getItem(ACTIVITY_KEY) const storedTime = stored ? Number(stored) : 0 const now = Date.now() // 如果存储的时间在未来(系统时间被改过),以当前时间为准 lastActivityTime = storedTime > 0 && storedTime <= now ? storedTime : now }这样刷新后如果已经超时,会立即触发登出。但要注意,如果用户是关闭浏览器一小时后重新打开,这个逻辑也会让他直接登出,这通常是符合预期的。
4. 常见问题排查与避坑实录
4.1 定时器在后台标签页不准确
前面提过,浏览器会节流后台标签页的定时器。实测 Chrome 在后台标签页里,setTimeout最小间隔被限制到 1 分钟。这意味着你的 30 秒检查间隔在后台实际是 1 分钟。对于 1 小时阈值来说影响不大,但如果你把阈值设成 5 分钟,误差就明显了。
应对办法就是前面说的visibilitychange监听,页面重新可见时立即检查。另外不要在后台依赖定时器做精确判断,时间比较用Date.now()差值,而不是数定时器触发次数。
4.2 用户操作被误判为“无操作”
有个真实案例:某系统里用户一直在看一个自动刷新的监控大屏,手没碰键盘鼠标,结果一小时后被登出。用户投诉说“我一直在看”。这种情况要么把自动刷新算作操作(不推荐,违背“人还在”的初衷),要么在登出前给个提醒。
我的做法是加一个“超时前提醒”:在剩余 5 分钟时弹一个对话框,用户点“继续使用”就重置计时器,不点就到期登出。这个提醒本身也算一次操作记录。
const WARN_BEFORE = 5 * 60 * 1000 function checkTimeout() { const idle = Date.now() - lastActivityTime const remaining = timeout - idle if (remaining <= 0) { doLogout('idle') } else if (remaining <= WARN_BEFORE && !warned) { warned = true showWarningDialog(() => { warned = false recordActivity() }) } }4.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 用户操作了还是被登出 | 事件没绑上或节流参数过大 | 检查events数组、throttle值 |
| 刷新后立即被登出 | localStorage存了过期时间 | 检查initLastActivity逻辑 |
| 多标签页行为不一致 | 没做storage事件同步 | 检查跨标签页通信 |
| 登出后还能返回上一页 | 用了router.push | 改用router.replace |
| 内存泄漏、页面卡顿 | 监听器没清理 | 检查onUnmounted里的stop |
| 后台标签页登出延迟大 | 定时器被浏览器节流 | 加visibilitychange检查 |
4.4 几个实操心得
第一,阈值做成可配置。不同业务对超时的要求不一样,财务系统可能 15 分钟,普通后台 1 小时,做成参数传入,别写死。
第二,登出前清理请求。用户超时登出时,可能还有轮询请求在飞,这些请求带着旧令牌会返回 401,触发额外的错误提示。在doLogout里先取消所有 pending 请求,体验会干净很多。
第三,测试时把阈值调小。开发阶段把timeout设成 10 秒、checkInterval设成 2 秒,方便快速验证各种边界情况。上线前再改回 1 小时。我见过有人直接拿 1 小时测,测一次等一小时,效率极低。
第四,Vue 2 的差异点。Vue 2 没有组合式 API,可以用 mixin 或者直接在App.vue的mounted/beforeDestroy里写。逻辑完全一样,只是生命周期钩子名字不同。如果项目用 Vue 2.7,组合式 API 也能用,那就和 Vue 3 写法一致。
第五,别忘了keep-alive场景。如果布局组件被keep-alive缓存,onUnmounted不会触发,监听器会残留。这种情况要用onActivated/onDeactivated来启停计时器,或者在路由守卫里统一管理。
这套逻辑我在三个项目里用过,从最初的粗糙版本迭代到现在这个封装,最大的体会是:会话超时这种功能,难点不在技术,而在对“用户意图”的建模。什么叫操作、刷新算不算续命、多标签页怎么算,这些产品层面的问题想清楚了,代码其实很短。真正让实现稳定的,是那些边界情况的处理——后台标签页、系统时间跳变、请求清理,这些才是区分“能跑”和“好用”的地方。