乐趣区

关于前端:前端白屏监控探索

援用下团体监控的 slogen:关注业务稳定性的人,运气都不会太差~

背景

不知从什么时候开始,前端白屏问题成为一个十分广泛的话题,’ 白屏 ’ 甚至成为了前端 bug 的代名词:_喂,你的页面白了。_而且,’ 白 ’ 这一景象仿佛对于用户体感上来说更增强,回忆起 windows 零碎的解体 ‘ 蓝屏 ’:
 
能够说是十分类似了,甚至能明确了白屏这个词汇是如何对立进去的。那么,体感如此强烈的景象势必会给用户带来一些不好的影响,如何能尽早监听,疾速打消影响就显得很重要了。

为什么独自监控白屏

不光光是白屏,白屏只是一种景象,咱们要做的是精细化的异样监控。异样监控各个公司必定都有本人的一套体系,团体也不例外,而且也足够成熟。然而通用的计划总归是有毛病的,如果对所有的异样都加以报警和监控,就无奈辨别异样的重大等级,并做出相应的响应,所以在通用的监控体系下定制精细化的异样监控是十分有必要的。这就是本文探讨白屏这一场景的起因,我把这一场景的边界圈定在了“白屏”这一景象。

计划调研

白屏大略可能的起因有两种:

  1. js 执行过程中的谬误
  2. 资源谬误

这两者方向不同,资源谬误影响面较多,且视状况而定,故不在上面计划思考范畴内。为此,参考了网上的一些实际加上本人的一些调研,大略总结出了一些计划:

一、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
    • 轮训
  2. DOM 检测:这个计划就很多了,除了上述的还能够:

    • elementsFromPoint api 采样
    • 图像识别
    • 基于 DOM 的各种数据的各种算法辨认

改变方向

几番尝试下来简直没有我想要的,其次要起因是准确率 — 这些计划都不能保障我监听到的是白屏,单从实践的推导就说不通。他们都有一个共同点:监听的是 ’ 白屏 ’ 这个景象,从景象去推导实质尽管能胜利,然而不够精确。所以我真正想要监听的是造成白屏的实质。

那么回到最开始,什么是白屏?他是如何造成的?是因为谬误导致的浏览器无奈渲染?不,在这个 spa 框架流行的当初实际上的白屏是框架造成的,实质是因为谬误导致框架不晓得怎么渲染所以罗唆就不渲染。因为咱们团队 React 技术栈居多,咱们来看看 React 官网的一段话:

React 认为把一个谬误的 UI 保留比齐全移除它更蹩脚。咱们不探讨这个认识的正确与否,至多咱们晓得了白屏的起因:渲染过程的异样且咱们没有捕捉异样并解决。

反观目前的支流框架:咱们把 DOM 的操作托管给了框架,所以渲染的异样解决不同框架办法必定不一样,这大略就是白屏监控难统一化产品化的起因。但大抵方向必定是一样的。

那么对于白屏我认为能够这么定义:异样导致的渲染失败

那么白屏的监控计划即:监控渲染异样。那么对于 React 而言,答案就是:Error Boundaries

Error Boundaries

咱们能够称之为谬误边界,谬误边界是什么?它其实就是一个生命周期,用来监听以后组件的 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; 
  }
}

一个有责任心的开发肯定不会放任谬误的产生。谬误边界能够包在任何地位并提供降级 UI,也就是说,一旦开发者 ’ 有责任心 ’ 页面就不会全白,这也是我之前说的计划一与之人造抵触且其余计划不稳固的状况。
那么,在这同时咱们上报异样信息,这里上报的异样肯定会导致咱们 定义的白屏,这一推导是 100% 正确的。

100% 这个词或者不够负责,接下来咱们来看看为什么我说这一推导是 100% 精确的:

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) 办法,这里有两种状况

    • 该组件 return 一个繁多节点,也就是返回一个 ReactElement 对象,反复 3 – 4 的步骤。并将以后 节点的 child 指向子节点 CurrentFiberNode.child = ChildFiberNode 并将子节点的 return 指向以后节点 ChildFiberNode.return = CurrentFiberNode
    • 该组件 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)且确实解决了异样的(申明了 getDerivedStateFromErrorcomponentDidCatch 生命周期)节点,如果有,则将那个节点标记为待捕捉 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,当然咱们能够借助 babelTypeScript 来帮咱们简略实现这一过程,其最终失去的成果是和 ErrorBoundry 相似的。

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

总结

那么最初总结下本文的出的几个论断:
我对白屏的定义:异样导致的渲染失败
对应计划是:资源监听 + 渲染流程监听

在目前 SPA 框架下白屏的监控须要针对场景做精细化的解决,这里以 React 为例子,通过监听渲染过程异样可能很好的取得白屏的信息,同时能加强开发者对异样解决的器重。而其余框架也会有相应的办法来解决这一景象。

当然这个计划也有弱点,因为是从实质推导景象其实无奈 cover 所有的白屏的场景,比方我要搭配资源的监听来解决资源异样导致的白屏。当然没有一个计划是完满的,我这里也是提供一个思路,欢送大家一起探讨。

作者:ES2049 / 金城武

文章可随便转载,但请保留此原文链接。
十分欢送有激情的你退出 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com。

退出移动版