译:SPA 应用的数据获取模式

原文:https://martinfowler.com/articles/data-fetch-spa.html
作者:邱俊涛
译者:ChatGPT 4 O

当一个单页应用程序需要从远程源获取数据时,它需要在保持响应的同时向用户提供反馈,通常在查询较慢时进行。五种模式可以帮助实现这一目标。异步状态处理器将这些查询与查询状态的元查询包裹在一起。并行数据获取最小化等待时间。后备标记在标记中指定后备显示。代码拆分只加载需要的代码。预取在可能需要数据之前获取数据,以减少使用时的延迟。

如今,大多数应用程序可以为一个页面发送数百个请求。例如,我的 Twitter 首页发送大约 300 个请求,而亚马逊产品详情页发送大约 600 个请求。其中一些是静态资产(JavaScript、CSS、字体文件、图标等)的请求,但仍然有大约 100 个是用于异步数据获取的请求——无论是时间线、朋友还是产品推荐,以及分析事件。这是相当多的请求量。

页面包含如此多请求的主要原因是为了提高性能和用户体验,特别是让应用程序对终端用户来说 感觉 更快。空白页面加载 5 秒的时代已经一去不复返了。在现代 web 应用程序中,用户通常在不到一秒的时间内看到带有样式和其他元素的基本页面,随后其他部分逐渐加载。

以亚马逊产品详情页为例。导航和顶部栏几乎立即出现,接着是产品图片、简介和描述。然后,当你滚动时,“赞助”内容、评级、推荐、浏览历史等都会出现。通常,用户只想快速浏览或比较产品(以及检查可用性),因此像“购买此商品的顾客也购买了”这样的部分就不那么重要,可以通过单独的请求来加载。

将内容拆分成较小的部分并并行加载是一个有效的策略,但在大型应用程序中,这远远不够。要正确且高效地获取数据,还有许多其他方面需要考虑。数据获取具有挑战性,不仅因为异步编程的性质不符合我们的线性思维,还有许多因素可能导致网络调用失败,此外,还需要考虑许多不明显的情况(数据格式、安全性、缓存、令牌过期等)。

在本文中,我想讨论一些在前端应用程序中获取数据时应该考虑的常见问题和模式。

我们将从 异步状态处理器 模式开始,它将数据获取与 UI 解耦,从而简化您的应用程序架构。接下来,我们将深入探讨 后备标记,以增强数据获取逻辑的直观性。为了加速初始数据加载过程,我们将探索避免 请求瀑布 并实施 并行数据获取 的策略。我们的讨论还将涉及 代码分割,以推迟加载非关键的应用程序部分,以及基于用户交互 预取 数据以提升用户体验。

我相信通过一个简单的例子讨论这些概念是最好的方法。我打算从简单开始,然后以可控的方式引入更多的复杂性。我还计划将代码片段(尤其是样式代码,我在 UI 中使用 TailwindCSS,这可能导致 React 组件中出现冗长的代码片段)保持在最低限度。对于那些对完整细节感兴趣的人,我已在此仓库中提供了详细信息。

服务器端也在不断进步,各种框架中流行的技术如流式服务器端渲染和服务器组件也在获得关注。此外,还出现了一些实验性的方法。然而,这些主题虽然可能同样重要,但可能会在未来的文章中探讨。目前,此讨论将仅集中在前端数据获取模式上。

注意,我们所介绍的技术并不仅限于 React 或任何特定的前端框架或库。我选择使用 React 来进行演示,是因为近年来我在这方面积累了丰富的经验。然而,像 代码分割预取 等原则同样适用于 Angular 或 Vue.js 等框架。我将分享的示例是前端开发中常见的场景,无论你使用的是哪个框架。

那么,让我们深入探讨将在本文中使用的示例,即单页应用程序的 Profile 页面。这是一个典型的应用程序,可能你之前也用过,至少这种场景是很常见的。我们需要从服务器端获取数据,然后在前端使用 JavaScript 动态构建 UI。

介绍应用程序

首先,在 Profile 页面上,我们将展示用户的简介(包括姓名、头像和简短的描述),然后我们还想展示他们的好友(类似于 Twitter 的关注者或 LinkedIn 的连接)。我们需要从远程服务获取用户及其好友的数据,然后将这些数据与 UI 组装显示在屏幕上。

图 1:Profile 页面

这些数据来自两个不同的 API 调用,用户简介 API /users/<id> 返回给定用户 ID 的用户简介,这是一个简单的对象,描述如下:

{
  "id": "u1",
  "name": "邱俊涛",
  "bio": "开发者、教育者、作者",
  "interests": [
    "技术",
    "户外",
    "旅行"
  ]
}

好友 API /users/<id>/friends 端点返回给定用户的好友列表,响应中的每个列表项与上述用户数据相同。我们有两个端点而不是返回用户 API 的 friends 部分的原因是,有些情况下一个用户可能有太多的好友(例如 1,000 个),但大多数人没有很多。这种不平衡的数据结构可能非常棘手,特别是在我们需要分页时。这里的重点是,有些情况下我们需要处理多个网络请求。

关于 React 概念的简要介绍

由于本文利用 React 来阐述各种模式,我不假设您对 React 有很多了解。我不希望您花费大量时间在 React 文档中寻找正确的部分,而是将简要介绍本文中将要利用的概念。如果您已经了解了什么是 React 组件,以及 useStateuseEffect 钩子的用法,您可以跳转到下一节。

对于那些寻求更全面教程的人来说,新的 React 文档是一个很好的资源。

什么是 React 组件?

在 React 中,组件是构建应用的基本单元。简而言之,React 组件是一个返回 UI 片段的函数,可以是一小段 HTML。考虑创建一个渲染导航栏的组件:

import React from 'react';

function Navigation() {
  return (
    <nav>
      <ol>
        <li>Home</li>
        <li>Blogs</li>
        <li>Books</li>
      </ol>
    </nav>
  );
}

乍一看,JavaScript 与 HTML 标签的混合可能看起来很奇怪(这被称为 JSX,是 JavaScript 的语法扩展。对于使用 TypeScript 的人来说,使用了类似的语法 TSX)。为了使此代码可用,需要一个编译器将 JSX 转换为有效的 JavaScript 代码。经过 Babel 编译后,代码大致会转换为以下内容:

function Navigation() {
  return React.createElement(
    "nav",
    null,
    React.createElement(
      "ol",
      null,
      React.createElement("li", null, "Home"),
      React.createElement("li", null, "Blogs"),
      React.createElement("li", null, "Books")
    )
  );
}

请注意,这里转换后的代码有一个名为 React.createElement 的函数,它是 React 中用于创建元素的基础函数。在 React 组件中编写的 JSX 会在幕后编译为 React.createElement 调用。

React.createElement 的基本语法是:

React.createElement(type, [props], [...children])
  • type:一个字符串(例如 ‘div’,‘span’),表示要创建的 DOM 节点类型,或者一个 React 组件(类组件或函数组件)用于更复杂的结构。
  • props:一个对象,包含传递给元素或组件的属性,包括事件处理程序、样式和 classNameid 等属性。
  • children:这些可选参数可以是额外的 React.createElement 调用、字符串、数字或它们的任意组合,表示元素的子节点。

例如,可以用 React.createElement 创建一个简单的元素,如下所示:

