灏天阁

一文搞懂页面白屏检测

· Yin灏

前言

前端页面白屏,是影响用户浏览体验的常见问题之一。它会导致页面长时间空白。尤其是对于 SPA 项目。

什么是页面白屏

页面白屏指页面在加载过程中长时间无法正常展示内容,用户处于等待状态的现象。 通常表现为:

  1. 页面空白,没有任何内容
  2. 页面仅显示背景色,没有文本、图片、页面元件等内容
  3. 页面处于加载中状态,但长时间无法完成加载
  4. etc.

页面白屏的主要原因

  1. 资源加载失败页面依赖的关键资源(CSS、JS、图片等)加载失败,导致页面无法正常渲染。
//例如我们在React项目中引入了不存在的css资源,导致资源加载失败,就会造成页面无法正常渲染,导致白屏。
//这是React 会在加载页面资源阶段就渲染 DOM 节点,但如果所依赖的资源(如图片)加载失败,则不会进行任何更新,导致用户长时间面对一个空白页面,产生白屏现象。
import "./demo.css";
function App() {
  return <div className="App">app</div>;
}
export default App;
  1. 资源加载延迟资源加载延迟(或阻塞),导致页面长时间等待资源加载完成,出现空白。
//例如我们在这里,App 组件在挂载后会使用 setTimeout 模拟资源加载延迟 8 秒。
//在资源加载完成前,imageURL 为空,因此不会渲染 <img> 标签。
//这就导致了用户在加载数秒内只看到一个空白页面(除非有其他内容),产生长时间空白导致的白屏现象。

import { useState, useEffect } from "react";
function App() {
  const [imageURL, setImageURL] = useState(null);
  useEffect(() => {
    setTimeout(() => {
      setImageURL("../test.jpg");
    }, 8000); // 模拟资源加载 8 秒
  }, []);
  return (
    <div className="App">{imageURL && <img src={imageURL} alt="logo" />}</div>
  );
}
export default App;

对于上述这种情况,我们可以采用如下方式优化:

  • 加一个 Loading 指示器
{
  imageURL ? <img src={imageURL} alt="logo" /> : <Loading />;
}
  • 等待资源加载完成后再渲染页面
//不在渲染时导入资源,而是在 useEffect 中导入,等待资源加载完成后再进行页面渲染
useEffect(() => {
  const logo = await import('./logo.png')
  setImageURL(logo)
}, [])
  • 骨架屏
  • ….
  1. JavaScript 执行错误,导致页面功能无法正常工作,出现空白。
//比如我们这里arr其实并未定义,这样执行就会报错,导致页面白屏
function App() {
  return <div className="App">{arr.map((item) => item * 2)}</div>;
}
export default App;
  1. 渲染时间过长   页面过于复杂,渲染时间超出预期,导致用户长时间处于等待状态。
  2. 第三方问题第三方服务或资源出现问题,导致页面功能依赖无法正常工作,出现白屏。
//App 组件从第三方 API 获取数据并渲染。如果 API 服务出现问题导致数据获取失
//败,useEffect 将永远处于 pending 状态,页面将只渲染一个 <Loader />。
//这将导致用户长时间面对加载指示器,产生依赖第三方服务问题导致的白屏现象。
import { useState, useEffect } from "react";
function App() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch("http://xxxxxxxxx/data")
      .then((res) => res.json())
      .then(setData);
  }, []);
  if (!data) return <Loader />;

  return (
    <div className="App">
      <h1>{data.title}</h1>
      <p>{data.content}</p>
    </div>
  );
}

export default App;

6. 其它异常   如网络异常、缓存问题、CDN 问题等也会导致页面白屏。

所以,总结来说,页面白屏是指:由于资源加载问题、执行错误、渲染时间过长等技术异常,导致页面长时间无法正常展示与响应的现象。它严重影响用户体验,因此我们需要采取有效措施来避免与修复页面白屏问题,保证 PAGE 体验质量。

如何监测页面白屏

方案一:检测根节点是否渲染+onerror 监听

原理很简单,在当前主流 SPA 框架下,DOM 一般挂载在一个根节点之下(比如  <div id="app"></div> ),发生白屏后通常是根节点下所有 DOM 被卸载。该方案就是通过监听全局的  onerror  事件,在异常发生时去检测根节点下是否挂载 DOM,若无则证明白屏。

这种方案,简单直接,直接检测根节点是否渲染完成即可。适用于 SPA。SPA 页面主要内容通过根节点下的组件渲染,所以监测根节点渲染情况可以判断 SPA 页面主要内容是否正常渲染。

这是简单明了且有效的方案。

