从写 BUG 工程师扭转攻 BUG 师!

698 阅读12分钟

前言

不知道小伙伴在开发过程中有没有遇到这样扎心的场景:项目本地运行 perfect ,自测也完美通过! 自己忍不住扬起得意嘴角信誓旦旦地跟测试同学说:“老哥!今天可以早点下班了,这个项目绝不会有严重的 BUG ”。

然后自己回到座位一键发布测试服,正想美美收拾行囊准备下班,就差一步检查一切是否运行正常,纳尼!结果页面空白,控制台也没有抛出有任何价值的异常信息!刷新、清除缓存、重新发布试一试,还是空白!纳尼?突然感觉脸有些痛辣!胸口有千万只某动物在奔腾!

在笔者多年的搬砖过程中不管是开发简单的活动页还是复杂的动态表单交互,总是会冒出一些莫名其妙的 BUG(可能是自己太水了的缘故),还有反复遇到同一个类型 BUG 比如跨域异常,笔者曾经在一个项目遇到了 6 个跨域 BUG!你可以想象一下我当时是什么心情吗?

image.png

经过大量的 BUG 毒打,我决定面壁思过、痛改前非,花时间去整理、复盘所遇到 BUG ,总结出的一套解决 BUG 的思维模型,以后只要一出现 BUG 我只要对号入座即可快速解决问题快速定位问题方向。

因为笔者的主要技术栈是 vue 所以这篇文章涉及大量 vue 相关的异常,下面内容没有严谨区分错误和异常,如果觉得有被误导到,那么对不起!我会努力提高自己认知水平的。(微笑.jpg)

常见前端异常

常见前端异常分类

  • JS 语法错误与异常
  • AJAX 请求异常
  • 静态资源加载异常
  • Promise 异常
  • Iframe 异常
  • 跨域 Script error
  • 崩溃和卡顿
  • Vue 异常

本节列举常见的 JS 语法错误与异常、AJAX 请求跨越异常、Vue 异常。

JS 语法错误与异常

RangeError:标记一个错误,当设置的数值超出相应的范围触发

eg: 无终止的递归调用,直到浏览器爆栈抛出异常
Uncaught RangeError: Maximum call stack size exceeded

function recursion(){
 console.log(1)
 recursion()
}
recursion()

ReferenceError:引用类型错误,当一个不存在的变量被引用时发生的错误

eg:

console.log(d) 

Uncaught ReferenceError: d is not defined

SyntaxError:语法错误

