译:提升 React INP 的 5 个技巧

原文:https://calendar.perfplanet.com/2024/5-tips-to-effectively-optimize-inp-in-react/
作者:Michal Matuška
译者:ChatGPT 4 Turbo

编者注:本文深入探讨了优化 React 应用 INP (Interaction to Next Paint) 性能的五个关键技巧:1) 减少 DOM 大小,建议控制在 2500 个元素以内,可通过删除非必要元素、使用懒加载等方式实现。2) 将组件分为简单版本和扩展版本,仅在必要时加载富交互功能。3) 合理使用 Suspense 组件进行选择性渲染,将页面分割为逻辑单元。4) 注意处理 hydration 错误,避免服务端和客户端渲染不一致。5) 谨慎使用 useEffect,需要注意它并非总是异步执行。

在这篇文章中,让我们探讨几种优化使用 React 构建的网站的 Core Web Vitals 指标的技术。

我们是来自捷克共和国的性能咨询团队,在本文中我们想分享一些为客户进行前端性能优化的经验。

我们将主要关注 交互到下一帧(INP) 指标,即对交互的响应速度。优化基于 React 构建的网站速度涉及优化 JavaScript 长任务。这与 React 的内部工作方式密切相关。

使用 React 的网站自动就会很快?错!

在内部,React 使用了几种巧妙的技术来帮助提升网站或 Web 应用的速度。

它高效地更新和渲染仅数据发生变化的组件。而 hook 系统 则部分地保护网站免受不必要的布局重新计算和所谓的 布局抖动

这是否意味着基于 React 构建的网站会自动变快?这是开发者中的一种普遍观点。答案很简单:即使是使用 React 构建的网站也需要优化

这一点从 HTTP Archive 的数据中也可以看出:


数据显示,使用 React 构建的网站满足 Core Web Vitals 指标的频率低于使用 PHP 构建的网站。

虽然 React 中的声明式组件使代码更可预测和易于理解,但对状态或组件数量的不当操作几乎肯定会导致交互性变慢

React 和其他工具一样,效果始终取决于开发者对它的了解程度。

现在让我们根据我们的工作经验来看看我们推荐的 React 优化技巧。如何保持 React 代码的可控性?

1) 减少 DOM 大小

调整 DOM 大小并优化它是一个基本要求。如果 DOM 太大(元素太多或嵌套太深),可能会降低性能,减慢渲染速度并增加内存负载。

在 React 中,这个建议尤其重要。更少的元素意味着更少的组件,这意味着需要下载和处理的 JavaScript 更少

可以通过 Google Chrome 控制台快速验证 DOM 大小。只需输入 document.querySelectorAll("*").length 脚本,就能知道当前状况。


控制台显示找到了 5,132 个 DOM 元素。这相当多。

Google 建议 DOM 最多包含 1,400 个元素。这个要求相当严格,特别是对于电商或应用等较大的网站。根据我们的经验,2,500 个 DOM 元素的情况下浏览器仍能相当流畅地处理。一旦 DOM 元素数量超过这个限制,情况就会迅速变得复杂。

如何处理 DOM 大小?删除和懒加载最有效

不在 DOM 中的内容不需要渲染,浏览器可以休息。因此,首先关注那些体积大且对 SEO 不重要的组件。将它们从 DOM 中移除,使用懒加载方式加载:

import { lazy } from 'react';

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

为了最大化效果,在用户需要时或组件出现在视口中时加载该组件。典型的组件包括:地图、图表、可视化、WYSIWYG 编辑器,以及表单和筛选器等,这些都在首屏之外。

简化组件结构

UI 中经常会创建大量组件。随着现代 HTML 和 CSS 功能的发展,我们需要的仅用于布局的包装元素越来越少。

一个典型的浪费示例是星级评分,以及用单独的"星星"不必要地扩展 DOM:

// 不好的做法
<StarRating>
  <SVGStar/>
  <SVGStar/>
  <SVGStar/>
  <SVGStar/>
  <SVGStar/>
</StarRating>

类似的功能可以通过一个具有宽度和重复背景的单个元素来解决。

有许多限制 DOM 大小的技术

还有其他有效减少渲染组件数量的技术。值得一提的是大型列表的虚拟滚动。

SSR 总是有帮助的

使用 SSR(服务器端渲染)时,构建第一个 HTML 响应所需的时间得到了优化。它的数据大小也更小。简单来说,这是各方面都赢的方案。

你应该始终考虑组件的优先级和 HTML 结构的效率。

查看其他有效解决大型 DOM 体积的方法。

2) 将组件分为简单版本和扩展版本

这个建议本质上仍然是删除 DOM 元素。但它有点不同。它从根本上改变了你看待 HTML 和 DOM 结构的范式。

即使一个组件或其内容对 SEO 或可访问性很重要,这并不意味着它在首次渲染时就必须具有完整的视觉质量。特别是当该组件在首屏之外时。

用户实际上会看到多少组件?有些组件隐藏在交互后面,例如大型菜单或模态窗口,其他组件可以在滚动后看到。是否真的需要在 HTML 中以最终形式呈现所有组件?

将组件分为简单版本和富版本的优化对于页面上重复多次的元素特别有效。这些通常是登录页面、产品列表或其他如图所示的产品:


一个组件示例,在页面加载时仅提供 SEO 相关数据。这种状态对用户来说是视觉隐藏的。"富版本"在组件显示在视口中时激活。

