React加载性能优化的若干问题

@理解高性能

如何定义一个“高性能”的React应用?仅仅加载时间短就是高性能吗?

“高性能”本质上是一种体验包含感觉因素,并非简单地看加载时间。

例如,一个应用可能在3秒内加载所有内容并一次性渲染,从纯粹的数字上看它最快,但用户在这3秒内什么也看不到。另一个应用可能在1秒内先显示侧边栏,然后其他部分陆续显示,从尽快显示的角度看它最快,但整体加载时间可能更长。第三个应用可能先显示主要内容,然后是侧边栏和评论,但这可能违反自然阅读流,导致用户体验“杂乱”。

所以,“高性能”取决于你的“讲故事”方式。你的故事中最重要的部分是什么?第二重要的部分是什么?你的故事是否有流程?你是想一次性展示所有内容,还是可以分块呈现?只有明确了你的“故事”应该是什么样子,才能着手优化应用以使其“尽可能快”。

真正的力量不来源于各种库,而是来来自对以下问题的回答:

  • 何时开始获取数据是合适的?
  • 数据获取进行中时我们可以做什么?
  • 数据获取完成后我们应该做什么?

@React请求基础

在React中,数据获取有哪些主要类型?它们之间有何异同?

在现代前端世界中,数据获取大致可以分为两类:初始数据获取按需数据获取

  • 初始数据获取:这是用户打开页面立即看到的数据。在没有服务器端渲染(SSR)的情况下,这类数据通常在useEffect(或类组件的componentDidMount)中触发。
  • 按需数据获取:这是用户与页面交互后获取的数据。各种自动完成、动态表单都属于这一类别。在React中,这类数据通常在回调函数中触发。

在 React 应用中基本的数据获取方法?

实现基本数据获取需要利用 useEffect Hook 和 useState Hook。

具体步骤如下:

  1. 定义状态变量:使用 useState 定义一个变量来存储从 API 获取的数据(例如 user),以及另外两个变量来管理加载状态(loading)和错误状态(error)。
  2. 使用 useEffect 发起请求:在 useEffect 回调函数中定义一个异步函数(例如 fetchUser),该函数负责调用一个数据获取工具函数(如 get),并处理 API 响应。
  3. 管理加载和错误状态:在异步函数中,在发起请求前将 loading 设置为 true,在请求完成后(无论成功或失败)将其设置为 false。同时,使用 try...catch 块来捕获潜在的网络错误或 API 响应错误,并更新 error 状态。
  4. 渲染内容:根据 loading 和 error 的状态,条件性地渲染不同的 UI,例如加载提示、错误消息或实际的数据。

什么是独立组件获取模式?

创建独立的组件来负责各自的数据获取逻辑,然后将这些组件组合到父组件中。

  • Profile 组件​:负责获取用户基本信息(例如从 /users/:id)。
  • Friends 组件​:负责获取用户的朋友列表(例如从 /users/:id/friends)。

这两个组件都包含自己的 useState (user 和 users),并在各自的 useEffect 中发起 API 请求。如果两个组件同时挂载,就可以并行发起请求。

独立组件获取模式存在哪些问题?

现代的数据方法,都是从以“解决独立组件请求模式的问题”作为起点,发展起来的。

  1. 串行请求​:如果一个页面需要获取多个独立的数据集(例如用户简介和朋友列表),而这些数据获取逻辑都放在不同的组件中,并且这些组件是嵌套或顺序渲染的,那么它们可能会以串行方式加载。例如,Profile 组件加载用户数据需要 1.5 秒,Friends 组件加载朋友数据也需要 1.5 秒,如果它们是依次加载的,总的页面显示时间就会累加到 3 秒。这会显著延长用户看到完整内容的时间。
  2. 全局加载状态​:当页面有多个独立的数据源时,管理全局的加载状态(例如,当所有数据都加载完成时才隐藏全局的加载指示器)会变得复杂。每个组件都有自己的 loading 状态,协调它们需要额外的逻辑。
  3. 缓存不足​:简单的 fetch 不包含内置的缓存机制。如果用户在应用内频繁切换页面或组件,导致相同的 API 请求被多次发起,会降低性能。

@优化方法:并行化

什么是串行请求?

“请求瀑布效应”是指在React应用中,组件按顺序渲染,并且每个组件在渲染时触发自己的网络请求,而后续组件的请求必须等待前一个组件的请求完成才能开始。

例如,一个父组件加载用户数据,然后一个子组件加载该用户的的朋友列表,朋友列表的请求就必须等待用户数据加载完毕。

如何通缓解串行请求?

