项目白屏是如何发生的?如何从前端角度实施监控?错误捕获

782 阅读11分钟

白屏产生的原因

  • js执行报错
  • 资源加载报错

方案

onerror + DOM 检测

当前主流一般是SPA 框架,这种框架DOM一般挂载在一个根节点之下(比如 <div id="root"></div> )发生白屏后通常现象是根节点下所有 DOM 被卸载,此方案就是通过监听全局的 onerror 事件,在异常发生时去检测根节点下是否挂载 DOM,若无则证明白屏。 是非常简单暴力且有效的方案。但弊端也显而易见:方案的合理性是建立在 白屏 === 根节点下 DOM 被卸载 成立的前提下,实际并非全部如此,比如一些微前端的框架。

Mutation Observer Api

文档

本质是监听 DOM 变化,并告诉你每次变化的 DOM 是被增加还是删除。为其考虑了多种方案:

  1. 搭配 onerror 使用,类似第一个方案,弊端:虽然其可以很好的知道 DOM 改变的动向,但无法和具体某个报错联系起来,两个都是事件监听,两者是没有必然联系的。
  2. 单独使用判断是否有大量 DOM 被卸载,弊端:白屏不一定是 DOM 被卸载,也有可能是压根没渲染,且正常情况也有可能大量 DOM 被卸载。完全走不通。
  3. 单独使用其监听时机配合 DOM 检测,其缺点和方案一一样,而且还不如方案一。因为它没法和具体错误联系起来,也就是没法定位。还有一种方法是:通过追踪用户行为数据来定位问题

饿了么-Emonitor 白屏监控方案

饿了么的白屏监控方案,其原理是记录页面打开 4s 前后 html 长度变化,并将数据上传到饿了么自研的时序数据库。如果一个页面是稳定的,那么页面长度变化的分布应该呈现「幂次分布」曲线的形态,p10、p20 (排在文档前 10%、20%)等数据线应该是平稳的,在一定的区间内波动,如果页面出现异常,那么曲线一定会出现掉底的情况。

其他

其他都大同小样,其实调研了一圈下来发现无非就是两点

  1. 监控时机: 调研下来常见的就三种:
  • onerror
  • mutation observer api
  • 轮训
  1. DOM 检测: 这个方案就很多了,除了上述的还可以:
  • elementsFromPoint api 采样
  • 图像识别
  • 基于 DOM 的各种数据的各种算法识别

总结

以上方案都各自有优缺点,但都不符合我们这篇文章的主题,其主要原因是准确率,这些方案都不能保证我监听到的是白屏,而且我们想要的是监听造成白屏的本质。

那么回到最开始,什么是白屏?他是如何造成的?是因为错误导致的浏览器无法渲染?不,在这个 spa 框架盛行的现在实际上的白屏是框架造成的,本质是由于错误导致框架不知道怎么渲染所以干脆就不渲染。我们来看看 React 官网的对于“白屏”的讲解:

image.png

React 认为把一个错误的 UI 保留比完全移除它更糟糕。白屏的原因:渲染过程异常且我们没有捕获异常并处理。

反观目前的主流框架:我们把 DOM 的操作托管给了框架,所以渲染的异常处理不同框架方法肯定不一样,这大概就是白屏监控难统一化产品化的原因。但大致方向肯定是一样的。

那么关于白屏我认为可以这么定义:异常导致的渲染失败

那么白屏的监控方案即:监控渲染异常

那么对于 React 而言,答案就是: 错误边界Error Boundaries

错误边界

文档

image.png

看react官网的介绍我们知道 错误边界 其实就是一个生命周期,用来监听当前组件的 children 渲染过程中的错误,并可以返回一个 降级的 UI 来渲染

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {    
      // 更新 state 使下一次渲染能够显示降级后的 UI    
      return { hasError: true };  
  }
  componentDidCatch(error, errorInfo) {    
      // 你同样可以将错误日志上报给服务器 
      logErrorToMyService(error, errorInfo);  
  }
  render() {
    if (this.state.hasError) {      
        // 你可以自定义降级后的 UI 并渲染      
        return <h1>Something went wrong.</h1>;    
    }
    return this.props.children; 
  }
}

然后你可以将它作为一个常规组件去使用:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