eg:

 if(){

Uncaught SyntaxError: Unexpected token '{'

TypeError:类型错误,表示值的类型非预期类型时发生的错误

eg:

console.log(a.c)

Uncaught TypeError: Cannot read property 'c' of undefined

AJAX 请求异常

Ajax 请求我这里分为有状态异常和无状态异常:有状态异常通常包括服务端异常 5xx 、客户端异常 4xx ;无状态异常一般就是网络异常(无响应);

服务端出现错误时默认返回 500 让后端同学去检查代码即可,而资源定位不到浏览器则返回 404 ,前端就要排查路径是否正确,或者按照约定返回 401 代表身份认证过期等, 这里我重点讲无状态异常的网络异常。

网络异常排除掉网络问题请求超时,那么遇到的最可能的问题便是跨域问题,下面跟大家回顾让我吐彩虹的 6 个跨域异常!

跨越请求异常

image.png 解决跨域请求的方法有 jsonp、nginx代理、cors、iframe 等,一般我们选用 cors(跨源资源共享)处理跨域 ,因为其他都会有一定的缺点,下面是用 cors 解决跨域时遇到问题和解决方案。

原本以为只要在服务端设置请求头:Access-Control-Allow-Origin: http://xxxx(你的网站域名)就可以解决跨域问题,可事情并没有那么简单。

withCredentials 配置异常

withCredentials 是否允许请求发送 Cookie 和 HTTP 认证信息,当前端配置 withCredentials = true 时报了下图异常:

image.png 原因:当前端配置 withCredentials = true 时,后端配置 Access-Control-Allow-Origin 不能为 *, 必须是具体域名地址。
注意:要 withCredentials = true 生效,后端需配置 Access-Control-Allow-Credentials:true

自定义请求头异常 和 contentType 异常

image.png 原因:cors 策略请求分为简单请求和非简单请求,当请求方法是 PUT 或 DELETE,或者请求 Content-Type 字段的类型是 application/json,或者自定义请求头,常见自定义身份认证 Authorization 请求头时为复杂请求,

浏览器会发起预检 options 请求,如果服务端没有配置相应的请求头就会抛出异常。想了解更深入请看阮一峰的 跨域资源共享 CORS 详解

解决:在服务端设置 Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: Content-Type,Authorization

低版本不支持通配符 *

image.png 原因:2017 年才开始支持通配符 * 导致低版本浏览器出现异常。
解决:把 * 设置为具体的请求域名、请求头或者请求方法即可。

重复代理异常

image.png 原因:access-control-allow-origin: * 设置了两次,可能是代码设置一次,Nginx 再设置了一次。
解决:去掉代码或者 Nginx 其中一个 access-control-allow-origin: * 配置即可。

vue 异常

这里的 vue 异常包括 vue、 vue-cli 、vue-router 全家桶相关异常,平时遇到的 BUG 绝不止下面列举的这些,笔者会去收集更多的 BUG 然后不定期更新。

vue-cli 异常

  • vue-cli2 发布后背景路径不对

解决:设置 rootPublicPath : process.env.NODE_ENV === 'production' ? './'  :  '/' 若发布静态资源不在根目录而是根目录下的 /xxx/xxx 文件下,那么把 '/' 改为 /xxx/xxx 即可

  • vue-cli4 打包文件 es6 没有转译成 es5,导致在低版本不兼容问题

原因:添加了测试环境配置文件 .env.test,想通过配置 NODE_ENV = 'test' 和 NODE_ENV = 'production' 区分测试和正式服,而 cli 只认NODE_ENV = 'production' 配置条件为生产环境。
解决:.env.test 配置 NODE_ENV = 'test' 改回 NODE_ENV = 'production',其他需要区分环境变量则通过自定义变量

  • env 文件自定义变量不生效

解决:自定义变量必须以 VUE_APP_ 开头。

vue-router 异常

  • 用 router.beforeEach 进行路由拦截时出现了死循环,Maximum call stack size exceeded

解决:在执行 next 前先判断当前的路由是否是要跳转的路由,若是则不执行。

router.beforeEach((to, from, next) => {
  if (localStorage.getItem('token')) { // 若已登录
    next();
  } else {
    if (to.path === '/login') { // 若当前是登录页,直接next()
      next();
    } else { // 否则跳转到登录页面
      next({
        path: '/login'
      });
    }
  }
});
  • keep-live 不生效
<keep-alive >
    <router-view v-if="$route.meta.keepAlive" />
</keep-alive>
<router-view v-if="!$route.meta.keepAlive"/>

解决:路由嵌套多层,要在正确的组件的 router-view 嵌 keep-live。

  • keep-alive 组件设置使用 include/exclude 未生效
<keep-alive include="a">
<router-view />      
</keep-alive>

原因:a 是组件 name 而不是路由的 name。

vue 异常

  • 未在 data 对象声明的属性,不能动态渲染

解决:Vue.set(this,obj,a,2)。

  • Duplicate keys detected: '0'. This may cause an update error

原因:v-for 设置的 key 值有重复。 解决:把 id 作为 key值。

  • 在子组件用新属性接收 props 对象,父组件的对象莫名被改变

场景:父组件 子组件定义 info 对象接收 props 即:this.info = this.childUserInfo,当 info 改变时父组件 userInfo 会跟着改变
原因:引用了同一个指针 解决:深拷贝 this.info = JSON.parse(JSON.stringfy(this.childUserInfo))

  • 子组件 mounted 函数中未能获取到在父组件 mounted 函数改变之后的对象

eg:

父组件
mounted(){
 this.commit('updateUserInfo',{name:xxx})
}

子组件
mounted(){
 console.log(this.state.userInfo) // 这里未获取到 userInfo 最新值
}

原因:组件渲染周期 父beforeCreate->父created->父beforeMount->子beforeCreate->子created->子beforeMount->子mounted->父mounted。
解决:在子组件加 v-if="this.state.userInfo.name",当数据获取成功之后再渲染子组件。

  • 赋值 computed 的属性报错

  image.png

场景:当一个属性依赖某个属性变化而变化时,如下 userName 依赖 phone 属性,但是 userName 又可以被赋值

 computed: {
    userName(){return this.phone}
    }
  

第一反应就是给 userName 加 setter 如下:

 computed: {
   userName: {
      get() {
        return this.phone ;
      },
      set(val) {
        this.userName = val;
      }
    }
  }

然后就悲剧了!

image.png

正确是姿势是:使用 watch 监听。

  watch:{
    'phone'(val){
     this.userName = val
    }
  }

(vue 异常将会不定期更新...)

异常排查套路

常见错误场景和解决思路

场景一:莫名奇妙页面不见了或者修改不起作用。 排解思路:不用怀疑百分百是弄错文件或者起错服务了。

场景二:本地运行测试没有任何异常,上线之后各种 BUG。 排解思路:

  • 空对象是否已处理;
  • 接口返回数据结构是否一致(字段名/类型);
  • 是否是测试代码没有去掉(多余 return);
  • 测试是否到位。

场景三:稍微修改了一下就报错了,而且定位不到报错信息。 排解思路:执行顺序是否正确,要依赖的参数是否获取到,可以用删除代码法定位问题。

场景四:某个 if 事件一直无法触发,例如无法触发某个弹窗弹出事件。
排解思路:确定预设 if 条件已满足情况下,记得看一下 if 代码逻辑有没有写错。

场景五:当遇到线上紧急问题时,比如某个页面可以获取路由参数 userId,另外一个页面却获取不到。
排解思路:正常我们会先去比较这两个页面代码的差异来寻求答案,往往花了大半天都没有结果,那么可以从源头出发排查,即考虑哪些逻辑会改变路由或者哪些逻辑会改变 userId。

场景六:本地运行正常,测试服发布成功代码却不生效。
排解思路:不要怀疑发布系统出了问题,就是你代码没写好,不信你加个 console.log 试一试。

从抛出异常信息开始排查

当我们项目运行遇到异常时,一般通过浏览器抛出的异常信息我们便可以定位到异常位置比如 vue 的异常如下图,出错的文件很明显是 SignIn.vue 点击链接即可定位到异常位置:

image.png
但是在生产环境如果我们配置了 productionSourceMap:false,我们将看到是这样异常信息

image.png

可点击链接进去看到是编译压缩过的代码,很崩溃有没有,别急下面有一招可以解决。

image.png

点击控制台的格式代码按钮,

image.png

神奇的事情发生了!一眼就能定位到是在 created 钩子报错了,再看一下黄色背景异常代码和其上下文的代码便可知道异常具体在哪个文件。

image.png

总结

出现异常首先从报错信息挖掘有价值信息,如果报错信息没有任何帮助时,那么可以从 debugger 入手,然后是代码删除法排除,终极大法是 ‘重启大法’。

制造 BUG 收割机(异常监控)

异常监控其实很简单,下面的三个事件即可获取 70% 以上的异常,但是要写一个错误埋点 sdk 要考虑收集哪些数据,以及如何整合这些数据。本章节参考文章:如何优雅处理前端异常?异常监控,推荐感兴趣的同学去看一下原文。

简单的异常捕获

// 捕获 js 异常和资源加载异常
window.addEventListener('error',(error) => {
  console.log('error',error)
  return true
},true) 
// 捕获 vue 异常
Vue.config.errorHandler = (err, vm, info) => {
console.log('error',err, vm, info)
}
// 捕获 promise 异常
window.addEventListener("unhandledrejection", (e)=>{
  console.log(e.reason)
});
// 数据上报,创建 img 标签的形式进行上报,因为 ajax 方式本身也有可能发生异常。
 report(url, dataStr) {
    if (Math.random() < 0.3) { // 只收集 30% 
      new Image().src = `${url}?logs=${dataStr}`;
    }
  }

完整的异常埋点 sdk

监控基类

// 监控基类
export default class BaseMonitor {
  constructor(params) {
    this.category = ''; // 分类
    this.filename = ''; // 错误文件
    this.timeStamp = ''; // 访问时间戳
    this.userAgent = navigator.userAgent; //客户端
    this.msg = ''; //错误信息
    this.type = ''; // 错误类型
    this.postion = ''; // 位置
    this.stack = ''; // 栈堆
    this.selector = ''; //选择器
    this.reportUrl = params.reportUrl;
  }
  report(url, dataStr) {
    try {
      if (Math.random() < 0.3) {
        new Image().src = `${url}?logs=${dataStr}`;
      }
    } catch (error) {}
  }
  recordError() {
    let data = this.handleErrorInfo();
    this.report(this.reportUrl, data);
  }
  handleErrorInfo() {
    try {
      let message = '';
      if (this.type === 'ajaxError') {
        message = `
      类别: ${this.category}\r\n
      异常类型: ${this.type}\r\n
      日志信息: ${this.msg}\r\n
      url:${this.url}\r\n
      status :${this.status}\r\n
      statusText:${this.statusText}\r\n
      客户端: ${this.userAgent}\r\n
      `;
      } else {
        message = `
       类别: ${this.category}\r\n
      异常类型: ${this.type}\r\n
      日志信息: ${this.msg}\r\n
      位置: ${this.postion}\r\n
      文件名: ${this.filename}\r\n
      堆栈: ${this.stack}\r\n
      客户端: ${this.userAgent}\r\n
      `;
      }
      this.selector && (message += `选择器: ${this.selector}`);
      console.log(message);
      return message;
    } catch (err) {
      console.log(err);
    }
  }
}

获取 js 和资源加载异常类

import BaseError from './base.js';
import getLastEvent from '../libs/getLastEvent';
import { getSelector } from '../libs/utils';

/*
捕获 js 异常和资源加载异常
*/
export default class JsResourceError extends BaseError {
  constructor(params) {
    super(params);
  }
  /*
     处理 js 和资源加载异常
    */
  hanleError() {
    window.addEventListener(
      'error',
      (event) => {
        try {
          console.log('event', event);
          let target = event.target;
          let isElementTarget =
            target instanceof HTMLScriptElement ||
            target instanceof HTMLLinkElement ||
            target instanceof HTMLImageElement;
          if (isElementTarget) {
            //资源加载错误
            this.type = 'resourceError'; // 错误类型
            this.filename = target.src || target.href;
            this.tagName = target.tagName;
            this.selector = getSelector(target)
          } else {
            let lastEvent = getLastEvent();
            this.msg = event.error.message; //错误信息
            this.type = 'jsError'; // 错误类型
            this.postion = event.lineno + ',' + event.colno; // 位置
            this.filename = event.filename;
            this.stack = event.error.stack;
            this.selector = lastEvent ? getSelector(lastEvent.path) : ''; //选择器
          }    
          this.category = 'stability';
          this.timeStamp = event.timeStamp; // 访问时间戳
          this.recordError();
        } catch (error) {
          console.log('资源加载收集异常', error);
        }
        if (!event) return;
      },
      true
    );
  }
}

捕获 promise 异常类

import BaseError from './base.js';
import getLastEvent from '../libs/getLastEvent';
import { getSelector } from '../libs/utils';
/*
捕获 promise 异常
*/
export default class ResourceError extends BaseError {
  constructor(params) {
    super(params);
  }
  /*
     处理 promise 异常
    */
  hanleError() {
    window.addEventListener('unhandledrejection', (event) => {
      try {
        console.log('event', event);
        if (!event || !event.reason) {
          return;
        }
        let lastEvent = getLastEvent();
        this.category = 'stability';
        this.timeStamp = event.timeStamp; // 访问时间戳
        this.type = 'promiseError'; // 错误类型
        this.selector = lastEvent ? getSelector(lastEvent.path) : ''; //选择器ERROR';
        let reason=event.reason
        if (typeof reason === 'string') {
          this.msg = reason;
        } else {
          this.msg = reason.message
          this.stack = reason.stack
          // at http://localhost:8080/:22:17
          let matchfile = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
          this.filename = matchfile[1]
          this.postion = matchfile[2] + ',' + matchfile[3]
        }
        this.recordError();
      } catch (error) {
        console.log('promise 异常' + error);
      }
    });
  }
}

捕获 Ajax 异常类

import BaseError from './base.js';

/*
捕获 ajax 异常
*/
export default class AjaxError extends BaseError {
  constructor(params) {
    super(params);
  }
  /*
     处理 ajax 异常
    */
  hanleError() {
    if (!window.XMLHttpRequest) return;
    let xhrSend = XMLHttpRequest.prototype.send;
    let _handler = (event) => {
       console.log('ajaxevent',event)
      try {
        this.category = 'stability';
        this.type = 'ajaxError'; // 错误类型
        this.msg = event.target.response;
        this.url = event.target.responseURL;
        this.status = event.target.status;
        this.statusText = event.target.statusText
        this.recordError();
      } catch (error) {
        console.log('error', error);
      }
    };
    XMLHttpRequest.prototype.send = function() {
      if (this.addEventListener) {
        this.addEventListener('error', _handler);
        this.addEventListener('load', _handler);
        this.addEventListener('abort', _handler);
      }
      return xhrSend.apply(this, arguments);
    };
    
  }
}

捕获 vue 异常类

import BaseError from './base.js';

/*
捕获 vue 异常
*/
export default class VueError extends BaseError {
  constructor(params) {
    super(params);
  }
  /*
     处理 vue 异常
    */
  hanleError(Vue) {
    if (!Vue) return;
    Vue.config.errorHandler = (error, vm, info) => {
      let matchfile = error.stack.match(/at\s+(.+):(\d+):(\d+)/);
      try {
        this.category = 'stability';
        this.filename = matchfile[1];
        this.type = 'vueError'; // 错误类型
        this.msg = info;
        this.postion = matchfile[2] + ':' + matchfile[3];
        this.stack = error.stack;
        this.recordError();
      } catch (error) {
        // console.log('vue error', error);
      }
    };
  }
}

异常监控类

import AjaxError from './ajaxError.js';
import PromiseError from './promiseError.js';
import JsResourceError from './jsResourceError.js';
import VueError from './vueError.js';
  
  class Monitor {
    constructor() {}
    init(options) {
      console.log('options',options)
      let param = {reportUrl:options.url}
      new PromiseError(param).hanleError();
      new JsResourceError(param).hanleError();
      new AjaxError(param).hanleError();
      options.vue && new VueError(param).hanleError(options.vue);
    }
  }
  
  export default Monitor

收集结果

image.png 想看完整版的同学请移步项目 github 异常监控 sdk,待完成有以下异常待捕获

  • iframe 异常
  • 跨域 Script error
  • 崩溃和卡顿

写在最后

你说奇不奇怪,写项目劈里啪啦 BUG 成堆出现,可是当我要去写这篇文章的时候,那些 BUG 都躲起来了,姐姐我花了 3 个星期才整理完这篇文章(求赞.jpg),嗯~,所以童靴们,出现 BUG 一定要做记录呀,这很利于你下次能快速定位问题而且有利于 BUG 复盘!

另外我想说的是:遇到问题如果不是非常紧急,一定要自己动手 debugger 去定位解决,利用好 google 这把强大的工具,如果 google 没有相关的答案那么去官网查文档和看常见问题或者到 GitHub 去看别人提的 issue ,一般都会有意外收获!如果都没有那么有可能你查找的方向本来就不对。

为什么说一定要自己动手呢,因为问别人虽然问题很快就得到解决,可是你错过了一次技术历练的机会,你要知道人与人之间的区别大概就是解决问题能力的区别!你只有把你遇到的问题逐个认真去击破,然后记录、复盘,那么最后一定会慢慢形成属于自己的解决问题思维模型!

参考文章

跨域资源共享 CORS 详解
如何优雅处理前端异常?
异常监控
前端JavaScript 常见的报错及异常捕获与处理方法