使用 Lighthouse 分析前端性能

lighthouse 是 Google Chrome 推出的一款开源自动化工具,它可以搜集多个现代网页性能指标,分析 Web 应用的性能并生成报告,为开发人员进行性能优化的提供了参考方向。

使用入门

在 Chrome DevTools 中使用

比较推荐的方法。我们可以直接打开调试面板中的 “Lighthouse”面板,然后点击生成报告:

使用 Chrome 插件

我们可以在应用商城下载并将插件添加到浏览器,点击插件面板开始分析,使用方法和上面类似。

在 Node 命令行工具中使用

在调试面板和插件中只能进行一些基本的配置,如果我们想要更加灵活地配置分析的内容,我们可以在 Node 命令行工具中使用:

  • 安装
npm install -g lighthouse
# or use yarn:
# yarn global add lighthouse
  • 分析:以 www.baidu.com 为例,使用以下命令可以生成一份中文报告:
lighthouse https://www.baidu.com/ --locale=zh-CN --preset=desktop --disable-network-throttling=true --disable-storage-reset=true

 

 

  • 登录验证:有的时候,我们需要拿到权限才能访问页面,这个时候,直接执行以上的命令会报错或者分析到的不是目标页。这里,需要我们手动登录或者借助 Puppeteer 模拟登录。参考文档,我们可以这样处理:
  • 运行 chrome-debug 启动一个 Chrome 实例(全局安装了 lighthouse 之后)
  • 访问我们的目标网址并完成登录
  • 在新的终端,运行上面的命令 lighthouse [https://example.com](https://www.baidu.com/) --preset=desktop --port=chrome.port
  • 更多的配置见 cli-options

使用 Node module

我们还可以在自己的本地项目中,将 Lighthouse 作为一个 module 使用:

  • 安装
yarn add --dev lighthouse
  • 自定义启动
const fs = require("fs");
const lighthouse = require("lighthouse");
const chromeLauncher = require("chrome-launcher");

const launchChromeAndRunLighthouse = async (url, opts, config = null) => {
  const options = {
    logLevel: "info",
    output: "html",
    onlyCategories: ["performance"],
    ...opts,
  };
  // 打开 chrome debug
  const chrome = await chromeLauncher.launch({ chromeFlags: opts.chromeFlags });
  // 开始分析
  const runnerResult = await lighthouse(
    url,
    { ...options, port: chrome.port },
    config
  );
  await chrome.kill();
  // 生成报告
  const reportHtml = runnerResult.report;
  fs.writeFileSync("lhreport.html", reportHtml);
};

// 启动
const run = async () => {
  const opts = {
    chromeFlags: ["--show-paint-rects"],
    preset: "desktop",
  };
  await launchChromeAndRunLighthouse("https://example.com", opts);
};
run();

性能指标

用户关心什么

性能评估的最终目的是提升用户的使用体验。因此,性能指标的制定也要从用户的角度出发。通常来说,从网站加载到可以正常使用,我们需要关注以下几个关键节点的用户感知体验:

 

  • 根据地址是否能正确访问到网页?服务器请求是否正常响应?
  • 页面是否渲染了用户可用的、关心的内容?
  • 页面是否可以正常响应用户的交互操作?
  • 用户的交互响应是否流畅自然?

从这些角度出发,我们在 这里 看下 Lighthouse 提供的几个性能评估指标:

Lighthouse 最新版的提供了 6 个性能指标:FCP、SI、LCP、TTI、TBT 和 CLS;权重分别是 15%,15%,25%,15%,25% 和 5%。Lighthouse 会根据权重计算得到一个分数值。 ​

内容呈现相关

FCP

FCP(First Contentful Paint)即首次内容绘制。它统计的是从进入页面到首次有 DOM 内容绘制所用的时间。这里的 DOM 内容指的是文本、图片、非空的 canvas 或者 SVG。我们也可以在 Performance 面板看到这个指标: ​

FCP 和我们常说的白屏问题相关,它记录了页面首次绘制内容的时间。一个常见的影响这个指标的问题是:FOIT(flash of invisible text,不可见文本闪烁问题),即网页使用了体积较大的外部字体库,导致在加载字体资源完成之前字体都不可见。可以通过 font-display API 来控制字体的展示来解决。

但值得注意的是,页面首次绘制的内容可能不是有意义的。比如页面绘制了一个占位的 loading 图片,这通常不是用户所关心的内容。

LCP

前面我们已经提到了,由于 FCP 指标统计的内容可能不是用户主要关心的,那么我们需要另一个指标来评估。 ​

LCP(Largest Contentful Paint)即最大内容绘制。它统计的是从页面开始加载到视窗内最大内容绘制的所需时间,这里的内容指文本、图片、视频、非空的 canvas 或者 SVG 等。

在 LCP 之前,lighthouse 还使用过 FMP(First Meaningful Paint,首次有意义内容绘制)指标。FMP 是根据布局对象(layout objects)变化最大的时刻来决定的。但是这个指标计算比较复杂,通常和具体的页面以及浏览器的实现相关,这也会导致计算不够准确。比如,用户在某个时刻绘制了大量的小图标。

Simpler is better!用户感知网页的加载速度以及当前的可用性,可以简单地用最大绘制的元素来测量。LCP 指向的最大元素通常会随着页面的加载而变化(只在用户交互操作之前),以下是一个网站的示例:

SI

SI(Speed Index)即速度指数。Lighthouse 会在页面加载过程中捕获视频,并通过 speedline 计算视频中帧与帧之间视觉变化的进度,这个指标反映了网页内容填充的速度。页面解析渲染过程中,资源的加载和主线程执行的任务会影响到速度指数的结果。 ​

CLS

前面几个指标关注的都是页面呈现的快慢,但是很多时候我们希望页面的视觉呈现保持相对稳定,比如,不会突然插入一张图片或者元素突然发生位移。 ​

这个时候,Lighthouse 使用 CLS(Cumulative Layout Shift)即累计布局位移进行评估。这个指标是通过比较单个元素在帧与帧之间的位置偏移来计算,计算公式是cls = impact fraction * distance fraction 。在以下例子中,文本块在两帧之间的 impact fraction 是红色框部分,占视窗 75%;distance fraction 是蓝色箭头的距离,占视窗 25%;那么最终的分数是 0.75 * 0.25 = 0.1875 。

用户交互相关

TTI

TTI(Time To Interactive)即页面可交互的时间。这个时间的确定需要同时满足以下几个条件:

  • 页面开始绘制内容,即 FCP 指标开始之后
  • 用户的交互可以及时响应:
  • 页面中大部分可见的元素已经注册了对应的监听事件(通常在 DOMContentLoaded 事件之后)
  • 在 TTI 之后持续 5 秒的时间内无长任务执行(没有超过 50 ms 的执行任务 & 没有超过 2 个 GET 请求)

 

TBT

TBT(Total Blocking Time)即阻塞总时间,测量的是 FCP 与 TTI 之间的时间间隔。这个指标反映了用户的交互是否能及时响应。 如果主线程执行了长任务会导致用户的输入无法及时响应。当主线执行的任务所需的时长超过 50ms,我们就认为这是一个长任务(long task)。假设在主线程上执行了一系列的任务,每个长任务的阻塞时间等于执行时间减去 50 ms,最后可以统计得到一个总的阻塞时间。

 

性能优化

我们可以在报告的 Opportunities 一节看到影响评估的关键原因,并进行一些优化: ​

优化 JavaScript 资源加载

在 lighthouse 的优化建议中,我们可以看到和 JavaScript 资源加载相关的建议有:

  • Reduce unused JavaScript
  • Minify JavaScript
  • Remove duplicate modules in JavaScript bundles

资源加载的优化通常有几个思路:

  • 合理的加载顺序/策略(延迟加载/预先加载)
  • 压缩优化资源的体积
  • 代码分割 & 公共提取 & 按需加载

分析

如何确定页面中加载了哪些不必要的资源呢?我们可以打开 Chrome Devtool Coverage 面板,查看当前使用资源的代码覆盖率,红色表示未使用到的代码。

在 webpack 项目里,可使用 webpack 的插件 webpack-bundle-analyzer 来分析,如下我们启动一个本地端口来查看 webpack 的打包情况。

new BundleAnalyzerPlugin({
  analyzerMode: "server",
  analyzerHost: "127.0.0.1",
  analyzerPort: 8888,
  reportFilename: "report.html",
  defaultSizes: "parsed",
  openAnalyzer: true,
  statsFilename: "stats.json",
  statsOptions: {
    exclude: ["vendor", "webpack", "hot"],
  },
  excludeAssets: ["webpack", "hot"],
  logLevel: "info",
});

打包后会生成如下的报告,我们可以查看模块打包的情况,还可以切换 Stat / Parsed / Gizzped 来查看开启压缩后代码体积的变化:

Code Splitting

减少无用的代码,首先我们需要更加精细合理地切分代码。在 webpack 项目中,通常有三种代码分割的技巧:

  • 多入口:基于 entry 配置,每个入口打包成单独的 bundle
const path = require("path");

module.exports = {
  entry: "./src/index.js",
  mode: "development",
  entry: {
    index: "./src/index.js",
    another: "./src/another-module.js",
  },
  output: {
    filename: "main.js",
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
};
  • 抽离公共代码:如果多个页面使用了公共的模块,可以通过 SpitChunksPlugin 将代码分割成多个 chunks:
module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: "async", // 代码分割时只对异步代码生效;all 全部生效;initial 同步代码生效
      minSize: 20000, // 代码分割的最小体积,
      minChunks: 1, // 做代码分割的最少引用次数
      maxAsyncRequests: 30, // 同时加载模块的最大数量
      cacheGroups: {
        venders: {
          chunks: "all",
          test: /[\\/]node_modules[\\/]/, // 将 node_modules 中的代码进行分割
          priority: -10, // 代码分割的优先级
          reuseExistingChunk: true, // 已经打包过的代码直接复用
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};
W;
  • 动态加载:动态加载通常是和上面两种方法结合使用。在打包阶段,通过动态 import 引入的模块会被 webpack 单独拆分成一个 chunk;在运行的阶段,webpack 会通过 chunkId 判断脚本是否已加载并缓存过,没有的话再通过 script 标签动态插入。在 React 项目中,可以结合 React.lazy 和 Suspense 进行路由的切分。
import React, { Suspense, lazy } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

const Home = lazy(() => import("./routes/Home"));
const About = lazy(() => import("./routes/About"));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
      </Switch>
    </Suspense>
  </Router>
);

DCE

摇树优化(Tree-Shaking)是用于消除死代码(Dead-Code Elimination)的一项技术。这主要是依赖于 ES6 模块的特性实现的。ES6 模块的依赖关系是确定的,和运行时状态无关,因此可以对代码进行可靠的静态分析,找到不被执行不被使用的代码然后消除。

对于无用的导入和变量,我们很容易通过 IDE 提示发现。而 webpack 会在 production 模式下自动进行一些优化,比如移除无用的导出模块:

mode: 'development',
 optimization: {
   usedExports: true,
 },

代码压缩

terser-webpack-plugin(webpack5 自带)可以用来压缩代码和清理无意义的代码:

const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};

之前提到的都是在打包阶段进行优化。除此之外,压缩资源体积更加有效的方法是开启 gzip,在 nginx 上我们可以很简单地进行配置:

gzip  on;
gzip_min_length  1k;
gzip_buffers     4 16k;
gzip_http_version 1.1;
gzip_comp_level 9;
gzip_types       text/plain application/x-javascript text/css application/xml text/javascript application/x-httpd-php application/javascript application/json;
gzip_disable "MSIE [1-6]\.";
gzip_vary on;

考虑脚本加载的顺序

当 HTML 解析时遇到 script 脚本会暂停 DOM 树的构建,非异步的脚本需要等待浏览器下载、解析、编译和执行,这会导致渲染的延迟。因此,对于页面中脚本的引用我们需要谨慎地权衡:

  • 关键的脚本内联使用,减少不必要的网络等待时间
  • 其他的脚本在文档底部引入,或者通过 async/defer 异步引用

优化 CSS 资源加载

同样地,lighthouse 给出了一些 CSS 资源优化的建议:

  • Reduce unused CSS
  • Minify CSS

按需加载

在 webpack 中我们可以使用 mini-css-extract-plugin,将 CSS 文件从 JS 文件中单独抽取出来,支持按需加载:

new MiniCssExtractPlugin({
        filename: "[name].[hash:8].css",
        chunkFilename: "[name].[contenthash:8].css",
      }),

资源压缩

对于 CSS 的压缩,我们可以使用 optimize-css-assets-webpack-plugin 移除无用的代码:

new OptimizeCssAssetsPlugin({
  assetNameRegExp: /\.optimize\.css$/g,
  cssProcessor: require("cssnano"),
  cssProcessorPluginOptions: {
    preset: ["default", { discardComments: { removeAll: true } }],
  },
  canPrint: true,
});

代码优化 & 模块化

除了借助工具来优化 CSS 资源,在开发中我们也要注意一些代码的组织和复用,例如下面这个例子:

h1 {
  background-color: #000000;
}
h2 {
  background-color: #000000;
}
// 减少文件代码
h1,
h2 {
  background-color: #000000;
}

随着迭代的开发,项目中很可能会冗余了大量不再需要的 CSS 代码。在前端组件化的时代,将样式和组件绑定,使用更加模块化的方案或许是更加有效的策略。我们可以使用一些 CSS-in-JS 的方案,比如 styled-components,也可以了解下 Atomic CSS(原子 CSS)

优化图片的加载

关于图片的加载,lighthouse 给出了很多方面的建议:

  • Defer offscreen images
  • Serve images in next-gen formats
  • Efficiently encode images
  • Properly size images

图片懒加载

图片的请求和加载通常需要占用较大的资源,特别是在一些以图片展示为主的电商网站上。对于当前页面不可见的图片,我们可以采用懒加载的方式进行处理。

// html
<img data-src="http://example.com" />;
// js
const lazyLoadIntersection = new IntersectionObserver((entries) => {
  entries.forEach((item, index) => {
    if (item.isIntersecting) {
      item.target.src = item.target.dataset.src;
      lazyLoadIntersection.unobserve(item.target);
    }
  });
});
const imgList = document.querySelectorAll(".img-list img");
Array.from(imgList).forEach((item) => {
  lazyLoadIntersection.observe(item);
});
  • 我们也可以通过原生 JS 实现:
const isInView = (el) => {
  const { top, left, height, width } = el.getBoundingClientRect();
  const clientHeight =
    window.innerHeight || document.documentElement.clientHeight;
  const clientWidth = window.innerWidth;
  document.documentElement.clientWidth;
  if (top < clientHeight && left < clientWidth) {
    return true;
  }
  return false;
};
const lazyload = () => {
  const imgLsit = document.querySelectorAll(".img-list img");
  if (!imgLsit.length) {
    document.removeEventListener("scroll", throttleScroll);
    return;
  }
  imgLsit.forEach((img) => {
    if (!img.src && isInView(img)) {
      img.src = img.dataset.src;
    }
  });
};
lazyload();
const throttleScroll = throttle(lazyload, 300);
document.addEventListener("scroll", throttleScroll, { passive: true });

响应式图片

除了懒加载,在一些响应式的网站,我们往往希望图片可以根据设备型号的不同,加载不同分辨率和大小的图片,这可以使用 Responsive images 方案来解决。主要是基于 srcset属性:

<img srcset="elva-fairy-480w.jpg 480w,
             elva-fairy-800w.jpg 800w"
     sizes="(max-width: 600px) 480px,
            800px"
     src="elva-fairy-800w.jpg"
     alt="Elva dressed as a fairy">

WebP 图片格式优化

WebP 是由谷歌(Google)推出的一种旨在加快图片加载速度的图片格式,在相同质量的情况下,WebP 的体积要比 JPEG 格式小 25% ~ 34%。目前,一些图片依托站点也提供了 webp 的支持,我们在使用之前需要检测当前浏览器的兼容情况。

<picture>
  <source type="image/svg+xml" srcset="pyramid.svg">
  <source type="image/webp" srcset="pyramid.webp">
  <img src="pyramid.png" alt="regular pyramid built from four equilateral triangles">
</picture>

优化网络请求

针对网络连接的建立和请求的缓存,lighthouse 也给出了一些参考的建议:

  • Use HTTP/2
  • Serve static assets with an efficient cache policy
  • Preload key requests
  • Preconnect to required origins
  • Avoid enormous network payloads
  • Fonts with font-display: optional are preloaded
  • Preload Largest Contentful Paint image

CDN 加速

我们可以将一些资源,比如图片、公共类库放到 CDN 上,基于内容分发和缓存提高响应速度,阿里云的 OSS 还提供了图片的多格式转换。

内容分发网络(Content Delivery Network,简称 CDN)是由分布在不同区域的边缘节点服务器群组成的。CDN 可以将源站内容分发到距离用户最近的节点,从而提高响应速度和成功率。其底层是依赖 DNS(域名解析服务)返回给用户最近的 IP 地址实现的。

合理的 HTTP 缓存策略

对于放置在 CDN 的资源,我们需要设置合理的 HTTP 缓存策略来资源的命中和加载。HTTP 有两种缓存机制,强缓存(基于 Expires 和 Cache-Control)以及协商缓存(基于 Last-Modified/if-Modified-Since 或 Etag / If-None-Match)。

  • 对于一些不需要经常变化的资源,或者 webpack 中每次打包会根据内容生成文件路径(contenthash)的资源,我们可以设置一个比较大的缓存时长或者过期时间
  • 而对于首页 index.html 等页面我们应该禁止缓存,或者设置一个比较短的缓存时间并检查资源的新鲜度

使用 HTTP/2

我们知道在 HTTP/1.1 中,浏览器对于同一个域名的 tcp 连接数有限制,通常只能同时发起 6 ~ 8 个连接。同时发起多个请求,在 Network 面板我们可以看到明显的阶梯式变化以及很多的 Queueing 排队等待的时间:

HTTP2 增加了多路复用、流优先级、头部压缩等功能,可以很好地解决 tcp 连接限制和队头阻塞问题。我们可以在 nginx 在配置,需要结合 https 使用:

listen 443 ssl http2;

使用 pre-* 预处理

  • preload:通过 rel="preload 对优先级较高的资源进行内容预加载,比如一些重要的样式、字体文件和图片,我们不希望页面在显示的时候出现闪动,可以预加载;也可以使用 preload-webpack-plugin
<link rel="preload" href="main.js" as="script" />
<link
  rel="preload"
  href="fonts/cicle_fina-webfont.ttf"
  as="font"
  type="font/ttf"
  crossorigin="anonymous"
/>
  • dns-prefetch:当页面通过网址请求服务器的时候,需要先通过 DNS 解析拿到 IP 地址才能发起请求。如果网站存在大量跨域的资源,DNS 的解析过程很可能会降低页面的性能。对于关键的跨域资源,我们最好进行 dns 预获取,还可以结合 preconnect 进行预连接:
<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin />
<link rel="dns-prefetch" href="https://fonts.gstatic.com/" />
  • prefetch: preload 通常用于预加载当前页面的一些关键资源,如果我们想预获取将要用到的资源或将要导航到的页面,可以使用 prefetch

优化页面渲染性能

 

 

我们知道,浏览器的页面渲染通常需要经过构建 DOM 树,构建 CSSOM 树,构建渲染树、样式计算和布局、绘制、合成帧并绘制到屏幕几个过程。在这个过程中,主线程复杂的 JS 任务会阻塞渲染。 ​

针对解析和渲染的过程,lighthouse 也提出了一些优化建议:

  • Avoid an excessive DOM size
  • Avoid large layout shifts
  • Avoid non-composited animations
  • Image elements do not have explicit width and height

高性能的动画

当我们进行一些样式变换,比如改变元素的宽高时,浏览器需要重新计算元素的几何元素和样式,这个过程可能需要耗费一些时间。合成线程可以开启硬件加速且无需等待主线程计算,因此,动画应该尽可能放到合成线程处理。通常有几种方法:

  • 使用 transform: scale() 代替 width 和 height
  • 使用 transform: translate() 代替 toprightbottomleft 属性

同时,要保证这些元素存在合成图层中,通常可以通过提升元素(在父元素中声明)来解决:

  • will-change: transform
  • transform: translate3d(0, 0, 0)

避免执行长任务

  • 通过时间分片(Time Slicing),将复杂的任务拆分成多个异步执行的任务
  • 将复杂的任务放到 worker 线程中,计算有了结果再通知主线程

参考资料

lighthouse

Lighthouse performance scoring

webpack

HTTP/2: the difference between HTTP/1.1, benefits and how to use it

How Browsers Work: Behind the scenes of modern web browsers

 

转:https://zhuanlan.zhihu.com/p/376925215

posted @   rmticocean  阅读(346)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示