应用性能提升之道 18深度解析 React (应用性能是什么意思)

应用性能提升之道 18深度解析 React (应用性能是什么意思)

React 18 引入的并发功能,从根本上改变了 React 应用程序的渲染方式。本文将带大家一同探讨这些最新功能的具体作用,特别是如何提高应用程序性能。

首先,我们先从长任务基础知识说起,聊聊其中的性能测量思路。

主线程与长任务

当我们在浏览器中运行 JavaScript 时,JS 引擎会在单线程环境下执行代码内容,而该环境通常称为主线程。除了执行 JS 代码之外,主线程还负责处理其他任务,包括管理用户交互(如鼠标单击和键盘输入)、处理网络事件、计时器、更新动画并管理浏览器的重排和重绘等。

在处理一项任务时,其余任务均处于等待状态。虽然浏览器在执行小规模任务时能提供丝滑无缝的用户体验,但面对更长的任务时则可能出现问题。由于任务耗时较长,其他任务可能一直被悬置和等待。一般来说,任何运行时间超过 50 毫秒的任务均被定义为“长任务”。

这里的 50 毫秒标准基于以下事实:设备必须每 16 毫秒生成一个新帧(相当于 60 fps)才能保持流畅的视觉体验。然而,设备在此期间还需要执行其他任务,例如响应用户输入和执行 JavaScript 代码。

所以 50 毫秒基准测试能保证设备为帧渲染和其他任务保留必要的资源,始终提供额外的约 33.33 毫秒时间执行其他任务。感兴趣的朋友可以参阅这篇博文:其中借由 RAIL 模型探讨了关于 50 毫秒基准测试的更多信息。


为了保持最佳性能,尽量控制长任务的数量显然非常重要。在测量网站性能时,我们一般通过两项指标来了解长耗时任务对应用程序性能的影响:总阻塞时间,以及下次绘制的交互。

总阻塞时间(TBT)是用于衡量首次内容绘制(FCP)和交互时间(TTI)之间间隔时长的重要指标。总阻塞时间代表着所有执行时间超过 50 毫秒的任务的总和,它们往往是对用户体验影响最大的因素。

上图中的总阻塞时间为 45 毫秒,因为我们有两个任务在首次交互时间(TTI)之前花费超过 50 毫秒,而超出这项阈值的部分分别是 30 毫秒和 15 毫秒。所以总阻塞时间就是这些值的累加:30 毫秒 +15 毫秒 =45 毫秒。

下次绘制的交互(INP)则是 Core Web Vitals 提出的新指标,测量的是用户在第一次与页面交互(例如单击某个按钮)到交互结果在屏幕上显示出来之间的间隔,即下次绘制。该指标对于包含大量用户交互元素的页面特别重要,例如电子商务网站或者社交媒体平台。它的衡量方式,是将用户当前访问期间所有 INP 的测量值累加起来,再计算出最差得分。

上图中的下次绘制的交互时间为 250 毫秒,这也是测量期间得到的最高视觉延迟。

要了解 React 18 的更新如何针对这些指标进行优化、从而改善用户体验,我们首先需要明确 React 以往版本的工作原理。

以往 React 中的渲染机制

React 中的视觉更新主要分为两个阶段:渲染阶段与提交阶段。这里的渲染阶段属于纯计算阶段,期间 React 元素与现有 DOM 进行协调(即比较)。此阶段需要创建新的 React 元素树,也被称为“虚拟 DOM”,它本质上就是 DOM 在轻量级内存中的表示形式。

在渲染阶段,React 会计算当前 DOM 和新 React 组件树之间的差异,并准备好必要的更新。

渲染阶段之后则是提交阶段。在此阶段,React 会将渲染阶段计算出的更新应用于实际 DOM。具体过程包括创建、更新和删除 DOM 节点,从而映射新的 React 组件树。


在传统的同步渲染中,React 会给组件树中的所有元素赋予相同的优先级。在对组件树进行渲染时,无论是在初始渲染还是在状态更新时,React 都会在单一不间断任务中持续渲染该树,之后将结果提交给 DOM 以直观更新屏幕上显示的组件。

