架构之路-性能监控的那些事

241 阅读7分钟

前言

​ 当项目运行在正式环境中的时候,js就像运行在一个黑盒当中。面对每回只有一行压缩代码的错误信息,你只能定位到页面,然后连蒙带猜的拆着这个“炸弹”,这个时候你想要是能像测试环境改着代码该有多好。
又譬如一个月前项目中引入的免费cdn资源挂了,过了半天才发现,致大量客户投诉,影响极其不好。这时候的你又想为什么前端没有操作日志和监控报警。当出现高级别错误或者资源加载错误时能够及时报警,从而快速解决问题。
如果你有上述的问题,接下来的内容一定会对你有所帮助。

收集错误信息

  • js语法错误、代码异常

    js语法错误一般在编译期就能发现,这里主要考察的是代码在同步和异步条件,promise未被catch条件下的js代码异常,候选人为window.onerror和window.addEventListener。

    1. window.onerror
    window.onerror = function(message, source, lineno, colno, error) {
       console.log('捕获到异常:',{message, source, lineno, colno, error});
    }
    

    对同步

    
    window.onerror = function(message, source, lineno, colno, error) {
       console.log('捕获到异常:',{message, source, lineno, colno, error});
    }
    const person = null
     person.name ="xugenqing"
    //✔捕获到异常
    

    image-20210904113459935.png

    ​ 对异步

    
    window.onerror = function(message, source, lineno, colno, error) {
        console.log('捕获到异常:',{message, source, lineno, colno, error});
    }
    setTimeout(() => {
     const person = null
     person.name ="xugenqing"
    });
    //✔捕获到异常
    ​
    

    ​ 对promise未被catch

    
    window.onerror = function(message, source, lineno, colno, error) {
        console.log('捕获到异常:',{message, source, lineno, colno, error});
    }
      axios.get("/err").then(res=>{
        const person = null
        person.name ="xugenqing"
      })
    //❌未捕获到异常
    ​
    ​
    

    1. window.addEventListener
    
    window.addEventListener('error',function(e){ // 拿到错误信息,进行处理}, true)
    

    对同步

    
    window.addEventListener('error',function(e){ // 拿到错误信息,进行处理 
    console.log('捕获到异常:',e)
    }, true)
    const person = null
     person.name ="真人类优质男性"
    //✔捕获到异常
    

    对异步

    
    window.addEventListener('error',function(e){ // 拿到错误信息,进行处理 
    console.log('捕获到异常:',e)
    }, true)
    setTimeout(() => {
     const person = null
     person.name ="真人类优质男性"
    });
    //✔捕获到异常
    

    对promise未被catch

    
    window.addEventListener('error',function(e){ // 拿到错误信息,进行处理 
    console.log('捕获到异常:',e)
    }, true)
      axios.get("/err").then(res=>{
        const person = null
        person.name ="真人类优质男性"
      })
    //❌未捕获到异常
    

    image-20210904125354104.png 对比发现两者在都能处理捕获同步和异步中错误的js代码,无法捕获promise中未被catch的代码,那么我们该选择哪一个呢。

    不慌,接着看下一个场景。

  • 资源加载错误

    1. window.onerror
    
    window.onerror = function(message, source, lineno, colno, error) {
        console.log('捕获到异常:',{message, source, lineno, colno, error});
    }
    <img src="./a.jpg"/>
    //❌未捕获到异常
    

    image-20210904163725602.png 2. window.addEventListener

    
    window.addEventListener = function(e){ // 拿到错误信息,进行处理 
        console.log('捕获到异常:',e)
    }, true)
    <img src="./a.jpg"/>
    //✔捕获到异常
    

    image-20210904171018091.png

    window.addEventListener相对于window.onerror还能监听到资源加载的错误,因此我们采用window.addEventListener来监听的js语法错误、代码异常资源加载错误

    window.addEventListener如何区分是资源加载错误还是js错误呢?查找mdn我们发现发生错误脚本时候我们捕获的是***ErrorEvent***类,该类继承自Event对象,它可以提供发生错误的脚本文件的文件名,以及发生错误时所在的行号等信息。

  • promise catch

    要解决promise未被catch的问题,我们要借助**unhandledrejection**,它继承自PromiseRejectionEvent

    
    window.addEventListener("unhandledrejection", event => {
       console.log('捕获到异常:',  {error:error.reason});
    });
      axios.get("/err").then(res=>{
        const person = null
        person.name ="真人类优质男性"
      })
    

    image-20210904201208548.png

  • Script error

    假如页面引用了属于 http://localhost:3000/error.js(一般指的是CDN资源,这里便于测试) 的 error.js 文件。

    若运行中demo.jsrun()方法 内部报了一个异常,那么前端的错误捕获脚本,只会检测到一个 script error的异常。

    这是由于浏览器基于安全考虑故意隐藏了其它域JS文件抛出的具体错误信息。这样可以有效避免敏感信息无意中被第三方(不受控制的)脚本捕获到,因此,浏览器只允许同域下的脚本捕获具体的错误信息。

    解决方法:

    1. 解决跨域问题

      给script标签增加 crossorigin 属性,让浏览器允许页面请求资源。

      
      //两种方式都可以
      <scrpit src="http://localhost:3000/error.js" crossorigin></script>
      <scrpit src="http://localhost:3000/error.js" crossorigin="anonymous"></script> 
      

      同时给静态资源响应头增加允许跨域标记,让服务器允许资源返回。

      服务器的HTTP响应头增加 Access-Control-Allow-Origin: * 或者 Access-Control-Allow-Origin: http://localhost:3000

    2. 对调用代码进行try···catch

      
        try{
          b()
        }catch(err){
          throw err
        }
      

      浏览器不会对 try-catch 起来的异常进行跨域拦截,所以 catch 到的时候,是有堆栈信息的。这时候

      
      function wrapErrors(fn) {
          if (!fn.__wrapped__) {
            fn.__wrapped__ = function () {
              try {
                return fn.apply(this, arguments);
              } catch (e) {
                throw e; 
              }
            };
          }
          return fn.__wrapped__;
        }
        wrapErrors(b)()
      

      第一种方式更加的方便(大部分主流CDN默认添加了Access-Control-Allow-Origin属性),但如果cdn不支持还是老老实实用第二种方式。

  • 崩溃和卡顿

    ​ 在谈监控页面的卡顿之前我们先引入一个概念:FPS(每秒传输帧数)。FPS越高,用户感觉页面越流畅,反之则会感到页面卡顿,一般认为最优的渲染帧数是60,即16.5ms 左右渲染一次。

    ​ 问题来了,怎么获取网页的FPS呢?

    ​ 这里要介绍一个浏览器的api:requestAnimationFrame。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。回调函数执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。

    一般连续出现3次低于20fps,浏览器就会出现明显的卡顿现象,我们就要将对应的信息记录下来,上报对应的错误信息。

    核心代码如下

    
      let loop = function () {
    let now = performance.now();
    let fs = (now - lastFameTime);   // 距上次的时间
    lastFameTime = now;
    let fps = Math.round(1000 / fs);   // FPS
    console.log(fps)
    if (fps < BELOW) {
      times++
      if (times >= LIMIT) {
        //上报错误信息
        times=0
      }
    } else {
      times = 0
    }
    frame++;
    ​
    if (now > INTERVAL + lastTime) {    // 两次不连续,间隔时间超过1s
      let fps = Math.round((frame * 1000) / (now - lastTime));
      frame = 0;
      lastTime = now;
     
      console.log(fps, "平均fps")
    };
        window.requestAnimationFrame(loop);
     }
    window.requestAnimationFrame(loop);
      
    

    ​ 浏览器崩溃时页面的js代码是无法执行的,这个时候就要提到另一个我们在性能优化中经常见到的老伙伴serviceWorker了。serviveWorker有独立的线程,因此当浏览器崩溃时,它一般不会崩溃。

    ​ 这里我们在浏览器加载后,注册serviceWorker,然后每过一段时间向worker发送一次心跳检测并附带相关信息。如果连续3次,worker未收到心跳则认为页面已经崩溃,上报错误信息。

    ​ worker.js

    
    const CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 检查一次
    const CRASH_THRESHOLD = 15 * 1000; // 15s 超过15s没有心跳则认为已经 crash
    //对于多开页面的处理
    const pages = {}
    let timer
    function checkCrash() {
      console.log(pages)
      const now = Date.now()
      for (var id in pages) {
        let page = pages[id]
        if ((now - page.t) > CRASH_THRESHOLD) {
          // 上报 crash
          delete pages[id]
        }
      }
      //无页面时清除定时器
      if (Object.keys(pages).length == 0) {
        clearInterval(timer)
        timer = null
      }
    }
    ​
    this.addEventListener('message', (e) => {
     console.log(e.data)
      const data = e.data;
      if (data.type === 'heartbeat') {
        pages[data.id] = {
          t: Date.now()
        }
        if (!timer) {
          timer = setInterval(function () {
            checkCrash()
          }, CHECK_CRASH_INTERVAL)
        }
      } else if (data.type === 'unload') {
        delete pages[data.id]
      }
    })
    

上传错误

​ 错误是都收集了,上报还不简单么,直接发送ajax请求不就可以了?

​ NONONO,直接通过ajax上传是存在一些问题的:

​ 1. 请求可能存在跨域问题,会携带cookie。

​ 2.ajax请求有时候会被浏览器强制cancel掉。

​ 这里我们采用了1x1的透明GIF 的方式完美解决了上述问题:首先图片是不存在跨域问题的,其次图片在new的时候就会发送请求,且不会有阻塞问题。

总结

​ 在平时项目开发中一般我们都采用比较成熟的服务例如sentry等对业务日志进行监控和分析,但是在技术成长中对性能监控的了解也是也是相当重要的。这里介绍了我们在平常在开发中常见的一些错误收集方法和上传方式,下一期会讲下在react和vue在平时开发过程中有关于性能优化的事。

附上性能监控的demo地址:github.com/breezeJACK/…

最后

技术架构之路道阻且长,行则将至,愿与君共勉之。

公众号:胡哥教你学前端

掘金号:Breeze同学