//main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
// JS 错误监听
window.onerror = function (msg, url, lineNo, columnNo, error) {
  const rootElement = document.querySelector("#root");
  if (!rootElement.firstChild) {
    console.log("#root节点不存在内容,判断为白屏!");
  }
};
ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

//App.jsx

import React from "react";
function App() {
  return (
    <div>
      App,
      {
        app.map((i) => i * 2) //我们这里app未定义,执行报错导致白屏
      }
    </div>
  );
}
export default App;

方案二:Mutation Observer 监听 DOM 变化

let timeoutId = null;
const observer = new MutationObserver(callback);
function callback(mutationsList, observer) {
  // 有 DOM 变化,说明页面还没有白屏,重置一个定时器
  clearTimeout(timeoutId);
  timeoutId = setTimeout(() => {
    // 一段时间内没有任何 DOM 变化,说明页面已经白屏
    console.log("页面白屏");
  }, 3000);
}
const targetNode = document.body;
const config = {
  childList: true,
  attributes: true,
  subtree: true,
  characterData: true,
};
observer.observe(targetNode, config);

使用 Mutation Observer 来监听 DOM 变化,从而判断页面是否白屏。需要注意的是,判断页面是否白屏的阈值时间应该根据页面的实际情况来确定,如果设置时间太短可能会误判,设置时间太长可能会影响页面性能。

同时如果用户长时间未操作 DOM,Mutation Observer 监听到一定时间内没有 DOM 变化,就可能会误判为页面白屏。

实际应用中,可以通过一些手段来增加 DOM 的变化,从而避免误判。比如,在页面初始化时,可以在页面中插入一些隐藏的元素,然后定时更新这些元素的样式或内容,从而让 Mutation Observer 监听到 DOM 的变化。另外,一些自动化的数据推送、广告展示等行为也会引起 DOM 的变化,这些行为也可以被 Mutation Observer 监听到,从而避免误判。

相比于监测根节点是否渲染的方式,Mutation Observe 有可判断渲染问题,不受资源加载影响,支持问题回溯等优势。

  • 问题回溯

Mutation Record 提供具体的 DOM 变更记录,可以支持白屏问题的回溯与定位。而检测根节点渲染无法提供问题的详细诊断信息。例如,如果一段时间内 DOM 变化只有删除元素操作,几乎没有新增或更新操作,可以判断可能存在删除逻辑错误导致白屏,这可以作为问题回溯的参考信息。 那如何利用 Mutation Observe 进行问题回溯呢?

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    switch (mutation.type) {
      case "childList":
        mutation.addedNodes.forEach((node) => {
          console.log(node.nodeName); // 新增节点名
        });
        mutation.removedNodes.forEach((node) => {
          console.log(node.nodeName); // 移除节点名
        });
        break;
      case "attributes":
        console.log(
          mutation.attributeName,
          mutation.oldValue,
          mutation.newValue
        );
        // 打印出变化的属性名与新旧值
        break;
    }
  });
});
observer.observe(document, {
  childList: true,
  attributes: true,
  subtree: true,
});
  • 可判断渲染问题

通过监听 DOM 添加、移除、更新等变化情况,可以判断 DOM 渲染是否正常进行,是否出现长时间不变化的情况。 而检测根节点渲染只能判断根节点是否已渲染完成,无法判断渲染过程中是否出现问题。

// Mutation Observer
let lastTime;
const observer = new MutationObserver((mutations) => {
  if (!lastTime) lastTime = Date.now();
  else if (Date.now() - lastTime > 1000) {
    // 1 秒内无任何 DOM 变化,判断为渲染逻辑错误导致白屏
  }
});
observer.observe(document, {
  childList: true,
  attributes: true,
});

// 检测根节点渲染
let root = document.querySelector("#root");
setInterval(() => {
  if (root.children.length > 0) {
    // 根节点有子元素,判断渲染完成,无法判断渲染过程问题
  }
}, 200);
  • 不受资源加载影响

Mutation Observer 判断白屏不依赖资源是否加载完成,可以判断出资源加载失败导致的白屏。  而检测根节点渲染需要资源正常加载完成,无法判断资源失败导致根节点无法渲染的白屏。例如,如果页面 CSS 文件加载失败,导致页面无法渲染,Mutation Observer 可以判断为白屏,而检测根节点渲染无法判断此问题。

这是因为,通过监听 DOM 变更判断页面是否正常工作,即使资源加载失败,也可以发现页面长时间没有渲染出新内容的问题。而检测根节点渲染的方式,无法判断资源加载失败的情况。同时它可以判断出某类型节点(如 img)长时间没有被添加的情况。例如,如果页面长时间没有新增 img 节点,这可能意味着图片资源加载失败,导致 img 节点无法渲染。而检测根节点渲染同样无法判断具体哪些资源加载失败。

