静态网站 h5 跳小程序-转B端项目页面性能统计实践

背景

因为转转的后端业务方向主要偏向C端,比如App端的H5、小程序中的H5等,而技术栈主要基于Hybrid(托管容器是转转的标准化webview) 。 但随着近年来业务的不断拓展,干数据平台、行星平台等FE项目逐渐出现,服务于B端。 但没有相关的性能数据作为参考支撑,比如需要分析用户体验的质量; 分析现有页面的性能缺陷以及后续性能优化的方向。 因此,需要一个符合转转内部埋点报告系统的PC端项目网页性能统计平台。

B端性能统计面临的问题

由于内部性能跟踪统计系统不支持批量/分段上报静态网站 h5 跳小程序,因此每个Router需要将其性能数据作为单独的页面上报一次。 B面,一些新的指标需要支持和特殊对待。 因此,在数据收集和统计方面,我们会遇到以下问题。

SPA Router问题转向内部C端项目主要采用混合技术栈,所以不需要对SPA项目的路由做特殊处理(因为每次都会打开一个webview,类似于多页面应用设想)。 然而基于React技术栈的B端项目是一个SPA项目。 为了方便统计每个Router页面的性能数据,我们需要对每个Router页面的加载进行一些特殊的处理。 SPA资源统计问题 目前后端SPA项目通常采用异步加载页面资源的方法来优化页面的打包体积,以提高页面首屏的性能。 因此,在进行资源统计时,我们需要对对应Router页面的加载资源单独进行统计处理。 B端指标定义转向B端性能统计主要指核心指标:白屏、首屏、满载。 页面性能评分评估也主要根据这三个指标进行加权估计。 但是,当Router页面加载时,我们会遇到核心性能指标很难直接获取的问题,因为Router的切换并不会引起页面的加载,而只是div的显示和隐藏。 当然,还需要其他B端具体业务标识定义,这里不一一列举。 主要内容 1、绩效指标定义

定义需要上报哪些性能指标是创建采集性能数据采集sdk的前提。 经过分析,指标主要分为两类:1、纯H5页面性能指标2、页面相关业务指标。

核心性能指标主要包括:白屏时间、首屏时间、页面满载时间,以及新增的用户体验指标LCP、FID、CLS。 辅助性能指标包括:DNS解析、请求响应时间、DOM开始创建时间、页面交互时间、DOM创建完成时间、网络速度、各种静态资源时长、ajax请求时长、LongTask等。

上述大部分指标都可以通过浏览器提供的 PerformanceNavigationTiming PerformanceResourceTiming API 以及 Google 团队提供的 web-vitals 工具功能轻松获取和估算。

所谓业务指标主要是作为查询分析的一些要素。 比如我们要查询某个业务线某个项目的某个页面在某个平台上的表现,那么某个性能指标的表现是多少呢? 那么就需要定义一些非页面性能本身的业务元素指标并上报进行统计。

业务指标主要包括:actiontype标记类型、pagetype业务线/项目标记、pageid页面标记、clientType终端信息、pagestate页面状态、pageurl页面url、cookieid用户id、fromType来源、loadcnt加载次数等。

PS:由于苹果和低版本Android的兼容性问题,web-vitals在C端不是强制选项,但B端大部分用户使用的是chromium内核浏览器,所以大胆将web-vitals纳入集合索引

2. 指标数据的获取和报告

上面已经做了各种指标的定义,那么如何高效有序的接入转转积分系统进行上报和统计呢? 虽然转转已经有了C端嵌点系统,但实际上只需要按照一定的规则连接即可,主要是因为性能平台的B端项目需要的数组以及前端已有的日志表结构末端被很好地映射和扩展。

为了解决上述B端项目特有的问题,满足上述所有的性能指标和业务指标,可以甜甜的上报统计数据,以便于代码层面更好的结构前馈,尝试实现性能估算统计的相关程序不会影响页面本身的性能。 在技​​术实现设计层,我们对里面的指标进行了分类,比如同步估算指标(基础业务同步指标、基础性能资源同步指标)、异步计算指标(性能异步指标、后期异步指标)等。如下图所示。

技术水平指标分类

下面详细介绍一下一些关键逻辑是如何处理的? 各项绩效指标具体是如何估算的? 下面列出了一些指标如何获取和估计的关键代码。

