前言
本文是公司业务的一次总结,由于前期方案设计原因,没有采用 WebSocket, 所以本次实现只基于 HTTP 的实时更新。
背景
根据服务器时间,每分钟更新下面图表数据,单个页面图表最大数量可达10个。此外图表可以进行昨日和今日数据对比。
难点
- 服务器时间同步
- 图表数据获取更新
- 性能优化
- 竞态请求
1、服务器时间同步
实时更新依据服务器时间,所以在客户端需要同步服务器时间,你可能想到了解决方案方法:
- 页面渲染,调用后台接口同步
- 每秒定时器模拟时间 显然问题不会如此简单,我们知道 JavaScript 是单线程语言,每隔一秒的定时器只是每隔一秒加入事件队列,而不是每隔一秒执行,如果同步执行时间 1S 以上,定时器可能会延迟 1S 之后,此外电脑熄屏或者切换其他TAB页面,浏览器为了节省资源,定时器会延迟或者停止执行。所以我们可能得出结论:定时器是不可靠的。
1.1 requestAnimationFrame
同样的道理,虽然 requestAnimationFrame 同步了页面渲染和浏览器的刷新频率,有效节省了性能,但是如果 JS 执行时间太长,一样会导致时间不能有效更新,此外浏览器为了提高性能,在电脑熄屏或者切换其他TAB页面时,requestAnimationFrame 会暂停执行。
function animation(){
requestAnimationFrame(()=>{
animation()
})
}
animation()
1.2 WebWorker
自然,我们会想到 WebWorker, 没有什么比开启一个线程去执行它更合适了。
function createWorker(func){
if(typeof func !== 'function') return;
let blob = new Blob(['(' + func.toString() + ')()'], {
type:"javascript"
});
const url = URL.createObjectURL(blob);
const worker = new WebWorker(url);
URL.revokeObjectURL(url);
}
function time(){
const timeMap = {};
self.onmessage = (e) => {
const { id, type, interval } = e.data;
switch (type) {
case "setInterval":
timeMap[id] = setInterval(() => {
self.postMessage("interval");
}, interval);
break;
case "clearInterval":
clearInterval(id);
delete timeMap[id];
break;
case "setTimeout":
timeMap[id] = setTimeout(() => {
self.postMessage("timeout");
}, interval);
break;
case "clearTimeout":
clearTimeout(id);
delete timeMap[id];
break;
};
};
}
const worker = createWorker(time);
worker.onmessage = (e) => {
// 定时器回调
}
worker.postMessage({
type: "setInterval",
interval: 1000,
id: 1
})
但是实践发现,在电脑熄屏状态下,WebWorker 也是有延迟的,所以我们需要一种校正方案,当熄屏大于 10 分钟,下次进来则刷新图表。
上图可以看出电脑熄屏状态,即使是 WebWorker 也会每隔一段时间执行一次回调。
let hideTime = 0;
const INTERVAL_TIME = 10 * 60 * 1000;
window.addEventListener('pageshow', () => {
if(hideTime - this.currentTime > INTERVAL_TIME){
// 发布更新
this.trigger();
hideTime = 0
}
});
window.addEventListener('pagehide', () => {
hideTime = this.currentTime;
})
1.3 断网场景
在断网场景下,再次连接,我们需要考虑时间、数据和服务器同步。
// 部分场景不可靠,可以通过发送一个空请求来 hack.
window.addEventListener('onLine', () => {
// 发布更新
this.trigger();
});
经过以上探索,我们的服务器时间可以告一段落了,下面我们来讲讲数据怎么更新。
2、数据更新与获取
我们先了解下图表基本交互:图表有些拥有筛选条件,有些则没有,有些有昨日对比,有些则没有,此外后期图表可能也会受权限管理。
基于以上信息,单个图表单个接口是必然的选择,所以这就是一个简单的一对多模型,发布-订阅模式非常适合这种场景了。
class EventEmitter {
events = Object.create(null)
on(type, handler){
if(!type) return
if(!handler || typeof handler !== 'function') return
let handlers = this.events[type]
if(!Array.isArray(handlers)){
handlers = this.events[type] = []
}
handlers.push(handler)
}
off(type, handler){
if(!type){
this.clear()
return
}
if(!handler){
delete this.events[type]
return
}
const handlers = this.events[type]
if(Array.isArray(handlers)){
for(let i = handlers.length - 1; i >= 0; i--){
if(handlers[i] === handler){
handlers.splice(i, 1)
}
}
}
}
emit(type, ...rest){
if(!type) return;
const handlers = this.events[type];
if(Array.isArray(handlers)){
handlers.forEach(handler => {
handler(...rest)
})
}
}
clear() {
this.events = Object.create(null)
}
}
此外我们给每个请求接口设置一个唯一ID, 这样在发布的时候我们就可以找到对应的订阅了。
function funcWrapper(id, callback, ...rest){
function f(){
return callback(...rest)
}
f.id = id
return f;
}
// 示例
function ajax(url, timeout){
return new Promise((resolve) => {
setTimeout(()=>{
resolve(url)
}, timeout)
})
}
// funcWrapper('api_id', api, api_params)
funcWrapper('ajax_id', ajax, 3000)
2.1 数据获取
数据获取我们采用 ConcurrencyRequest 类来统一管理,数据分发采用发布订阅模式。
class ConcurrencyRequest extends EventEmitter{
tasks = []
current = 0
status = 'running'
constructor(tasks){
super()
this.addTasks(tasks)
}
addTasks(tasks){
if(tasks && !Array.isArray(tasks)){
tasks = [tasks]
}
for(let i = 0; i < tasks.length; i++){
this.tasks.push(tasks[i])
}
this.run();
}
run(){
while(this.tasks.length && this.status === 'running'){
// 取队列第一个节点
this.next(this.tasks.shift())
this.current++
}
}
async next(task){
const res = await task();
// 触发回调
this.emit(task.id, res)
// 重新运行
this.run()
}
clearTasks(){
this.status = 'destroyed'
this.tasks = []
this.current = 0
this.clear();
}
static destroy() {
if(this.instance){
this.instance.clearTasks();
}
this.instance = null;
}
static getSingleInstance(tasks){
if(!this.instance){
this.instance = new ConcurrencyRequest(tasks)
}else{
if(tasks){
this.instance.addTasks(tasks)
}
}
return this.instance
}
}
由于我们接口是分发在不同的组件内部的,所以我们采用单例模式来获取 ConcurrencyRequest 的唯一实例。
由于图表有许多过滤条件和对比条件,所以我们需要根据不同条件来获取接口请求参数(以下伪代码)
getRequestParams(isRefresh = false, isFilter = true, isCompare = false) {
let { timeStamp } = this.global
const { oldTimeStamp } = this.global
const interval = this.getTimeInterval()
const params = {
start_time: timeStamp - 3 * 60 * 1000 - 29 * 60 * 1000,
end_time: timeStamp - 3 * 60 * 1000
}
// 增量更新
if (interval <= 29 && !isRefresh) {
params['start_time'] = params['end_time'] - ((interval - 1) * 60 * 1000)
}
// 对比参数
if (isCompare) {
params['start_time'] = params['start_time'] - 24 * 60 * 60 * 1000
params['end_time'] = params['end_time'] - 24 * 60 * 60 * 1000
}
// 启用过滤
if (isFilter) {
// 过滤参数
}
return params
}
使用示例:
import { ajax } from "@/api"
const api_params = getRequestParams()
const tasks = [
funcWrapper('api_id', ajax, api_params)
]
ConcurrencyRequest.getSingleInstance(tasks)
ConcurrencyRequest.getSingleInstance().on('api_id', (data) => {
console.log(data)
})
2.2 最大并发限制
我们知道,在 http 1.1 的时候,浏览器是存在并发限制的,一般 Chrome 是 6 个, IE 是 11 个,而我们的实时更新每次发送请求是比较多的,所以为了解决这个问题,我们可以通过队列来保存最大并发数。
class ConcurrencyRequest extends EventEmitter{
tasks = []
current = 0
status = 'running'
constructor(tasks, max = 6){
super()
this.max = max;
this.addTasks(tasks)
}
addTasks(tasks){
if(tasks && !Array.isArray(tasks)){
tasks = [tasks]
}
for(let i = 0; i < tasks.length; i++){
this.tasks.push(tasks[i])
}
this.run();
}
run(){
while(this.current < this.max && this.tasks.length && this.status === 'running'){
// 取队列第一个节点
this.next(this.tasks.shift())
this.current++
}
}
async next(task){
const res = await task();
// 当前指针减一
this.current--
// 触发回调
this.emit(task.id, res)
// 重新运行
this.run()
}
clearTasks(){
this.status = 'destroyed'
this.tasks = []
this.current = 0
this.clear();
}
static destroy() {
if(this.instance){
this.instance.clearTasks();
}
this.instance = null;
}
static getSingleInstance(tasks){
if(!this.instance){
this.instance = new ConcurrencyRequest(tasks)
}else{
if(tasks){
this.instance.addTasks(tasks)
}
}
return this.instance
}
}
2.3 竞态请求
如果在接近更新临界点突然刷新界面,临界点会发出一轮请求,刷新初始化会发出一轮请求,那么存在请求竞态问题:新发出的请求,响应可能在后发出的请求的后面,此时我们得到的旧的请求结果,而我们需要使用最新的请求结果。我们改写下上面的 ConcurrencyRequest 类,添加候选队列和任务哈希表来管理竞态问题。
添加任务的时候,判断该任务是否存在当前队列中,如果存在,则加入候选列表,否则添加队列中,并添加到哈希表进行标记。请求完成,则从哈希表删除该记录,并将候选列表中不存在当前队列中的任务添加到队列中,等待执行。
class ConcurrencyRequest extends EventEmitter {
tasks = []
candidateList = []
taskMap = new Map()
current = 0
status = 'running'
constructor(tasks, max = 6){
super();
this.max = max;
this.addTasks(tasks)
}
addTasks(tasks){
if(tasks && !Array.isArray(tasks)){
tasks = [tasks]
}
for(let i = 0; i < tasks.length; i++){
// 当前列表存在任务,则加入候选列表
if(this.taskMap.has(tasks[i].id)){
this.candidateList.unshift(tasks[i])
}else{
this.taskMap.set(tasks[i].id, tasks[i])
this.tasks.push(tasks[i])
}
}
this.run();
}
run(bool = false){
if(bool) {
for(let i = this.candidateList.length - 1; i >= 0; i--){
// 不存在候选列表内,则加入任务进行执行
if(!this.taskMap.has(this.candidateList[i].id)){
this.taskMap.set(this.candidateList[i].id, this.candidateList[i])
this.tasks.push(this.candidateList[i])
this.candidateList.splice(i, 1)
}
}
}
while(this.current < this.max && this.tasks.length && this.status === 'running'){
// 取队列第一个节点
this.next(this.tasks.shift())
this.current++
}
}
async next(task){
const res = await task();
// 当前指针减一
this.current--
// 从任务表移除任务
this.taskMap.delete(task.id)
// 触发回调
this.emit(task.id, res)
// 重新运行
this.run(true)
}
clearTasks(){
this.status = 'destroyed'
this.tasks = []
this.candidateList = []
this.current = 0
this.taskMap.clear()
this.clear()
}
static destroy() {
if(this.instance){
this.instance.clearTasks();
}
this.instance = null;
}
static getSingleInstance(tasks){
if(!this.instance){
this.instance = new ConcurrencyRequest(tasks)
}else{
if(tasks){
this.instance.addTasks(tasks)
}
}
return this.instance
}
}
总结
本次讨论了基于 HTTP 实时更新的一些关键点,由于其他原因受限没有使用 WebSocket,如果能上 WebSocket 的话,还是推荐采用 WebSocket。尤其是定时更新那块,由于种种限制,浏览器端模拟定时器很难在保证性能情况下,达到实时更新。