React.createElement('div', { className: 'greeting' }, 'Hello, world!');

这相当于 JSX 版本:

<div className="greeting">Hello, world!</div>

在底层,React 调用原生 DOM API(例如 document.createElement("ol"))根据需要生成 DOM 元素。然后你可以将自定义组件组装成一个树,类似于 HTML 代码:

import React from 'react';
import Navigation from './Navigation.tsx';
import Content from './Content.tsx';
import Sidebar from './Sidebar.tsx';
import ProductList from './ProductList.tsx';

function App() {
  return <Page />;
}

function Page() {
  return <Container>
    <Navigation />
    <Content>
      <Sidebar />
      <ProductList />
    </Content>
    <Footer />
  </Container>;
}

最终,你的应用程序需要一个根节点来挂载,在此时 React 接管并管理后续的渲染和重新渲染:

import ReactDOM from "react-dom/client";
import App from "./App.tsx";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

使用 JSX 生成动态内容

初始示例展示了一个简单的用例,但让我们探讨如何动态生成内容。例如,如何动态生成一个数据列表?在 React 中,如前所述,一个组件本质上是一个函数,使我们能够传递参数给它。

import React from 'react';

function Navigation({ nav }) {
  return (
    <nav>
      <ol>
        {nav.map(item => <li key={item}>{item}</li>)}
      </ol>
    </nav>
  );
}

在这个修改后的 Navigation 组件中,我们期望参数是一个字符串数组。我们使用 map 函数遍历每个项目,将它们转换为 <li> 元素。大括号 {} 表示内部的 JavaScript 表达式应该被评估并渲染。对于那些对编译后的动态内容处理感到好奇的人:

function Navigation(props) {
  var nav = props.nav;

  return React.createElement(
    "nav",
    null,
    React.createElement(
      "ol",
      null,
      nav.map(function(item) {
        return React.createElement("li", { key: item }, item);
      })
    )
  );
}

与其像调用常规函数那样调用 Navigation,使用 JSX 语法进行组件调用更像是在编写标记,增强了可读性:

// 不是这样调用
Navigation(["Home", "Blogs", "Books"])

// 而是这样调用
<Navigation nav={["Home", "Blogs", "Books"]} />

React 中的组件可以接收多种数据,这些数据被称为 props,以修改它们的行为,就像将参数传递给函数一样(区别在于使用 JSX 语法,使代码对那些具备 HTML 知识的人来说更加熟悉和易读,这很好地符合大多数前端开发人员的技能)。

import React from 'react';
import Checkbox from './Checkbox';
import BookList from './BookList';

function App() {
  let showNewOnly = false; // 这个标志的值通常基于特定逻辑设置。

  const filteredBooks = showNewOnly
    ? booksData.filter(book => book.isNewPublished)
    : booksData;

  return (
    <div>
      <Checkbox checked={showNewOnly}>
        Show New Published Books Only
      </Checkbox>
      <BookList books={filteredBooks} />
    </div>
  );
}

在这个示例代码片段(虽然非功能性但旨在展示概念)中,我们通过传递一本书的数组来操控 BookList 组件的显示内容。根据 showNewOnly 标志,这个数组要么是所有可用的书籍,要么是仅包含新出版的书籍,展示了如何使用 props 动态调整组件输出。

管理渲染之间的内部状态:useState

构建用户界面(UI)时,通常不仅仅是生成静态 HTML。组件经常需要“记住”某些状态,并动态响应用户交互。例如,当用户点击产品组件中的“添加”按钮时,需要更新购物车组件以反映总价格和更新后的商品列表。

在前面的代码片段中,尝试在事件处理器中将 showNewOnly 变量设置为 true 并没有达到预期效果:

function App () {
  let showNewOnly = false;

  const handleCheckboxChange = () => {
    showNewOnly = true; // 这不起作用
  };

  const filteredBooks = showNewOnly
    ? booksData.filter(book => book.isNewPublished)
    : booksData;

  return (
    <div>
      <Checkbox checked={showNewOnly} onChange={handleCheckboxChange}>
        Show New Published Books Only
      </Checkbox>

      <BookList books={filteredBooks}/>
    </div>
  );
};

这种方法失败的原因是函数组件内部的局部变量在渲染之间不会持久化。当 React 重新渲染此组件时,它会从头开始,忽略对局部变量所做的任何更改,因为这些更改不会触发重新渲染。React 无法知道需要更新组件以反映新数据。

这个限制强调了 React state 的必要性。具体来说,函数组件利用 useState 钩子在渲染之间记住状态。重新审视 App 示例,我们可以有效地记住 showNewOnly 状态,如下所示:

import React, { useState } from 'react';
import Checkbox from './Checkbox';
import BookList from './BookList';

function App () {
  const [showNewOnly, setShowNewOnly] = useState(false);

  const handleCheckboxChange = () => {
    setShowNewOnly(!showNewOnly);
  };

  const filteredBooks = showNewOnly
    ? booksData.filter(book => book.isNewPublished)
    : booksData;

  return (
    <div>
      <Checkbox checked={showNewOnly} onChange={handleCheckboxChange}>
        Show New Published Books Only
      </Checkbox>

      <BookList books={filteredBooks}/>
    </div>
  );
};

useState 钩子是 React 钩子系统的基石,引入它是为了使函数组件能够管理内部状态。它通过以下语法向函数组件引入状态:

const [state, setState] = useState(initialState);
  • initialState:这个参数是状态变量的初始值。它可以是一个简单的值,比如数字、字符串、布尔值,或者是一个更复杂的对象或数组。initialState 仅在第一次渲染时用于初始化状态。
  • 返回值useState 返回一个包含两个元素的数组。第一个元素是当前状态值,第二个元素是一个函数,允许更新这个值。通过数组解构,我们为这些返回项赋予名称,通常是 statesetState,当然你也可以选择任何有效的变量名。
  • state:表示状态的当前值。它是将在组件的 UI 和逻辑中使用的值。
  • setState:一个用于更新状态的函数。这个函数接受一个新的状态值或一个基于之前状态产生新状态的函数。当调用时,它会安排一次状态更新并触发重新渲染以反映变化。

React 将状态视为快照;更新状态不会改变现有的状态变量,而是触发一次重新渲染。在这次重新渲染过程中,React 会识别更新后的状态,确保 BookList 组件接收到正确的数据,从而向用户反映更新后的书单。这种状态的快照行为促进了 React 组件的动态和响应性,使它们能够直观地响应用户交互和其他变化。

管理副作用:useEffect

在深入讨论之前,有必要介绍一下副作用的概念。副作用是指与 React 生态系统外部进行交互的操作。常见的例子包括从远程服务器获取数据或动态操纵 DOM,比如更改页面标题。

React 主要关注将数据渲染到 DOM,并不固有地处理数据获取或直接的 DOM 操作。为了便于这些副作用,React 提供了 useEffect 钩子。这个钩子允许在 React 完成渲染过程后执行副作用。如果这些副作用导致数据变化,React 会安排一次重新渲染以反映这些更新。

useEffect 钩子接受两个参数:

  • 包含副作用逻辑的函数。
  • 一个可选的依赖项数组,指定何时重新调用副作用。

省略第二个参数会导致在每次渲染后运行副作用。提供一个空数组 [] 表示你的副作用不依赖于任何来自 props 或 state 的值,因此不需要重新运行。在数组中包含特定值意味着只有这些值变化时副作用才会重新执行。

