前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >聊一聊H5营销页面的性能优化

聊一聊H5营销页面的性能优化

作者头像
前端森林
发布2022-12-10 16:27:10
8900
发布2022-12-10 16:27:10
举报
文章被收录于专栏:前端森林

我来自机票BU,目前负责机票营销的业务开发,众所周知营销业务的普遍特点是:访问量很大。每次营销活动,对于不同角色的同学,关注的点也不一样。

运营/产品同学更为关注产品的转化、拉新、留存、用户行为。

普通用户更为关注产品的新特性、吸引力、性价比、产品体验等。

开发同学更为关注页面性能和异常情况。

归纳上述诉求:

  • 从业务发展角度来看,我们需要知道用户的使用情况,包括 PV、UV、停留时长、访问深度、页面跳转等。
  • 从体验优化角度来看,我们需要知道页面真实的性能数据,包括页面加载和资源加载的耗时。
  • 从问题快速排查角度来看,我们需要知道用户的使用快照,包括发生问题时的接口请求、页面报错等。

第一部分属于行为监控的范畴,产品一般会让我们在关键的节点埋点,然后产品上线后,通过BI拉数据来进行分析。我们能做的并不多。

第三部分,用户使用快照这里我们接入了组内其他同学开发的coffeebean系统,可对用户的操作行为进行录制,在排查线上问题时能给予很大的帮助。页面报错(js error、promise 错误、自定义错误等)我们可在cdata查看。

唯独第二部分,用户体验这部分的数据(对应性能监控)对我们影响很大,也是我们着力在提升的。

本文就分享一下我们在用户体验优化和页面性能提升上做的一系列改造工作,希望能给看文章的你一些启发。

1看一看什么是性能监控?

首先来看一下关于性能监控的一些基础知识。

上面这张图梳理了性能监控需要关注的几个核心指标。

衡量一个Web页面的体验和质量一直有非常多的工具和指标。

如果我们全面的了解了 Web 性能标准,就能知道性能指标是如何定义的、性能指标的计算方式以及有哪些浏览器支持的优化策略。基于这些了解,我们才能说如何去排查性能问题、如何去优化性能以及如何去采集这些性能数据来做性能监控。

上图中的指标都会基于下面这张图:

我估计你也会有同样的疑惑:怎么这么多指标。。。

每次我们去关注这些指标的时候都会非常痛苦,因为这些指标真的是又多又难理解,测量这些指标的工具也非常多。但毕竟我们不是性能工程师,我们只是前端工程师。😂

但不用着急,在之前发布的Chrome 83中,Google 认为不用每个人都成为网站性能方面的专家,大家只需要关注那些最核心最有价值的指标即可,于是提出了 Core Web Vitals,它是 Web Vitals 的子集。那Web Vitals又是什么呢?

Web Vitals ?

什么是 Web VitalsGoogle给的定义是一个良好网站的基本指标 (Essential metrics for a healthy site)。由于过去要衡量一个好的网站,需要使用的指标太多,于是推出了 Web Vitals ,也是为了简化学习的曲线,开发者只需要关注 Web Vitals 指标表现即可。

