业务场景
最近接到一个优化需求,某页面会轮询请求两个接口获取未读消息的数量,但是当用户同时在多个tab打开了页面的话,这些页面都会进行轮询请求,有用户打开tab页太多,1分钟请求了几千次,触发了风控,需要优化一下,多tab保持只有一个页面会请求接口,其他页面共享这个页面的数据。
跨页面通信
同源跨页面通信的方式有好几种,BroadCast Channel、Service Worker等,这种更适用于页面通过固定事件主动触发的通信(如用户点击),不主动触发就不响应。但当前业务需要确保有一个页面在请求数据,所以请求到的数据需要携带一个时间信息保存起来以便其他页面校验数据是否已过期,因此更适合用LocalStorage进行通信。关于各通信方式可以看这篇文章。
LocalStorage实现通信
当更新localStorage时,会在其他同源页面触发storage事件,所以在A页面写localStorage,在B页面监听storage事件,从localStorage中取出改变的值,即可实现通信。
打开两个tab,在一个tab点击,在另一个tab可看到打印
window.addEventListener('click', () => {
localStorage.setItem('number', Math.random()) // 点击屏幕改变storage
})
window.addEventListener('storage', ({ key, newValue }) => {
console.log('监听到localStorage变化', key, newValue) // 监听localStorage变化
})
需要注意的是,localStorage的值必须真的改变才会触发storage事件。下面的代码中第二次setItem并不会触发storage事件。
localStorage.setItem('number', '123')
localStorage.setItem('number', '123')
业务实现过程
首先在页面打开的时候开始请求数据,执行轮询,并且开始监听localStorage的变化,当localStorage变化时即认为其他页面正在请求数据,保存一个otherTabIsGettingData=true的状态。请求到数据后将取到的数据加上时间戳存到localStorage中(这一步即会使其他页面都停止网络请求),再次轮询请求时,先判断是否有其他页面正在请求数据(otherTabIsGettingData),若有,从localStorage中取数据,取到之后再根据时间戳判断该数据是否已超过轮询间隔,若超过则可能之前的tab页已被关掉,那么将当前otherTabIsGettingData置为false,重新进行网络请求。
代码实现
/**
* 轮询事件保持多tab页只有一个执行
*/
export class SingleLoop {
/**
* @param {string} storageKey - 用来将数据储存到localStorage的key
* @param {function} loopFn - 获取数据的异步(async)函数 返回promise
* @param {number} ms - 轮询时间
* @param {function} callback - 轮询获取到数据后的回调
*/
constructor(storageKey, loopFn, ms, callback) {
this.key = storageKey // localStorage中储存数据的key
this.loopFn = loopFn // 事件
this.ms = ms // 时间间隔 ms
this.callback = callback
this.otherTabIsGettingData = false // 其他tab正在请求数据
this.timer = null
}
/** 启动轮询 */
start() {
window.addEventListener('storage', this.onStorageChange)
this.loop()
}
stop() {
window.removeEventListener('storage', this.onStorageChange)
clearTimeout(this.timer)
}
onStorageChange = ({ key }) => {
if (key === this.key) {
const storage = this.getDataFromStorage()
this.otherTabIsGettingData = true
console.log('监听到数据变化:', key, data, '停止当前页面的数据获取')
this.callback(storage.data)
}
}
loop() {
this.getData()
this.timer = setTimeout(() => {
this.loop()
}, this.ms)
}
/** 从接口或localStorage中取数据 */
async getData() {
let data
if (this.otherTabIsGettingData) {
// 已有tab在请求数据
const storageData = this.getDataFromStorage()
const now = Date.now()
const storageDataTime = storageData.time || 0
if (now - storageDataTime > this.ms + 1000) { // 由于数据请求和代码执行的延迟,给1s缓冲时间
// 旧数据已过期,可能请求数据的tab已被关闭,在当前页面开始请求数据
console.log('数据已过期', this.key, now - storageData.time, '重新启动')
this.otherTabIsGettingData = false
data = await this.setDataToStorage()
} else {
data = storageData.data
}
} else {
data = await this.setDataToStorage()
console.log(this.key, '没有其他页面请求数据,执行http请求')
}
this.callback(data)
}
getDataFromStorage() {
return JSON.parse(localStorage.getItem(this.key) || '{}')
}
/** 从接口获取数据并存入到localStorage */
async setDataToStorage() {
const data = await this.loopFn()
const value = JSON.stringify({
data,
time: Date.now()
})
localStorage.setItem(this.key, value)
return data
}
}
调用
function fetchData() {
// 模拟http请求
return new Promise((resolve) => {
setTimeout(() => {
resolve({ num: Math.random() })
}, 300)
})
}
function callback(data) {
console.log('取到data', data)
}
const loop = new SingleLoop('count', fetchData, 4 * 1000, callback)
loop.start()
// 对于react、vue应用可以在卸载组件时使用loop.stop()关闭定时器和storage事件的监听
关于定时器的补充说明
在chrome中浏览器针对定时器做了有性能优化,当tab页切走时,定时器会被延迟执行(延迟时间不定),所以执行上面的代码后,打开A、B、C、D四个页面,假设本来是A在执行http数据请求,按正常逻辑是只要A不关闭,只在四个页面间切换,始终应该是A在请求数据,但实际当切换到B页面停留时,因为定时器被延迟执行的原因,A的定时器任务过了段时间才执行,B页面从localStorage取到的数据就被检测到过期,那么B页面就会开始请求,A页面的不会再从http请求数据(从结果上来说也还是保持了一个页面请求)。只不过由于上面在校验过期时做了1s的缓冲,可能效果不明显,将缓冲时间改为500ms左右再切换tab就可以很明显的看到效果了。如果想解决这个问题可以使用Web Worker解决,这里就不再详细说明了。