在处理异步数据获取时,useEffect 内的工作流程包括发起网络请求。一旦数据被检索到,它会通过 useState 钩子捕获,更新组件的内部状态并在渲染之间保留获取到的数据。React 识别到状态更新后,会进行另一次渲染周期以包含新数据。

以下是一个关于数据获取和状态管理的实际例子:

import { useEffect, useState } from "react";

type User = {
  id: string;
  name: string;
};

const UserSection = ({ id }) => {
  const [user, setUser] = useState<User | undefined>();

  useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch(`/api/users/${id}`);
      const jsonData = await response.json();
      setUser(jsonData);
    };

    fetchUser();
  }, [id]);

  return <div>
    <h2>{user?.name}</h2>
  </div>;
};

在上述代码片段中,在 useEffect 内定义了一个异步函数 fetchUser,并立即调用。这种模式是必要的,因为 useEffect 不直接支持异步函数作为其回调。定义异步函数以使用 await 进行获取操作,确保代码执行等待响应然后处理 JSON 数据。一旦数据可用,它通过 setUser 更新组件的状态。

依赖数组 [id] 确保了 useEffect 只在 id 改变时重新运行,这防止了每次渲染时不必要的网络请求,并在 id prop 更新时获取新的用户数据。

这种在 useEffect 中处理异步数据获取的方法是 React 开发中的标准实践,提供了一种结构化且高效的方式将异步操作集成到 React 组件的生命周期中。

此外,在实际应用中,管理不同的状态(如加载、错误和数据展示)也是至关重要的(我们将在后续章节中看到它是如何工作的)。例如,考虑在 User 组件中实现状态指示器,以反映加载、错误或数据状态,在数据获取操作期间提供反馈,从而增强用户体验。

图 2:组件的不同状态

这只是对本文中使用的概念的简要概述。要深入了解其他概念和模式,我建议探索 新的 React 文档 或查阅其他在线资源。有了这个基础,你现在应该准备好和我一起深入探讨本文讨论的数据获取模式了。

实现 Profile 组件

让我们创建 Profile 组件来发出请求并渲染结果。在典型的 React 应用中,这种数据获取是在 useEffect 块中处理的。以下是一个可能的实现示例:

import { useEffect, useState } from "react";

const Profile = ({ id }: { id: string }) => {
  const [user, setUser] = useState<User | undefined>();

  useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch(`/api/users/${id}`);
      const jsonData = await response.json();
      setUser(jsonData);
    };

    fetchUser();
  }, [id]);

  return (
    <UserBrief user={user} />
  );
};

这种初始方法假设网络请求是瞬间完成的,而这通常并不是现实情况。实际场景需要处理各种网络状况,包括延迟和失败。为了有效管理这些情况,我们在组件中加入了加载和错误状态。这一添加使我们能够在数据获取期间向用户提供反馈,例如如果数据延迟则显示加载指示器或骨架屏幕,并在发生错误时进行处理。

以下是加入了加载和错误管理后的增强组件:

import { useEffect, useState } from "react";
import { get } from "../utils.ts";

import type { User } from "../types.ts";

const Profile = ({ id }: { id: string }) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | undefined>();
  const [user, setUser] = useState<User | undefined>();

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const data = await get<User>(`/users/${id}`);
        setUser(data);
      } catch (e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [id]);

  if (loading || !user) {
    return <div>Loading...</div>;
  }

  return (
    <>
      {user && <UserBrief user={user} />}
    </>
  );
};

现在在 Profile 组件中,我们使用 useState 初始化了加载、错误和用户数据的状态。使用 useEffect,我们根据 id 获取用户数据,切换加载状态并相应地处理错误。在成功获取数据后,我们更新用户状态,否则显示加载指示器。

get 函数如下所示,通过将端点附加到预定义的基本 URL 来简化从特定端点获取数据。它检查响应的成功状态,并返回解析后的 JSON 数据或为不成功的请求抛出错误,从而简化了我们应用中的错误处理和数据获取。注意,这是纯 TypeScript 代码,可以在应用的其他非 React 部分使用。

const baseurl = "https://icodeit.com.au/api/v2";

async function get<T>(url: string): Promise<T> {
  const response = await fetch(`${baseurl}${url}`);

  if (!response.ok) {
    throw new Error("Network response was not ok");
  }

  return await response.json() as Promise<T>;
}

React 会尝试最初渲染组件,但由于数据 user 不可用,它会在一个 div 中返回“loading…”。然后调用 useEffect,请求开始。一旦在某个时刻,响应返回,React 会重新渲染包含已填充 userProfile 组件,因此你现在可以看到带有名字、头像和头衔的用户部分。

如果我们可视化上述代码的时间线,你会看到以下顺序。浏览器首先下载 HTML 页面,然后当它遇到 script 标签和 style 标签时,可能会暂停并下载这些文件,然后解析它们以形成最终页面。请注意,这是一个相对复杂的过程,我在这里进行了简化,但基本的顺序思想是正确的。

图 3: 获取用户数据

因此 React 只能在 JS 被解析和执行之后开始渲染,然后它找到用于数据获取的 useEffect;它必须等到数据可用才能重新渲染。

现在在浏览器中,当应用程序启动时,我们可以看到一个“loading…”,然后在几秒钟后(我们可以通过在 API 端点添加一些延迟来模拟这种情况)用户简介部分在数据加载后显示出来。

图 4: 用户简介组件

这种代码结构(在 useEffect 中触发请求,并相应地更新状态如 loadingerror)在 React 代码库中被广泛使用。在常规大小的应用程序中,常常可以在各个组件中找到许多这样的数据获取逻辑实例。

异步状态处理器

使用元查询包装异步查询,以获取查询的状态。

远程调用可能很慢,在进行这些调用时,不能让 UI 冻结是至关重要的。因此,我们以异步方式处理它们,并使用指示器显示正在进行的过程,这可以提升用户体验——让用户知道正在发生某些事情。

此外,由于连接问题,远程调用可能会失败,需要向用户清楚地传达这些失败信息。因此,最好将每个远程调用封装在一个处理程序模块中,该模块负责管理结果、进度更新和错误。该模块允许 UI 访问有关调用状态的元数据,从而使其能够在预期结果未出现时显示备选信息或选项。

一个简单的实现可以是一个名为 getAsyncStates 的函数,它返回这些元数据,它以 URL 作为参数,并返回一个包含管理异步操作所需信息的对象。这样设置可以让我们适当地响应网络请求的不同状态,无论是正在进行中、成功解析还是遇到错误。

const { loading, error, data } = getAsyncStates(url);

if (loading) {
  // 显示加载动画
}

if (error) {
  // 显示错误消息
}

// 使用数据进行渲染

这里的假设是 getAsyncStates 在被调用时会自动启动网络请求。然而,这可能并不总是符合调用者的需求。为了提供更多的控制,我们还可以在返回的对象中公开一个 fetch 函数,允许在更合适的时间启动请求,根据调用者的意愿。此外,可以提供一个 refetch 函数,以便调用者在需要时重新启动请求,例如在发生错误后或需要更新数据时。fetchrefetch 函数可以在实现上相同,或者 refetch 可能包含逻辑来检查缓存的结果,并仅在必要时重新获取数据。