而在 Web Vitals 指标中,Core Web Vitals 是其中的核心,目前包含三个指标:

  • LCP:(Largest Contentful Paint) 从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间(衡量网站初次载入速度)
  • FID:(First Input Delay) 首次输入延迟时间 (衡量网站互动顺畅程度)
  • CLS:(Cumulative Layout Shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数(衡量视觉稳定性)

指标标准如下图:

衡量网站初次载入速度

在过去的设计中,常用 loadDOMContentLoaded 事件反映页面完成载入,但为了更精准地抓到页面完成渲染的持续时间,需要使用 FCP 指标。不过在 SPA 时代,页面常常一开始是先显示一个加载图标,此时,FCP 就很难反映出页面初次载入直到 Web 能够提供使用的那个时间点。

后来WICG 上孵化了一个新的指标 LCP ,可以简单清楚地以网页 Viewport 最大 Element 载入为基准点,衡量标准如下图所示,在 2.5 秒内加载完最大 Element 为良好的网页载入速度:

衡量网站互动顺畅程度

FID 指标用来衡量用户交互上的体验,即用户第一次输入后经过多久得到了响应,这也能很好的体现出网站的互动流畅程度。如图示,页面的FID应该小于100ms

衡量视觉稳定性

视觉稳定性这个比较好解释:你在访问一个web页面的时候,有没有碰到阅读文章时页面突然滚动或者本应点击按钮却点到了别的区块:

出现这种情况的罪魁祸首通常是由于异步加载资源或将 DOM 元素动态添加到现有内容上方导致的,一般是图片尺寸未知或者动态调整自身大小的第三方广告或小部件。

如图示,为了更好的用户体验,CLS分数应小于0.1

web-vitals

现在你可以使用标准的 Web APIJavaScript 中测量每个指标。Google 提供了一个 npm 包:web-vitals,这个库提供了非常简单的 API,测量每个指标就像调用一个普通函数一样简单:

代码语言:javascript
复制
import {getCLS, getFID, getLCP} from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify(metric);
  // Use `navigator.sendBeacon()` if available, falling back to `fetch()`.
  (navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
      fetch('/analytics', {body, method: 'POST', keepalive: true});
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);

白屏时间

白屏会在页面加载之前触发,在这段时间里,不会呈现任何内容和信息给用户。虽然背景色会很快完成绘制,但是实际的内容和交互可能要花很长的时间去加载,因此,白屏时间过长,会让用户认为我们的页面不能用或可用性差。

那白屏时间如何计算呢?

我们通常认为浏览器开始渲染 <body> 标签或者解析完 <head> 标签的时刻就是页面白屏结束的时间点。

代码语言:javascript
复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>白屏</title>
  <script type="text/javascript">
    // 不兼容performance.timing 的浏览器,如IE8
    window.pageStartTime = Date.now();
  </script>
  <!-- 页面 CSS 资源 -->
  <link rel="stylesheet" href="common.css">
  <link rel="stylesheet" href="page.css">
  <script type="text/javascript">
    // 白屏时间结束点
    window.firstPaint = Date.now();
  </script>
</head>
<body>
  <!-- 页面内容 -->
</body>

因此白屏时间则可以这样计算出:

  • 可使用 Performance API 时:
    • 白屏时间 = firstPaint - performance.timing.navigationStart;
  • 不可使用 Performance API
    • 白屏时间 = firstPaint - pageStartTime;

首屏渲染时间

首屏这个概念目前来说没有一个官方的定义,一般来说都以约定俗成的说法为准即 从输入 URL 开始到第一屏(可视区域)的内容加载完毕的时间。根据业务场景的不同,也有不同的指标和规范。以我们目前的业务场景来看就是首屏最大的一张图片加载完成的时间

首屏时间计算主要是基于getBoundingClientRectMutationObserver,通过观察在页面一段时间内DOM变化的情况,然后通过判断是否在首屏显示进行数据过滤,找出最大一张图片的加载时间。

代码语言:javascript
复制
class FCP {
  static details = [];
  static ignoreEleList = ["script", "style", "link", "br"];
  constructor() {}
  static isEleInArray(target, arr) {
    if (!target || target === document.documentElement) {
      return false;
    } else if (arr.indexOf(target) !== -1) {
      return true;
    } else {
      return this.isEleInArray(target.parentElement, arr);
    }
  }
  // 判断元素是否在首屏内
  static isInFirstScreen(target) {
    if (!target || !target.getBoundingClientRect) return false;
    var rect = target.getBoundingClientRect(),
      screenHeight = window.innerHeight,
      screenWidth = window.innerWidth;
    return (
      rect.left >= 0 &&
      rect.left < screenWidth &&
      rect.top >= 0 &&
      rect.top < screenHeight
    );
  }

  static getFCP() {
    return new Promise((resolve, reject) => {
      // 5s之内先收集所有的dom变化,并以key(时间戳)、value(dom list)的结构存起来。
      var observeDom = new MutationObserver((mutations) => {
        if (!mutations || !mutations.forEach) return;
        var detail = {
          time: performance.now(),
          roots: [],
        };
        mutations.forEach((mutation) => {
          if (!mutation || !mutation.addedNodes || !mutation.addedNodes.forEach)
            return;
          mutation.addedNodes.forEach((ele) => {
            if (
              // nodeType = 1 代表元素节点
              ele.nodeType === 1 &&
              this.ignoreEleList.indexOf(ele.nodeName.toLocaleLowerCase()) ===
                -1
            ) {
              if (!this.isEleInArray(ele, detail.roots)) {
                detail.roots.push(ele);
              }
            }
          });
        });
        if (detail.roots.length) {
          this.details.push(detail);
        }
      });
      observeDom.observe(document, {
        childList: true,
        subtree: true,
      });
      setTimeout(() => {
        observeDom.disconnect();
        resolve(this.details);
      }, 5000);
    }).then((details) => {
      // 分析上面收集到的数据,返回最终的结果
      var result;
      details.forEach((detail) => {
        for (var i = 0; i < detail.roots.length; i++) {
          if (this.isInFirstScreen(detail.roots[i])) {
            result = detail.time;
            break;
          }
        }
      });
      // 遍历当前请求的图片中,如果有开始请求时间在首屏dom渲染期间的,则表明该图片是首屏渲染中的一部分,
      // 所以dom渲染时间和图片返回时间中大的为首屏渲染时间
      window.performance
        .getEntriesByType("resource")
        .forEach(function (resource) {
          if (
            resource.initiatorType === "img" &&
            (resource.fetchStart < result || resource.startTime < result) &&
            resource.responseEnd > result
          ) {
            result = resource.responseEnd;
          }
        });
      return result;
    });
  }
}

帧率(FPS)

FPS一般用来检测页面卡顿。通过如何监控网页的卡顿?,我们知道可以通过浏览器的requestAnimationFrame来计算其值:

代码语言:javascript
复制
const next = window.requestAnimationFrame
  ? requestAnimationFrame
  : (callback) => {
      setTimeout(callback, 1000 / 60);
    };

const frames = [];

export default function fps() {
  let frame = 0;
  let lastTime = performance.now();

  function calculateFPS() {
    frame++;
    const now = performance.now();
    if (lastTime + 1000 <= now) {
      const fps = Math.round((frame * 1000) / (now - lastTime));
      frames.push(fps);

      frame = 0;
      lastTime = now;
    }
    next(calculateFPS);
  }

  calculateFPS();
}

得到了FPS,那么怎么通过它来监控页面的卡顿呢?

一般当连续出现三个低于 20 的FPS时,就可断定页面出现了卡顿:

代码语言:javascript
复制
export function isBlocking(fpsList, below = 20, last = 3) {
  let count = 0;
  for (let i = 0; i < fpsList.length; i++) {
    if (fpsList[i] && fpsList[i] < below) {
      count++;
    } else {
      count = 0;
    }

    if (count >= last) return true;
  }

  return false;
}

接口请求耗时

接口请求耗时需要对 XMLHttpRequestfetch 进行监听。

监听 XMLHttpRequest

接口耗时可根据请求前后的时间差计算。

代码语言:javascript
复制
const originalProto = XMLHttpRequest.prototype;
const originalOpen = originalProto.open;
const originalSend = originalProto.send;

originalProto.open = function newOpen(...args) {
  // ...省略
  return originalOpen.call(this, args);
};

originalProto.send = function newSend(...args) {
    // ...省略

    this.addEventListener("readystatechange", function () {
      if (this.readyState === 4) {
        if (this.status >= 200 && this.status < 300) {
          // ...省略
        } else {
          // ...省略
        }
      }
    });

    return originalSend.apply(this, args);
  };
监听fetch

对于 fetch,可以通过Object.defineProperty来进行拦截。并根据返回数据中的的 ok 字段判断请求是否成功,如果为 true 则请求成功,否则失败:

代码语言:javascript
复制
const originalFetch = window.fetch;

Object.defineProperty(window, "fetch", {
  configurable: true,
  enumerable: true,
  get() {
    return (url: string, options: any = {}) => {
      return originalFetch(url, options)
        .then((res) => {
            // ...
        })
    };
  }
});

除了在接口请求成功/失败时上报耗时情况,也应分别添加自定义数据(request、response 信息),方便问题排查。

资源加载时间

通过 PerformanceObserver 可以监听 resourcenavigation 事件,如果浏览器不支持 PerformanceObserver,还可以通过 performance.getEntriesByType(entryType) 来进行降级处理。

2性能优化,我们做了什么?

上面啰啰嗦嗦说了这么多,都是为了在业务中实践打基础。

话不多说,拿一个产线上已有的营销页面来跑跑分吧:

好家伙,只有 17 分,,,

结合上文提到的性能优化指标和评测给出的建议,我们得出几个可改进的点,

  • CLS - 页面偏移严重,主要原因是图片宽高未提前给出,导致渲染图片前后页面大量偏移。
  • LCP - 最大内容渲染时间久,主要原因图片过大,加载耗时过久,拖慢最大内容加载时间
  • FCP - 首次渲染内容偏慢,当前依赖于加载 js,请求接口,渲染数据的整体流程完成之后才渲染第一块内容,白屏时间长。

知道病因所在,下一步就是逐步去优化改进了。

CLS - 页面偏移

上面也分析了,营销页面存在比较多的图片,但是都没有提前给到宽高,页面加载时会出现明显的偏移抖动效果,用户体验非常不好,进而严重拉低了 CLS 评分。

实现方案很简单,配置活动上传图片后就存储图片的宽高,在页面加载时获取对应图片宽高。为了防止图片变形,宽度取当前屏幕的宽度,高度则用当前屏幕宽度乘以原图片宽高比:

代码语言:javascript
复制
<ImageComponent
  v-show="!loading"
  style="display: block; width: 100%;"
  :width="Width"
  :height="Height"
  :src="
    layerInfo.topImgs && layerInfo.topImgs[0] && layerInfo.topImgs[0].imgUrl
  "
/>

Width() {
  return window.screen.width;
},
Height() {
  const { topImgs = [] } = this.layerInfo;
  const { height = 0, width = 0 } = topImgs[0] || {};
  let imgHeght = 0;
  if (width > 0 && height > 0) {
    imgHeght = (height / width) * window.screen.width;
  }
  return imgHeght;
}


LCP - 最大内容渲染时间久

这个是因为图片 size 都比较大,很大程度上影响了渲染时长。

最开始是借鉴了NFES-ServiceWorker-webp。

但一番试用下来,发现nfes-serviceworker-webp 工具依赖于延迟加载 sw 文件,无法在图片加载的时机保证 sw 注册完成,对于二次缓存有较强的帮助,不适用与我们改善首次加载的场景。

既然这种方法不适合我们的业务场景,我们就自己做了调研,最终改为项目内部判断 webp 的支持性,并直接加载 webp 图片,以原图作为兜底展示。

首先,我们要检测客户端是否支持 webp,这里参考了how_can_i_detect_browser_supportWebp_using_javascript

代码语言:javascript
复制
// check_webp_feature:
//   'feature' can be one of 'lossy', 'lossless', 'alpha' or 'animation'.
//   'callback(feature, result)' will be passed back the detection result (in an asynchronous way!)
function check_webp_feature(feature, callback) {
    var kTestImages = {
        lossy: "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA",
        lossless: "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==",
        alpha: "UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAARBxAR/Q9ERP8DAABWUDggGAAAABQBAJ0BKgEAAQAAAP4AAA3AAP7mtQAAAA==",
        animation: "UklGRlIAAABXRUJQVlA4WAoAAAASAAAAAAAAAAAAQU5JTQYAAAD/////AABBTk1GJgAAAAAAAAAAAAAAAAAAAGQAAABWUDhMDQAAAC8AAAAQBxAREYiI/gcA"
    };
    var img = new Image();
    img.onload = function () {
        var result = (img.width > 0) && (img.height > 0);
        callback(feature, result);
    };
    img.onerror = function () {
        callback(feature, false);
    };
    img.src = "data:image/webp;base64," + kTestImages[feature];
}

这种方式检测地比较全面,可以检测客户端对多个 webp 压缩级别的支持度测试。

然后在页面加载时根据对 webp 支持程度来区分加载何种类型的图片:

代码语言:javascript
复制
this.check_webp_feature("lossless",(feature, result) => {
  if(result) {
    this.imageSrc = this.src + ".webp"
  } else {
    this.imageSrc = this.src
  }

})

除了图片格式采用 webp 的外,我们还采用了图片懒加载的方式。

这里没有采用传统的方式来实现(性能考虑),而是采用IntersectionObserver

IntersectionObserver API,可以自动"观察"元素是否可见,Chrome 51+ 已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做交叉观察器

它的用法也非常简单。

代码语言:javascript
复制
var io = new IntersectionObserver(callback, option);

上面代码中,IntersectionObserver是浏览器原生提供的构造函数,接受两个参数:callback是可见性变化时的回调函数,option是配置对象(该参数可选)。

构造函数的返回值是一个观察器实例。实例的observe方法可以指定观察哪个 DOM 节点。

代码语言:javascript
复制
// 开始观察
io.observe(document.getElementById('container'));

// 停止观察
io.unobserve(element);

// 关闭观察器
io.disconnect();

上面代码中,observe的参数是一个 DOM 节点对象。

如果要观察多个节点,就要多次调用这个方法。

代码语言:javascript
复制
io.observe(elementA);
io.observe(elementB);

了解了IntersectionObserver的使用,我们就来看下基于IntersectionObserver实现图片懒加载的思路:

  • 每个 img 标签被创建时,自动将添加到观察者队列
    • 没进入视窗时,src 被赋予了 loading 图片地址,真实的地址被保存在 data-src 属性中
    • 进入视窗后,从 data-src 属性中取出真正的地址,赋予给 src 属性,完成加载
  • 加载完图片后,把该 img 标签从观察者队列中删除,不再被观察

对应实现就是:

代码语言:javascript
复制
const imgs = document.querySelectorAll('img') //获取所有待观察的目标元素
var options = {}
function lazyLoad(target) {
  const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entrie => {
      if (entrie.isIntersecting) {
        const img = entrie.target;
        const src = img.getAttribute('data-src');
        img.setAttribute('src', src)
        observer.unobserve(img); // 停止监听已开始加载的图片
      }

    })
  }, options);
  observer.observe(target)
}