为了说明,以下代码示例展示了如何使用 Intersection Observer 来加载组件的富版本:

import React from "react";
import { useInView } from "react-intersection-observer";

const Offer = ({images, title}) => {
  const { ref, inView, entry } = useInView();

  return (
    <article className="offer" ref={ref}>
      <div className="gallery">
        {!inView ? <Image data={images[0]}> : <ImagesCarousel data={images} />}
        <h3>{title}<h3>
      </div>
    </article>
  );
};

当你比较重要内容和最终的 HTML 时,你会发现 DOM 的骨架可以相当简单。视觉丰富性可以在用户访问期间添加到前端。

但是,始终要注意布局稳定性,以避免在渲染优化过程中破坏 CLS 指标

服务器组件可以在这里提供帮助

本节提到的一些问题可以通过相对较新的 React 服务器组件 来解决,它允许你编写仅在服务器上可用而不在客户端 JavaScript 中可用的组件。

在这种情况下,浏览器接收已渲染的内容,不需要重新运行 JavaScript 来显示或动画内容。即便如此,仍要保持 DOM 结构尽可能小。我们的建议是优化浏览器中 UI 的实际渲染。

3) 使用 <Suspense>

好的,我们已经优化了 DOM。这通常会大幅改善 INP 指标。让我们考虑是否可以进一步将工作负载分散到时间上。

<Suspense> 标签在 React 中主要用于在加载不同组件时显示占位内容。

然而,很少有人知道 <Suspense> 的隐藏能力是它可以开启选择性渲染。这允许将页面及其组件分为重要和不太重要的部分。


组件树使用 <Suspense> 标签进行分割。红色组件通过此标签标记为不太重要。

在使用 SSR(服务器端渲染)时,<Suspense> 标签的效果尤为关键。

Hydration(水合)是服务器端生成的代码在浏览器客户端复活的时刻。服务器提供快速可见的 HTML,然后 React 添加交互性。如果不使用 <Suspense>,这总是一个长时间的 JavaScript 任务。

将页面分成单独的逻辑单元,并用各自的 <Suspense> 标签包装它们。


booking.com 网站被分成单独的逻辑单元。

但要注意!使用该标签也可能适得其反。如果用户快速对 <Suspense> 内的元素执行操作,React 必须切换焦点并处理整个块。否则,它就无法准确知道用户做了什么。这会导致同步处理,从而延长整个事件。

因此,永远不要用这个标签包装一个大块,而是包装较小的部分 – 区段。同时,不要对首屏可见的元素使用 <Suspense>

4) 注意 hydration 错误

我们已经解释了什么是 hydration。但我们还没有提到一件重要的事情。在 hydration 结束时,会进行验证,比较最终的元素树(DOM)与从服务器传来的状态。HTML 必须与 React 在客户端期望的完全匹配。

如果这些版本之间存在差异,React 会在浏览器控制台中抛出错误:


在浏览器控制台中显示 hydration 错误。

此时,React 可能会使页面上的所有或大多数组件失效,并触发它们通过客户端 JavaScript 更新,这会通过延长 hydration 阶段来降低性能,并可能令人烦恼地延迟 LCP 指标。

更糟糕的是,你很容易遇到这个错误。例如,在服务器和客户端渲染时直接使用 Math.random()Date.now() 可能导致内容不一致。

错误的另一部分是有条件地使用仅在浏览器中可用的 API。

// 不好的做法 
function LanguageComponent() {
  const language = window?.navigator?.language ?? "en";

  return <h1>Your language is: {language}</h1>;
}

在这种情况下,你需要使用 useEffect 函数。这会使代码稍微复杂一些,但它会处理错误。

// 好的做法
function LanguageComponent() {
  const [language, setLanguage] = useState("en"); 
  useEffect(() => {
    // 使用客户端 API 更新语言
    setLanguage(window.navigator.language);
  }, []);

  return <h1>Your language is: {language}</h1>;
}

5) 注意 useEffect()

React 中的 useEffect 是一个特殊函数,它做一件事:对组件或其状态的变化做出反应。

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  // useEffect 监视 'count' 的变化并对其作出反应
  useEffect(() => {
    console.log(`计数已更新为:${count}`);
  }, [count]); // 仅监视 'count' 的变化

  return (
    <div>
      <h1>点击次数:{count}</h1>
      <button onClick={() => setCount(count + 1)}>点击我</button>
    </div>
  );
}

export default Counter;

根据定义,这是一个在变化发生后调用的函数。React 开发者普遍认为该函数甚至是在 HTML 渲染后调用的。

不幸的是,这并不正确。useEffect 并不总是异步的。当用户触发输入(例如点击)时,所有 React 代码都是同步执行的,包括"effect hooks"

如果要使用 hook 实际推迟工作到下一个渲染周期,你必须使用 setTimeout 或其他方法

useEffect(() => {
  // 将工作推迟到单独的任务中:
  setTimeout(() => {
    sendAnalytics();
  }, 0);
}, []);

要特别注意任何分析代码。

结论

我希望我已经帮助你了解了优化基于 React 构建的网站性能的具体方法,重点关注 INP 指标。

基本上很简单 – 注意大型 DOM 推迟可以推迟的内容,并特别注意 hydration 过程。

React 只是一个工具。关键是要很好地了解它。在这方面,我们强烈推荐 React Internals Deep Dive 系列文章。也可以查看其他优化 INP 的方法。