let imgCount = 0
let time = 0
const observer = new MutationObserver(mutations => {
  mutations.forEach(mutation => {
    if (mutation.addedNodes.find(n => n.nodeName === 'IMG')) {
      imgCount++ // 有 img 节点新增,重置时间
      time = 0
    }
  })
})
observer.observe(document, { childList: true, subtree: true })
setInterval(() => {
  time += 200
  在回调中判断,如果一定时间( 3 ) imgCount 数为 0,则判断图片资源加载失败
  if (time > 3000 && imgCount === 0) {
    // 3 秒内无 img 节点新增,判断图片资源加载失败
  }
}, 200)

同理,可以判断其他资源(如 JS、CSS 文件)加载失败。

方案三:关键点采样对比

所谓关键点采样就是在我们的屏幕中,随机取几个固定的点,利用 document.elementsFromPoint(x,y)该函数返还在特定坐标点下的 HTML 元素数组。

关键点的选取我们一般采用:垂直选取

image.png

假设,要在 X,Y 轴上各埋 9 个点,每个点的距离相等,那么 X 轴上的点坐标就是(i/10 * 屏幕的宽度,1/2 * 屏幕的高度, i 代表第几个点。那么 Y 轴上的坐标就是(1/2 * 屏幕的宽度,i / 10 * 屏幕的高度)。

除了垂直选取,还有交叉选取,以及垂直交叉选取

交叉选取:

image.png

垂直交叉选取:

image.png

语法:

var elements = document.elementsFromPoint(x, y);
// x:坐标点的水平坐标值,y:坐标点的垂向坐标值
//返回值是一个包含[`element`](https://developer.mozilla.org/zh-CN/docs/Web/API/Element) 对象的数组。

具体使用:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .content {
        width: 100px;
        height: 100px;
      }
    </style>
  </head>
  <body>
    <div class="content"></div>
    <script>
      let ele = document.elementsFromPoint(50, 50);
      console.log("ele", ele[0]);
    </script>
  </body>
</html>

image.png

采样对比代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div class="main"></div>
    <script>
      function onload() {
        if (document.readyState === "complete") {
          whiteScreen();
        } else {
          window.addEventListener("load", whiteScreen);
        }
      }
      let wrapperElements = ["html", "body", ".content"]; //首先定义容器列表
      let emptyPoints = 0; //空白点数量
      function getSelector(element) {
        //获取节点的容器
        if (element.id) {
          return "#" + element.id;
        } else if (element.className) {
          //content main==> .content.main  主要为了处理类名是多个的情况
          return (
            "." +
            element.className
              .split(" ")
              .filter((item) => !!item)
              .join(".")
          );
        } else {
          return element.nodeName.toLowerCase();
        }
      }
      function isWrapper(element) {
        //判断关键点是否在wrapperElements定义的容器内
        let selector = getSelector(element);
        if (wrapperElements.indexOf(selector) != -1) {
          emptyPoints++; //如果采样的关键点是在wrapperElements容器内,则说明此关键点是空白点,则数量加1
        }
      }
      function whiteScreen() {
        for (let i = 1; i <= 9; i++) {
          let xElement = document.elementsFromPoint(
            (window.innerWidth * i) / 10,
            window.innerHeight / 2
          ); //在x轴方向上,取10个点
          let yElement = document.elementsFromPoint(
            window.innerWidth / 2,
            (window.innerHeight * i) / 10
          ); //在y轴方向上,取10个点
          isWrapper(xElement[0]);
          isWrapper(yElement[0]);
        }
        if (emptyPoints != 18) {
          //如果18个点不都是空白点,则说明页面正常显示
          clearInterval(window.loopFun);
          window.loopFun = null;
        } else {
          console.log("页面白屏了");
          if (!window.loopFun) {
            loop();
          }
        }
      }
      window.loopFun = null;
      function loop() {
        if (window.loopFun) return;
        window.loopFun = setInterval(() => {
          emptyPoints = 0;
          whiteScreen();
        }, 2000);
      }
      onload();
    </script>
    <script>
      let content = document.querySelector(".main");
      setTimeout(() => {
        content.style.width = "500px";
        content.style.height = "500px";
        content.style.backgroundColor = "red";
      }, 4000);
    </script>
  </body>
</html>

总结:

这是我自己的第一篇文章,对于页面白屏的一些看法,希望大佬们多多指正,写的不好见谅。

参考:

前端白屏的检测方案,让你知道自己的页面白了

- Book Lists -