| 8 min read

某天产品经理,在群里吐槽,自己打开自家的网站,浏览器崩溃了。然后又有人运营跟着评论,说某天也遇见了这种情况。这个时候 HR 也跳出来说面试的候选人也说遇到过这种情况。似乎这个时候,无论我们自己的电脑怎样,但是这个问题就必须有结论。

*前端不想看到的崩溃界面*

确定问题

其实大家日常在开发的时候,也偶尔会遇到 Crash 的问题,但是大多数原因是我们代码逻辑的问题,因此复现率非常高,我们也容易定位问题。

其实Chrome 崩溃无疑从两个维度去判断,比如是不是存在特别消耗 CPU 计算和内存占用的代码执行,Chrome是采用垃圾回收机制来消除不在引用到的对象,如果我们代码如果没有写好,容易造成无限增加无法销毁的对象,容易引起内存率占用非常高。又或者我们在对页面的 DOM 操作产生死循环,不停的进行 DOM 的操作。这回导致整个 render process 卡死。

第二中便是和用户的系统或者电脑本身的软件冲突或者环境相关,这种更多可能需要客服去用户进行沟通。

当然前面只是说了可能得原因,如果我们可以确认多数人都有复现的案例,并且分布在不同的操作系统以及Chrome 版本中,我们就暂且将它为一个在某种条件下一定复现的问题。

Crash 埋点

由于这种问题更多的是分布在用户浏览器端,我们需要关键的信息来确保,我们可以监听用户那边发生了 Crash。

目前比较常规的手段是 基于 Service Worker 心跳的方案

大致原理,就是 JS Main Thread 会对 Service Worker 发送心跳消息,告诉它,"我还在正常工作";然后突然一会,Service Worker 发现,没有人跟他发消息了,而且页面也没通知它我是被关掉了,那么这里大概率是页面已经崩溃了。

至于为什么 Service Worker 可以实现这些监听,有兴趣可以阅读
Is Service Worker in Sandbox》
里对 Service Worker 的架构介绍,你就可以明白为什么 Service Worker 能够处理崩溃的埋点实现。

代码设计

由于我们埋点库是单独抽离出来的,因此我们会设计成插件的形式,方便引入和配置。

我们新建 sw-start.js ,这是需要你注入 Service Worker 时候引入的代码。

const CHECK_CRASH_INTERVAL = 10000;
const CRASH_THRESHOLD = 15000;

// your log send service
function _sendlog(data) {
  const url = 'xxx';
  fetch(url, data);
}

function _equalObjectValue(a, b) {
  return JSON.stringify(a) === JSON.stringify(b);
}

function initLogSWCrashWatch() {
  let pages = {};
  let timer;
  let sendData = {};
  function _checkCrash() {
    const now = Date.now();
    Object.keys(pages).forEach((id) => {
      const page = pages[id];
      // 进行时间比较
      if ((now - page.t) > CRASH_THRESHOLD) {
        if (!_equalObjectValue(sendData, pages[id].data)) {
          _sendlog(pages[id].data);
          sendData = pages[id].data;
        }
        delete pages[id];
      }
    });
    if (Object.keys(pages).length === 0) {
      clearInterval(timer);
      timer = null;
    }
  }
  // 监听发来的消息
  self.addEventListener('message', (e) => {
    const data = e.data;
    if (data.type === 'heartbeat') {
      pages[data.id] = {
        t: Date.now(),
        data: data.data,
      };
      if (!timer) {
        timer = setInterval(() => {
          _checkCrash();
        }, CHECK_CRASH_INTERVAL);
      }
    } else if (data.type === 'unload') {
      delete pages[data.id];
    } else if (data.type === 'start') {
      pages = {};
    }
  });
}

initLogSWCrashWatch();

完成之后,需要你将代码引入到的 PWA 注册时候的 sw.js 文件里面:

importScripts('https://your_cdn.com/_对应版本号_/sw-start.js');
...

我们在前端 JS 引入库的初始化和消息发送:

const HEART_INTERVAL = 10000;

const DEFAULT_OPTIONS = {
  collectData() {
    return {};
  },
};

class SWService {
  constructor(options = {}) {
	 // 搜集全局标记关键 action 信息
    if (!window.logCrashActions) {
      window.logCrashActions = [];
    }
    this.options = Object.assign(DEFAULT_OPTIONS, options);
    if (this._checkServiceWorker()) {
      this.init();
      this.recordAction();
    }
  }

  _checkServiceWorker() {
    return navigator.serviceWorker && navigator.serviceWorker.controller;
  }