const { loading, error, data, fetch, refetch } = getAsyncStates(url);

const onInit = () => {
  fetch();
};

const onRefreshClicked = () => {
  refetch();
};

if (loading) {
  // 显示加载动画
}

if (error) {
  // 显示错误信息
}

// 使用数据进行渲染

这种模式提供了一种处理异步请求的多功能方法,使开发者可以灵活地显式触发数据获取,并有效管理 UI 对加载、错误和成功状态的响应。通过将获取逻辑与其启动分离,应用程序可以更动态地适应用户交互和其他运行时条件,从而增强用户体验和应用程序的可靠性。

在 React 中使用钩子实现异步状态处理

这种模式可以在不同的前端库中实现。例如,我们可以将这种方法提炼为一个自定义 Hook,用于 React 应用中的 Profile 组件:

import { useEffect, useState } from "react";
import { get } from "../utils.ts";

const useUser = (id: string) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | undefined>();
  const [user, setUser] = useState<User | undefined>();

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const data = await get<User>(`/users/${id}`);
        setUser(data);
      } catch (e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [id]);

  return {
    loading,
    error,
    user,
  };
};

请注意,在自定义 Hook 中,我们没有任何 JSX 代码——这意味着它完全不涉及 UI,但具有可共享的有状态逻辑。而且 useUser 在被调用时会自动启动数据获取。在 Profile 组件中,利用 useUser Hook 简化其逻辑:

import { useUser } from './useUser.ts';
import UserBrief from './UserBrief.tsx';

const Profile = ({ id }: { id: string }) => {
  const { loading, error, user } = useUser(id);

  if (loading || !user) {
    return <div>加载中...</div>;
  }

  if (error) {
    return <div>出现错误...</div>;
  }

  return (
    <>
      {user && <UserBrief user={user} />}
    </>
  );
};

泛化参数使用

在大多数应用程序中,获取不同类型的数据——从主页上的用户详细信息到搜索结果中的产品列表以及其下方的推荐内容——是一个常见的需求。为每种数据类型编写单独的获取函数可能既繁琐又难以维护。更好的方法是将此功能抽象为一个通用的、可重用的钩子,以高效地处理各种数据类型。

考虑将远程 API 端点视为服务,并使用一个接受 URL 作为参数的通用 useService 钩子,同时管理与异步请求相关的所有元数据:

import { get } from "../utils.ts";

function useService<T>(url: string) {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | undefined>();
  const [data, setData] = useState<T | undefined>();

  const fetch = async () => {
    try {
      setLoading(true);
      const data = await get<T>(url);
      setData(data);
    } catch (e) {
      setError(e as Error);
    } finally {
      setLoading(false);
    }
  };

  return {
    loading,
    error,
    data,
    fetch,
  };
}

这个钩子抽象了数据获取过程,使其更容易集成到任何需要从远程源获取数据的组件中。它还集中处理常见的错误处理场景,例如区分对待特定错误:

import { useService } from './useService.ts';

const {
  loading,
  error,
  data: user,
  fetch: fetchUser,
} = useService(`/users/${id}`);

通过使用 useService,我们可以简化组件获取和处理数据的方式,使代码库更简洁和更易维护。

模式的变体

useUser 的一个变体是公开 fetchUsers 函数,并且它不会自己触发数据获取:

import { useState } from "react";

const useUser = (id: string) => {
  // 定义状态

  const fetchUser = async () => {
    try {
      setLoading(true);
      const data = await get<User>(`/users/${id}`);
      setUser(data);
    } catch (e) {
      setError(e as Error);
    } finally {
      setLoading(false);
    }
  };

  return {
    loading,
    error,
    user,
    fetchUser,
  };
};

然后在调用端,Profile 组件使用 useEffect 来获取数据并渲染不同的状态。

const Profile = ({ id }: { id: string }) => {
  const { loading, error, user, fetchUser } = useUser(id);

  useEffect(() => {
    fetchUser();
  }, []);

  // 根据状态进行渲染
};

这种划分的优势在于能够在不同的组件中重用这些状态逻辑。例如,另一个需要相同数据(使用用户 ID 进行的用户 API 调用)的组件可以简单地导入 useUser Hook 并利用其状态。不同的 UI 组件可能会选择以各种方式与这些状态交互,或许使用不同的加载指示器(适合调用组件的小型旋转器)或错误消息,但获取数据的基本逻辑保持一致并且是共享的。

何时使用

将数据获取逻辑与 UI 组件分离有时会引入不必要的复杂性,特别是在较小的应用程序中。将这种逻辑集成在组件内,类似于 css-in-js 的方法,可以简化导航,并且对于一些开发人员来说更容易管理。在我的文章《使用既定的 UI 模式模块化 React 应用程序》中,我探讨了应用程序结构中不同层次的复杂性。对于范围有限的应用程序 —— 只有几个页面和几个数据获取操作 —— 通常实际且推荐将数据获取逻辑保留在 UI 组件内。

然而,随着应用程序规模的扩大和开发团队的增长,这种策略可能会导致效率低下。深层的组件树会减慢应用程序的速度(我们将在后面的部分看到示例以及如何解决它们)并生成冗余的样板代码。引入一个异步状态处理程序可以通过将数据获取与 UI 渲染解耦来缓解这些问题,从而提高性能和可维护性。

在项目发展过程中,平衡简单性和结构化的方法至关重要。这确保了您的开发实践能够有效响应应用程序的需求,保持最佳性能和开发人员效率,无论项目规模如何。

实现好友列表

现在我们来看看 Profile 的第二部分 – 好友列表。我们可以创建一个单独的组件 Friends 并在其中获取数据(使用我们上面定义的 useService 自定义 hook),逻辑与我们在 Profile 组件中看到的非常相似。

const Friends = ({ id }: { id: string }) => {
  const { loading, error, data: friends } = useService(`/users/${id}/friends`);

  // 处理 loading 和 error...

  return (
    <div>
      <h2>Friends</h2>
      <div>
        {friends.map((user) => (
        // 渲染用户列表
        ))}
      </div>
    </div>
  );
};

然后在 Profile 组件中,我们可以像常规组件一样使用 Friends,并将 id 作为 prop 传入:

const Profile = ({ id }: { id: string }) => {
  //...

  return (
    <>
      {user && <UserBrief user={user} />}
      <Friends id={id} />
    </>
  );
};

代码工作正常,看起来也很简洁可读,UserBrief 渲染传入的 user 对象,而 Friends 则管理自己的数据获取和渲染逻辑。如果我们将组件树可视化,可能会像这样:

图 5: 组件结构

ProfileFriends 都有数据获取、加载检查和错误处理的逻辑。由于有两个独立的数据获取调用,如果我们查看请求时间线,会注意到一些有趣的事情。

图 6: 请求瀑布流

Friends 组件不会在用户状态设置之前启动数据获取。这被称为 Fetch-On-Render 方法,其中初始渲染由于数据不可用而暂停,React 需要等待从服务器端检索数据。

这个等待期有些低效,因为虽然 React 的渲染过程只需要几毫秒,但数据获取可能需要更长的时间,通常是几秒钟。结果是,Friends 组件大部分时间都处于闲置状态,等待数据。这种情况导致了一个常见的挑战,称为 请求瀑布流,这是前端应用中涉及多个数据获取操作时的常见问题。

并行数据获取

