6797c24b5c
refactor(layout): 优化页脚布局和备案信息展示 feat(constants): 添加ICP备案和公安备案信息 feat(header): 实现移动端加载时的骨架屏效果 style(globals): 调整文字颜色和添加移动端响应样式 feat(breadcrumb): 增加返回按钮和响应式优化 feat(e2e): 添加移动端测试工具和测试用例 docs: 添加页脚重设计文档
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
import { Page } from '@playwright/test';
|
|
import { PerformanceMetrics, PerformanceThresholds } from '../types';
|
|
|
|
export class PerformanceMonitor {
|
|
private page: Page;
|
|
private metrics: PerformanceMetrics;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
this.metrics = {
|
|
loadTime: 0,
|
|
firstContentfulPaint: 0,
|
|
largestContentfulPaint: 0,
|
|
timeToInteractive: 0,
|
|
cumulativeLayoutShift: 0,
|
|
firstInputDelay: 0,
|
|
};
|
|
}
|
|
|
|
async startMonitoring(): Promise<void> {
|
|
await this.page.evaluate(() => {
|
|
window.performance.clearResourceTimings();
|
|
});
|
|
|
|
await this.page.evaluate(() => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
entries.forEach((entry) => {
|
|
if (entry.entryType === 'layout-shift' && !(entry as any).hadRecentInput) {
|
|
(window as any).cumulativeLayoutShift = ((window as any).cumulativeLayoutShift || 0) + (entry as any).value;
|
|
}
|
|
});
|
|
});
|
|
observer.observe({ entryTypes: ['layout-shift'] });
|
|
}
|
|
});
|
|
}
|
|
|
|
async collectMetrics(): Promise<PerformanceMetrics> {
|
|
const navigationTiming = await this.page.evaluate(() => {
|
|
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
return {
|
|
loadTime: timing.loadEventEnd - timing.fetchStart,
|
|
domContentLoaded: timing.domContentLoadedEventEnd - timing.fetchStart,
|
|
firstPaint: timing.responseStart - timing.fetchStart,
|
|
};
|
|
});
|
|
|
|
const paintTiming = await this.page.evaluate(() => {
|
|
const paints = performance.getEntriesByType('paint');
|
|
const fcp = paints.find((p) => p.name === 'first-contentful-paint');
|
|
return {
|
|
firstContentfulPaint: fcp ? fcp.startTime : 0,
|
|
};
|
|
});
|
|
|
|
const lcp = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
const lastEntry = entries[entries.length - 1];
|
|
resolve(lastEntry ? lastEntry.startTime : 0);
|
|
});
|
|
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
|
setTimeout(() => resolve(0), 5000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
const cls = await this.page.evaluate(() => {
|
|
return (window as any).cumulativeLayoutShift || 0;
|
|
});
|
|
|
|
const tti = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
const longTasks = entries.filter((e) => e.duration > 50);
|
|
if (longTasks.length > 0) {
|
|
resolve(longTasks[0]?.startTime || 0);
|
|
}
|
|
});
|
|
observer.observe({ entryTypes: ['longtask'] });
|
|
setTimeout(() => resolve(0), 10000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
const fid = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
if (entries.length > 0) {
|
|
const entry = entries[0] as any;
|
|
resolve((entry?.processingStart || 0) - (entry?.startTime || 0));
|
|
}
|
|
});
|
|
observer.observe({ entryTypes: ['first-input'] });
|
|
setTimeout(() => resolve(0), 5000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
this.metrics = {
|
|
loadTime: navigationTiming.loadTime,
|
|
firstContentfulPaint: paintTiming.firstContentfulPaint,
|
|
largestContentfulPaint: lcp,
|
|
timeToInteractive: tti,
|
|
cumulativeLayoutShift: cls,
|
|
firstInputDelay: fid,
|
|
};
|
|
|
|
return this.metrics;
|
|
}
|
|
|
|
async measurePageLoad(): Promise<number> {
|
|
const startTime = Date.now();
|
|
await this.page.waitForLoadState('networkidle');
|
|
const endTime = Date.now();
|
|
return endTime - startTime;
|
|
}
|
|
|
|
async measureFirstContentfulPaint(): Promise<number> {
|
|
const fcp = await this.page.evaluate(() => {
|
|
const paints = performance.getEntriesByType('paint');
|
|
const fcpEntry = paints.find((p) => p.name === 'first-contentful-paint');
|
|
return fcpEntry ? fcpEntry.startTime : 0;
|
|
});
|
|
return fcp;
|
|
}
|
|
|
|
async measureLargestContentfulPaint(): Promise<number> {
|
|
const lcp = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
const lastEntry = entries[entries.length - 1];
|
|
resolve(lastEntry ? lastEntry.startTime : 0);
|
|
});
|
|
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
|
setTimeout(() => resolve(0), 5000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
return lcp;
|
|
}
|
|
|
|
async measureCumulativeLayoutShift(): Promise<number> {
|
|
const cls = await this.page.evaluate(() => {
|
|
return (window as any).cumulativeLayoutShift || 0;
|
|
});
|
|
return cls;
|
|
}
|
|
|
|
async measureTimeToInteractive(): Promise<number> {
|
|
const tti = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
const longTasks = entries.filter((e) => e.duration > 50);
|
|
if (longTasks.length > 0) {
|
|
resolve(longTasks[0]?.startTime || 0);
|
|
}
|
|
});
|
|
observer.observe({ entryTypes: ['longtask'] });
|
|
setTimeout(() => resolve(0), 10000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
return tti;
|
|
}
|
|
|
|
async measureFirstInputDelay(): Promise<number> {
|
|
const fid = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
if (entries.length > 0) {
|
|
const entry = entries[0] as any;
|
|
resolve((entry?.processingStart || 0) - (entry?.startTime || 0));
|
|
}
|
|
});
|
|
observer.observe({ entryTypes: ['first-input'] });
|
|
setTimeout(() => resolve(0), 5000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
return fid;
|
|
}
|
|
|
|
async measureResourceTiming(): Promise<any[]> {
|
|
const resources = await this.page.evaluate(() => {
|
|
return performance.getEntriesByType('resource').map((r) => {
|
|
const resource = r as any;
|
|
return {
|
|
name: resource.name,
|
|
duration: resource.duration,
|
|
size: resource.transferSize,
|
|
type: resource.initiatorType,
|
|
};
|
|
});
|
|
});
|
|
return resources;
|
|
}
|
|
|
|
async measureMemoryUsage(): Promise<number> {
|
|
const memory = await this.page.evaluate(() => {
|
|
return (performance as any).memory?.usedJSHeapSize || 0;
|
|
});
|
|
return memory;
|
|
}
|
|
|
|
async measureFrameRate(): Promise<number> {
|
|
const frameRate = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
let frames = 0;
|
|
const startTime = performance.now();
|
|
|
|
function countFrames() {
|
|
frames++;
|
|
if (performance.now() - startTime >= 1000) {
|
|
resolve(frames);
|
|
} else {
|
|
requestAnimationFrame(countFrames);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(countFrames);
|
|
});
|
|
});
|
|
return frameRate;
|
|
}
|
|
|
|
async measureDomContentLoaded(): Promise<number> {
|
|
const dcl = await this.page.evaluate(() => {
|
|
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
return timing.domContentLoadedEventEnd - timing.fetchStart;
|
|
});
|
|
return dcl;
|
|
}
|
|
|
|
validateMetrics(thresholds: PerformanceThresholds): { passed: boolean; violations: string[] } {
|
|
const violations: string[] = [];
|
|
|
|
if (this.metrics.loadTime > thresholds.loadTime) {
|
|
violations.push(`页面加载时间 ${this.metrics.loadTime}ms 超过阈值 ${thresholds.loadTime}ms`);
|
|
}
|
|
if (this.metrics.firstContentfulPaint > thresholds.firstContentfulPaint) {
|
|
violations.push(`首次内容绘制 ${this.metrics.firstContentfulPaint}ms 超过阈值 ${thresholds.firstContentfulPaint}ms`);
|
|
}
|
|
if (this.metrics.largestContentfulPaint > thresholds.largestContentfulPaint) {
|
|
violations.push(`最大内容绘制 ${this.metrics.largestContentfulPaint}ms 超过阈值 ${thresholds.largestContentfulPaint}ms`);
|
|
}
|
|
if (this.metrics.timeToInteractive > thresholds.timeToInteractive) {
|
|
violations.push(`可交互时间 ${this.metrics.timeToInteractive}ms 超过阈值 ${thresholds.timeToInteractive}ms`);
|
|
}
|
|
if (this.metrics.cumulativeLayoutShift > thresholds.cumulativeLayoutShift) {
|
|
violations.push(`累积布局偏移 ${this.metrics.cumulativeLayoutShift} 超过阈值 ${thresholds.cumulativeLayoutShift}`);
|
|
}
|
|
if (this.metrics.firstInputDelay > thresholds.firstInputDelay) {
|
|
violations.push(`首次输入延迟 ${this.metrics.firstInputDelay}ms 超过阈值 ${thresholds.firstInputDelay}ms`);
|
|
}
|
|
|
|
return {
|
|
passed: violations.length === 0,
|
|
violations,
|
|
};
|
|
}
|
|
|
|
getMetrics(): PerformanceMetrics {
|
|
return this.metrics;
|
|
}
|
|
|
|
async generateReport(): Promise<string> {
|
|
const metrics = await this.collectMetrics();
|
|
const resources = await this.measureResourceTiming();
|
|
|
|
let report = '=== 性能测试报告 ===\n\n';
|
|
report += '核心指标:\n';
|
|
report += `- 页面加载时间: ${metrics.loadTime.toFixed(2)}ms\n`;
|
|
report += `- 首次内容绘制: ${metrics.firstContentfulPaint.toFixed(2)}ms\n`;
|
|
report += `- 最大内容绘制: ${metrics.largestContentfulPaint.toFixed(2)}ms\n`;
|
|
report += `- 可交互时间: ${metrics.timeToInteractive.toFixed(2)}ms\n`;
|
|
report += `- 累积布局偏移: ${metrics.cumulativeLayoutShift.toFixed(4)}\n`;
|
|
report += `- 首次输入延迟: ${metrics.firstInputDelay.toFixed(2)}ms\n\n`;
|
|
|
|
report += '资源加载:\n';
|
|
const totalResources = resources.length;
|
|
const totalSize = resources.reduce((sum, r) => sum + (r.size || 0), 0);
|
|
const avgDuration = resources.reduce((sum, r) => sum + r.duration, 0) / totalResources;
|
|
|
|
report += `- 总资源数: ${totalResources}\n`;
|
|
report += `- 总大小: ${(totalSize / 1024).toFixed(2)}KB\n`;
|
|
report += `- 平均加载时间: ${avgDuration.toFixed(2)}ms\n`;
|
|
|
|
return report;
|
|
}
|
|
|
|
async measureFirstMeaningfulPaint(): Promise<number> {
|
|
const fmp = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
if ('PerformanceObserver' in window) {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
if (entries.length > 0) {
|
|
resolve(entries[0].startTime);
|
|
}
|
|
});
|
|
observer.observe({ entryTypes: ['first-meaningful-paint'] });
|
|
setTimeout(() => resolve(0), 5000);
|
|
} else {
|
|
resolve(0);
|
|
}
|
|
});
|
|
});
|
|
return fmp;
|
|
}
|
|
|
|
async measureNetworkPerformance(): Promise<{
|
|
dnsLookup: number;
|
|
tcpConnection: number;
|
|
sslHandshake: number;
|
|
requestTime: number;
|
|
responseTime: number;
|
|
totalTime: number;
|
|
}> {
|
|
const timing = await this.page.evaluate(() => {
|
|
const perf = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
return {
|
|
dnsLookup: perf.domainLookupEnd - perf.domainLookupStart,
|
|
tcpConnection: perf.connectEnd - perf.connectStart,
|
|
sslHandshake: perf.secureConnectionStart > 0 ? perf.connectEnd - perf.secureConnectionStart : 0,
|
|
requestTime: perf.responseStart - perf.requestStart,
|
|
responseTime: perf.responseEnd - perf.responseStart,
|
|
totalTime: perf.loadEventEnd - perf.fetchStart,
|
|
};
|
|
});
|
|
return timing;
|
|
}
|
|
|
|
async measureBatteryImpact(): Promise<{
|
|
estimatedImpact: string;
|
|
recommendations: string[];
|
|
}> {
|
|
const metrics = await this.collectMetrics();
|
|
const recommendations: string[] = [];
|
|
let impact = 'low';
|
|
|
|
if (metrics.loadTime > 3000) {
|
|
recommendations.push('页面加载时间过长,建议优化资源加载');
|
|
impact = 'high';
|
|
}
|
|
if (metrics.firstInputDelay > 100) {
|
|
recommendations.push('首次输入延迟较高,建议优化JavaScript执行');
|
|
impact = impact === 'high' ? 'high' : 'medium';
|
|
}
|
|
if (metrics.largestContentfulPaint > 2500) {
|
|
recommendations.push('最大内容绘制时间过长,建议优化关键渲染路径');
|
|
impact = impact === 'high' ? 'high' : 'medium';
|
|
}
|
|
|
|
return {
|
|
estimatedImpact: impact,
|
|
recommendations,
|
|
};
|
|
}
|
|
|
|
async validateLCP(value: number, threshold: number = 2500): boolean {
|
|
return value <= threshold;
|
|
}
|
|
|
|
async validateFID(value: number, threshold: number = 100): boolean {
|
|
return value <= threshold;
|
|
}
|
|
|
|
async validateCLS(value: number, threshold: number = 0.1): boolean {
|
|
return value <= threshold;
|
|
}
|
|
|
|
async validateTTI(value: number, threshold: number = 3500): boolean {
|
|
return value <= threshold;
|
|
}
|
|
|
|
async validateTTFB(value: number, threshold: number = 600): boolean {
|
|
return value <= threshold;
|
|
}
|
|
|
|
async validateFCP(value: number, threshold: number = 1800): boolean {
|
|
return value <= threshold;
|
|
}
|
|
|
|
async getCoreWebVitalsSummary(): Promise<{
|
|
lcp: { value: number; threshold: number; passed: boolean };
|
|
fid: { value: number; threshold: number; passed: boolean };
|
|
cls: { value: number; threshold: number; passed: boolean };
|
|
tti: { value: number; threshold: number; passed: boolean };
|
|
ttfb: { value: number; threshold: number; passed: boolean };
|
|
fcp: { value: number; threshold: number; passed: boolean };
|
|
}> {
|
|
const metrics = await this.collectMetrics();
|
|
const ttfb = await this.measureFirstByteTime();
|
|
const fcp = await this.measureFirstContentfulPaint();
|
|
|
|
return {
|
|
lcp: {
|
|
value: metrics.largestContentfulPaint,
|
|
threshold: 2500,
|
|
passed: await this.validateLCP(metrics.largestContentfulPaint),
|
|
},
|
|
fid: {
|
|
value: metrics.firstInputDelay,
|
|
threshold: 100,
|
|
passed: await this.validateFID(metrics.firstInputDelay),
|
|
},
|
|
cls: {
|
|
value: metrics.cumulativeLayoutShift,
|
|
threshold: 0.1,
|
|
passed: await this.validateCLS(metrics.cumulativeLayoutShift),
|
|
},
|
|
tti: {
|
|
value: metrics.timeToInteractive,
|
|
threshold: 3500,
|
|
passed: await this.validateTTI(metrics.timeToInteractive),
|
|
},
|
|
ttfb: {
|
|
value: ttfb,
|
|
threshold: 600,
|
|
passed: await this.validateTTFB(ttfb),
|
|
},
|
|
fcp: {
|
|
value: fcp,
|
|
threshold: 1800,
|
|
passed: await this.validateFCP(fcp),
|
|
},
|
|
};
|
|
}
|
|
|
|
async measureFirstByteTime(): Promise<number> {
|
|
const ttfb = await this.page.evaluate(() => {
|
|
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
return timing.responseStart - timing.fetchStart;
|
|
});
|
|
return ttfb;
|
|
}
|
|
|
|
async measureMobileSpecificMetrics(): Promise<{
|
|
touchResponseTime: number;
|
|
scrollPerformance: number;
|
|
gestureLatency: number;
|
|
}> {
|
|
const touchResponseTime = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
const startTime = performance.now();
|
|
document.addEventListener('touchstart', () => {
|
|
resolve(performance.now() - startTime);
|
|
}, { once: true });
|
|
setTimeout(() => resolve(0), 1000);
|
|
});
|
|
});
|
|
|
|
const scrollPerformance = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
const startTime = performance.now();
|
|
let frames = 0;
|
|
|
|
function countFrames() {
|
|
frames++;
|
|
if (performance.now() - startTime >= 1000) {
|
|
resolve(frames);
|
|
} else {
|
|
requestAnimationFrame(countFrames);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(countFrames);
|
|
});
|
|
});
|
|
|
|
const gestureLatency = await this.page.evaluate(() => {
|
|
return new Promise<number>((resolve) => {
|
|
const startTime = performance.now();
|
|
document.addEventListener('touchmove', () => {
|
|
resolve(performance.now() - startTime);
|
|
}, { once: true });
|
|
setTimeout(() => resolve(0), 500);
|
|
});
|
|
});
|
|
|
|
return {
|
|
touchResponseTime,
|
|
scrollPerformance,
|
|
gestureLatency,
|
|
};
|
|
}
|
|
}
|