不会吧,实时更新还有这么多事儿

238 阅读4分钟

image.png

前言

本文是公司业务的一次总结,由于前期方案设计原因,没有采用 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 分钟,下次进来则刷新图表。 wecom-temp-8ca2b07217937c1fee625979df5f141f.png

image.png 上图可以看出电脑熄屏状态,即使是 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。尤其是定时更新那块,由于种种限制,浏览器端模拟定时器很难在保证性能情况下,达到实时更新。