使用Promise.all()实现并行请求,缓解请求瀑布效应。组件首次渲染时,同时发起所有必需的网络请求,而不是等待每个请求依次完成。

为了配合并行数据获取策略,更多的子组件被修改为“展示型组件”通过props接收所需的数据。

Promise.all() 接收一个Promise数组,并在所有Promise都成功解析后返回一个包含所有解析结果的数组。这种方法将总等待时间缩短为所有并行请求中最长的一个的完成时间,从而大大提高应用性能。例如,如果用户数据和朋友列表的请求同时发起,并且都耗时1.5秒,那么总的等待时间就是1.5秒,而不是3秒。

什么是必要的请求依赖?

加载优化的一个重要原则是“所有的串行请求都应该改为并行请求”,但有些请求存在必要的依赖关系是无法并行的。

例如,可能需要先获取用户数据,然后才能根据用户兴趣获取推荐的文章。在这种情况下,需要结合并行和顺序请求。这意味着首先并行获取所有独立的请求,一旦这些请求完成,再根据它们的返回数据发起后续的依赖请求。例如,Profile组件可以并行获取用户和朋友数据,一旦用户数据可用,就可以使用用户的兴趣信息来发起获取推荐文章的请求。这种混合方法仍然比纯顺序请求更高效。

为什么并行请求后,UI仍需要等待?

即使所有请求都已通过Promise.all()并行发起,UI仍然可能需要等待。

因为Promise.all()只有在所有输入的Promise都成功解析后才会解析。这意味着,如果其中一个并行请求的响应时间比其他请求长,那么UI更新(即显示完整数据)的时间将由最慢的那个请求决定。例如,如果两个请求一个耗时1秒,另一个耗时3秒,那么Promise.all()将需要3秒才能解析,UI也会等待3秒才能完全更新,即使1秒的请求在更早的时候就已经完成了。

如果不想等待所有请求完成才渲染,可以将每个fetch请求转换为独立的Promise,并在它们各自的数据可用时独立地更新状态。这样,可以根据数据的可用性逐步渲染组件,例如侧边栏数据加载完成后立即渲染侧边栏,而主内容和评论则在各自数据准备好时再渲染。尽管这可能导致多次父组件重新渲染,但它能显著改善用户体验的感知速度。

@优化方法:懒加载

什么是 懒加载(Lazy Loading)?

懒加载(Lazy Loading)几乎与代码分割(Code Splitting)同义,指的是将应用程序的代码分割成更小的块(bundles),只有当用户实际需要某个组件或库时,才加载相应的代码。

React.lazy 函数与 Suspense 结合使用,提供直接实现代码分割的方法

React 的 lazy() 函数与 Suspense 组件配合使用,允许我们将动态导入的组件视为常规组件进行渲染。它的工作方式是,接受一个函数作为参数,该函数必须调用 import() 来动态加载组件。例如,const LazyComponent = lazy(() => import('./LazyComponent')); 会在需要时才加载 LazyComponent。

Suspense 提供了一种指定备用 UI(例如,加载指示器)的方式,该备用 UI 在组件加载或数据获取期间显示。

懒加载的潜在问题?

当一个组件(如 Friend)被懒加载时,它可能会触发对多个资源(如其自身的 JavaScript 包和从 API 获取的用户详细信息)的串行请求。使用预请求技术允许数据和 JavaScript 包并行加载。

@优化方法:预请求

什么是预请求?

在初始页面加载后,提前加载某些很快就需要但并非立即需要的资源。

预请求实际也是一种串行请求并行化的方法——被预请求的资源往往是由用户稍后的交互事件触发,在时间线上跟初始化的加载构成串行。

如何利用缓存技术实现预请求?

预请求想要实现必须结合缓存技术,最好还要补充重新验证策略。这些都可以通过请求库来实现。

举例,当鼠标进入组件区域时(通过 onMouseEnter),它会调用 preload。这会主动获取并缓存该用户的详细数据。SWR 使用获取 URL 作为键将响应存储在缓存中,当实际需要显示这些数据的组件被渲染时,如果数据在重新验证间隔内,它将从缓存中检索,从而立即可用。

@优化方法:SSR

服务器端渲染 (SSR)?

服务器响应用户请求时直接生成完整的网页 HTML。这种方法认为,服务器上的处理时间,小于客户端 JavaScript 渲染花费的时间,从而更快展示页面。搜索引擎优化是带来的附加价值。

