前端白屏检测完整方案
白屏检测是前端监控体系中非常重要的一环,用于检测页面是否正常渲染,及时发现并上报白屏异常。
一、白屏检测的核心原理
白屏检测主要有以下几种实现思路:
-
采样点检测法 - 在页面关键位置采样,判断是否有有效内容
-
DOM 元素检测法 - 检测页面关键 DOM 元素是否存在
-
MutationObserver 监听法 - 监听 DOM 变化判断页面渲染状态
-
骨架屏检测法 - 检测骨架屏是否被替换为实际内容
-
截图对比法 - 通过 Canvas 截图分析页面内容
-
Performance API 检测法 - 利用浏览器性能 API 判断渲染状态
二、完整的白屏检测 SDK 实现
// ==================== 类型定义 ====================
/**
* 白屏检测配置接口
*/
interface WhiteScreenConfig {
// 采样点数量(水平和垂直方向)
samplingPoints?: number;
// 检测延迟时间(毫秒)
delay?: number;
// 检测超时时间(毫秒)
timeout?: number;
// 白屏阈值(0-1之间,超过该比例认为是白屏)
threshold?: number;
// 是否启用 DOM 检测
enableDOMDetection?: boolean;
// 是否启用采样点检测
enableSamplingDetection?: boolean;
// 是否启用 MutationObserver 检测
enableMutationDetection?: boolean;
// 是否启用截图检测
enableScreenshotDetection?: boolean;
// 是否启用骨架屏检测
enableSkeletonDetection?: boolean;
// 骨架屏容器选择器
skeletonSelector?: string;
// 关键元素选择器列表
keyElementSelectors?: string[];
// 需要忽略的元素选择器
ignoreSelectors?: string[];
// 容器元素(默认为 document.body)
container?: HTMLElement | null;
// 上报回调函数
onReport?: (data: WhiteScreenReport) => void;
// 检测完成回调
onDetectionComplete?: (result: DetectionResult) => void;
// 是否在开发环境启用
enableInDev?: boolean;
// 最大重试次数
maxRetries?: number;
// 重试间隔(毫秒)
retryInterval?: number;
// 自定义白屏判断函数
customDetector?: () => boolean | Promise<boolean>;
}
/**
* 白屏检测报告接口
*/
interface WhiteScreenReport {
// 是否白屏
isWhiteScreen: boolean;
// 检测时间戳
timestamp: number;
// 页面 URL
url: string;
// 检测方法
detectionMethod: DetectionMethod;
// 采样点结果
samplingResult?: SamplingResult;
// DOM 检测结果
domResult?: DOMDetectionResult;
// 截图检测结果
screenshotResult?: ScreenshotResult;
// 骨架屏检测结果
skeletonResult?: SkeletonResult;
// 页面性能数据
performanceData?: PerformanceData;
// 用户代理信息
userAgent: string;
// 视口尺寸
viewport: ViewportSize;
// 设备像素比
devicePixelRatio: number;
// 网络信息
networkInfo?: NetworkInfo;
// 错误信息
errorInfo?: ErrorInfo;
// 自定义数据
customData?: Record<string, unknown>;
}
/**
* 检测方法枚举
*/
enum DetectionMethod {
SAMPLING = 'sampling',
DOM = 'dom',
MUTATION = 'mutation',
SCREENSHOT = 'screenshot',
SKELETON = 'skeleton',
PERFORMANCE = 'performance',
CUSTOM = 'custom',
COMBINED = 'combined'
}
/**
* 采样点结果接口
*/
interface SamplingResult {
// 总采样点数
totalPoints: number;
// 空白点数
emptyPoints: number;
// 空白比例
emptyRatio: number;
// 采样点详情
pointDetails: SamplingPointDetail[];
}
/**
* 采样点详情
*/
interface SamplingPointDetail {
// X 坐标
x: number;
// Y 坐标
y: number;
// 元素标签名
tagName: string | null;
// 是否为空白点
isEmpty: boolean;
// 元素类名
className?: string;
// 元素 ID
id?: string;
}
/**
* DOM 检测结果接口
*/
interface DOMDetectionResult {
// 是否通过检测
passed: boolean;
// 检测到的关键元素数量
foundElements: number;
// 期望的关键元素数量
expectedElements: number;
// 缺失的元素选择器
missingSelectors: string[];
// 元素详情
elementDetails: ElementDetail[];
}
/**
* 元素详情
*/
interface ElementDetail {
// 选择器
selector: string;
// 是否存在
exists: boolean;
// 是否可见
isVisible?: boolean;
// 元素尺寸
dimensions?: {
width: number;
height: number;
};
}
/**
* 截图检测结果
*/
interface ScreenshotResult {
// 是否白屏
isWhiteScreen: boolean;
// 白色像素比例
whitePixelRatio: number;
// 总像素数
totalPixels: number;
// 白色像素数
whitePixels: number;
// 颜色分布
colorDistribution?: ColorDistribution;
}
/**
* 颜色分布
*/
interface ColorDistribution {
white: number;
black: number;
gray: number;
colored: number;
}
/**
* 骨架屏检测结果
*/
interface SkeletonResult {
// 骨架屏是否存在
skeletonExists: boolean;
// 骨架屏是否已移除
skeletonRemoved: boolean;
// 检测时间
detectionTime: number;
}
/**
* 性能数据
*/
interface PerformanceData {
// DOM 加载完成时间
domContentLoaded?: number;
// 页面完全加载时间
loadComplete?: number;
// 首次内容绘制时间
firstContentfulPaint?: number;
// 最大内容绘制时间
largestContentfulPaint?: number;
// 首次输入延迟
firstInputDelay?: number;
// 累计布局偏移
cumulativeLayoutShift?: number;
// 可交互时间
timeToInteractive?: number;
}
/**
* 视口尺寸
*/
interface ViewportSize {
width: number;
height: number;
}
/**
* 网络信息
*/
interface NetworkInfo {
// 网络类型
effectiveType?: string;
// 下行带宽
downlink?: number;
// RTT
rtt?: number;
// 是否在线
online: boolean;
}
/**
* 错误信息
*/
interface ErrorInfo {
// 错误消息
message: string;
// 错误堆栈
stack?: string;
// 错误类型
type: string;
}
/**
* 检测结果
*/
interface DetectionResult {
// 是否白屏
isWhiteScreen: boolean;
// 置信度(0-1)
confidence: number;
// 检测方法
methods: DetectionMethod[];
// 各方法结果
methodResults: Map<DetectionMethod, boolean>;
// 最终判定依据
basis: string;
}
// ==================== 工具函数 ====================
/**
* 防抖函数
*/
function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (this: unknown, ...args: Parameters<T>) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func.apply(this, args);
timeoutId = null;
}, wait);
};
}
/**
* 节流函数
*/
function throttle<T extends (...args: unknown[]) => unknown>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return function (this: unknown, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
/**
* 延迟执行
*/
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 带超时的 Promise
*/
function withTimeout<T>(
promise: Promise<T>,
ms: number,
errorMessage = 'Operation timed out'
): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(errorMessage)), ms);
});
return Promise.race([promise, timeout]);
}
/**
* 重试函数
*/
async function retry<T>(
fn: () => Promise<T>,
maxRetries: number,
retryInterval: number
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
await delay(retryInterval);
}
}
}
throw lastError;
}
/**
* 生成唯一 ID
*/
function generateUniqueId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 深度合并对象
*/
function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Partial<T>
): T {
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = result[key];
if (
typeof sourceValue === 'object' &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === 'object' &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as T[Extract<keyof T, string>];
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[Extract<keyof T, string>];
}
}
}
return result;
}
/**
* 检查元素是否可见
*/
function isElementVisible(element: Element): boolean {
if (!element) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none') return false;
if (style.visibility === 'hidden') return false;
if (style.opacity === '0') return false;
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
return true;
}
/**
* 获取元素在指定坐标处
*/
function getElementAtPoint(x: number, y: number): Element | null {
try {
return document.elementFromPoint(x, y);
} catch {
return null;
}
}
/**
* 判断是否为包装元素(通常用于布局的空元素)
*/
function isWrapperElement(element: Element | null): boolean {
if (!element) return true;
const wrapperTags = [
'HTML', 'BODY', 'DIV', 'SECTION', 'ARTICLE', 'MAIN',
'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'SPAN'
];
const tagName = element.tagName.toUpperCase();
if (!wrapperTags.includes(tagName)) {
return false;
}
// 检查是否有实际内容
const hasText = element.textContent?.trim().length ?? 0 > 0;
const hasChildren = element.children.length > 0;
const hasBackground = hasBackgroundContent(element);
// 如果只是空的包装元素,认为是包装元素
if (!hasText && !hasBackground && element === document.body) {
return true;
}
return false;
}
/**
* 检查元素是否有背景内容
*/
function hasBackgroundContent(element: Element): boolean {
const style = window.getComputedStyle(element);
// 检查背景颜色(排除白色和透明)
const bgColor = style.backgroundColor;
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
// 解析颜色值判断是否为白色
const isWhite = isWhiteColor(bgColor);
if (!isWhite) return true;
}
// 检查背景图片
const bgImage = style.backgroundImage;
if (bgImage && bgImage !== 'none') {
return true;
}
return false;
}
/**
* 判断颜色是否为白色
*/
function isWhiteColor(color: string): boolean {
// 处理 rgb/rgba 格式
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
// 接近白色的阈值
return r > 250 && g > 250 && b > 250;
}
// 处理十六进制格式
if (color.startsWith('#')) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return r > 250 && g > 250 && b > 250;
}
return color === 'white' || color === '#fff' || color === '#ffffff';
}
/**
* 获取网络信息
*/
function getNetworkInfo(): NetworkInfo {
const connection = (navigator as Navigator & {
connection?: {
effectiveType?: string;
downlink?: number;
rtt?: number;
};
}).connection;
return {
effectiveType: connection?.effectiveType,
downlink: connection?.downlink,
rtt: connection?.rtt,
online: navigator.onLine
};
}
/**
* 获取性能数据
*/
function getPerformanceData(): PerformanceData {
const performanceData: PerformanceData = {};
// 获取导航性能数据
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (navigation) {
performanceData.domContentLoaded = navigation.domContentLoadedEventEnd - navigation.startTime;
performanceData.loadComplete = navigation.loadEventEnd - navigation.startTime;
}
// 获取绘制性能数据
const paintEntries = performance.getEntriesByType('paint');
paintEntries.forEach(entry => {
if (entry.name === 'first-contentful-paint') {
performanceData.firstContentfulPaint = entry.startTime;
}
});
// 获取 LCP
try {
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
if (lcpEntries.length > 0) {
const lastLcp = lcpEntries[lcpEntries.length - 1] as PerformanceEntry & { startTime: number };
performanceData.largestContentfulPaint = lastLcp.startTime;
}
} catch {
// LCP 可能不被支持
}
return performanceData;
}
// ==================== 采样点检测器 ====================
/**
* 采样点检测器类
*/
class SamplingDetector {
private config: Required<Pick<WhiteScreenConfig,
'samplingPoints' | 'threshold' | 'ignoreSelectors' | 'container'
>>;
constructor(config: Partial<WhiteScreenConfig> = {}) {
this.config = {
samplingPoints: config.samplingPoints ?? 17,
threshold: config.threshold ?? 0.95,
ignoreSelectors: config.ignoreSelectors ?? [],
container: config.container ?? document.body
};
}
/**
* 执行采样检测
*/
detect(): SamplingResult {
const { samplingPoints, ignoreSelectors, container } = this.config;
const points: SamplingPointDetail[] = [];
// 获取视口尺寸
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
// 计算采样间隔
const horizontalStep = viewportWidth / (samplingPoints + 1);
const verticalStep = viewportHeight / (samplingPoints + 1);
let emptyPoints = 0;
// 生成采样点矩阵
for (let i = 1; i <= samplingPoints; i++) {
for (let j = 1; j <= samplingPoints; j++) {
const x = Math.floor(horizontalStep * i);
const y = Math.floor(verticalStep * j);
const pointResult = this.checkPoint(x, y, ignoreSelectors);
points.push(pointResult);
if (pointResult.isEmpty) {
emptyPoints++;
}
}
}
// 添加中心点检测(权重更高)
const centerX = Math.floor(viewportWidth / 2);
const centerY = Math.floor(viewportHeight / 2);
const centerPoints = this.checkCenterRegion(centerX, centerY, ignoreSelectors);
points.push(...centerPoints);
centerPoints.forEach(point => {
if (point.isEmpty) emptyPoints++;
});
const totalPoints = points.length;
const emptyRatio = totalPoints > 0 ? emptyPoints / totalPoints : 1;
return {
totalPoints,
emptyPoints,
emptyRatio,
pointDetails: points
};
}
/**
* 检查单个采样点
*/
private checkPoint(
x: number,
y: number,
ignoreSelectors: string[]
): SamplingPointDetail {
const element = getElementAtPoint(x, y);
const detail: SamplingPointDetail = {
x,
y,
tagName: element?.tagName ?? null,
isEmpty: true,
className: element?.className?.toString(),
id: element?.id
};
if (!element) {
return detail;
}
// 检查是否在忽略列表中
if (this.shouldIgnoreElement(element, ignoreSelectors)) {
return detail;
}
// 检查是否为有效内容元素
detail.isEmpty = !this.isContentElement(element);
return detail;
}
/**
* 检查中心区域(九宫格)
*/
private checkCenterRegion(
centerX: number,
centerY: number,
ignoreSelectors: string[]
): SamplingPointDetail[] {
const points: SamplingPointDetail[] = [];
const offsets = [-50, 0, 50];
for (const offsetX of offsets) {
for (const offsetY of offsets) {
if (offsetX === 0 && offsetY === 0) continue; // 跳过正中心,已在主循环中处理
const x = centerX + offsetX;
const y = centerY + offsetY;
if (x > 0 && y > 0) {
points.push(this.checkPoint(x, y, ignoreSelectors));
}
}
}
return points;
}
/**
* 判断元素是否应被忽略
*/
private shouldIgnoreElement(element: Element, ignoreSelectors: string[]): boolean {
for (const selector of ignoreSelectors) {
try {
if (element.matches(selector) || element.closest(selector)) {
return true;
}
} catch {
// 选择器无效,忽略
}
}
return false;
}
/**
* 判断是否为有内容的元素
*/
private isContentElement(element: Element): boolean {
const tagName = element.tagName.toUpperCase();
// 明确的内容元素
const contentTags = [
'IMG', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IFRAME',
'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON',
'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'P', 'A', 'SPAN', 'LABEL', 'LI', 'TD', 'TH',
'STRONG', 'EM', 'B', 'I', 'U', 'CODE', 'PRE'
];
if (contentTags.includes(tagName)) {
return isElementVisible(element);
}
// 检查是否有文本内容
const textContent = element.textContent?.trim();
if (textContent && textContent.length > 0) {
return isElementVisible(element);
}
// 检查是否有背景内容
if (hasBackgroundContent(element)) {
return isElementVisible(element);
}
// 检查是否为包装元素
if (isWrapperElement(element)) {
return false;
}
return isElementVisible(element);
}
/**
* 判断是否为白屏
*/
isWhiteScreen(result: SamplingResult): boolean {
return result.emptyRatio >= this.config.threshold;
}
}
// ==================== DOM 检测器 ====================
/**
* DOM 检测器类
*/
class DOMDetector {
private config: Required<Pick<WhiteScreenConfig,
'keyElementSelectors' | 'container'
>>;
constructor(config: Partial<WhiteScreenConfig> = {}) {
this.config = {
keyElementSelectors: config.keyElementSelectors ?? [
'#app', '#root', '.app', '.main-content',
'main', '[data-page]', '.page-container'
],
container: config.container ?? document.body
};
}
/**
* 执行 DOM 检测
*/
detect(): DOMDetectionResult {
const { keyElementSelectors, container } = this.config;
const elementDetails: ElementDetail[] = [];
const missingSelectors: string[] = [];
let foundElements = 0;
for (const selector of keyElementSelectors) {
const result = this.checkElement(selector, container);
elementDetails.push(result);
if (result.exists && result.isVisible) {
foundElements++;
} else {
missingSelectors.push(selector);
}
}
const expectedElements = keyElementSelectors.length;
const passed = foundElements > 0;
return {
passed,
foundElements,
expectedElements,
missingSelectors,
elementDetails
};
}
/**
* 检查单个元素
*/
private checkElement(
selector: string,
container: HTMLElement | null
): ElementDetail {
const searchRoot = container ?? document;
try {
const element = searchRoot.querySelector(selector);
if (!element) {
return {
selector,
exists: false,
isVisible: false
};
}
const isVisible = isElementVisible(element);
const rect = element.getBoundingClientRect();
return {
selector,
exists: true,
isVisible,
dimensions: {
width: rect.width,
height: rect.height
}
};
} catch {
return {
selector,
exists: false,
isVisible: false
};
}
}
/**
* 检测页面是否有有效内容
*/
hasValidContent(): boolean {
const body = document.body;
if (!body) return false;
// 检查 body 是否有子元素
if (body.children.length === 0) return false;
// 检查是否有可见的子元素
const children = Array.from(body.children);
const visibleChildren = children.filter(child => isElementVisible(child));
if (visibleChildren.length === 0) return false;
// 检查是否有实际内容(文本或媒体)
const hasContent = visibleChildren.some(child => {
// 检查文本内容
const text = child.textContent?.trim();
if (text && text.length > 0) return true;
// 检查媒体元素
const mediaElements = child.querySelectorAll('img, video, canvas, svg, iframe');
if (mediaElements.length > 0) return true;
// 检查背景
if (hasBackgroundContent(child)) return true;
return false;
});
return hasContent;
}
/**
* 获取页面 DOM 统计信息
*/
getDOMStats(): {
totalElements: number;
visibleElements: number;
textNodes: number;
mediaElements: number;
interactiveElements: number;
} {
const allElements = document.querySelectorAll('*');
let visibleElements = 0;
let textNodes = 0;
let mediaElements = 0;
let interactiveElements = 0;
allElements.forEach(element => {
if (isElementVisible(element)) {
visibleElements++;
}
const tagName = element.tagName.toUpperCase();
if (['IMG', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IFRAME'].includes(tagName)) {
mediaElements++;
}
if (['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'A'].includes(tagName)) {
interactiveElements++;
}
});
// 统计文本节点
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null
);
while (walker.nextNode()) {
const node = walker.currentNode;
if (node.textContent?.trim()) {
textNodes++;
}
}
return {
totalElements: allElements.length,
visibleElements,
textNodes,
mediaElements,
interactiveElements
};
}
}
// ==================== MutationObserver 检测器 ====================
/**
* MutationObserver 检测器类
*/
class MutationDetector {
private observer: MutationObserver | null = null;
private mutations: MutationRecord[] = [];
private startTime: number = 0;
private isObserving: boolean = false;
private config: {
timeout: number;
minMutations: number;
stableTime: number;
};
private resolvePromise: ((value: boolean) => void) | null = null;
private timeoutId: ReturnType<typeof setTimeout> | null = null;
private stableTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(config: Partial<WhiteScreenConfig> = {}) {
this.config = {
timeout: config.timeout ?? 10000,
minMutations: 10,
stableTime: 1000
};
}
/**
* 开始观察
*/
observe(): Promise<boolean> {
return new Promise((resolve) => {
this.resolvePromise = resolve;
this.startTime = Date.now();
this.mutations = [];
this.isObserving = true;
// 创建 MutationObserver
this.observer = new MutationObserver((mutations) => {
this.handleMutations(mutations);
});
// 配置观察选项
const observerConfig: MutationObserverInit = {
childList: true,
subtree: true,
attributes: true,
characterData: true,
attributeOldValue: false,
characterDataOldValue: false
};
// 开始观察
this.observer.observe(document.body, observerConfig);
// 设置超时
this.timeoutId = setTimeout(() => {
this.complete(false);
}, this.config.timeout);
});
}
/**
* 处理 mutation 记录
*/
private handleMutations(mutations: MutationRecord[]): void {
if (!this.isObserving) return;
this.mutations.push(...mutations);
// 重置稳定计时器
if (this.stableTimeoutId) {
clearTimeout(this.stableTimeoutId);
}
// 设置新的稳定计时器
this.stableTimeoutId = setTimeout(() => {
this.checkStability();
}, this.config.stableTime);
}
/**
* 检查页面稳定性
*/
private checkStability(): void {
if (!this.isObserving) return;
// 检查是否有足够的 mutations(表示页面有渲染活动)
const hasSufficientMutations = this.mutations.length >= this.config.minMutations;
// 检查是否有有意义的内容变化
const hasContentChanges = this.hasContentMutations();
if (hasSufficientMutations && hasContentChanges) {
this.complete(true);
} else if (Date.now() - this.startTime > this.config.timeout / 2) {
// 如果已经过了一半的超时时间,且没有足够的活动,可能是白屏
this.complete(false);
}
}
/**
* 检查是否有内容相关的 mutations
*/
private hasContentMutations(): boolean {
let contentMutations = 0;
for (const mutation of this.mutations) {
if (mutation.type === 'childList') {
// 检查是否添加了有意义的节点
const addedNodes = Array.from(mutation.addedNodes);
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (isElementVisible(element)) {
contentMutations++;
}
} else if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent?.trim()) {
contentMutations++;
}
}
}
}
}
return contentMutations >= 5;
}
/**
* 完成检测
*/
private complete(hasContent: boolean): void {
if (!this.isObserving) return;
this.isObserving = false;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this.stableTimeoutId) {
clearTimeout(this.stableTimeoutId);
this.stableTimeoutId = null;
}
if (this.resolvePromise) {
this.resolvePromise(hasContent);
this.resolvePromise = null;
}
}
/**
* 获取 mutation 统计
*/
getMutationStats(): {
totalMutations: number;
childListMutations: number;
attributeMutations: number;
characterDataMutations: number;
duration: number;
} {
let childListMutations = 0;
let attributeMutations = 0;
let characterDataMutations = 0;
for (const mutation of this.mutations) {
switch (mutation.type) {
case 'childList':
childListMutations++;
break;
case 'attributes':
attributeMutations++;
break;
case 'characterData':
characterDataMutations++;
break;
}
}
return {
totalMutations: this.mutations.length,
childListMutations,
attributeMutations,
characterDataMutations,
duration: Date.now() - this.startTime
};
}
/**
* 停止观察
*/
stop(): void {
this.complete(false);
}
}
// ==================== 截图检测器 ====================
/**
* 截图检测器类
*/
class ScreenshotDetector {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private config: {
sampleWidth: number;
sampleHeight: number;
whiteThreshold: number;
brightnessThreshold: number;
};
constructor(config: Partial<WhiteScreenConfig> = {}) {
this.config = {
sampleWidth: 100,
sampleHeight: 100,
whiteThreshold: config.threshold ?? 0.95,
brightnessThreshold: 250
};
}
/**
* 初始化 Canvas
*/
private initCanvas(): boolean {
try {
this.canvas = document.createElement('canvas');
this.canvas.width = this.config.sampleWidth;
this.canvas.height = this.config.sampleHeight;
this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
return this.ctx !== null;
} catch {
return false;
}
}
/**
* 执行截图检测
*/
async detect(): Promise<ScreenshotResult> {
if (!this.initCanvas() || !this.canvas || !this.ctx) {
return {
isWhiteScreen: false,
whitePixelRatio: 0,
totalPixels: 0,
whitePixels: 0
};
}
try {
// 使用 html2canvas 或原生方法截图
await this.captureScreen();
// 分析图像
return this.analyzeImage();
} catch (error) {
console.error('Screenshot detection failed:', error);
return {
isWhiteScreen: false,
whitePixelRatio: 0,
totalPixels: 0,
whitePixels: 0
};
}
}
/**
* 捕获屏幕
*/
private async captureScreen(): Promise<void> {
if (!this.canvas || !this.ctx) return;
const { sampleWidth, sampleHeight } = this.config;
// 方案1: 使用 html2canvas(需要引入库)
// 这里使用简化的方案:遍历可见元素并绘制
// 先填充白色背景
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, sampleWidth, sampleHeight);
// 获取 body 的背景色
const bodyStyle = window.getComputedStyle(document.body);
const bodyBgColor = bodyStyle.backgroundColor;
if (bodyBgColor && bodyBgColor !== 'rgba(0, 0, 0, 0)') {
this.ctx.fillStyle = bodyBgColor;
this.ctx.fillRect(0, 0, sampleWidth, sampleHeight);
}
// 绘制可见元素的简化表示
const elements = document.querySelectorAll('*');
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scaleX = sampleWidth / viewportWidth;
const scaleY = sampleHeight / viewportHeight;
elements.forEach(element => {
if (!isElementVisible(element)) return;
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
// 检查是否在视口内
if (rect.right < 0 || rect.bottom < 0) return;
if (rect.left > viewportWidth || rect.top > viewportHeight) return;
const style = window.getComputedStyle(element);
const bgColor = style.backgroundColor;
// 只绘制有背景色的元素
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
const x = rect.left * scaleX;
const y = rect.top * scaleY;
const width = rect.width * scaleX;
const height = rect.height * scaleY;
this.ctx!.fillStyle = bgColor;
this.ctx!.fillRect(x, y, width, height);
}
// 绘制文本区域
const text = element.textContent?.trim();
if (text && text.length > 0 && element.children.length === 0) {
const x = rect.left * scaleX;
const y = rect.top * scaleY;
const width = rect.width * scaleX;
const height = rect.height * scaleY;
// 用深色表示文本区域
this.ctx!.fillStyle = style.color || '#000000';
this.ctx!.fillRect(x, y, Math.max(width, 2), Math.max(height, 2));
}
});
// 绘制图片元素
const images = document.querySelectorAll('img');
for (const img of images) {
if (!isElementVisible(img)) continue;
const rect = img.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
try {
const x = rect.left * scaleX;
const y = rect.top * scaleY;
const width = rect.width * scaleX;
const height = rect.height * scaleY;
// 用灰色表示图片区域
this.ctx!.fillStyle = '#808080';
this.ctx!.fillRect(x, y, width, height);
} catch {
// 跨域图片无法绘制
}
}
}
/**
* 分析图像
*/
private analyzeImage(): ScreenshotResult {
if (!this.canvas || !this.ctx) {
return {
isWhiteScreen: false,
whitePixelRatio: 0,
totalPixels: 0,
whitePixels: 0
};
}
const { sampleWidth, sampleHeight, brightnessThreshold } = this.config;
const imageData = this.ctx.getImageData(0, 0, sampleWidth, sampleHeight);
const data = imageData.data;
let whitePixels = 0;
let blackPixels = 0;
let grayPixels = 0;
let coloredPixels = 0;
const totalPixels = (sampleWidth * sampleHeight);
// 遍历像素
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 计算亮度
const brightness = (r + g + b) / 3;
// 判断像素颜色类型
if (brightness >= brightnessThreshold) {
whitePixels++;
} else if (brightness <= 10) {
blackPixels++;
} else if (Math.abs(r - g) <= 20 && Math.abs(g - b) <= 20 && Math.abs(r - b) <= 20) {
grayPixels++;
} else {
coloredPixels++;
}
}
const whitePixelRatio = whitePixels / totalPixels;
const isWhiteScreen = whitePixelRatio >= this.config.whiteThreshold;
return {
isWhiteScreen,
whitePixelRatio,
totalPixels,
whitePixels,
colorDistribution: {
white: whitePixels / totalPixels,
black: blackPixels / totalPixels,
gray: grayPixels / totalPixels,
colored: coloredPixels / totalPixels
}
};
}
/**
* 释放资源
*/
dispose(): void {
this.canvas = null;
this.ctx = null;
}
}
// ==================== 骨架屏检测器 ====================
/**
* 骨架屏检测器类
*/
class SkeletonDetector {
private config: {
skeletonSelector: string;
timeout: number;
checkInterval: number;
};
private intervalId: ReturnType<typeof setInterval> | null = null;
private startTime: number = 0;
constructor(config: Partial<WhiteScreenConfig> = {}) {
this.config = {
skeletonSelector: config.skeletonSelector ?? '.skeleton, [data-skeleton], .loading-skeleton',
timeout: config.timeout ?? 10000,
checkInterval: 500
};
}
/**
* 检测骨架屏状态
*/
detect(): Promise<SkeletonResult> {
return new Promise((resolve) => {
this.startTime = Date.now();
// 首先检查骨架屏是否存在
const skeletonExists = this.isSkeletonPresent();
if (!skeletonExists) {
// 骨架屏不存在,可能已经加载完成或根本没有骨架屏
resolve({
skeletonExists: false,
skeletonRemoved: true,
detectionTime: 0
});
return;
}
// 开始定期检查骨架屏是否消失
this.intervalId = setInterval(() => {
const elapsed = Date.now() - this.startTime;
if (!this.isSkeletonPresent()) {
// 骨架屏已消失
this.stop();
resolve({
skeletonExists: true,
skeletonRemoved: true,
detectionTime: elapsed
});
return;
}
if (elapsed >= this.config.timeout) {
// 超时,骨架屏仍然存在
this.stop();
resolve({
skeletonExists: true,
skeletonRemoved: false,
detectionTime: elapsed
});
}
}, this.config.checkInterval);
});
}
/**
* 检查骨架屏是否存在
*/
private isSkeletonPresent(): boolean {
const { skeletonSelector } = this.config;
try {
const selectors = skeletonSelector.split(',').map(s => s.trim());
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const element of elements) {
if (isElementVisible(element)) {
return true;
}
}
}
return false;
} catch {
return false;
}
}
/**
* 停止检测
*/
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* 获取所有骨架屏元素
*/
getSkeletonElements(): Element[] {
const { skeletonSelector } = this.config;
const elements: Element[] = [];
try {
const selectors = skeletonSelector.split(',').map(s => s.trim());
for (const selector of selectors) {
const found = document.querySelectorAll(selector);
elements.push(...Array.from(found));
}
} catch {
// 选择器无效
}
return elements;
}
}
// ==================== Performance API 检测器 ====================
/**
* Performance API 检测器类
*/
class PerformanceDetector {
private config: {
fcpThreshold: number;
lcpThreshold: number;
};
private lcpObserver: PerformanceObserver | null = null;
private lcpValue: number = 0;
constructor(config: Partial<WhiteScreenConfig> = {}) {
this.config = {
fcpThreshold: 3000,
lcpThreshold: 4000
};
}
/**
* 初始化 LCP 观察器
*/
initLCPObserver(): void {
try {
this.lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length > 0) {
const lastEntry = entries[entries.length - 1] as PerformanceEntry & { startTime: number };
this.lcpValue = lastEntry.startTime;
}
});
this.lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch {
// LCP 观察器可能不被支持
}
}
/**
* 获取性能指标
*/
getMetrics(): PerformanceData {
const data = getPerformanceData();
// 添加 LCP
if (this.lcpValue > 0) {
data.largestContentfulPaint = this.lcpValue;
}
return data;
}
/**
* 判断是否可能白屏(基于性能指标)
*/
isPotentialWhiteScreen(): boolean {
const metrics = this.getMetrics();
// 如果 FCP 超过阈值,可能是白屏
if (metrics.firstContentfulPaint && metrics.firstContentfulPaint > this.config.fcpThreshold) {
return true;
}
// 如果 LCP 超过阈值,可能有问题
if (metrics.largestContentfulPaint && metrics.largestContentfulPaint > this.config.lcpThreshold) {
return true;
}
return false;
}
/**
* 获取资源加载统计
*/
getResourceStats(): {
totalResources: number;
failedResources: number;
slowResources: number;
resourceDetails: {
name: string;
type: string;
duration: number;
size: number;
failed: boolean;
}[];
} {
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const resourceDetails: {
name: string;
type: string;
duration: number;
size: number;
failed: boolean;
}[] = [];
let failedResources = 0;
let slowResources = 0;
for (const resource of resources) {
const failed = resource.transferSize === 0 && resource.decodedBodySize === 0;
const slow = resource.duration > 2000;
if (failed) failedResources++;
if (slow) slowResources++;
resourceDetails.push({
name: resource.name,
type: resource.initiatorType,
duration: resource.duration,
size: resource.transferSize || 0,
failed
});
}
return {
totalResources: resources.length,
failedResources,
slowResources,
resourceDetails
};
}
/**
* 停止观察
*/
stop(): void {
if (this.lcpObserver) {
this.lcpObserver.disconnect();
this.lcpObserver = null;
}
}
}
// ==================== 错误监听器 ====================
/**
* 错误监听器类
*/
class ErrorMonitor {
private errors: ErrorInfo[] = [];
private maxErrors: number = 50;
private listeners: {
error: (event: ErrorEvent) => void;
unhandledrejection: (event: PromiseRejectionEvent) => void;
};
constructor() {
this.listeners = {
error: this.handleError.bind(this),
unhandledrejection: this.handleUnhandledRejection.bind(this)
};
}
/**
* 开始监听
*/
start(): void {
window.addEventListener('error', this.listeners.error, true);
window.addEventListener('unhandledrejection', this.listeners.unhandledrejection);
}
/**
* 停止监听
*/
stop(): void {
window.removeEventListener('error', this.listeners.error, true);
window.removeEventListener('unhandledrejection', this.listeners.unhandledrejection);
}
/**
* 处理错误事件
*/
private handleError(event: ErrorEvent): void {
if (this.errors.length >= this.maxErrors) return;
this.errors.push({
message: event.message || 'Unknown error',
stack: event.error?.stack,
type: 'error'
});
}
/**
* 处理未处理的 Promise 拒绝
*/
private handleUnhandledRejection(event: PromiseRejectionEvent): void {
if (this.errors.length >= this.maxErrors) return;
let message = 'Unhandled Promise rejection';
let stack: string | undefined;
if (event.reason instanceof Error) {
message = event.reason.message;
stack = event.reason.stack;
} else if (typeof event.reason === 'string') {
message = event.reason;
}
this.errors.push({
message,
stack,
type: 'unhandledrejection'
});
}
/**
* 获取所有错误
*/
getErrors(): ErrorInfo[] {
return [...this.errors];
}
/**
* 判断是否有关键错误
*/
hasCriticalErrors(): boolean {
// 检查是否有可能导致白屏的错误
const criticalPatterns = [
/chunk.*failed/i,
/loading.*chunk/i,
/script.*error/i,
/syntaxerror/i,
/referenceerror/i,
/cannot read/i,
/is not defined/i,
/unexpected token/i
];
for (const error of this.errors) {
for (const pattern of criticalPatterns) {
if (pattern.test(error.message)) {
return true;
}
}
}
return false;
}
/**
* 清除错误
*/
clear(): void {
this.errors = [];
}
}
// ==================== 数据上报器 ====================
/**
* 数据上报器类
*/
class Reporter {
private config: {
endpoint?: string;
enableConsole: boolean;
enableBeacon: boolean;
sampleRate: number;
customHeaders?: Record<string, string>;
};
private queue: WhiteScreenReport[] = [];
private maxQueueSize: number = 10;
private isFlushing: boolean = false;
constructor(config: {
endpoint?: string;
enableConsole?: boolean;
enableBeacon?: boolean;
sampleRate?: number;
customHeaders?: Record<string, string>;
} = {}) {
this.config = {
endpoint: config.endpoint,
enableConsole: config.enableConsole ?? true,
enableBeacon: config.enableBeacon ?? true,
sampleRate: config.sampleRate ?? 1,
customHeaders: config.customHeaders
};
// 页面卸载时发送队列中的数据
window.addEventListener('beforeunload', () => {
this.flush();
});
window.addEventListener('pagehide', () => {
this.flush();
});
}
/**
* 上报数据
*/
report(data: WhiteScreenReport): void {
// 采样
if (Math.random() > this.config.sampleRate) {
return;
}
// 控制台输出
if (this.config.enableConsole) {
this.logToConsole(data);
}
// 加入队列
this.queue.push(data);
// 队列满了就发送
if (this.queue.length >= this.maxQueueSize) {
this.flush();
}
// 也可以立即发送(针对白屏这种重要事件)
if (data.isWhiteScreen) {
this.flush();
}
}
/**
* 发送队列中的数据
*/
flush(): void {
if (this.isFlushing || this.queue.length === 0) return;
this.isFlushing = true;
const dataToSend = [...this.queue];
this.queue = [];
if (this.config.endpoint) {
this.sendData(dataToSend);
}
this.isFlushing = false;
}
/**
* 发送数据
*/
private sendData(data: WhiteScreenReport[]): void {
const payload = JSON.stringify(data);
// 优先使用 Beacon API
if (this.config.enableBeacon && navigator.sendBeacon) {
try {
const blob = new Blob([payload], { type: 'application/json' });
const success = navigator.sendBeacon(this.config.endpoint!, blob);
if (success) return;
} catch {
// Beacon 失败,降级到 fetch
}
}
// 降级使用 fetch
this.sendWithFetch(payload);
}
/**
* 使用 fetch 发送
*/
private sendWithFetch(payload: string): void {
if (!this.config.endpoint) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...this.config.customHeaders
};
fetch(this.config.endpoint, {
method: 'POST',
headers,
body: payload,
keepalive: true
}).catch(() => {
// 静默失败
});
}
/**
* 控制台输出
*/
private logToConsole(data: WhiteScreenReport): void {
const prefix = '[WhiteScreen]';
if (data.isWhiteScreen) {
console.warn(
`${prefix} 检测到白屏!`,
'\n方法:', data.detectionMethod,
'\nURL:', data.url,
'\n时间:', new Date(data.timestamp).toISOString(),
'\n详情:', data
);
} else {
console.log(
`${prefix} 页面正常`,
'\n方法:', data.detectionMethod,
'\n耗时:', Date.now() - data.timestamp, 'ms'
);
}
}
/**
* 设置上报端点
*/
setEndpoint(endpoint: string): void {
this.config.endpoint = endpoint;
}
/**
* 设置采样率
*/
setSampleRate(rate: number): void {
this.config.sampleRate = Math.max(0, Math.min(1, rate));
}
}
// ==================== 主 SDK 类 ====================
/**
* 白屏检测 SDK 主类
*/
class WhiteScreenSDK {
private config: Required<WhiteScreenConfig>;
private samplingDetector: SamplingDetector;
private domDetector: DOMDetector;
private mutationDetector: MutationDetector;
private screenshotDetector: ScreenshotDetector;
private skeletonDetector: SkeletonDetector;
private performanceDetector: PerformanceDetector;
private errorMonitor: ErrorMonitor;
private reporter: Reporter;
private isInitialized: boolean = false;
private isDetecting: boolean = false;
private detectionCount: number = 0;
private lastDetectionTime: number = 0;
// 默认配置
private static defaultConfig: Required<WhiteScreenConfig> = {
samplingPoints: 17,
delay: 1000,
timeout: 10000,
threshold: 0.95,
enableDOMDetection: true,
enableSamplingDetection: true,
enableMutationDetection: true,
enableScreenshotDetection: false,
enableSkeletonDetection: true,
skeletonSelector: '.skeleton, [data-skeleton], .loading-skeleton',
keyElementSelectors: ['#app', '#root', '.app', 'main'],
ignoreSelectors: ['script', 'style', 'link', 'meta'],
container: null,
onReport: () => {},
onDetectionComplete: () => {},
enableInDev: false,
maxRetries: 3,
retryInterval: 1000,
customDetector: undefined as unknown as () => boolean
};
constructor(config: Partial<WhiteScreenConfig> = {}) {
// 合并配置
this.config = deepMerge(WhiteScreenSDK.defaultConfig, config);
// 初始化各个检测器
this.samplingDetector = new SamplingDetector(this.config);
this.domDetector = new DOMDetector(this.config);
this.mutationDetector = new MutationDetector(this.config);
this.screenshotDetector = new ScreenshotDetector(this.config);
this.skeletonDetector = new SkeletonDetector(this.config);
this.performanceDetector = new PerformanceDetector(this.config);
this.errorMonitor = new ErrorMonitor();
// 初始化上报器
this.reporter = new Reporter({
enableConsole: true,
enableBeacon: true
});
}
/**
* 初始化 SDK
*/
init(): WhiteScreenSDK {
if (this.isInitialized) {
console.warn('[WhiteScreenSDK] Already initialized');
return this;
}
// 检查是否在开发环境
if (!this.config.enableInDev && this.isDevelopment()) {
console.log('[WhiteScreenSDK] Disabled in development environment');
return this;
}
// 开始错误监听
this.errorMonitor.start();
// 初始化性能检测器
this.performanceDetector.initLCPObserver();
// 在页面加载完成后开始检测
if (document.readyState === 'complete') {
this.scheduleDetection();
} else {
window.addEventListener('load', () => {
this.scheduleDetection();
});
}
// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !this.isDetecting) {
this.scheduleDetection();
}
});
this.isInitialized = true;
console.log('[WhiteScreenSDK] Initialized');
return this;
}
/**
* 判断是否为开发环境
*/
private isDevelopment(): boolean {
return (
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname.includes('.local') ||
window.location.port !== ''
);
}
/**
* 调度检测
*/
private scheduleDetection(): void {
setTimeout(() => {
this.detect();
}, this.config.delay);
}
/**
* 执行检测
*/
async detect(): Promise<DetectionResult> {
if (this.isDetecting) {
return {
isWhiteScreen: false,
confidence: 0,
methods: [],
methodResults: new Map(),
basis: 'Already detecting'
};
}
this.isDetecting = true;
this.detectionCount++;
this.lastDetectionTime = Date.now();
const methodResults = new Map<DetectionMethod, boolean>();
const usedMethods: DetectionMethod[] = [];
try {
// 执行各种检测方法
// 1. 采样点检测
if (this.config.enableSamplingDetection) {
const samplingResult = this.samplingDetector.detect();
const isWhite = this.samplingDetector.isWhiteScreen(samplingResult);
methodResults.set(DetectionMethod.SAMPLING, isWhite);
usedMethods.push(DetectionMethod.SAMPLING);
}
// 2. DOM 检测
if (this.config.enableDOMDetection) {
const domResult = this.domDetector.detect();
const isWhite = !domResult.passed || !this.domDetector.hasValidContent();
methodResults.set(DetectionMethod.DOM, isWhite);
usedMethods.push(DetectionMethod.DOM);
}
// 3. 骨架屏检测
if (this.config.enableSkeletonDetection) {
const skeletonResult = await this.skeletonDetector.detect();
const isWhite = skeletonResult.skeletonExists && !skeletonResult.skeletonRemoved;
methodResults.set(DetectionMethod.SKELETON, isWhite);
usedMethods.push(DetectionMethod.SKELETON);
}
// 4. 截图检测(较慢,可选)
if (this.config.enableScreenshotDetection) {
const screenshotResult = await this.screenshotDetector.detect();
methodResults.set(DetectionMethod.SCREENSHOT, screenshotResult.isWhiteScreen);
usedMethods.push(DetectionMethod.SCREENSHOT);
}
// 5. 自定义检测器
if (this.config.customDetector) {
try {
const customResult = await this.config.customDetector();
methodResults.set(DetectionMethod.CUSTOM, customResult);
usedMethods.push(DetectionMethod.CUSTOM);
} catch {
// 自定义检测器失败
}
}
// 综合判断
const result = this.combineResults(methodResults, usedMethods);
// 生成报告
const report = this.generateReport(result, methodResults);
// 上报
this.reporter.report(report);
this.config.onReport(report);
this.config.onDetectionComplete(result);
// 如果检测到白屏,可能需要重试
if (result.isWhiteScreen && this.detectionCount < this.config.maxRetries) {
setTimeout(() => {
this.isDetecting = false;
this.detect();
}, this.config.retryInterval);
}
return result;
} catch (error) {
console.error('[WhiteScreenSDK] Detection error:', error);
return {
isWhiteScreen: false,
confidence: 0,
methods: usedMethods,
methodResults,
basis: `Error: ${(error as Error).message}`
};
} finally {
this.isDetecting = false;
}
}
/**
* 综合各方法结果
*/
private combineResults(
methodResults: Map<DetectionMethod, boolean>,
usedMethods: DetectionMethod[]
): DetectionResult {
if (usedMethods.length === 0) {
return {
isWhiteScreen: false,
confidence: 0,
methods: [],
methodResults,
basis: 'No detection methods enabled'
};
}
// 计算白屏票数
let whiteVotes = 0;
let totalVotes = 0;
// 不同方法的权重
const weights: Record<DetectionMethod, number> = {
[DetectionMethod.SAMPLING]: 3,
[DetectionMethod.DOM]: 2,
[DetectionMethod.MUTATION]: 2,
[DetectionMethod.SCREENSHOT]: 2,
[DetectionMethod.SKELETON]: 1,
[DetectionMethod.PERFORMANCE]: 1,
[DetectionMethod.CUSTOM]: 2,
[DetectionMethod.COMBINED]: 1
};
for (const method of usedMethods) {
const isWhite = methodResults.get(method);
const weight = weights[method] || 1;
totalVotes += weight;
if (isWhite) {
whiteVotes += weight;
}
}
// 计算置信度
const confidence = totalVotes > 0 ? Math.abs(whiteVotes - totalVotes / 2) / (totalVotes / 2) : 0;
// 判断是否白屏(超过半数加权投票)
const isWhiteScreen = whiteVotes > totalVotes / 2;
// 确定判定依据
let basis = isWhiteScreen ? 'Multiple methods indicate white screen' : 'Page appears normal';
// 检查是否有关键错误
if (this.errorMonitor.hasCriticalErrors()) {
basis += ' (Critical JS errors detected)';
}
return {
isWhiteScreen,
confidence,
methods: usedMethods,
methodResults,
basis
};
}
/**
* 生成报告
*/
private generateReport(
result: DetectionResult,
methodResults: Map<DetectionMethod, boolean>
): WhiteScreenReport {
const report: WhiteScreenReport = {
isWhiteScreen: result.isWhiteScreen,
timestamp: Date.now(),
url: window.location.href,
detectionMethod: result.methods.length > 1 ? DetectionMethod.COMBINED : result.methods[0],
userAgent: navigator.userAgent,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
devicePixelRatio: window.devicePixelRatio,
networkInfo: getNetworkInfo(),
performanceData: this.performanceDetector.getMetrics()
};
// 添加采样结果
if (methodResults.has(DetectionMethod.SAMPLING)) {
report.samplingResult = this.samplingDetector.detect();
}
// 添加 DOM 检测结果
if (methodResults.has(DetectionMethod.DOM)) {
report.domResult = this.domDetector.detect();
}
// 添加错误信息
const errors = this.errorMonitor.getErrors();
if (errors.length > 0) {
report.errorInfo = errors[0];
}
return report;
}
/**
* 手动触发检测
*/
manualDetect(): Promise<DetectionResult> {
return this.detect();
}
/**
* 设置上报回调
*/
setReportCallback(callback: (data: WhiteScreenReport) => void): void {
this.config.onReport = callback;
}
/**
* 设置检测完成回调
*/
setDetectionCompleteCallback(callback: (result: DetectionResult) => void): void {
this.config.onDetectionComplete = callback;
}
/**
* 获取检测统计
*/
getStats(): {
detectionCount: number;
lastDetectionTime: number;
errors: ErrorInfo[];
performance: PerformanceData;
} {
return {
detectionCount: this.detectionCount,
lastDetectionTime: this.lastDetectionTime,
errors: this.errorMonitor.getErrors(),
performance: this.performanceDetector.getMetrics()
};
}
/**
* 销毁 SDK
*/
destroy(): void {
this.errorMonitor.stop();
this.performanceDetector.stop();
this.skeletonDetector.stop();
this.screenshotDetector.dispose();
this.isInitialized = false;
console.log('[WhiteScreenSDK] Destroyed');
}
}
// ==================== 导出 ====================
// 创建单例
let sdkInstance: WhiteScreenSDK | null = null;
/**
* 获取 SDK 实例(单例模式)
*/
function getWhiteScreenSDK(config?: Partial<WhiteScreenConfig>): WhiteScreenSDK {
if (!sdkInstance) {
sdkInstance = new WhiteScreenSDK(config);
}
return sdkInstance;
}
/**
* 快速初始化并返回实例
*/
function initWhiteScreenDetection(config?: Partial<WhiteScreenConfig>): WhiteScreenSDK {
const sdk = getWhiteScreenSDK(config);
sdk.init();
return sdk;
}
// ES Module 导出
export {
WhiteScreenSDK,
WhiteScreenConfig,
WhiteScreenReport,
DetectionMethod,
DetectionResult,
SamplingDetector,
DOMDetector,
MutationDetector,
ScreenshotDetector,
SkeletonDetector,
PerformanceDetector,
ErrorMonitor,
Reporter,
getWhiteScreenSDK,
initWhiteScreenDetection
};
// UMD 导出(兼容浏览器直接使用)
if (typeof window !== 'undefined') {
(window as Window & { WhiteScreenSDK: typeof WhiteScreenSDK; initWhiteScreenDetection: typeof initWhiteScreenDetection }).WhiteScreenSDK = WhiteScreenSDK;
(window as Window & { initWhiteScreenDetection: typeof initWhiteScreenDetection }).initWhiteScreenDetection = initWhiteScreenDetection;
}
三、使用示例
1. 基础使用
// 最简单的使用方式
import { initWhiteScreenDetection } from './white-screen-sdk';
// 初始化并开始检测
const sdk = initWhiteScreenDetection({
threshold: 0.9,
delay: 2000,
onReport: (report) => {
if (report.isWhiteScreen) {
// 发送到监控平台
sendToMonitor(report);
}
}
});
2. 完整配置示例
import { WhiteScreenSDK, WhiteScreenReport, DetectionResult } from './white-screen-sdk';
// 创建 SDK 实例,完整配置
const sdk = new WhiteScreenSDK({
// 采样配置
samplingPoints: 20,
threshold: 0.9,
// 时间配置
delay: 1500,
timeout: 15000,
// 功能开关
enableDOMDetection: true,
enableSamplingDetection: true,
enableMutationDetection: true,
enableScreenshotDetection: false,
enableSkeletonDetection: true,
// 选择器配置
keyElementSelectors: [
'#app',
'#root',
'.main-content',
'[data-page-ready]'
],
skeletonSelector: '.skeleton, .loading-placeholder',
ignoreSelectors: [
'script',
'style',
'.loading-indicator',
'.modal-backdrop'
],
// 重试配置
maxRetries: 3,
retryInterval: 2000,
// 环境配置
enableInDev: false,
// 回调函数
onReport: (report: WhiteScreenReport) => {
console.log('检测报告:', report);
if (report.isWhiteScreen) {
// 上报到监控系统
reportToMonitoringSystem(report);
// 可选:尝试恢复页面
attemptPageRecovery();
}
},
onDetectionComplete: (result: DetectionResult) => {
console.log('检测完成:', result);
console.log('置信度:', result.confidence);
console.log('判定依据:', result.basis);
},
// 自定义检测逻辑
customDetector: () => {
// 自定义白屏判断逻辑
const appElement = document.getElementById('app');
if (!appElement) return true;
// 检查是否有实际内容
const hasContent = appElement.children.length > 0;
const hasText = (appElement.textContent?.trim().length ?? 0) > 100;
return !hasContent && !hasText;
}
});
// 初始化
sdk.init();
// 手动触发检测
document.getElementById('checkBtn')?.addEventListener('click', async () => {
const result = await sdk.manualDetect();
console.log('手动检测结果:', result);
});
// 上报函数
function reportToMonitoringSystem(report: WhiteScreenReport): void {
// 发送到监控后端
fetch('/api/monitor/white-screen', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(report)
}).catch(console.error);
}
// 页面恢复尝试
function attemptPageRecovery(): void {
// 尝试重新加载关键资源
const failedScripts = document.querySelectorAll('script[data-retry]');
failedScripts.forEach(script => {
const newScript = document.createElement('script');
newScript.src = (script as HTMLScriptElement).src;
document.body.appendChild(newScript);
});
// 或者刷新页面
// window.location.reload();
}
3. Vue 3 集成示例
// white-screen-plugin.ts
import { App, Plugin } from 'vue';
import { WhiteScreenSDK, WhiteScreenConfig } from './white-screen-sdk';
export interface WhiteScreenPluginOptions extends Partial<WhiteScreenConfig> {
reportEndpoint?: string;
}
export const WhiteScreenPlugin: Plugin = {
install(app: App, options: WhiteScreenPluginOptions = {}) {
// 创建 SDK 实例
const sdk = new WhiteScreenSDK({
// 默认配置
keyElementSelectors: ['#app', '[data-v-app]'],
enableInDev: false,
// 合并用户配置
...options,
// 设置上报回调
onReport: (report) => {
// 调用用户提供的回调
options.onReport?.(report);
// 如果提供了上报端点,自动上报
if (options.reportEndpoint && report.isWhiteScreen) {
fetch(options.reportEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...report,
framework: 'vue',
version: app.version
})
}).catch(console.error);
}
}
});
// 注册全局属性
app.config.globalProperties.$whiteScreen = sdk;
// 提供给 Composition API 使用
app.provide('whiteScreenSDK', sdk);
// 在应用挂载后初始化
const originalMount = app.mount.bind(app);
app.mount = (rootContainer) => {
const vm = originalMount(rootContainer);
// 延迟初始化,等待 Vue 渲染完成
setTimeout(() => {
sdk.init();
}, 100);
return vm;
};
// 在应用卸载时销毁
const originalUnmount = app.unmount.bind(app);
app.unmount = () => {
sdk.destroy();
originalUnmount();
};
}
};
// 类型声明
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$whiteScreen: WhiteScreenSDK;
}
}
// 组合式 API Hook
import { inject } from 'vue';
export function useWhiteScreen(): WhiteScreenSDK | undefined {
return inject<WhiteScreenSDK>('whiteScreenSDK');
}
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { WhiteScreenPlugin } from './plugins/white-screen-plugin';
const app = createApp(App);
app.use(WhiteScreenPlugin, {
threshold: 0.9,
delay: 2000,
reportEndpoint: '/api/monitor/white-screen',
enableInDev: false,
keyElementSelectors: ['#app', '.main-layout', '[data-page-content]'],
onDetectionComplete: (result) => {
if (result.isWhiteScreen) {
// 可以在这里触发重试逻辑或显示友好提示
console.warn('页面可能出现白屏,正在尝试恢复...');
}
}
});
app.mount('#app');
<!-- 在组件中使用 -->
<template>
<div class="page">
<h1>示例页面</h1>
<button @click="checkWhiteScreen">手动检测白屏</button>
</div>
</template>
<script setup lang="ts">
import { useWhiteScreen } from '@/plugins/white-screen-plugin';
const whiteScreenSDK = useWhiteScreen();
async function checkWhiteScreen() {
if (whiteScreenSDK) {
const result = await whiteScreenSDK.manualDetect();
console.log('检测结果:', result);
if (result.isWhiteScreen) {
alert('检测到白屏问题!');
} else {
alert('页面正常');
}
}
}
</script>
4. React 集成示例
// WhiteScreenContext.tsx
import React, { createContext, useContext, useEffect, useRef, ReactNode } from 'react';
import { WhiteScreenSDK, WhiteScreenConfig, DetectionResult } from './white-screen-sdk';
interface WhiteScreenContextValue {
sdk: WhiteScreenSDK | null;
manualDetect: () => Promise<DetectionResult | null>;
getStats: () => ReturnType<WhiteScreenSDK['getStats']> | null;
}
const WhiteScreenContext = createContext<WhiteScreenContextValue>({
sdk: null,
manualDetect: async () => null,
getStats: () => null
});
export interface WhiteScreenProviderProps {
children: ReactNode;
config?: Partial<WhiteScreenConfig>;
onWhiteScreen?: (result: DetectionResult) => void;
}
export function WhiteScreenProvider({
children,
config = {},
onWhiteScreen
}: WhiteScreenProviderProps) {
const sdkRef = useRef<WhiteScreenSDK | null>(null);
useEffect(() => {
// 创建 SDK 实例
const sdk = new WhiteScreenSDK({
keyElementSelectors: ['#root', '[data-reactroot]', '.app-container'],
enableInDev: false,
...config,
onDetectionComplete: (result) => {
config.onDetectionComplete?.(result);
if (result.isWhiteScreen && onWhiteScreen) {
onWhiteScreen(result);
}
}
});
sdkRef.current = sdk;
// 初始化
sdk.init();
// 清理
return () => {
sdk.destroy();
sdkRef.current = null;
};
}, []);
const manualDetect = async () => {
if (sdkRef.current) {
return sdkRef.current.manualDetect();
}
return null;
};
const getStats = () => {
if (sdkRef.current) {
return sdkRef.current.getStats();
}
return null;
};
return (
<WhiteScreenContext.Provider value={{
sdk: sdkRef.current,
manualDetect,
getStats
}}>
{children}
</WhiteScreenContext.Provider>
);
}
// Hook
export function useWhiteScreen() {
return useContext(WhiteScreenContext);
}
// HOC
export function withWhiteScreenDetection<P extends object>(
WrappedComponent: React.ComponentType<P>,
config?: Partial<WhiteScreenConfig>
) {
return function WithWhiteScreenComponent(props: P) {
return (
<WhiteScreenProvider config={config}>
<WrappedComponent {...props} />
</WhiteScreenProvider>
);
};
}
// App.tsx
import React from 'react';
import { WhiteScreenProvider } from './contexts/WhiteScreenContext';
function App() {
const handleWhiteScreen = (result) => {
// 白屏时的处理逻辑
console.error('检测到白屏:', result);
// 可以显示错误边界或重试按钮
// setShowErrorBoundary(true);
};
return (
<WhiteScreenProvider
config={{
threshold: 0.85,
delay: 2000,
enableScreenshotDetection: false
}}
onWhiteScreen={handleWhiteScreen}
>
<div className="app">
{/* 应用内容 */}
</div>
</WhiteScreenProvider>
);
}
export default App;
// 在组件中使用
import React from 'react';
import { useWhiteScreen } from './contexts/WhiteScreenContext';
function DebugPanel() {
const { manualDetect, getStats } = useWhiteScreen();
const handleCheck = async () => {
const result = await manualDetect();
if (result) {
console.log('检测结果:', result);
alert(result.isWhiteScreen ? '检测到白屏' : '页面正常');
}
};
const handleShowStats = () => {
const stats = getStats();
console.log('统计信息:', stats);
};
return (
<div className="debug-panel">
<button onClick={handleCheck}>手动检测白屏</button>
<button onClick={handleShowStats}>查看统计</button>
</div>
);
}
四、服务端数据处理示例
// server/white-screen-handler.ts
import { Request, Response } from 'express';
interface WhiteScreenReport {
isWhiteScreen: boolean;
timestamp: number;
url: string;
detectionMethod: string;
userAgent: string;
viewport: { width: number; height: number };
performanceData?: Record<string, number>;
errorInfo?: { message: string; stack?: string; type: string };
// ... 其他字段
}
interface WhiteScreenStats {
totalReports: number;
whiteScreenCount: number;
whiteScreenRate: number;
topUrls: { url: string; count: number }[];
topErrors: { message: string; count: number }[];
hourlyDistribution: { hour: number; count: number }[];
}
class WhiteScreenAnalyzer {
private reports: WhiteScreenReport[] = [];
private maxReports: number = 10000;
/**
* 添加报告
*/
addReport(report: WhiteScreenReport): void {
this.reports.push(report);
// 保持报告数量在限制内
if (this.reports.length > this.maxReports) {
this.reports = this.reports.slice(-this.maxReports);
}
}
/**
* 获取统计数据
*/
getStats(timeRange?: { start: number; end: number }): WhiteScreenStats {
let filteredReports = this.reports;
if (timeRange) {
filteredReports = this.reports.filter(
r => r.timestamp >= timeRange.start && r.timestamp <= timeRange.end
);
}
const totalReports = filteredReports.length;
const whiteScreenReports = filteredReports.filter(r => r.isWhiteScreen);
const whiteScreenCount = whiteScreenReports.length;
const whiteScreenRate = totalReports > 0 ? whiteScreenCount / totalReports : 0;
// 统计 Top URLs
const urlCounts = new Map<string, number>();
whiteScreenReports.forEach(r => {
const url = new URL(r.url).pathname;
urlCounts.set(url, (urlCounts.get(url) || 0) + 1);
});
const topUrls = Array.from(urlCounts.entries())
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// 统计 Top Errors
const errorCounts = new Map<string, number>();
whiteScreenReports.forEach(r => {
if (r.errorInfo?.message) {
const msg = r.errorInfo.message.slice(0, 100);
errorCounts.set(msg, (errorCounts.get(msg) || 0) + 1);
}
});
const topErrors = Array.from(errorCounts.entries())
.map(([message, count]) => ({ message, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// 小时分布
const hourCounts = new Array(24).fill(0);
whiteScreenReports.forEach(r => {
const hour = new Date(r.timestamp).getHours();
hourCounts[hour]++;
});
const hourlyDistribution = hourCounts.map((count, hour) => ({ hour, count }));
return {
totalReports,
whiteScreenCount,
whiteScreenRate,
topUrls,
topErrors,
hourlyDistribution
};
}
/**
* 检查是否需要告警
*/
shouldAlert(): { alert: boolean; reason: string } {
const recentReports = this.reports.filter(
r => Date.now() - r.timestamp < 5 * 60 * 1000 // 最近5分钟
);
if (recentReports.length === 0) {
return { alert: false, reason: '' };
}
const whiteScreenRate = recentReports.filter(r => r.isWhiteScreen).length / recentReports.length;
if (whiteScreenRate > 0.1) {
return {
alert: true,
reason: `白屏率过高: ${(whiteScreenRate * 100).toFixed(1)}%`
};
}
return { alert: false, reason: '' };
}
}
// Express 路由处理
const analyzer = new WhiteScreenAnalyzer();
export function handleWhiteScreenReport(req: Request, res: Response): void {
try {
const reports: WhiteScreenReport[] = Array.isArray(req.body) ? req.body : [req.body];
reports.forEach(report => {
analyzer.addReport(report);
});
// 检查是否需要告警
const alertStatus = analyzer.shouldAlert();
if (alertStatus.alert) {
// 发送告警(例如:发送邮件、短信、钉钉通知等)
sendAlert(alertStatus.reason);
}
res.status(200).json({ success: true, received: reports.length });
} catch (error) {
console.error('处理白屏报告失败:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
}
export function getWhiteScreenStats(req: Request, res: Response): void {
try {
const { start, end } = req.query;
const timeRange = start && end
? { start: Number(start), end: Number(end) }
: undefined;
const stats = analyzer.getStats(timeRange);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
}
function sendAlert(reason: string): void {
// 实现告警逻辑
console.warn('[白屏告警]', reason);
// 可以集成各种告警渠道:邮件、短信、钉钉、企业微信等
}
五、总结
以上代码实现了一个完整的前端白屏检测 SDK,包含以下核心功能:
-
多种检测方法:
- 采样点检测 - 在页面关键位置采样判断
- DOM 检测 - 检查关键 DOM 元素是否存在和可见
- MutationObserver 检测 - 监听 DOM 变化
- 截图检测 - 通过 Canvas 分析页面内容
- 骨架屏检测 - 检测 loading 状态
-
完善的上报机制:
- 支持 Beacon API 和 fetch 降级
- 队列批量上报
- 页面卸载时发送
-
框架集成:
- Vue 3 插件和 Composition API Hook
- React Context 和 Hook
-
服务端处理:
实际使用时,可以根据项目需求选择合适的检测方法组合,并配置合理的阈值和延迟时间,以达到最佳的检测效果。