并行运行远程数据获取以最小化等待时间

想象一下,当我们构建一个较大的应用程序时,一个需要数据的组件可能深深嵌套在组件树中,更糟糕的是,这些组件是由不同的团队开发的,很难看清我们阻塞了谁。


图 7:请求瀑布

请求瀑布会降低用户体验,这是我们希望避免的。分析数据后,我们看到用户 API 和朋友 API 是独立的,可以并行获取。发起这些并行请求对于应用程序性能至关重要。

一种方法是在更高的层次集中数据获取,靠近根部。在应用程序生命周期的早期,我们同时启动所有数据获取。依赖这些数据的组件仅等待最慢的请求,这通常会导致更快的整体加载时间。

我们可以使用 Promise API Promise.all 来同时发送请求以获取用户的基本信息和他们的朋友列表。Promise.all 是一个 JavaScript 方法,允许多个 Promise 并行执行。它接受一个 Promise 数组作为输入,并返回一个单一的 Promise,当所有输入的 Promise 都解析时,它会解析并提供它们的结果作为一个数组。如果任何一个 Promise 失败,Promise.all 会立即因第一个拒绝的 Promise 而拒绝。

例如,在应用程序的根部,我们可以定义一个综合的数据模型:

type ProfileState = {
  user: User;
  friends: User[];
};

const getProfileData = async (id: string) =>
  Promise.all([
    get<User>(`/users/${id}`),
    get<User[]>(`/users/${id}/friends`),
  ]);

const App = () => {
  // 在应用程序启动的最开始获取数据
  const onInit = async () => {
    const [user, friends] = await getProfileData(id);
  }

  // 对应地渲染子树
}

在 React 中实现并行数据获取

在应用程序启动时,数据获取开始,将获取过程抽象出来,独立于子组件。例如,在 Profile 组件中,UserBrief 和 Friends 都是响应传递数据的展示组件。这样我们可以单独开发这些组件(例如为不同状态添加样式)。由于我们将数据获取和渲染分离开来,这些展示组件通常容易测试和修改。

我们可以定义一个自定义钩子 useProfileData,使用 Promise.all 来实现与用户及其朋友相关的数据的并行获取。此方法允许同时请求,优化加载过程并将数据结构化为预定义的格式,即 ProfileData

下面是钩子实现的详细步骤:

import { useCallback, useEffect, useState } from "react";

type ProfileData = {
  user: User;
  friends: User[];
};

const useProfileData = (id: string) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | undefined>(undefined);
  const [profileState, setProfileState] = useState<ProfileData>();

  const fetchProfileState = useCallback(async () => {
    try {
      setLoading(true);
      const [user, friends] = await Promise.all([
        get<User>(`/users/${id}`),
        get<User[]>(`/users/${id}/friends`),
      ]);
      setProfileState({ user, friends });
    } catch (e) {
      setError(e as Error);
    } finally {
      setLoading(false);
    }
  }, [id]);

  return {
    loading,
    error,
    profileState,
    fetchProfileState,
  };

};

该钩子为 Profile 组件提供必要的数据状态(loadingerrorprofileState)以及一个 fetchProfileState 函数,使组件能够根据需要启动获取操作。注意这里我们使用 useCallback 钩子来包装用于数据获取的异步函数。React 中的 useCallback 钩子用于记忆函数,确保在组件重新渲染时保持相同的函数实例,除非其依赖项发生变化。与 useEffect 类似,它接受一个函数和一个依赖数组,只有当这些依赖项中的任何一个发生变化时,函数才会重新创建,从而避免 React 渲染周期中的意外行为。

Profile 组件使用这个钩子,并通过 useEffect 控制数据获取的时机:

const Profile = ({ id }: { id: string }) => {
  const { loading, error, profileState, fetchProfileState } = useProfileData(id);

  useEffect(() => {
    fetchProfileState();
  }, [fetchProfileState]);

  if (loading) {
    return <div>加载中...</div>;
  }

  if (error) {
    return <div>出现了一些问题...</div>;
  }

  return (
    <>
      {profileState && (
        <>
          <UserBrief user={profileState.user} />
          <Friends users={profileState.friends} />
        </>
      )}
    </>
  );
};

这种方法也被称为 Fetch-Then-Render,表明其目的是在页面加载时尽早发起请求。随后,获取的数据用于驱动 React 的应用渲染,从而避免在渲染过程中管理数据获取。这种策略简化了渲染过程,使代码更易于测试和修改。

如果可视化组件结构,它会像下面的图示一样

图 8:重构后的组件结构

时间线比之前的要短得多,因为我们并行发送了两个请求。当 Friends 组件开始渲染时,数据已经准备好并传入,因此它可以在几毫秒内渲染完毕。

图 9:并行请求

注意,最长的等待时间取决于最慢的网络请求,但这比顺序请求要快得多。如果我们能在组件树的更高层级同时发送尽可能多的独立请求,用户体验会更好。

随着应用程序的扩展,在根级别管理越来越多的请求变得具有挑战性。对于离根较远的组件尤其如此,传递数据变得繁琐。一种方法是将所有数据全局存储,通过函数(如 Redux 或 React Context API)访问,从而避免深层的属性传递。

何时使用

并行运行查询在查询可能较慢且不会显著干扰其他性能的情况下是有用的。这通常是远程查询的情况。即使远程机器的 I/O 和计算速度很快,远程调用始终存在潜在的延迟问题。并行查询的主要缺点是设置一些异步机制,这在某些语言环境中可能会很困难。

不使用并行数据获取的主要原因是在我们获取了一些数据之后才知道需要获取哪些数据。由于请求之间存在依赖关系,某些情况下需要顺序数据获取。例如,考虑一个在 Profile 页面上的场景,其中生成个性化推荐内容取决于首先从用户 API 获取用户的兴趣

以下是用户 API 的示例响应,其中包括兴趣:

{
  "id": "u1",
  "name": "Juntao Qiu",
  "bio": "Developer, Educator, Author",
  "interests": [
    "Technology",
    "Outdoors",
    "Travel"
  ]
}

在这种情况下,只有在从初始 API 调用中接收到用户的兴趣之后,才能获取推荐内容。这种顺序依赖性阻止了我们利用并行获取,因为第二个请求依赖于从第一个请求获取的数据。

在这些约束条件下,讨论异步数据管理的替代策略变得重要。其中一种策略是「备用标记」。这种方法允许开发人员明确指定需要哪些数据以及如何获取数据,从而清晰定义依赖关系,使得更容易管理应用程序中的复杂数据关系。

另一个不适用并行数据获取的示例是涉及需要实时数据验证的用户交互场景。

考虑一个列表的情况,其中每个项目都有一个“批准”上下文菜单。当用户点击项目的“批准”选项时,会出现一个下拉菜单,提供“批准”或“拒绝”的选择。如果此项目的批准状态可能同时由其他管理员更改,则菜单选项必须反映最新状态,以避免冲突操作。

图 10:需要实时状态的审批列表

为了解决这个问题,每次激活上下文菜单时都会启动一个服务调用。该服务会获取项目的最新状态,确保下拉菜单的构建具有最准确和最新的选项。因此,这些请求无法与其他数据获取活动并行进行,因为下拉菜单的内容完全依赖于从服务器获取的实时状态。

备用标记

在页面标记中指定备用显示。