  init() {
    const sessionId = util.getUUID();
    const self = this;
    const heartbeat = function () {
	 // collectData 为传入自定义函数用于包装埋点数据,比如 url, UA 等
      const data = self.options.collectData({
        lastAction: window.libCollectorCrashActions,
      });
      navigator.serviceWorker.controller.postMessage({
        type: 'heartbeat',
        id: sessionId,
        data,
      });
    };
    document.addEventListener('DOMContentLoaded', () => {
      navigator.serviceWorker.controller.postMessage({
        type: 'start',
        id: sessionId,
      });
    });
    window.addEventListener('beforeunload', () => {
      navigator.serviceWorker.controller.postMessage({
        type: 'unload',
        id: sessionId,
      });
      clearInterval(heartbeat);
    });
    setInterval(heartbeat, HEART_INTERVAL);
    heartbeat();
  }

  recordAction() {
    // delegate.bind(document, '[data-crash-action]', 'click', (e) => {
    //   lastAction = e.dataset.crashAction;
    // });
  }
}

export default SWService;

这样你引入后,通过你JS 来控制本身的实例化即可。

单独抽离出来,方便别的开发引入,毕竟 Crash 监听不一定会在大多数应用中使用到,比如很多内部平台对性能要求不高的花,其实不必写到常规功能中去。

定位原因

在搜集到埋点曲线后,我们通过 url 做页面级别分类,发现其实大多数的崩溃来自播放页面,而且其中 iOS Safari局多,也有一定比例来自 Chrome。

终于我们似乎用数据回答了产品经理群里的报告。在范围大幅度定位到页面级别的时候,其实我们在排查难度相对降低了很多。这个时候我们联系到内部经常反馈的发热的问题,说打开页面不一会便会发热,虽然没有出现崩溃的情况,但是肯定内在一定是联系的。由于我们的场景主要是借助 WebGL 做 360 视频流的渲染,我们做了简单的竞品分析。

我们对同一个 Source 类型的视频,在 Youtube 和 自家的平台进行播放的页面性能监听,其中重点关注 CPU 和 内存曲线。我们可以在 Chrome 菜单按钮 more tool 打开 task manager(任务管理)看到我们需要的信息。

通过对比,我们可以看到我们本身在内存占用上超出 Chrome 很多,而且随着时间和页面的刷新内存会不停的增长。

WebGL 销毁

由于我们依赖 React-360 框架,因此我们定位播放的问题,不得不对 360 框架本身进行一些代码排查。

多次刷新,发现内存并没销毁,而且不停的增长,这和我们常规遇到的问题。因此我们第一思路,是我们是否正常释放了内存,WebGL 占用内存很大一部分是我们的 Texture 的 buffer 对象,我们发现了逻辑漏洞是我们并非调用底层 dispose 的方法,而 Chrome 在自己的设计中处于 WebGL 的内存的管理,当页面关掉后,并不会立即释放掉GPU Shared Memory 。

因此我们不得不在监听页面 beforeunloaded 手动触发 dispose 进行当前渲染 context 的对象的删除。

根据分辨率降低栅格数

尽管我们解决多了页面多次刷新内存异常的问题,我们还是没有解决本身占用内存就比竞品高出一头的问题。WebGL 渲染我们比较关心是我们渲染 Texture 所带来的的内存影响,我们在用球面体的时候,会做纹理映射,阅读源码我们发现 我们在球面初始化的时候写死了对于宽高的细分数目,

// ...
    this._panoGeomHemisphere = new THREE.SphereGeometry(1000, 1000, 1000, 0, Math.PI);

实际上在测试数据中表现,我们并不需要这么精细的划分,尤其我们在移动端界面的时候,于是我们在原有基础上实现对该变量的配置,实际上 < 50 的分度,完全就可以避免渲染造成的球面折线。

// ...
this._panoGeomHemisphere = new THREE.SphereGeometry(
        this._options.radius,
        this._options.widthSegs,
        this._options.heightSegs,
        ...
      );

在这些对于 WebGL 的一些处理之后,我们再次做了竞品数据的对比,发现已经相差无几。

上线后的观察

我们在对优化的版本灰度上线后,发现数据确实有明显的降低,也是在这样埋点的发现了一些 Safari 的异常,以及本身监控代码实现的优化。从而确保上报的准确性。当然最重要的是,再也没有听到产品经理的小报告和 HR 遇见候选人的尴尬问题。

扩展阅读

You Can Speak "Hi" to Me in Those Ways