同步渲染属于是“全有或全无”的操作,它会保证已经开始渲染的组件必定能够完成。而根据组件的具体复杂性,渲染阶段可能要持续一段时间才能完成。在此期间,主线程将始终被阻塞,因此用户与 UI 间的交互将失去响应,直到 React 完成渲染并将结果提交至 DOM。


以下演示就展现了这样的情况。我们设置了一个文本输入字段,外加一份巨大的城市列表,这里要根据文本输入的当前值进行过滤。在同步渲染中,React 会在每次键盘输入时重新渲染 CityList 组件。因为列表当中包含数以万计的城市,所以计算负担相当沉重,导致键盘输入和显示文本响应之间出现相当明显的视觉反馈延迟。

import React, { useState } from "react";import CityList from "./CityList";export default function SearchCities() {const [text, setText] = useState("Am");<h1>Traditional Rendering</h1><input type="text" onChange={(e) => setText(e.target.value) }/>
复制代码

CityList.js:

import cities from "cities-list";import React, { useEffect, useState } from "react";const citiesList = Object.keys(cities);const CityList = React.memo(({ searchQuery }) => {const [filteredCities, setCities] = useState([]);useEffect(() => {if (!searchQuery) return;setCities(() =>citiesList.filter((x) =>x.toLowerCase().startsWith(searchQuery.toLowerCase())}, [searchQuery]);{filteredCities.map((city) => (export default CityList;
复制代码
import { StrictMode } from "react";import ReactDOM from "react-dom";import App from "./App";import "./styles.css";const rootElement = document.getElementById("root");ReactDOM.render(<StrictMode><App /></StrictMode>,rootElement);
复制代码

style.css:

* {font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;}:root {--foreground-rgb: 0, 0, 0;--background-rgb: 244, 244, 245;--border-rgb: 228, 228, 231;}@media (prefers-color-scheme: dark) {:root {--foreground-rgb: 255, 255, 255;--background-rgb: 0, 0, 0;--border-rgb: 39, 39, 42;--input-background-rgb: 28, 28, 28;}}body {color: rgb(var(--foreground-rgb));background: rgb(var(--background-rgb));}h1 {margin-bottom: 2em;font-size: 1.5em;}input {border: 1px solid rgb(var(--border-rgb));border-radius: 3px;padding: 1em 2em;font-size: 1.1em;background-color: rgb(var(--input-background-rgb));color: rgb(var(--foreground-rgb));outline: none;min-width: 70vw;}code {font-family: Menlo;font-size: 90%;background: rgb(var(--border-rgb));padding: 0.3em 0.5em;border-radius: 3px;}main {padding: 1em 3em;display: flex;flex-direction: column;align-items: center;}ul {overflow: scroll;padding: 0;min-width: 70vw;}li {list-style-type: none;padding: 1em;border-bottom: 1px solid rgb(var(--border-rgb));}
复制代码

如果大家使用的是 MacBook 这样高端计算设备,可能需要限制 CPU 4x 来模拟低端设备性能。大家可以在 Devtools > Performance > ⚙️ > CPU 中找到这项设置。

在查看性能选项卡时,可以发现每次键盘输入都会产生长任务,这明显有违优化原则。

标有红角的任务就是“长任务”。请注意,这里的总阻塞时间为 4425.40 毫秒。

在这种情况下,React 开发人员经常会使用 debounce 等第三方库来做延迟渲染,但 React 本身并没有内置的解决方案。


React 18 引入了幕后运行的新并发渲染器。该渲染器为我们提供了一些将某些渲染任务标记为非紧急的办法。

当渲染低优先级组件(粉色)时,React 会返回主线程以检查是否存在更重要的任务。

在这种情况下,React 每 5 毫秒就会返回一次主线程,看看有没有更重要的任务等待处理。这可能是用户输入,也可能是渲染另一个对当前用户体验更重要的 React 组件状态更新。通过不断返回主线程,React 就能让此类渲染获得非阻塞保障、凭借更高的重要性得到优先处理。

并发渲染器并不再为每次渲染执行一项不可中断的任务,而是在低优先级组件的(重新)渲染期间以 5 毫秒为间隔,定期为主线程提供控制权。

此外,并发渲染器还能在后台“同时”渲染组件树的多个版本,且无需立即提交结果。

相较于全有或全无的同步渲染计算,并发渲染器允许 React 暂停和恢复对一个或多个组件树的渲染,从而实现最佳用户体验。

React 会根据用户交互暂时叫停当前渲染,强制要求主线程优先渲染另一更新。

使用并发功能,React 能够根据外部事件(如用户交互)暂停和恢复组件渲染。当用户开始与 COmponentTwo 交互时,React 会暂停当前渲染、优先渲染 ComponentTwo,之后再恢复渲染 ComponentOne。我们将在暂停部分具体讨论这部分内容。

过渡(Transitions)

我们可以使用 userTransition 钩子提供的 startTransition 函数,将某些更新标记为非紧急。这是一项强大的新功能,允许我们将某些状态更新标记为“transitions”,表明它们与视觉变化相关,如果继续以同步渲染方式处理则可能会破坏用户体验。

通过在 startTransition 中打包状态更新,我们可以要求 React 推迟或中断当前渲染,腾出手来优先处理更重要的任务,从而保证当前用户界面的可交互性。

import { useTransition } from "react";function Button() {const [isPending, startTransition] = useTransition();onClick={() => {urgentUpdate();startTransition(() => {nonUrgentUpdate()
复制代码

对于演示中的 CityList 用例来说,过渡机制的表现堪称完美。我们可以将状态拆分成两个值,并将 searchQuery 的状态更新打包在 startTransition 当中,而不再在每次键盘输入时直接将更新传递给 searchQuery 参数(这会导致每次键盘输入都触发同步渲染)。

这种方式相当于告知 React,某些状态更新可能会导致视觉变化,进而对用户造成干扰。因此 React 应该尽量保持当前 UI 的可交互性,同时在后台准备新状态、但暂时不立即提交。

import { StrictMode } from "react";import ReactDOM from "react-dom/client";import App from "./App";import "./styles.css";const rootElement = document.getElementById("root");const root = ReactDOM.createRoot(rootElement);root.render(<StrictMode><App /></StrictMode>);
复制代码
import React, { useState, useTransition } from "react";import CityList from "./CityList";export default function SearchCities() {const [text, setText] = useState("Am");const [searchQuery, setSearchQuery] = useState(text);const [isPending, startTransition] = useTransition();<h1><code>startTransition</code></h1>type="text" value={text}onChange={(e) => {setText(e.target.value)startTransition(() => {setSearchQuery(e.target.value)
复制代码

CityList.js:

import cities from "cities-list";import React, { useEffect, useState } from "react";const citiesList = Object.keys(cities);const CityList = React.memo(({ searchQuery }) => {const [filteredCities, setCities] = useState([]);useEffect(() => {if (!searchQuery) return;setCities(() =>citiesList.filter((x) =>x.toLowerCase().startsWith(searchQuery.toLowerCase())}, [searchQuery]);{filteredCities.map((city) => (export default CityList;
复制代码

style.css:

* {font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;}:root {--foreground-rgb: 0, 0, 0;--background-rgb: 244, 244, 245;--border-rgb: 228, 228, 231;}@media (prefers-color-scheme: dark) {:root {--foreground-rgb: 255, 255, 255;--background-rgb: 0, 0, 0;--border-rgb: 39, 39, 42;--input-background-rgb: 28, 28, 28;}}body {color: rgb(var(--foreground-rgb));background: rgb(var(--background-rgb));}h1 {margin-bottom: 2em;font-size: 1.5em;}input {border: 1px solid rgb(var(--border-rgb));border-radius: 3px;padding: 1em 2em;font-size: 1.1em;background-color: rgb(var(--input-background-rgb));color: rgb(var(--foreground-rgb));outline: none;min-width: 70vw;}code {font-family: Menlo;font-size: 90%;background: rgb(var(--border-rgb));padding: 0.3em 0.5em;border-radius: 3px;}main {padding: 1em 3em;display: flex;flex-direction: column;align-items: center;}ul {overflow: scroll;padding: 0;min-width: 70vw;}li {list-style-type: none;padding: 1em;border-bottom: 1px solid rgb(var(--border-rgb));}
复制代码

现在,当我们在输入字段处键入内容时,用户的输入感受始终流畅,每次键入间没有任何视觉延迟。这是因为 text 状态始终保持同步更新,并供输入字段作为 value 使用。

在后台,React 开始在每次键入时渲染新树。但这时其执行的已经不再是全有或者全无的同步任务,React 开始在内存中准备组件树的新版本,而当前 UI(显示「旧」状态)则继续响应用户的每一步后续输入。

下面来看性能选项卡。与不使用 transition 的实现相比,将状态更新打包进 startTransition 能显著缩降低长任务数量和总阻塞时间。

性能选项卡显示,长任务数量和总阻塞时间均有显著降低。

过渡也是 React 渲染模型中的根本性转变之一,令 React 能够同时渲染多个版本的 UI,并管理不同任务间的优先级差异。这将带来更流畅、响应更灵敏的用户体验,这一点在处理高频更新或 CPU 密集型渲染任务时效果尤其突出。

React Server Components

React Server Components 是 React 18 中的一项实验性功能,但已经为框架应用做好了准备。有了这个前提,下面我们开始深入探讨 Next.js。

在传统上,React 为我们的应用程序提供几种主要的渲染方式。我们可以完全在客户端侧渲染所有内容(即客户端渲染),也可以在服务器上将组件树渲染为 HTML,再使用 JavaScript 捆绑包将该静态 HTML 发送至客户端,从而在客户端上填充组件(即服务器侧渲染)。

这两种方法都基于这样一个事实:同步的 React 渲染器需要使用附带的 JavaScript 捆绑包在客户端重建组件树,即使该组件树已经在服务器上可用也不例外。


React Server Components 允许 React 将实际的序列化组件树发送至客户端。客户端 React 渲染器能够识别这种格式,并使用它高效重建 React 组件树,期间无需发送 HTML 文件或者 JavaScript 捆绑包。

我们可以将 react-server-dom-webpack/server 的 renderToPipeableStream 方法与 react-dom/client 的 createRoot 方法结合起来,使用这种新的渲染模式。

// server/index.jsimport App from '../src/App.js'app.get('/rsc', async function(req, res) {const {pipe} = renderToPipeableStream(React.createElement(App));return pipe(res);// src/index.jsimport { createRoot } from 'react-dom/client';import { createFromFetch } from 'react-server-dom-webpack/client';export function Index() {return createFromFetch(fetch('/rsc'));const root = createRoot(document.getElementById('root'));root.render(<Index />);
复制代码

⚠️ 这里是后文中 CodeSandbox 演示的极度简化(请注意!)示例。

点击此处查看完整的 CodeSandbox 演示()。在下一节中,我们将介绍更为详尽的示例。


在默认情况下,React 不会对 React 服务器组件进行水合。这些组件不应使用任何客户端交互性(例如 window 对象),也不会使用 useState 或 useEffect 等钩子。

要将组件及其导入添加至发送到客户端的 JavaScript 捆绑包中以使其具有交互性,我们可以使用文件开头的“use client”捆绑器指令。这是告知捆绑器将此组件及其导入添加到客户端捆绑包内,并告知 React 对树客户端进行水合以添加交互性。此类组件,就被称为 Client Components。

注意:框架的具体实现可能有所区别。例如,Next.js 会在服务器上将客户端组件预渲染为 HTML,类似于传统 SSR 方法。但在默认情况下,Client Components 的渲染更类似于 CSR 方法。

在使用 Client Components 时,开发人员需要对捆绑包大小进行优化。具体优化方式包括以下几种:

React 18 中另一个重要新并发功能是 Suspense。虽然 Suspense 并非全新(最初发布于 React 16),最初用于通过 React.lazy 进行代码分割,但 React 18 将 Suspense 扩展到了数据获取。

使用 Suspense,我们可以延迟组件渲染,直到满足某些条件(例如从远程源加载数据)再恢复。同时,我们可以渲染一个后备组件,指示该组件仍然加载。

通过对加载状态做声明性定义,我们减少了对渲染逻辑条件的需求。将 Suspense 与 React Server Components 结合使用,我们能够直接访问服务器侧数据源(例如数据库或文件系统),而不需要借助单独的 API 端点。

async function BlogPosts() {const posts = await db.posts.findAll();return '...';export default function Page() {
复制代码

使用 React Server Components 与 Suspense 无缝协作,我们能够在组件仍在加载时定义加载状态。

Suspense 的真正力量,来自它与 React 并发功能的深度集成。当组件被挂起时,例如仍在等待数据加载,React 不会在组件真正收到数据前保持闲置。相反,它会暂停组件的渲染,并将其焦点转移至其他任务。

在此期间,我们可以告知 React 渲染一个后备 UI,以指示该组件仍在加载。一旦等待的数据可用,React 将以可中断的方式无缝恢复对先前挂起组件的渲染,效果与我们之前讨论的过渡(transition)一样。

React 还能根据用户交互重新调整各组件的优先级。例如,当用户与当前未渲染的挂起组件进行交互时,React 会挂起当前正进行的渲染,并优先处理用户正在与之交互的组件。

在准备就绪之后,React 会将其提交至 DOM,并恢复之前的渲染。这确保了用户交互的优先级,且 UI 将保持响应并根据用户输入保持随时最新状态。

Suspense 与 React Server Component 的可流式传输格式相结合,将使高优先级更新在准备好后立即发送至客户端,而无需等待低优先级渲染任务的完成。这样客户端能够更快开始处理数据,并保证将非阻塞方式到达的内容逐渐显示出来,从而提供更流畅的用户体验。

这种可中断的渲染机制与 Suspense 处理异步操作的能力相结合,带来更流畅、更加以用户为中心的体验。这在具有大量数据获取需求的复杂应用程序中尤其重要。

数据获取

除了渲染更新之外,React 18 还引入一个新的 API,用以有效获取数据并在内存中存储结果。

React 18 现在提供一个缓存函数,能够存储已打包的函数调用的结果。如果您在同一渲染通道中使用具有相同参数的同一函数,则 React 18 会使用内存内的值,无需再次执行该函数。

import { cache } from 'react'export const getUser = cache(async (id) => {const user = await db.user.findUnique({ id })return user;getUser(1)getUser(1) // Called within same render pass: returns memoized result.
复制代码

在 fetch 调用中,React 18 现在默认包含类似的缓存机制,而无需使用 cache。这有助于减少单个渲染通道中的网络请求数量,从而提高应用程序性能并降低 API 成本。

export const fetchPost = (id) => {const res = await fetch(`${id}`);const>return { post:>}fetchPost(1)fetchPost(1) // Called within same render pass: returns memoized result.
复制代码

这些功能能够与 React Server Components 配合发挥重要作用。因为 React Server Components 无法访问 Context API,而缓存与获取的自动缓存行为允许从全局模块导出单个函数,并在整个应用程序范围内复用。

async function fetchBlogPost(id) {const res = await fetch(`/api/posts/${id}`);return res.json();async function BlogPostLayout() {const post = await fetchBlogPost('123');return '...'async function BlogPostContent() {const post = await fetchBlogPost('123'); // Returns memoized valuereturn '...'export default function Page() {
复制代码

总结

总的来说,React 18 的各项新功能在诸多方面提高了应用程序的性能水平。

原文链接:

top="15886.453125">相关阅读:

ReactJS 广受业界认可,高级开发者年薪百万

从新React文档看未来 Web 的开发趋势

我被React劫持了,很痛苦又离不开

React开发者们的 Solid.js快速入门教程

声明:本文来自用户分享和网络收集,仅供学习与参考,测试请备份。