互动大屏和普通后台页面不一样。后台页面通常是用户点一下、查一下;大屏页面则是一直挂着,自动轮播、自动刷新、自动滚动、自动播放动画。
也正因为它一直运行,很多小问题都会被放大:一个没有清理的 setInterval,本地看不出来,现场跑一晚就可能导致卡顿。
说的是一个很典型的问题:Vue2 大屏里 Swiper 实例、轮询接口、滚动定时器、页面状态切换混在一起时,怎么做生命周期治理。
问题背景
一个大屏页面通常会有这些模块:
- 聊天消息区。
- 照片墙。
- 排行榜。
- 点歌信息。
- 底部 tab 或模块导航。
- 自动滚动的列表。
- Swiper 自动轮播。
- 定时接口轮询。
每个模块单看都不复杂。复杂的是它们会同时运行,而且页面状态会变。
比如:
- 进入游戏状态后初始化一个 Swiper。
- 离开游戏状态后需要销毁这个 Swiper。
- 排行榜每 1.2 秒刷新一次。
- 大屏配置每 30 秒自动刷新一次。
- 鼠标移入列表时暂停滚动,移出时继续滚动。
- 路由切换后所有定时器都应该停止。
如果这些逻辑散落在 mounted、watch、自定义指令和子组件里,后面就会越来越难排查。
问题现象
常见现象有:
- 切换状态后 Swiper 速度越来越快。
- 页面离开后接口还在请求。
- 鼠标移入移出几次后,列表滚动变快。
- 同一个 DOM 上绑定了多个轮播实例。
- 大屏运行时间越久越卡。
- 明明
destroyed里清了一两个定时器,还是有漏网的异步任务。
这类问题的共同点是:资源被创建了,但没有被统一登记,也没有在退出时统一释放。
初始写法
项目里经常会出现类似结构:
mounted() {setInterval(() => {api.refreshScreen(this.screenId)}, 30000)
},
watch: {gameState(value) {if (value === 'playing') {setTimeout(() => {this.swiper = new Swiper('.swiper-container', {autoplay: {delay: 7000,disableOnInteraction: false},loop: true,effect: 'cube'})}, 500)}}
},
destroyed() {clearInterval(this.rankTimer)
}
这段代码最大的问题是:创建资源的地方很多,释放资源的地方很少。
特别是 setTimeout 初始化 Swiper 这种写法,如果状态快速变化,可能出现“组件已经离开,但延迟初始化还在执行”的情况。
根因
大屏页面的资源可以分为四类:
- 定时器资源:
setInterval、setTimeout。 - 第三方实例:Swiper、弹幕库、播放器。
- DOM 事件:鼠标事件、滚动事件、窗口事件。
- 请求轮询:排行榜、配置刷新、状态同步。
它们不是 Vue 自动帮你清理的。Vue 只会销毁组件本身,组件外部创建的资源要自己管理。
所以关键不是“在哪里 clearInterval”,而是建立一个资源登记表。
资源桶设计
我喜欢用一个很朴素的 ResourceBucket:
function createResourceBucket() {const cleaners = []function add(cleaner) {if (typeof cleaner === 'function') {cleaners.push(cleaner)}}function interval(fn, delay) {const timer = setInterval(fn, delay)add(() => clearInterval(timer))return timer}function timeout(fn, delay) {const timer = setTimeout(fn, delay)add(() => clearTimeout(timer))return timer}function event(target, type, handler, options) {target.addEventListener(type, handler, options)add(() => target.removeEventListener(type, handler, options))}function destroy() {while (cleaners.length) {const clean = cleaners.pop()try {clean()} catch (error) {console.warn('clean resource failed', error)}}}return {add,interval,timeout,event,destroy}
}
组件里所有副作用都通过它登记:
mounted() {this.bucket = createResourceBucket()this.bucket.interval(() => {this.refreshScreenConfig()}, 30000)this.bucket.interval(() => {this.refreshRank()}, 1200)
},
beforeDestroy() {this.bucket.destroy()this.destroySwiper()
}
这样不用靠脑子记“我到底创建了几个 timer”。
Swiper 初始化治理
Swiper 的坑在于:它依赖 DOM,通常要等 v-if 渲染后才能初始化。
直接 setTimeout 能跑,但不稳。更好的方式是把初始化做成幂等:
methods: {async initGameSwiper() {await this.$nextTick()if (this.destroyedFlag) returnif (this.gameSwiper) returnconst el = this.$el.querySelector('.swiper-container')if (!el) returnthis.gameSwiper = new Swiper(el, {autoplay: {delay: 7000,disableOnInteraction: false},loop: true,effect: 'cube',speed: 1000})},destroyGameSwiper() {if (!this.gameSwiper) returnthis.gameSwiper.destroy(true, true)this.gameSwiper = null}
}
状态变化时只调用这两个方法:
watch: {gameStage(stage) {if (stage === 'playing') {this.initGameSwiper()} else {this.destroyGameSwiper()}}
}
这里最关键的是两点:
- 初始化前检查实例是否已经存在。
- 退出状态时主动销毁实例。
自动滚动治理
大屏列表经常需要自动滚动,鼠标移入暂停,移出恢复。不要在每次 mouseout 时都无脑创建新 interval。
可以写成一个控制器:
function createAutoScroller(el, options = {}) {let timer = nulllet destroyed = falsefunction start() {if (timer || destroyed) returntimer = setInterval(() => {const prev = el.scrollTopel.scrollTop += options.step || 1if (el.scrollTop === prev && el.scrollTop !== 0) {el.scrollTop = 0}}, options.delay || 20)}function stop() {clearInterval(timer)timer = null}function destroy() {destroyed = truestop()el.removeEventListener('mouseenter', stop)el.removeEventListener('mouseleave', start)}el.addEventListener('mouseenter', stop)el.addEventListener('mouseleave', start)start()return {start,stop,destroy}
}
在组件销毁时,把 destroy 放进资源桶即可。
请求轮询治理
轮询接口也要考虑两个问题:
- 上一次请求还没回来,下一次请求又发出去了。
- 页面已经销毁,请求回来后还在改状态。
可以加一个轻量锁:
function createPolling(task, delay) {let timer = nulllet running = falselet destroyed = falseasync function tick() {if (running || destroyed) returnrunning = truetry {await task()} finally {running = false}}function start() {if (timer) returntick()timer = setInterval(tick, delay)}function stop() {clearInterval(timer)timer = null}function destroy() {destroyed = truestop()}return {start,stop,destroy}
}
对于大屏项目,这种“防重入”比单纯 setInterval 稳很多。
方案对比
可以有三种处理方式。
第一种是每个地方自己清理。这种最常见,但随着页面变复杂,漏清理几乎不可避免。
第二种是把所有 timer 名字都挂在 data 上,然后在 destroyed 一个个清。它比第一种好,但还是依赖人工维护。
第三种是资源桶。所有副作用创建时就登记清理函数,组件退出时统一释放。这个方案最适合大屏这种长时间运行的页面。
验证
我会用下面的方式验证:
1. 进入大屏页面,记录接口轮询次数。
2. 来回切换页面 5 次,确认轮询没有叠加。
3. 游戏状态 playing/finished 来回切换,确认 Swiper 实例始终只有一个。
4. 鼠标反复移入移出滚动区域,确认滚动速度不变。
5. 页面销毁后等待 1 分钟,确认没有新的接口请求。
6. Performance 面板录制 5 分钟,观察 timer 数量和内存趋势。
小结
大屏项目不是写几个好看的动画就完了。它真正考验前端的是长时间运行能力。
Swiper、轮询、滚动、播放器这些东西都属于“组件外资源”。只要创建了,就必须知道什么时候释放。资源生命周期清楚了,大屏页面才不会越跑越乱。