错误边界的工作方式类似于 JavaScript 的 catch {},不同的地方在于错误边界只针对 React 组件。只有 class 组件才可以成为错误边界组件。大多数情况下, 你只需要声明一次错误边界组件, 并在整个应用中使用它。

注意:错误边界仅可以捕获其子组件的错误,它无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会冒泡至最近的上层错误边界,这也类似于 JavaScript 中 catch {} 的工作机制。

错误边界的粒度由你来决定,可以将其包装在最顶层的路由组件并为用户展示一个 “Something went wrong” 的错误信息,就像服务端框架经常处理崩溃一样。你也可以将单独的部件包装在错误边界以保护应用其他部分不崩溃。

componentDidCatch里面捕获的错误如果不错UI降级处理一定会导致页面白屏,所以我们做好处理 页面再也不会白屏啦~

那么为啥错误会导致页面白屏呢?我们接下来来捋一下react页面渲染的流程

react页面渲染流程

阶段:render => 任务调度 => 任务循环 => 提交 => 展示 举一个简单的例子来展示其整个过程:

const App = ({ children }) => (
  <>
    <p>hello</p>
    { children }
  </>
);
const Child = () => <p>I'm child</p>

const a = ReactDOM.render(
  <App><Child/></App>,
  document.getElementById('root')
);

准备

首先浏览器是不认识我们的 jsx 语法的,所以我们通过 babel 编译大概能得到下面的代码:

var App = function App(_ref2) {
  var children = _ref2.children;
  return React.createElement("p", null, "hello"), children);
};

var Child = function Child() {
  return React.createElement("p", null, "I'm child");
};

ReactDOM.render(React.createElement(App, null, React.createElement(Child, null)), document.getElementById('root'));

babel 插件将所有的 jsx 都转成了 createElement 方法,执行它会得到一个描述对象 ReactElement 大概长这样子:

{
  $$typeof: Symbol(react.element),
  key: null,
  props: {}, // createElement 第二个参数 注意 children 也在这里,children 也会是一个 ReactElement 或 数组
  type: 'h1' // createElement 的第一个参数,可能是原生的节点字符串,也可能是一个组件对象(Function、Class...)
}

所有的节点包括原生的 <a></a> 、 <p></p> 都会创建一个 FiberNode ,他的结构大概长这样:

FiberNode = {
  elementType: null, // 传入 createElement 的第一个参数
  key: null,
  type: HostRoot, // 节点类型(根节点、函数组件、类组件等等)
  return: null, // 父 FiberNode
  child: null, // 第一个子 FiberNode
  sibling: null, // 下一个兄弟 FiberNode
  flag: null, // 状态标记
}

你可以把它理解为 Virtual Dom 只不过多了许多调度的东西。最开始我们会为根节点创建一个 FiberNodeRoot 如果有且仅有一个 ReactDOM.render 那么他就是唯一的根,当前有且仅有一个 FiberNode 树。

我只保留了一些渲染过程中重要的字段,其他还有很多用于调度、判断的字段我这边就不放出来了,有兴趣自行了解

render

现在我们要开始渲染页面,我们刚才的例子,执行 ReactDOM.render 。这里我们有个全局 workInProgress 对象标志当前处理的 FiberNode

  1. 首先我们为根节点初始化一个 FiberNodeRoot ,他的结构就如上面所示,并将 workInProgress= FiberNodeRoot
  2. 接下来我们执行 ReactDOM.render 方法的第一个参数,我们得到一个 ReactElement :
ReactElement = {
  $$typeof: Symbol(react.element),
  key: null,
  props: {
    children: {
      $$typeof: Symbol(react.element),
      key: null,
      props: {},
      ref: null,
      type: ƒ Child(),
    }
  }
  ref: null,
  type: f App()
}