React 服务器组件 (Server Components) 和客户端组件 (Client Components) 有什么区别?

  • 服务器组件 (Server Components):在服务器中运行,能够直接访问文件系统和其他后端服务,用来产出响应产物。自身代码不包含在响应产物中,自身代码不使用 React 状态,从而产物中也没有状态,适合产出非交互式元素。默认情况下,Next.js 使用服务器组件。
  • 客户端组件 (Client Components):使用 'use client' 指令标记客户端组件,可以自由使用 useState 等 React 钩子,就像在标准 React 应用程序中一样。

为什么服务器端请求更具优势?

1.数据检索

  • 物理距离优势网络延迟极低,通常在毫秒级别
  • 批量处理能力:服务器能够一次性从数据库获取所有相关数据,例如一次查询即可获取用户资料及其朋友列表。而前端应用为了获取相同的信息,可能需要向服务器发起多个请求
  • 格式化:服务器在将数以最精简、最符合其需求的形式发送给前端,减少了传输的数据量,并减轻了前端设备的计算负担

2. 数据缓存

  • 多级缓存控制:服务器可以实现多层次的缓存策略,包括:
    • 数据库查询缓存
    • CDN(内容分发网络)边缘缓存
    • HTTP响应缓存
  • 细粒度缓存控制:服务器可以根据不同的业务需求设置更精细的缓存策略。例如,可以为热门商品设置更长的缓存时间。
  • 主动缓存控制:服务器能够监听数据变更事件,并在数据更新时主动刷新或使相关缓存失效。例如,当数据库中的一条记录被修改时,服务器可以立即清除对应的缓存条目。这确保了用户总是能获取到最新鲜的数据。

3. 安全

  • 敏感信息保护API密钥、数据库凭证、第三方服务令牌等敏感信息永远不会暴露给客户端
  • 减少安全措施的冗余:例如基于前端不可信原则, 基于角色的访问控制(RBAC)往往既要在前端页面实施,也要在服务器端实施, 是一种冗余。

SSR 与 SSG 有何不同?

  • SSR:HTML 在运行时(即响应用户请求时)生成。数据(例如用户个人资料)直到客户端发出请求时才可知晓。适用于数据和页面具有很强的动态性。
  • SSG:HTML 在构建时(即部署应用程序之前)生成。实现超快速的页面加载。SSG 通常适用于内容不经常变化的页面。

@其他请求问题

什么是请求竞态如何解决?

竞态条件在 React 应用中尤其常见,useEffect 钩子可能快速触发新的数据获取。由于这些 Promise 是异步的,它们完成的顺序是不确定的。当一个先出发的 Promise 晚到达,它可能会尝试更新组件的状态,从而导致界面闪烁或显示不正确的数据。

1.强制组件重新挂载

强制组件重新挂载是解决竞态条件的一种方法,例如通过改变组件的 key 属性 <Page id={page} key={page} />。当组件被重新挂载时,旧的组件实例会被完全销毁,包括其内部的状态和正在进行的 Promise。它也有明显的缺点,例如可能导致性能问题、状态丢失,因此,它通常不被推荐作为通用的解决方案。

2.设置请求ID

一种更温和的解决方案是,在 Promise 的 .then() 回调中,检查返回的结果是否与 useRef 钩子中存储的 id 或 url 值相匹配。

3.useEffect 的清理函数和 isActive 标志

useEffect 的清理函数在组件卸载时或每次依赖项改变导致重新渲染之前运行。

我们可以利用这个机制来解决竞态条件。在 useEffect 内部声明一个局部变量,例如 let isActive = true;。在 Promise 的 .then() 回调中,在更新状态之前检查 isActive 的值。在 useEffect 的清理函数中,将 isActive 设置为 false。由于 JavaScript 的闭包特性,每次 useEffect 运行时都会创建一个新的闭包,包含独立的 isActive 变量。当依赖项改变触发新的 useEffect 运行之前,旧的 useEffect 的清理函数会被调用,将其对应的 isActive 设置为 false。因此,只有最新(活动)的 Promise 才能将其 isActive 保持为 true,从而允许更新状态,而所有旧的 Promise 即使完成也会因为 isActive 为 false 而被丢弃。

3.AbortController

AbortController 是浏览器 API,允许取消Web 请求。

在 useEffect 中,可以创建一个 AbortController 实例,并将它的 signal 传递给 fetch 请求的选项。在 useEffect 的清理函数中,调用 controller.abort()。当新的 fetch 请求被触发时,旧的 useEffect 的清理函数会立即运行,调用 abort(),从而取消之前正在进行的 fetch 请求。被取消的 Promise 会立即拒绝(抛出 AbortError),这样它就不会再尝试更新组件状态,从而有效避免了竞态条件。虽然需要处理Promise拒绝,但这是处理任何异步操作的良好实践。