SPA项目的路由页面拦截关键逻辑:

const hackRouter = () => {
  if (!window?.history?.pushState) {
    return;
  }
  // 浏览器的历史记录发生变化时被触发, 导航前进、后退
  const oldOnPopState = window.onpopstate;
  window.onpopstate = function(this: WindowEventHandlers, ...args: any[]): any {
    const to = window.location.href;
    const from = lastHref;
    lastHref = to;
    // 通知订阅的回调
    triggerHandlers('history', {
      from,
      to
    });
    if (oldOnPopState) {
      try {
        return oldOnPopState.apply(this, args);
      } catch (e) {}
    }
  };
  // history pushState 或 replaceState 触发,通过 history api 方式
  const wrapHistoryFn = (type: 'pushState'|'replaceState') => {
    const originalHistoryFunction = window.history[type]
    return function(this: History, ...args: any[]): void {
      const url = args.length > 2 ? args[2] : undefined;
      if (url) {
        // coerce to string (this is what pushState does)
        const from = lastHref;
        const to = String(url);
        lastHref = to;
        // 通知订阅的回调
        triggerHandlers('history', {
          from,
          to
        });
      }
      return originalHistoryFunction.apply(this, args);
    };
  };
  window.history.pushState = wrapHistoryFn("pushState");
  window.history.replaceState = wrapHistoryFn("replaceState");
}

获取基本性能指标的相关代码:

// 获取 PerformanceTiming 相关数据
export const getPerformanceTimingData = (task: TaskTypes) => {
  if (!window?.performance?.timing) return {}
  const { metrics } = task;
  const { state } = task.ctx;
  const ptiming = performance.timing;
  // 默认为 -1 方便过滤无效值
  const result = {
    blankTime: -1,
    dnsTime: -1,
    httpTime: -1,
    domTime: -1,
    domReady: -1,
    // ...
  }
  // 页面加载状态
  if(state === 'pageload') {
    // ...
    // 白屏
    result.blankTime = fix(ptiming.responseStart - ptiming.navigationStart);
    // DNS查询
    result.dnsTime = fix(ptiming.domainLookupEnd - ptiming.domainLookupStart);
    // HTTP请求
    result.httpTime = fix(ptiming.responseEnd - ptiming.responseStart);
    // 解析dom树
    result.domTime = fix(ptiming.domComplete - ptiming.domInteractive);
    // DOMready
    result.domReady = fix(ptiming.domContentLoadedEventEnd - ptiming.navigationStart)
    // ...
  }
  // 路由切换状态
  if (state === 'navigation') {
    // ...
  }
  return result
}

资源相关指标数据采集关键逻辑:

// 记录
let performanceCursor: number = 0;
// 获取当前页面资源列表
export const startPerformance = (task: TaskTypes) => {
  const { timeOrigin } = task.ctx;
  if (!window.performance || !window.performance.getEntries || !timeOrigin) {
    return;
  }
  // performanceEntries
  const performanceEntries = performance.getEntries();
  const pss = performanceEntries.slice(performanceCursor);
  // 处理 各种 performanceEntry 资源
  formatResourceEntries(task, pss);
  performanceCursor = Math.max(performanceEntries.length - 1, 0);
}
export const formatResourceEntries = (task: TaskTypes, entries: PerformanceEntryList) => {
  const { state, startTimestamp, timeOrigin } = task.ctx;
  const { metrics } = task
  entries.forEach(entry => {
    const startTime = entry.startTime;
    // console.log( timeOrigin, startTime, startTimestamp, timeOrigin + startTime < startTimestamp)
    if (state === 'navigation' && timeOrigin + startTime < startTimestamp) {
      return;
    }
    const baseStartTime = startTimestamp - timeOrigin;
    switch (entry.entryType) {
      case 'navigation':
        // 处理 bodysize 
        // ...
      case 'paint':
        // 处理 paint 指标 fcp fp
        // ...
      case 'resource':
        // 序列化各种资源, 如js/css/img/jsonp/ajax/fetch/iframe...
        calcResource(entry, result, baseStartTime);
    }
    // ...
}

业务指标数据获取:

// 初始化基础业务指标
export const initBaseData = (task: TaskTypes) => {
  const { params = { backup: {} }, options = {} } = task;
  // ...
  Object.assign(params, {
    pagetype: options?.pagetype || pagetype,
    actiontype: options?.actiontype || actiontype,
    appid: options?.appid || appid,
    // and more ...
  });
  return task;
}

longTask的记录获取:

function startLongTasks(): void {
  const entryHandler = (entries: PerformanceEntry[]): void => {
    for (const entry of entries) {
      const startTime = entry.startTime
      const duration = entry.duration;
      const endTime = startTime + duration;
      const longtask = {
        name: `longtask-${++n}`,
        startTime,
        endTime,
        duration
      }
      longTasks.push(longtask);
    }
  };
  if(PerformanceObserver?.supportedEntryTypes?.includes('longtask')) {
    // 注册 longtask 异步任务
    observe('longtask', entryHandler);
  }
}

在实际项目统计中,需要注意一些性能指标算法的适用性:

LCP 算法存在问题。

例如:触发条件限制的问题,当测量用户输入时,FMP算法会停止估计,这会导致某些场景无法触发(例如在主要内容显示之前点击页面)。 白屏占位符图像的问题。 当页面最初有较大的崩溃占位符图像时,即使将侧面删除,LCP算法也会将其视为主要内容。

FMP算法不适合个别特殊场景。 例如:2/3是菱形的图像布局,底部1/3区域有瀑布。 由于FMP算法计算规则会导致在瀑布请求之后解释统计时间,从而形成直观的页面首屏。 时间变大了。

数据是可以估计和获取的,那么如何进行友好的处理和报告呢?

由于内部埋点不支持回复方式分段上报,需要提前准备好所有需要上报数据的处理,整体B端SPA项目绩效数据处理的上报处理机制,以及同步任务数据。 ,异步任务数据任务的处理流程如下图所示。

3、上报数据量优化

上报数据时,如果页面静态资源加载/ajax请求数量较多,则埋点上报请求socket的body会很大,导致请求时长较长,影响页面本身的性能。 因此,为了解决body过大的问题,对一些资源的统计进行了序列化。

例如:单个静态资源的原始数据结构为:

const entry:PerformanceResourceTiming = {
  "name": "https://xxx.zzz.com/yyy.css?v=5J1NDtbnnIr2Rc2SdhEMlMxD4l9Eydj88B31E7_NhS4",
  "entryType": "resource",
  "startTime": 1924.6000000238419,
  "duration": 1400.5999999642372,
  "initiatorType": "link",
  "fetchStart": 1924.6000000238419,
  "responseEnd": 3325.199999988079,
}

序列化后,将各个关键数据合并成一个字符串静态网站 h5 跳小程序,即:

// 将 entries 分类,并把单个entry 进行字符串化后,再将所有 css entry 合并
const cssEntry:string = 'https://xxx.zzz.com/yyy.css|1924|1924|3325'

可以发现,经过序列化和精简后,255个字符优化为42个字符。

通常,B端SPA项目中的静态资源和请求有几十甚至上百个。 经过序列化处理和合并后,埋点上报请求的主体大小可以减少数千字节。 当然,如果服务支持编码和解码,也可以使用其他更好的序列化方案来压缩主体体积。

4. 数据存储与处理

在处理数据的过程中,也遇到了一些问题。

每天报告的绩效跟踪数据存储在哪里?

如何估算数据? 如何扩充数据? 如何查询数据?

第二次估算后数据量还是很大,怎么办?

为了解决数据处理中的两个核心问题,我们采用了这个完整的管道。 当面对如此海量的数据时,我们需要考虑它们存储在哪里。 同时,我们还需要考虑如何寻找和估算所需的指标。 这个流程可以帮助我们更好的处理数据,提高效率。

此外,这一过程对于保证数据的准确性和完整性也发挥着重要作用。 在数据处理过程中,我们需要遵守一定的规则和标准,以保证数据的可靠性。 这使得我们在分析数据时能够得出正确的推论,更好地进行有针对性的优化。

5、绩效查询展示平台

Web平台部分功能页面展示如下:

历史变化曲线

性能数据查询

总结

在B端项目中,页面性能统计是非常有必要的,因为它可以帮助我们了解实际用户特定页面的加载率和用户体验,从而了解当前页面的质量,为优化页面性能提供方向,从而提高用户满意度。

最后,感谢您的阅读,如有不足之处,敬请谅解。