该结构描述了 <App><Child /></App>

  1. 我们为 ReactElement 生成一个 FiberNode 并把 return 指向父 FiberNode ,最开始是我们的根节点,并将 workInProgress = FiberNode
{
  elementType: f App(), // type 就是 App 函数
  key: null,
  type: FunctionComponent, // 函数组件类型
  return: FiberNodeRoot, // 我们的根节点
  child: null,
  sibling: null,
  flags: null
}
  1. 只要workInProgress 存在我们就要处理其指向的 FiberNode 。节点类型有很多,处理方法也不太一样,不过整体流程是相同的,我们以当前函数式组件为例子,直接执行 App(props) 方法,这里有两种情况: a. 该组件 return 一个单一节点,也就是返回一个 ReactElement 对象,重复 3 - 4 的步骤。并将当前 节点的 child 指向子节点 CurrentFiberNode.child = ChildFiberNode 并将子节点的 return 指向当前节点 ChildFiberNode.return = CurrentFiberNode b. 该组件 return 多个节点(数组或者 Fragment ),此时我们会得到一个 ChildiFberNode 的数组。我们循环他,每一个节点执行 3 - 4 步骤。将当前节点的 child 指向第一个子节点 CurrentFiberNode.child = ChildFiberNodeList[0] ,同时每个子节点的 sibling 指向其下一个子节点(如果有) ChildFiberNode[i].sibling = ChildFiberNode[i + 1] ,每个子节点的 return 都指向当前节点 ChildFiberNode[i].return = CurrentFiberNode

如果无异常每个节点都会被标记为待布局 FiberNode.flags = Placement

  1. 重复步骤直到处理完全部节点 workInProgress 为空。

最终我们能大概得到这样一个 FiberNode 树:

FiberNodeRoot = {
  elementType: null,
  type: HostRoot,
  return: null,
  child: FiberNode<App>,
  sibling: null,
  flags: Placement, // 待布局状态
}

FiberNode<App> {
  elementType: f App(),
  type: FunctionComponent,
  return: FiberNodeRoot,
  child: FiberNode<p>,
  sibling: null,
  flags: Placement // 待布局状态
}

FiberNode<p> {
  elementType: 'p',
  type: HostComponent,
  return: FiberNode<App>,
  sibling: FiberNode<Child>,
  child: null,
  flags: Placement // 待布局状态
}

FiberNode<Child> {
  elementType: f Child(),
  type: FunctionComponent,
  return: FiberNode<App>,
  child: null,
  flags: Placement // 待布局状态
}

提交阶段

提交阶段简单来讲就是拿着这棵树进行深度优先遍历 child => sibling,放置 DOM 节点并调用生命周期。

那么整个正常的渲染流程简单来讲就是这样。接下来看看异常处理

错误边界流程

刚刚我们了解了正常的流程现在我们制造一些错误并捕获他:

const App = ({ children }) => (
  <>
  <p>hello</p>
  { children }
  </>
);
const Child = () => <p>I'm child {a.a}</p>

const a = ReactDOM.render(
  <App>
    <ErrorBoundary><Child/></ErrorBoundary>
  </App>,
  document.getElementById('root')
);

执行步骤 4 的函数体是包裹在 try...catch 内的如果捕获到了异常则会走异常的流程:

do {
  try {
    workLoopSync(); // 上述 步骤 4
    break;
  } catch (thrownValue) {
    handleError(root, thrownValue);
  }
} while (true);

执行步骤 4 时我们调用 Child 方法由于我们加了个不存在的表达式 {a.a} 此时会抛出异常进入我们的 handleError 流程此时我们处理的目标是 FiberNode<Child> ,我们来看看 handleError :

function handleError(root, thrownValue): void {
  let erroredWork = workInProgress; // 当前处理的 FiberNode 也就是异常的 节点
  throwException(
    root, // 我们的根 FiberNode
    erroredWork.return, // 父节点
    erroredWork,
    thrownValue, // 异常内容
  );
    completeUnitOfWork(erroredWork);
}

function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
) {
  // The source fiber did not complete.
  sourceFiber.flags |= Incomplete;

  let workInProgress = returnFiber;
  do {
    switch (workInProgress.tag) {
      case HostRoot: {
        workInProgress.flags |= ShouldCapture;
        return;
      }
      case ClassComponent:
        // Capture and retry
        const ctor = workInProgress.type;
        const instance = workInProgress.stateNode;
        if (
          (workInProgress.flags & DidCapture) === NoFlags &&
          (typeof ctor.getDerivedStateFromError === 'function' ||
            (instance !== null &&
              typeof instance.componentDidCatch === 'function' &&
              !isAlreadyFailedLegacyErrorBoundary(instance)))
        ) {
          workInProgress.flags |= ShouldCapture;
          return;
        }
        break;
      default:
        break;
    }
    workInProgress = workInProgress.return;
  } while (workInProgress !== null);
}