这种模式利用框架或库提供的抽象来处理数据检索过程,包括在后台管理加载、成功和错误等状态。它使开发人员能够专注于应用程序中数据的结构和展示,促进代码的简洁和可维护性。

让我们再看看上一节中的 Friends 组件。它必须维护三种不同的状态,并在 useEffect 中注册回调,在适当的时候正确设置标志,为不同的状态安排不同的 UI:

const Friends = ({ id }: { id: string }) => {
  //...
  const {
    loading,
    error,
    data: friends,
    fetch: fetchFriends,
  } = useService(`/users/${id}/friends`);

  useEffect(() => {
    fetchFriends();
  }, []);

  if (loading) {
    // 显示加载指示器
  }

  if (error) {
    // 显示错误信息组件
  }

  // 显示实际的朋友列表
};

你会注意到,在组件内部我们必须处理不同的状态,即使我们提取了自定义 Hook 来减少组件中的噪音,我们仍然需要注意在组件内部处理 loadingerror。这些样板代码可能是繁琐和分散注意力的,往往会使我们的代码库的可读性变得混乱。

如果我们考虑声明式 API,比如我们如何使用 JSX 构建 UI,代码可以用以下方式编写,让你专注于组件在做什么 – 而不是如何做

<WhenError fallback={<ErrorMessage />}>
  <WhenInProgress fallback={<Loading />}>
    <Friends />
  </WhenInProgress>
</WhenError>

在上面的代码片段中,意图简单明了:当发生错误时,显示 ErrorMessage。操作进行中时,显示 Loading。一旦操作完成且没有错误,渲染 Friends 组件。

上述代码片段与一些已经在几个库(包括 React 和 Vue.js)中实现的内容非常相似。例如,React 中的新 Suspense 允许开发人员更有效地管理组件内的异步操作,改进了加载状态、错误状态的处理和并发任务的协调。

在 React 中使用 Suspense 实现后备标记

React 中的 Suspense 是一种以声明方式高效处理异步操作(如数据获取或资源加载)的机制。通过将组件包裹在 Suspense 边界中,开发人员可以指定在等待组件的数据依赖项满足时要显示的后备内容,从而简化加载状态下的用户体验。

使用 Suspense API 时,在 Friends 中描述你想要获取的内容然后进行渲染:

import useSWR from "swr";
import { get } from "../utils.ts";

function Friends({ id }: { id: string }) {
  const { data: users } = useSWR("/api/profile", () => get<User[]>(`/users/${id}/friends`), {
    suspense: true,
  });

  return (
    <div>
      <h2>Friends</h2>
      <div>
        {friends.map((user) => (
          <Friend user={user} key={user.id} />
        ))}
      </div>
    </div>
  );
}

在使用 Friends 时,可以使用 Suspense 边界包裹 Friends 组件:

<Suspense fallback={<FriendsSkeleton />}>
  <Friends id={id} />
</Suspense>

Suspense 管理 Friends 组件的异步加载,在组件的数据依赖项解析之前显示 FriendsSkeleton 占位符。此设置确保用户界面在数据获取期间保持响应性和信息性,改进了整体用户体验。

在 Vue.js 中使用该模式

值得注意的是,Vue.js 也在探索一种类似的实验模式,你可以使用 Fallback Markup,如下所示:

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    加载中...
  </template>
</Suspense>

在第一次渲染时,<Suspense> 会尝试在幕后渲染其默认内容。如果在此阶段遇到任何异步依赖项,它将进入挂起状态,显示后备内容。一旦所有异步依赖项成功加载,<Suspense> 会移动到已解析状态,并渲染最初打算显示的内容(默认插槽内容)。

决定加载组件的位置

你可能会想在哪里放置 FriendsSkeleton 组件以及谁应该管理它。通常,在不使用 Fallback Markup 的情况下,这个决定是直接在管理数据获取的组件内做出的:

const Friends = ({ id }: { id: string }) => {
  // 数据获取逻辑...

  if (loading) {
    // 显示加载指示器
  }

  if (error) {
    // 显示错误信息组件
  }

  // 渲染实际的好友列表
};

在这种设置中,显示加载指示器或错误信息的逻辑自然位于 Friends 组件内。然而,采用 Fallback Markup 将这项责任转移到了组件的使用者:

<Suspense fallback={<FriendsSkeleton />}>
  <Friends id={id} />
</Suspense>

在实际应用中,处理加载体验的最佳方法在很大程度上取决于所需的用户交互和应用程序的结构。例如,分层加载方法中,父组件停止显示加载指示器,而子组件继续显示,可能会破坏用户体验。因此,必须仔细考虑在组件层次结构中的哪个级别显示加载指示器或占位符。

FriendsFriendsSkeleton 可以看作两种不同的组件状态——一种表示有数据,另一种表示没有数据。这种概念有点类似于面向对象编程中的 Special Case 模式,其中 FriendsSkeleton 充当 Friends 组件的“空”状态处理。

关键在于确定显示加载指示器的粒度,并在应用程序中保持这些决策的一致性。这样可以实现更平滑和更可预测的用户体验。

何时使用

在 UI 中使用备用标记可以通过增强代码的可读性和可维护性来简化代码。当在应用程序中利用标准组件处理各种状态(如加载、错误、骨架屏和空视图)时,这种模式特别有效。它减少了冗余,清理了样板代码,使组件能够专注于渲染和功能。

像 React 的 Suspense 这样的备用标记标准化了异步加载的处理,确保一致的用户体验。它还通过优化资源加载和渲染来提高应用程序性能,这在具有深层组件树的复杂应用程序中特别有利。

然而,备用标记的有效性取决于所使用框架的能力。例如,React 的 Suspense 对数据获取的实现仍然需要第三方库,Vue 对类似功能的支持还在实验阶段。此外,尽管备用标记可以减少跨组件管理状态的复杂性,但在简单应用程序中直接在组件内管理状态可能更为合适。这种模式可能会引入一些开销,并可能限制对加载和错误状态的详细控制——需要不同错误类型的不同处理情况,可能无法通过通用的备用方法轻松管理。

介绍 UserDetailCard 组件

假设我们需要一个功能,当用户悬停在一个 Friend 上时,显示一个弹出窗口,以便他们可以查看更多关于该用户的详细信息。

图 11:悬停时显示用户详细信息卡片组件

当弹出窗口显示时,我们需要发送另一个服务调用以获取用户详细信息(如他们的主页和连接数等)。我们需要将 Friend 组件(我们用来渲染好友列表中的每一项)更新为如下内容。

import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/react";
import { UserBrief } from "./user.tsx";

import UserDetailCard from "./user-detail-card.tsx";

export const Friend = ({ user }: { user: User }) => {
  return (
    <Popover placement="bottom" showArrow offset={10}>
      <PopoverTrigger>
        <button>
          <UserBrief user={user} />
        </button>
      </PopoverTrigger>
      <PopoverContent>
        <UserDetailCard id={user.id} />
      </PopoverContent>
    </Popover>
  );
};

UserDetailCardProfile 组件非常相似,它发送请求加载数据,然后在收到响应后渲染结果。

export function UserDetailCard({ id }: { id: string }) {
  const { loading, error, detail } = useUserDetail(id);

  if (loading || !detail) {
    return <div>Loading...</div>;
  }

  return (
    <div>
    {/* 渲染用户详细信息 */}
    </div>
  );
}