imgs.forEach(lazyLoad)

其实关于图片懒加载还有一种更简单的方式:img.loading=lazy。但其实支持程度还不是特别好,所以我们就没采用这种方式。

FCP - 首次渲染内容偏慢

这块对应的是白屏时间,也就是用户的等待时间,对用户体验来说至关重要的。针对这块我们也做了很多工作:

  • 缓存
  • 资源动态加载(组件/图片)
  • 合并请求
  • 骨架屏
  • 文件压缩

做完上面这些优化工作,我们对原来的页面又做了一次评测:

这个结果显然还不是我们最终想要的,不过做到现在,也算有提升了😂

后续会继续致力于页面性能优化,也有考虑在内的方案:

  • 替代已有CSR为SSR
  • 整合首页加载资源关系,非首屏须加载资源可延后加载
  • CDN分发
  • DOM结构优化
  • ...
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-07-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端森林 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1看一看什么是性能监控?
    • Web Vitals ?
      • 衡量网站初次载入速度
      • 衡量网站互动顺畅程度
      • 衡量视觉稳定性
      • web-vitals
    • 白屏时间
      • 首屏渲染时间
        • 帧率(FPS)
          • 接口请求耗时
            • 监听 XMLHttpRequest
            • 监听fetch
          • 资源加载时间
          • 2性能优化,我们做了什么?
            • CLS - 页面偏移
              • LCP - 最大内容渲染时间久
                • FCP - 首次渲染内容偏慢
                相关产品与服务
                日志服务
                日志服务(Cloud Log Service,CLS)是腾讯云提供的一站式日志服务平台,提供了从日志采集、日志存储到日志检索,图表分析、监控告警、日志投递等多项服务,协助用户通过日志来解决业务运维、服务监控、日志审计等场景问题。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档