代码过长截取一部分 先看 throwException 方法,核心两件事:

  1. 将当前也就是出问题的节点状态标志为未完成 FiberNode.flags = Incomplete
  2. 从父节点开始冒泡,向上寻找有能力处理异常( ClassComponent )且的确处理了异常的(声明了 getDerivedStateFromError 或 componentDidCatch 生命周期)节点,如果有,则将那个节点标志为待捕获 workInProgress.flags |= ShouldCapture ,如果没有则是根节点。

completeUnitOfWork 方法也类似,从父节点开始冒泡,找到 ShouldCapture 标记的节点,如果有就标记为已捕获 DidCapture ,如果没找到,则一路把所有的节点都标记为 Incomplete 直到根节点,并把 workInProgress 指向当前捕获的节点。

之后从当前捕获的节点(也有可能没捕获是根节点)开始重新走流程,由于其状态 react 只会渲染其降级 UI,如果有 sibling 节点则会继续走下面的流程。我们看看上述例子最终得到的 FiberNode 树:

FiberNodeRoot = {
  elementType: null,
  type: HostRoot,
  return: null,
  child: FiberNode<App>,
  sibling: null,
  flags: Placement, // 待布局状态
}

FiberNode<App> {
  elementType: f App(),
  type: FunctionComponent,
  return: FiberNodeRoot,
  child: FiberNode<p>,
  sibling: null,
  flags: Placement // 待布局状态
}

FiberNode<p> {
  elementType: 'p',
  type: HostComponent,
  return: FiberNode<App>,
  sibling: FiberNode<ErrorBoundary>,
  child: null,
  flags: Placement // 待布局状态
}

FiberNode<ErrorBoundary> {
  elementType: f ErrorBoundary(),
  type: ClassComponent,
  return: FiberNode<App>,
  child: null,
  flags: DidCapture // 已捕获状态
}

FiberNode<h1> {
  elementType: f ErrorBoundary(),
  type: ClassComponent,
  return: FiberNode<ErrorBoundary>,
  child: null,
  flags: Placement // 待布局状态
}

如果没有配置错误边界那么根节点下就没有任何节点,自然无法渲染出任何内容。

ok,相信到这里大家应该清楚错误边界的处理流程了,也应该能理解为什么我之前说由 ErrorBoundry 推导白屏是 100% 正确的。当然这个 100% 指的是由 ErrorBoundry 捕捉的异常基本上会导致白屏,并不是指它能捕获全部的白屏异常。以下场景也是他无法捕获的:

错误边界无法捕获的有

  • 事件处理
  • 异步代码
  • SSR
  • 自身抛出来的错误

React SSR 设计使用流式传输,这意味着服务端在发送已经处理好的元素的同时,剩下的仍然在生成 HTML,也就是其父元素无法捕获子组件的错误并隐藏错误的组件。这种情况似乎只能将所有的 render 函数包裹 try...catch ,当然我们可以借助 babel 或 TypeScript 来帮我们简单实现这一过程,其最终得到的效果是和 ErrorBoundry 类似的。 ​

而事件和异步则很巧,虽说 ErrorBoundry 无法捕获他们之中的异常,不过其产生的异常也恰好不会造成白屏(如果是错误的设置状态,间接导致了白屏,刚好还是会被捕获到)。这就在白屏监控的职责边界之外了,需要别的精细化监控能力来处理它。

终结

那么最后总结下本文的出的几个结论: 我对白屏的定义:异常导致的渲染失败。 对应方案是:资源监听 + 渲染流程监听

在目前 SPA 框架下白屏的监控需要针对场景做精细化的处理,这里以 React 为例子,通过监听渲染过程异常能够很好的获得白屏的信息,同时能增强开发者对异常处理的重视。而其他框架也会有相应的方法来处理这一现象。

当然这个方案也有弱点,由于是从本质推导现象其实无法 cover 所有的白屏的场景,比如我要搭配资源的监听来处理资源异常导致的白屏。

欢迎讨论学习·

参考