我们使用 nextuiPopover 及其支持组件,它提供了许多美观且开箱即用的组件用于构建现代 UI。这里唯一的问题是,nextui 包本身相对较大,并且不是每个人都使用这个功能(悬停显示详细信息),所以为每个人加载这个额外的大包并不理想——最好是按需加载 UserDetailCard,即在需要时加载。

图 12:包含 UserDetailCard 的组件结构

代码分割

将代码分割成独立的模块并根据需要动态加载它们。

代码分割通过将捆绑包分成更小的块来解决 web 应用程序中捆绑包过大的问题。这些块根据需要加载,而不是一次性全部加载,从而改善初始加载时间和性能,尤其对大型应用程序或具有多个路由的应用程序尤为重要。

这种优化通常在构建时进行,将复杂或较大的模块分离成独立的捆绑包。然后这些捆绑包会动态加载,要么响应用户交互,要么提前加载,而不会妨碍应用程序的关键渲染路径。

利用动态导入操作符

JavaScript 中的动态导入操作符简化了加载模块的过程。尽管在代码中它看起来像是一个函数调用,例如 import("./user-detail-card.tsx"),但重要的是要认识到 import 实际上是一个关键字,而不是一个函数。这个操作符允许异步和动态加载 JavaScript 模块。

使用动态导入,你可以按需加载模块。例如,我们仅在按钮被点击时加载一个模块:

button.addEventListener("click", (e) => {

  import("/modules/some-useful-module.js")
    .then((module) => {
      module.doSomethingInteresting();
    })
    .catch(error => {
      console.error("Failed to load the module:", error);
    });
});

模块不会在初始页面加载时加载。相反,import() 调用被放置在事件监听器内,所以只有在用户与该按钮交互时才会加载。

你可以在 React 和 Vue.js 等库中使用动态导入操作符。React 通过 React.lazySuspense API 简化了代码分割和延迟加载。通过将导入语句包裹在 React.lazy 中,并随后将组件(例如 UserDetailCard)包裹在 Suspense 中,React 将推迟组件渲染,直到所需模块加载完成。在加载阶段,会显示一个后备 UI,加载完成后无缝过渡到实际组件。

import React, { Suspense } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/react";
import { UserBrief } from "./user.tsx";

const UserDetailCard = React.lazy(() => import("./user-detail-card.tsx"));

export const Friend = ({ user }: { user: User }) => {
  return (
    <Popover placement="bottom" showArrow offset={10}>
      <PopoverTrigger>
        <button>
          <UserBrief user={user} />
        </button>
      </PopoverTrigger>
      <PopoverContent>
        <Suspense fallback={<div>Loading...</div>}>
          <UserDetailCard id={user.id} />
        </Suspense>
      </PopoverContent>
    </Popover>
  );
};

此代码段定义了一个 Friend 组件,显示来自 Next UI 的用户详情弹出框,该弹出框在交互时出现。它利用 React.lazy 进行代码拆分,仅在需要时加载 UserDetailCard 组件。此懒加载与 Suspense 结合,通过拆分包并在加载时显示后备内容来提高性能。

如果我们可视化上述代码,它按以下顺序呈现。

图 13:按需动态加载组件

请注意,当用户悬停并下载 JavaScript 包时,浏览器解析 JavaScript 需要一些额外时间。一旦这部分工作完成,我们可以通过调用 /users/<id>/details API 获取用户详情。最终,我们可以使用这些数据来渲染弹出框 UserDetailCard 的内容。

何时使用

拆分额外的包并按需加载是一种可行的策略,但关键在于如何实现它。请求和处理额外的包确实可以节省带宽,并让用户仅加载他们需要的内容。然而,在某些情况下,这种方法也可能会减慢用户体验。例如,如果用户悬停在触发包加载的按钮上,加载、解析和执行渲染所需的 JavaScript 可能需要几秒钟。尽管这种延迟仅在第一次交互时发生,但可能不会提供理想的体验。

为了提升感知性能,使用 React Suspense 展示骨架屏或其他加载指示器可以帮助使加载过程看起来更快。此外,如果单独的 bundle 并不大,将其集成到主 bundle 中可能是一种更直接且成本效益更高的方法。这样,当用户悬停在 UserBrief 等组件上时,响应可以是即时的,增强用户互动,而不需要单独的加载步骤。

在其他前端库中懒加载

同样,这种模式在其他前端库中也被广泛采用。例如,在 Vue.js 中可以使用 defineAsyncComponent 来实现类似的效果——只有在需要渲染时才加载组件:

<template>
  <Popover placement="bottom" show-arrow offset="10">
  <!-- 模板的其他部分 -->
  </Popover>
</template>

<script>
import { defineAsyncComponent } from 'vue';
import Popover from 'path-to-popover-component';
import UserBrief from './UserBrief.vue';

const UserDetailCard = defineAsyncComponent(() => import('./UserDetailCard.vue'));

// 渲染逻辑
</script>

defineAsyncComponent 函数定义了一个异步组件,只有在渲染时才懒加载,就像 React.lazy 一样。

正如你可能已经注意到的,我们再次遇到了请求瀑布问题:我们首先加载 JavaScript bundle,然后在执行时按顺序调用用户详细信息 API,这会产生一些额外的等待时间。我们可以并行请求 JavaScript bundle 和网络请求。也就是说,每当悬停在 Friend 组件上时,我们可以触发网络请求(获取渲染用户详细信息的数据)并缓存结果,这样当 bundle 下载完成时,我们可以立即使用数据来渲染组件。

预获取

预先获取数据可以减少延迟。

预获取涉及在实际需要之前加载资源或数据,旨在减少后续操作中的等待时间。这种技术在用户行为可以预测的情况下特别有用,例如导航到不同的页面或显示需要远程数据的模态对话框。

在实际操作中,==预取可以使用原生 HTML <link> 标签和 rel="preload" 属性实现,也可以通过 fetch API 编程实现,以提前加载数据或资源。==对于预先确定的数据,最简单的方法是在 HTML <head> 中使用 <link> 标签:

<!doctype html>
<html lang="en">
  <head>
    <link rel="preload" href="/bootstrap.js" as="script">

    <link rel="preload" href="/users/u1" as="fetch" crossorigin="anonymous">
    <link rel="preload" href="/users/u1/friends" as="fetch" crossorigin="anonymous">

    <script type="module" src="/app.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

通过这种设置,bootstrap.js 和用户 API 的请求会在 HTML 解析后立即发送,比其他脚本处理得早得多。浏览器随后会缓存数据,确保在应用程序初始化时数据已准备好。

然而,通常无法提前知道确切的 URL,这需要一种更动态的预取方式。通常通过事件处理程序管理,基于用户交互或其他条件触发预取。

例如,给按钮附加一个 mouseover 事件监听器可以触发数据预取。此方法允许数据被获取并存储在本地状态或缓存中,当实际需要这些数据的组件或内容被交互或渲染时可以立即使用。这种主动加载方式可以最小化延迟,通过提前准备数据提升用户体验。

document.getElementById('button').addEventListener('mouseover', () => {
  fetch(`/user/${user.id}/details`)
    .then(response => response.json())
    .then(data => {
      sessionStorage.setItem('userDetails', JSON.stringify(data));
    })
    .catch(error => console.error(error));
});

在需要渲染数据的地方,可以从 sessionStorage 中读取数据,如果不可用,则显示加载指示器。通常用户体验会快得多。

在 React 中实现预取

例如,我们可以使用 swr 包中的 preload(函数名称有点误导,但它在这里执行预取操作),然后将 onMouseEnter 事件注册到 Popover 的触发组件,

import { preload } from "swr";
import { getUserDetail } from "../api.ts";

const UserDetailCard = React.lazy(() => import("./user-detail-card.tsx"));

export const Friend = ({ user }: { user: User }) => {
  const handleMouseEnter = () => {
    preload(`/user/${user.id}/details`, () => getUserDetail(user.id));
  };

  return (
    <Popover placement="bottom" showArrow offset={10}>
      <PopoverTrigger>
        <button onMouseEnter={handleMouseEnter}>
          <UserBrief user={user} />
        </button>
      </PopoverTrigger>
      <PopoverContent>
        <Suspense fallback={<div>Loading...</div>}>
          <UserDetailCard id={user.id} />
        </Suspense>
      </PopoverContent>
    </Popover>
  );
};

这样,弹出框本身的渲染时间就会大大减少,从而带来更好的用户体验。

图 14:并行预取的动态加载

所以当用户将鼠标悬停在 Friend 上时,我们会下载相应的 JavaScript 包以及渲染 UserDetailCard 所需的数据,当 UserDetailCard 渲染时,它会看到现有的数据并立即渲染。

图 15:具有动态加载的组件结构

由于数据获取和加载被转移到 Friend 组件中,而对于 UserDetailCard 来说,它从 swr 维护的本地缓存中读取。

import useSWR from "swr";

export function UserDetailCard({ id }: { id: string }) {
  const { data: detail, isLoading: loading } = useSWR(
    `/user/${id}/details`,
    () => getUserDetail(id)
  );

  if (loading || !detail) {
    return <div>Loading...</div>;
  }

  return (
    <div>
    {/* 渲染用户详情 */}
    </div>
  );
}

这个组件使用 useSWR 钩子来获取数据,使 UserDetailCard 能够根据给定的 id 动态加载用户详情。useSWR 提供高效的数据获取功能,包括缓存、重新验证和自动错误处理。组件在数据获取之前显示加载状态。一旦数据可用,它就会开始渲染用户详情。

总的来说,我们已经探索了关键的数据获取策略:异步状态处理并行数据获取备用标记代码分割预取。通过并行执行请求可以提高效率,尽管在处理由不同团队开发且缺乏完整可见性的组件时,这并不总是那么简单。代码分割允许根据用户交互(如点击或悬停)动态加载非关键资源,利用预取来并行加载资源。

何时使用

当你注意到应用程序的初始加载时间变慢,或者有许多功能在初始屏幕上并不是立即必要但可能很快需要时,考虑应用预取。预取特别适用于由用户交互触发的资源,例如鼠标悬停或点击。当浏览器忙于获取其他资源(如 JavaScript 包或资产)时,预取可以提前加载额外的数据,从而为用户实际需要查看内容做好准备。通过在空闲时间加载资源,预取可以更有效地利用网络,分散负载而不是引起需求高峰。

遵循一个通用指南是明智的:在明确需要之前,不要实现复杂的模式如预取。这可能是如果性能问题变得明显,尤其是在初始加载期间,或者如果你的用户中有相当一部分是使用带宽较低且 JavaScript 引擎较慢的移动设备访问应用程序时的情况。此外,还应考虑其他性能优化策略,如在各个层次上进行缓存、使用 CDN 存储静态资产以及确保资产被压缩。这些方法可以通过更简单的配置和无需额外编码来增强性能。预取的有效性依赖于准确预测用户的操作。不正确的假设可能导致无效的预取,甚至通过延迟实际需要的资源加载来降低用户体验。

选择合适的模式

在 web 开发中,选择适当的数据获取和渲染模式并非一刀切。通常,多种策略结合使用以满足特定要求。例如,您可能需要在服务器端生成一些内容——使用服务器端渲染技术——并通过客户端 Fetch-Then-Render 补充动态内容。此外,非必要部分可以拆分成单独的包进行懒加载,可能通过用户操作(如悬停或点击)触发 预取

以 Jira 的问题页面为例。顶部导航和侧边栏是静态的,首先加载以提供用户立即的上下文。一开始,您会看到问题的标题、描述和关键细节,如报告者和被分配者。对于不那么紧急的信息,比如问题底部的历史部分,只有在用户交互(如点击标签)时才加载。这利用懒加载和数据获取来有效管理资源并增强用户体验。

图 16:使用多种模式结合

此外,某些策略相较于默认的、较不优化的解决方案需要额外的设置。例如,实施 代码拆分 需要打包工具的支持。如果您当前的打包工具缺乏此功能,可能需要升级,这对较旧、较不稳定的系统来说是不切实际的。

我们已经涵盖了广泛的模式以及它们如何应用于各种挑战。我意识到这里有很多内容需要消化,从代码示例到图表。如果你想要更有指导性的学习方式,我在我的网站上准备了一个综合教程或者如果你只想查看工作代码,它们都托管在这个 GitHub 仓库

结论

数据获取是开发中的一个复杂方面,但掌握适当的技术可以大大提升我们的应用程序。在我们结束关于数据获取和内容渲染策略的旅程时,有必要强调我们主要的见解:

  • 异步状态处理器:使用自定义钩子或可组合的 API,将数据获取和状态管理从组件中抽象出来。这种模式集中异步逻辑,简化组件设计并增强应用程序中的可重用性。
  • 后备标记:React 增强的 Suspense 模型支持一种更具声明性的方法来异步获取数据,简化了你的代码库。
  • 并行数据获取:通过并行获取数据来最大化效率,减少等待时间并提升应用程序的响应速度。
  • 代码分割:在初始加载期间对非必要组件进行懒加载,利用 Suspense 优雅地处理加载状态和代码分割,从而确保你的应用程序保持高性能。
  • 预取:通过基于预测用户操作预先加载数据,可以实现流畅和快速的用户体验。

虽然这些见解是在 React 生态系统中提出的,但必须认识到这些模式并不限于 React。它们是广泛适用和有益的策略,可以——并且应该——适用于其他库和框架。通过仔细实施这些方法,开发人员可以创建不仅高效和可扩展的应用程序,还能通过有效的数据获取和内容渲染实践,提供卓越的用户体验。

致谢

感谢 Martin Fowler 对本文结构和内容的深刻评论。他的参与不仅仅是审阅,更像是共同作者。

感谢 Atlassian 的同事们,特别是 Jira 团队的成员,他们提供了我们复杂代码库中的众多代码示例。这些示例对于理解模式在现实场景中的应用至关重要。

特别感谢 Jason Sheehy 在最近的项目中展示了代码分割和相关技术,Tom Gasson 在与预取和回退标记模式的实验中进行合作,以及 Dmitry Gonchar 激发了异步状态处理器的灵感。Dmitry 在 Jira 产品的第一个版本中对 useService 的工作尤其具有启发性。

重大修订

2024 年 5 月 29 日: 发布文章剩余部分
2024 年 5 月 23 日: 发布代码分割
2024 年 5 月 21 日: 发布回退标记
2024 年 5 月 15 日: 发布并行数据获取
2024 年 5 月 14 日: 发布异步状态处理器