React-流畅编程-全-

React 流畅编程(全)

原文:zh.annas-archive.org/md5/9e0656b2e638cc2bb6a66ff142b1b921

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

作为 Web 开发人员,要构建用户喜爱的应用程序,你需要理解很多东西。特别是在 React 领域,有大量的资料可供参考,这也是问题的一部分。并非所有资料都是一致的,你必须在所有可用的教程和博客文章中找到自己的道路,希望能够整合一个没有矛盾、不会留下知识漏洞的课程。你总是担心你学到的东西已经过时。

这正是 Tejas Kumar 通过这本书要做的事情。Tejas 在 React 领域有多年的经验,并且深入研究了一些主题,这将为你提供扎实的知识基础。他是一位经验丰富的工程师和知识的源泉,将帮助你在 React 上感到自信。多年来,我有幸在开源、会议演讲、教育内容创建以及个人层面与 Tejas 有所接触,希望你明白你手中拿着的书是由一个才华横溢且出色的人创作的。

在这本书中,你将深入探讨可能不会在其他地方接触到的主题,这将帮助你用正确的思维模式“思考 React”。你将了解 React 存在的目的,这将为你在考虑将 React 作为解决问题的工具时提供一个良好的参考框架。React 并不是孤立存在的,理解其起源故事将帮助你了解 React 所打算解决的问题,从而避免把圆钉放进方孔中。

你将理解像 JSX、虚拟 DOM、协调和并发 React 这样的基础概念,这将帮助你更有效地使用这个工具。我一直认为提升你对工具的使用经验的最佳方式是了解这个工具的工作原理。因此,即使你目前已经使用 React 多年,这些部分也将为你开启新的可能性和理解,使你真正理解 React,而不仅仅是拼凑和希望(指望着)结果是积极的。

你将发现专业人士用来构建有效和强大抽象的模式。React 本身是一个非常快速的 UI 库,但在构建复杂应用程序时,你可能需要进行性能优化,Tejas 将向你展示如何通过 React 中可用的记忆化、延迟加载和状态管理技术来实现。此外,你还将理解生态系统中最佳库如何利用复合组件、渲染道具、属性获取器、状态减少器和控制道具等模式。这些都是在构建 React 应用程序时不可或缺的工具。即使你只是使用现成的解决方案,了解这些强大抽象的工作原理将帮助你更有效地使用它们。

Tejas 不仅限于理论性的 React,你还将了解到像 Remix 和 Next.js 这样的实用框架,这些框架对于充分利用 React 的全栈能力,以提供最佳用户体验至关重要。从 React 的服务端渲染能力开始,你可以掌控自己的命运,将 React 用于前端和后端,构建整个用户体验。此外,你还将了解到 React 正在使用的 Server Components 和 Server Actions 等前沿技术,以提升用户体验的水平。

我确信,阅读 Tejas 为你准备的内容后,你将具备构建出色 React 应用所需的知识。祝你在学习 React 这个世界上使用最广泛的 UI 库时一切顺利。享受这段旅程!

Kent C. Dodds

kentcdodds.com

前言

本书不适合想要学习如何使用 React 的人。如果你对 React 不熟悉并且正在寻找教程,一个很好的起点是react.dev上的 React 文档。相反,本书适合那些好奇的人:那些对如何使用 React 不太感兴趣,但对React 如何工作更感兴趣的人。

在我们在一起的时间里,我们将通过一些 React 概念的探索,并理解它们的基础机制,探索所有这些如何组合在一起,使我们能更有效地使用 React 创建应用程序。在我们追求理解基础机制的过程中,我们将开发必要的思维模型,以便以高度的准确性推理 React 及其生态系统。

本书假定我们对以下声明有了一个满意的理解:浏览器渲染网页。网页是由 CSS 样式化并通过 JavaScript 交互的 HTML 文档。它还假设我们对如何使用 React 有一些了解,并且我们在过去的时间里构建过一两个 React 应用程序。理想情况下,我们的一些 React 应用程序已经投入使用。

我们将从 React 的简介开始,并回顾其历史,回溯到 2013 年它首次作为开源软件发布的时候。从那里开始,我们将探索 React 的核心概念,包括组件模型、虚拟 DOM 和调和过程。我们将深入探讨 JSX 的编译器理论,讨论 fiber,以及深入理解其并发编程模型。这样我们将获得强大的收获,帮助我们更流畅地记忆应该被记忆的内容,并通过像React.memouseTransition这样的强大基元推迟渲染工作。

在本书的后半部分,我们将探讨 React 框架:它们解决的问题以及它们解决问题的机制。我们将通过编写我们自己的框架来做到这一点,该框架解决了几乎所有 Web 应用程序中的三个突出问题:服务器渲染、路由和数据获取。

一旦我们自己解决了这些问题,理解框架如何解决它们就变得更加可接近。我们还将深入探讨 React Server Components(RSCs)和服务器动作,理解下一代工具如捆绑器和同构路由的作用。

最后,我们将放大镜头,远离 React,探索诸如 Vue、Solid、Angular、Qwik 等的替代方案。我们将探索信号和细粒度反应性,与 React 的较粗略的响应性模型进行对比。我们还将探讨 React 对信号的响应:Forget 工具链,以及与信号比较时的表现。

这里有很多内容要涉及,所以让我们不再浪费时间。让我们开始吧!

本书使用的约定

本书中使用了以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及段落中用来指代程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

Constant width bold 和浅灰色文本

用于印刷版第十章中,用以突出代码块中的差异。

注意

此元素表示一般说明。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台让您随时访问现场培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问https://oreilly.com

如何联系我们

有关此书的评论和问题,请联系出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969(美国或加拿大)

  • 707-827-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们有这本书的网页,上面列出了勘误、示例和任何其他信息。您可以访问https://oreil.ly/fluent-react

有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上关注我们:https://youtube.com/oreillymedia

致谢

这是我写过的第一本书,我非常感激我并不孤单。您即将阅读的内容是许多杰出人士共同努力的成果。在这里,我们感谢这些人为这段文字作出的贡献。

请不要忽视这些人,因为这些人值得您的关注和感激。让我们从直接帮助我完成这本书的人开始:

  • 第一位永远是我的妻子,Lea。我花了很多时间写这本书,常常以牺牲共处和家庭时间为代价。由于我对主题的热爱和希望与大家分享,这本书的编写稍微影响了假期和其他与妻子共度时间的机会。她一直给予我支持和鼓励,我为此心怀感激。

  • Shira Evans,我在这本书的开发编辑。来自 O'Reilly 的 Shira 一直是一位非常愉快的合作伙伴,始终给予支持、鼓励和理解,即使我们因为 React 的新事物不断涌现(如 Forget 和服务器动作)而遇到了一些延迟。Shira 耐心地帮助我们度过了这一切,我对她心怀感激。

  • 我亲爱的朋友和兄弟 Kent C. Dodds (@kentcdodds) 在这本书以外继续的指导,以及他为这本书写的前言。多年来,Kent 一直是我的亲密朋友和导师,我对他的持续支持和鼓励感激不尽。

  • 评审人员。感谢那些与我合作的评审人员在这本书中对细节的出色关注和关心:

    • Adam Rackis (@adamrackis)

    • Daniel Afonso (@danieljcafonso)

    • Fabien Bernard (@fabien0102)

    • Kent C. Dodds (@kentcdodds)

    • Mark Erikson (@acemarke)

    • Lenz Weber-Tronic (@phry)

    • Rick Hanlon II (@rickhanlonii)

    • Sergeii Kirianov (@SergiiKirianov)

    • Matheus Albuquerque (@ythecombinator)

  • Meta 的 React 团队,感谢他们在 React 上持续的工作,不断推动 React 的可能性边界,并通过他们的才华、创造力和工程能力使其使用起来愉悦。特别感谢 Dan Abramov (@dan_abramov),他花时间解释了 React 服务器组件架构中捆绑器的角色,并为 第九章 的大部分内容做出了贡献。

最后,我要感谢你,读者,对这本书的兴趣。我希望你发现它和我写作时一样有益。

第一章:入门级内容

让我们先声明一点:React 被设计成供所有人使用。事实上,你可以一辈子都不读这本书,继续使用 React 而毫无问题!这本书深入探讨 React,适合那些对其基本机制、高级模式和最佳实践感兴趣的人。它更适合了解 React 的工作原理,而不是学习如何使用 React。有很多其他书籍旨在教授用户如何作为终端用户使用 React。相比之下,本书将帮助你了解 React,从库或框架作者的角度,而不是终端用户的角度。为了贯彻这一主题,让我们一起深入挖掘,从最高层开始:高级入门话题。我们将从 React 的基础知识开始,然后深入到 React 工作的细节。

在本章中,我们将讨论 React 存在的原因、它的工作原理以及它解决的问题。我们将介绍它最初的灵感和设计,并从它在 Facebook 起步的谦逊开始,追踪到它如今成为流行解决方案的过程。这一章有点元章节(无双关语),因为在我们深入细节之前了解 React 的背景非常重要。

为什么 React 这么重要?

简而言之,答案是:更新。在互联网早期,我们有很多静态页面。我们填写表单,点击提交,加载一个全新的页面。这在一段时间内还算可以,但随着网络体验能力的显著增长,我们渴望在网页上获得更出色的用户体验。我们希望能够即时看到页面更新,而不必等待新页面的渲染和加载。我们希望网页和页面感觉更加迅速和更加“即时”。然而,问题在于,这些即时更新在大规模情况下相当难以实现,原因有几个:

性能

对网页进行更新通常会导致性能瓶颈,因为我们往往会触发浏览器重新计算页面布局(称为回流)并重绘页面。

可靠性

跟踪状态并确保状态在丰富的网页体验中一致是很困难的,因为我们必须在多个地方跟踪状态,并确保所有这些地方的状态保持一致。当多人在同一代码库上工作时,这一点尤其难以做到。

安全性

我们必须确保对注入页面的所有 HTML 和 JavaScript 进行消毒,以防止跨站脚本(XSS)和跨站请求伪造(CSRF)等利用漏洞。

要完全理解和欣赏 React 是如何为我们解决这些问题的,我们需要了解 React 创建的背景以及没有或在 React 之前的世界。让我们现在开始。

React 之前的世界

在 React 出现之前,对于我们这些构建 Web 应用程序的人来说,这些都是一些大问题。我们必须想办法让应用程序看起来即时响应,同时还要能够扩展到数百万用户并且以安全可靠的方式工作。例如,让我们考虑一个按钮点击的情况:当用户点击按钮时,我们希望更新用户界面以反映按钮已被点击。我们需要考虑用户界面可能处于的至少四种不同状态:

点击前

按钮处于默认状态,尚未被点击。

已点击但待处理

按钮已被点击,但按钮应执行的操作尚未完成。

已点击并成功

按钮已被点击,并且按钮应执行的操作已完成。从这里,我们可能希望将按钮恢复到其点击前的状态,或者我们可能希望按钮变色(绿色)以表示成功。

已点击并失败

按钮已被点击,但按钮应执行的操作失败了。从这里,我们可能希望将按钮恢复到其点击前的状态,或者我们可能希望按钮变色(红色)以表示失败。

一旦我们有了这些状态,我们需要找出如何更新用户界面以反映这些状态。通常情况下,更新用户界面需要以下步骤:

  1. 在主机环境(通常是浏览器)中找到按钮,使用某种类型的元素定位器 API,如document.querySelectordocument.getElementById

  2. 将事件侦听器附加到按钮,以监听点击事件。

  3. 在响应事件时执行任何状态更新。

  4. 当按钮离开页面时,移除事件侦听器并清理任何状态。

这是一个简单的示例,但这是一个很好的起点。假设我们有一个标记为“赞”的按钮,当用户点击它时,我们希望将按钮更新为“已赞”。我们该如何做到这一点?首先,我们会有一个 HTML 元素:

<button>Like</button>

我们需要一种方法来在 JavaScript 中引用此按钮,因此我们会给它一个id属性:

<button id="likeButton">Like</button>

太棒了!现在有了一个id,JavaScript 可以与之配合使其交互。我们可以使用document.getElementById获取按钮的引用,然后为按钮添加事件侦听器以监听点击事件:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  // do something
});

现在我们有了一个事件侦听器,当按钮被点击时我们可以做一些事情。假设我们希望在按钮被点击时将按钮标签更新为“已赞”。我们可以通过更新按钮的文本内容来实现这一点:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  likeButton.textContent = "Liked";
});

太棒了!现在我们有了一个标签为“赞”的按钮,当它被点击时,它会显示“已赞”。问题在于我们不能“取消赞”。让我们修复这个问题,如果按钮在其“已赞”状态下被点击,我们将更新按钮以再次显示“赞”。我们需要向按钮添加一些状态来跟踪其是否已被点击。我们可以通过向按钮添加一个data-liked属性来实现这一点:

<button id="likeButton" data-liked="false">Like</button>

现在我们有了这个属性,我们可以用它来跟踪按钮是否已被点击。我们可以根据这个属性的值更新按钮的文本内容:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  const liked = likeButton.getAttribute("data-liked") === "true";
  likeButton.setAttribute("data-liked", !liked);
  likeButton.textContent = liked ? "Like" : "Liked";
});

等等,但我们只是改变按钮的textContent!我们并没有真正将“喜欢”的状态保存到数据库中。通常情况下,我们需要通过网络进行通信,就像这样:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  var liked = likeButton.getAttribute("data-liked") === "true";

  // communicate over the network
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/like", true);
  xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");

  xhr.onload = function () {
    if (xhr.status >= 200 && xhr.status < 400) {
      // Success!
      likeButton.setAttribute("data-liked", !liked);
      likeButton.textContent = liked ? "Like" : "Liked";
    } else {
      // We reached our target server, but it returned an error
      console.error("Server returned an error:", xhr.statusText);
    }
  };

  xhr.onerror = function () {
    // There was a connection error of some sort
    console.error("Network error.");
  };

  xhr.send(JSON.stringify({ liked: !liked }));
});

当然,我们正在使用XMLHttpRequestvar以保持时间的相关性。React 在 2013 年作为开源软件发布,更常见的fetch API 是在 2015 年引入的。在XMLHttpRequestfetch之间,我们有 jQuery,通常通过像$.ajax()$.post()等原语来抽象一些复杂性。

如果今天我们要编写这个功能,它可能会更像这样:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  const liked = likeButton.getAttribute("data-liked") === "true";

  // communicate over the network
  fetch("/like", {
    method: "POST",
    body: JSON.stringify({ liked: !liked }),
  }).then(() => {
    likeButton.setAttribute("data-liked", !liked);
    likeButton.textContent = liked ? "Like" : "Liked";
  });
});

不要太偏离主题,现在的重点是我们正在通过网络进行通信,但如果网络请求失败会怎么样?我们需要更新按钮的文本内容来反映失败。我们可以通过向按钮添加data-failed属性来实现这一点:

<button id="likeButton" data-liked="false" data-failed="false">Like</button>

现在,我们可以根据这个属性的值更新按钮的文本内容:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  const liked = likeButton.getAttribute("data-liked") === "true";

  // communicate over the network
  fetch("/like", {
    method: "POST",
    body: JSON.stringify({ liked: !liked }),
  })
    .then(() => {
      likeButton.setAttribute("data-liked", !liked);
      likeButton.textContent = liked ? "Like" : "Liked";
    })
    .catch(() => {
      likeButton.setAttribute("data-failed", true);
      likeButton.textContent = "Failed";
    });
});

还有一个情况需要处理:当前正在“喜欢”某物的过程。也就是说,挂起状态。要在代码中建模这一点,我们可以通过向按钮添加data-pending属性来设置挂起状态,就像这样:

<button
  id="likeButton"
  data-pending="false"
  data-liked="false"
  data-failed="false"
>
  Like
</button>

现在,如果网络请求正在进行中,我们可以禁用按钮,这样多次点击不会排队进行网络请求,从而导致奇怪的竞态条件和服务器超载。我们可以这样做:

const likeButton = document.getElementById("likeButton");
likeButton.addEventListener("click", () => {
  const liked = likeButton.getAttribute("data-liked") === "true";
  const isPending = likeButton.getAttribute("data-pending") === "true";

  likeButton.setAttribute("data-pending", "true");
  likeButton.setAttribute("disabled", "disabled");

  // communicate over the network
  fetch("/like", {
    method: "POST",
    body: JSON.stringify({ liked: !liked }),
  })
    .then(() => {
      likeButton.setAttribute("data-liked", !liked);
      likeButton.textContent = liked ? "Like" : "Liked";
      likeButton.setAttribute("disabled", null);
    })
    .catch(() => {
      likeButton.setAttribute("data-failed", "true");
      likeButton.textContent = "Failed";
    })
    .finally(() => {
      likeButton.setAttribute("data-pending", "false");
    });
});

我们还可以利用强大的技术,如去抖动(debouncing)和节流(throttling),以防止用户执行冗余或重复的操作。

注意

顺便说一句,我们提到了去抖动和节流。为了清晰起见,去抖动会延迟函数的执行,直到自上次事件触发以来经过了一段时间(例如,等待用户停止输入以处理输入),而节流会限制函数在设置的时间间隔内最多运行一次,确保它不会执行得太频繁(例如,以设置的间隔处理滚动事件)。这两种技术通过控制函数执行速率来优化性能。

好的,现在我们的按钮有点强大了,可以处理多种状态——但仍然有一些问题需要解决:

  • data-pending真的有必要吗?我们不能只检查按钮是否已禁用吗?可能不行,因为禁用按钮可能是由于其他原因,比如用户未登录或没有权限点击按钮。

  • 使用data-state属性会更合理吗?其中data-state可以是pendinglikedunliked之一,而不是这么多其他数据属性?也许吧,但然后我们需要添加一个大的 switch/case 或类似的代码块来处理每种情况。最终,处理两种方法的代码量是不可比的:无论哪种方式,我们仍然会面临复杂性和冗余性。

  • 我们如何单独测试这个按钮?我们可以吗?

  • 为什么我们首先在 HTML 中写入按钮,然后在 JavaScript 中处理它?如果我们只需使用 document.createElement('button') 创建按钮,然后 document.appendChild(likeButton),不是更好吗?这将使测试变得更容易,并使代码更加自包含,但如果其父元素不是 document,我们就必须跟踪它的父元素。事实上,我们可能需要跟踪页面上的所有父元素。

React 帮助我们解决了其中一些问题,但并非全部问题:例如,如何将状态分解为单独的标志(isPendinghasFailed 等)或单个状态变量(例如 state)的问题,并不是 React 为我们提供答案的问题。这是我们必须自己回答的问题。但是,React 确实帮助我们解决了规模的问题:以一种最小化和高效的方式创建需要交互的大量按钮,并根据事件更新用户界面,并以可测试、可重现、声明性、高性能、可预测和可靠的方式执行这些操作。

此外,React 帮助我们通过完全拥有用户界面的状态并基于该状态进行渲染,使状态变得更加可预测。这与由浏览器拥有和操作的状态形成鲜明对比,浏览器的状态可能由于多种因素(例如在页面上运行的其他客户端脚本、浏览器扩展、设备限制等)而变得不太可靠。

我们的 Like 按钮示例是一个非常简单的例子,但是这是一个很好的开始。到目前为止,我们已经看到如何使用 JavaScript 使按钮交互,但是如果我们想要做得更好,这是一个非常手动的过程:我们必须在浏览器中找到按钮,添加事件侦听器,更新按钮的文本内容,并考虑多种边缘情况。这是很多工作,并且不太可扩展。如果页面上有很多按钮怎么办?如果我们有很多需要交互的按钮怎么办?如果我们有很多需要交互的按钮,并且需要根据事件更新用户界面怎么办?我们会使用事件委托(或事件冒泡)并将事件侦听器附加到更高级别的 document 吗?还是应该为每个按钮附加事件侦听器?

如前言所述,本书假设我们对这一陈述有了满意的理解:浏览器渲染网页。网页是由 HTML 文档样式化为 CSS,并使用 JavaScript 交互式地进行制作。数十年来,这种方法一直运作良好,现在仍然如此,但是要用这些技术构建现代的 Web 应用程序,以便服务于大量(想象一下,数百万)用户,就需要在安全性和可靠性方面进行相当程度的抽象,以尽可能减少错误的可能性。不幸的是,根据我们一直在探索的 Like 按钮的例子,显然我们需要一些帮助。

让我们考虑另一个稍微复杂一点的例子,比我们的“喜欢”按钮更复杂。我们从一个简单的例子开始:一个项目列表。假设我们有一个项目列表,并且我们希望向列表中添加一个新项目。我们可以使用一个类似下面的 HTML 表单来做到这一点:

<ul id="list-parent"></ul>

<form id="add-item-form" action="/api/add-item" method="POST">
  <input type="text" id="new-list-item-label" />
  <button type="submit">Add Item</button>
</form>

JavaScript 让我们可以访问文档对象模型(DOM)API。对于不了解的人来说,DOM 是网页文档结构的内存模型:它是表示页面元素的对象树,通过 JavaScript 可以与它们进行交互。问题是,用户设备上的 DOM 就像是一个外星行星:我们不知道他们使用的浏览器、网络条件以及操作系统(OS)。结果是什么呢?我们必须编写能够适应所有这些因素的代码。

正如我们讨论过的,当更新应用程序状态时,如果没有某种状态协调机制来跟踪事务,应用程序状态变得非常难以预测。继续以我们的列表示例为例,让我们考虑一些 JavaScript 代码来向列表中添加新项目:

(function myApp() {
  var listItems = ["I love", "React", "and", "TypeScript"];
  var parentList = document.getElementById("list-parent");
  var addForm = document.getElementById("add-item-form");
  var newListItemLabel = document.getElementById("new-list-item-label");

  addForm.onsubmit = function (event) {
    event.preventDefault();
    listItems.push(newListItemLabel.value);
    renderListItems();
  };

  function renderListItems() {
    for (i = 0; i < listItems.length; i++) {
      var el = document.createElement("li");
      el.textContent = listItems[i];
      parentList.appendChild(el);
    }
  }

  renderListItems();
})();

这段代码片段是为了尽可能看起来与早期的 Web 应用程序相似。为什么随着时间的推移会变得一团糟?主要是因为构建旨在随时间推移扩展的应用程序会带来一些问题,使它们成为“步枪脚”,这样做会:

容易出错

addFormonsubmit 属性可以轻松被页面上的其他客户端 JavaScript 重写。我们可以改用 addEventListener,但这会带来更多问题:

  • 我们应该在何时何地使用 removeEventListener 进行清理呢?

  • 如果我们不小心会积累大量的事件侦听器吗?

  • 因此我们会支付什么样的代价?

  • 事件委托如何适应其中?

不可预测

我们的真相来源混杂:我们在 JavaScript 数组中保存列表项,但依赖于 DOM 中存在的元素(如具有 id="list-parent" 的元素)来完成我们的应用程序。由于 JavaScript 和 HTML 之间的这些相互依赖,我们还需要考虑一些其他因素:

  • 如果错误地存在多个具有相同 id 的元素会怎么样?

  • 如果元素根本不存在会怎样?

  • 如果它不是 ul 呢?我们能否将列表项 (li 元素) 添加到其他父元素中?

  • 如果我们使用类名而不是 ID 呢?

    我们的真相来源混杂在 JavaScript 和 HTML 之间,真相不可靠。我们更希望有一个单一的真相来源。此外,客户端 JavaScript 经常向 DOM 添加和删除元素。如果我们依赖于这些特定元素的存在,我们的应用程序就无法保证可靠地工作,因为 UI 不断更新。在这种情况下,我们的应用程序充满了“副作用”,其成功或失败取决于一些用户关注的问题。React 通过提倡受函数式编程启发的模型来解决了这个问题,其中副作用被有意标记和隔离。

效率低下

renderListItems 依次在屏幕上呈现项目。每次 DOM 的变化可能在计算上是昂贵的,特别是在涉及布局移动和重排的情况下。因为我们在一个未知计算能力的外星球上,对于大型列表来说这可能相当危险。请记住,我们打算的大规模 Web 应用将被全球数百万用户使用,包括那些来自世界各地社区、没有最新和最好的 Apple M3 Max 处理器的低功率设备用户。在这种情况下,与其每次单个列表项的顺序更新 DOM,也许更理想的是以某种方式批处理这些操作,并同时应用于 DOM。但也许对我们作为工程师来说这并不值得,因为也许浏览器最终会更新它们处理 DOM 的方式,并自动为我们批处理事务。

这些都是在 React 和其他抽象出现之前多年来困扰 Web 开发者的问题。以可维护、可重用和可预测的方式打包代码,在行业中没有太多标准化的共识,一直是一个问题。当时许多 Web 公司都分享了创建可靠和可伸缩用户界面的痛苦。正是在这个时候,我们看到了多种基于 JavaScript 的解决方案的兴起:Backbone、KnockoutJS、AngularJS 和 jQuery。让我们依次看看这些解决方案是如何解决这个问题的。这将帮助我们理解 React 如何与这些解决方案不同,甚至可能比它们更优秀。

jQuery

让我们探索如何使用比 React 更早的工具解决这些问题,从而了解为什么 React 如此重要。我们将从 jQuery 开始,并通过重新访问之前的“点赞”按钮示例来做到这一点。

概括地说,我们在浏览器中有一个“点赞”按钮,我们希望使其交互:

<button id="likeButton">Like</button>

使用 jQuery,我们像之前一样为其添加“点赞”行为,如下所示:

$("#likeButton").on("click", function () {
  this.prop("disabled", true);
  fetch("/like", {
    method: "POST",
    body: JSON.stringify({ liked: this.text() === "Like" }),
  })
    .then(() => {
      this.text(this.text() === "Like" ? "Liked" : "Like");
    })
    .catch(() => {
      this.text("Failed");
    })
    .finally(() => {
      this.prop("disabled", false);
    });
});

从这个例子中,我们观察到我们正在将数据绑定到用户界面,并使用这些数据绑定来直接更新用户界面。作为一种工具,jQuery 在直接操作用户界面方面非常活跃。

jQuery 以一种高度“副作用”的方式运行,不断地与并修改其控制范围外的状态交互。我们说这是“副作用”,因为它允许从代码的任何位置,包括其他导入的模块甚至远程脚本执行,直接和全局地修改页面结构!这可能导致行为不可预测和交互复杂,难以跟踪和理解,因为页面的一个部分的更改可能以不可预见的方式影响其他部分。这种分散和无结构的操作使得代码难以维护和调试。

现代框架通过提供结构化、可预测的方式更新 UI,而无需直接操作 DOM 来解决这些问题。这种模式在当时很常见,但难以推理和测试,因为代码周围的应用状态——即与代码相邻的应用状态——在不断变化。在某个时刻,我们不得不停下来问自己:“当前浏览器中应用的状态是什么?”——随着我们的应用程序复杂性的增加,这个问题的答案变得越来越困难。

此外,使用 jQuery 的按钮很难进行测试,因为它只是一个事件处理程序。如果我们要编写一个测试,它将如下所示:

test("LikeButton", () => {
  const $button = $("#likeButton");
  expect($button.text()).toBe("Like");
  $button.trigger("click");
  expect($button.text()).toBe("Liked");
});

唯一的问题是在测试环境中$('#likeButton')返回null,因为它不是一个真实的浏览器。我们需要模拟浏览器环境来测试这段代码,这是很多工作。这是 jQuery 的一个常见问题:它很难测试,因为很难隔离其添加的行为。jQuery 还严重依赖于浏览器环境。此外,jQuery 与浏览器共享用户界面的所有权,这使得推理和测试变得困难:浏览器拥有界面,而 jQuery 只是一个客人。这种与“单向数据流”范式的偏离是当时库常见的问题。

随着 Web 的发展和对更强大、可扩展解决方案需求的逐渐明显,jQuery 开始失去其流行性。虽然 jQuery 仍然在许多生产应用中使用,但它不再是构建现代 Web 应用的首选解决方案。以下是 jQuery 失宠的一些原因:

体积和加载时间

jQuery 的一个显著批评点是其体积过大。将完整的 jQuery 库集成到 Web 项目中会增加额外的负担,尤其是对于追求快速加载时间的网站来说,这一点尤为明显。在当前移动浏览的时代,许多用户可能处于较慢或有限的数据连接状态,每个千字节都至关重要。因此,整个 jQuery 库的包含可能会对移动用户的性能和体验产生负面影响。

在 React 出现之前的一种常见做法是为类似 jQuery 和 Mootools 的库提供配置器,用户可以选择他们想要的功能。虽然这有助于减少代码量,但它确实增加了开发者需要做出的决策的复杂性,并增加了整体开发工作流程的复杂性。

现代浏览器的冗余

当 jQuery 首次出现时,它解决了许多浏览器之间的不一致性,并为开发者提供了一个统一的方式来处理这些差异,从而在选择和修改浏览器中的元素时。随着 Web 的发展,Web 浏览器也在发展。许多使 jQuery 成为必备的特性,如一致的 DOM 操作或围绕数据获取的网络导向功能,现代浏览器现在都原生支持并保持一致。在当代 Web 开发中为这些任务使用 jQuery 可以被视为多余,增加了不必要的复杂性。

例如,document.querySelector 可轻松替代 jQuery 内置的 $ 选择器 API。

性能考虑

尽管 jQuery 简化了许多任务,但通常以性能为代价。随着每个浏览器版本的提升,本机运行级别的 JavaScript 方法也在改进,因此在某些情况下可能比其 jQuery 等效方法执行更快。对于小型项目,这种差异可能微不足道。然而,在更大更复杂的 Web 应用程序中,这些复杂性可能会积累,导致明显的卡顿或响应速度降低。

因此,尽管 jQuery 在 Web 发展中起到了重要作用并简化了开发者面临的许多挑战,但现代 Web 环境提供了原生解决方案,这些解决方案通常使 jQuery 的影响力减弱。作为开发者,我们需要权衡 jQuery 的便利性和潜在的缺点,特别是在当前 Web 项目的背景下。

尽管 jQuery 有其缺点,但它在当时绝对革命了我们与 DOM 交互的方式。以至于出现了其他使用 jQuery 但增加了可预测性和可重用性的库。其中一个就是 Backbone,它试图解决 React 今天解决的同样问题,只不过比 React 早得多。让我们深入探讨一下。

Backbone

Backbone,在 2010 年代初开发,是我们在 React 出现之前探索的问题的第一个解决方案之一:浏览器和 JavaScript 之间的状态不一致,代码重用性,可测试性等等。它是一个优雅简洁的解决方案:一个提供创建“模型”和“视图”方式的库。Backbone 对传统的 MVC(模型-视图-控制器)模式有自己的理解(见图 1-1)。让我们稍微了解一下这种模式,以帮助我们理解 React 并形成更高质量的讨论的基础。

mvc

图 1-1. 传统 MVC 模式

MVC 模式

MVC 模式是一种设计理念,将软件应用程序划分为三个相互连接的组件,以将信息的内部表示与其向用户呈现或接受的方式分离。以下是详细解析:

模型

模型负责应用程序的数据和业务规则。模型不知道视图和控制器,确保业务逻辑与用户界面隔离。

视图

视图代表应用程序的用户界面。它将模型中的数据显示给用户,并将用户命令发送给控制器。视图是被动的,意味着它等待模型提供要显示的数据,不直接获取或保存数据。视图也不单独处理用户交互,而是将这一责任委托给下一个组件:控制器。

控制器

控制器充当模型(Model)和视图(View)之间的接口。它从视图获取用户输入,处理它(可能更新模型),然后将输出显示返回给视图。控制器解耦了模型和视图,使系统架构更加灵活。

MVC 模式的主要优势在于关注点分离,即业务逻辑、用户界面和用户输入被分离到代码库的不同部分。这不仅使应用程序更加模块化,而且更易于维护、扩展和测试。MVC 模式在 Web 应用程序中被广泛使用,许多框架如 Django、Ruby on Rails 和 ASP.NET MVC 都内置支持该模式。

多年来,MVC 模式一直是软件设计的重要模式,特别是在 Web 开发中。然而,随着 Web 应用程序的发展和用户对交互性和动态界面的期望增长,传统 MVC 的一些局限性变得显而易见。这就是 MVC 可能不足的地方,以及 React 如何解决这些挑战:

复杂的交互和状态管理

传统的 MVC 架构在处理具有许多交互元素的复杂用户界面时通常会遇到困难。随着应用程序的增长,管理状态变化及其对 UI 各个部分的影响可能变得笨重,因为控制器堆积,有时可能会与其他控制器发生冲突,某些控制器控制的视图并不代表它们自身,或者 MVC 组件的分离在产品代码中并不准确。

React 以其基于组件的架构和虚拟 DOM,通过将 UI 组件视为函数来简化对状态变化及其对 UI 的影响的推理过程。这种思维模型大大简化了 MVC 模式,因为函数在 JavaScript 中是非常普遍的,而且比起不是编程语言本身的外部思维模型来说更易接近。

双向数据绑定

一些 MVC 框架使用双向数据绑定,如果不小心管理,可能会导致意外的副作用,有时视图与模型之间或者反之之间会不同步。此外,双向数据绑定还涉及数据所有权的问题,答案往往比较简单,对关注点的分离不够清晰。特别是因为虽然 MVC 是一个对于完全理解其用例中如何分离关注点的团队来说被证明有效的模型,但是这些分离规则往往不被强制执行,特别是在高速输出和快速启动增长的情况下,这使得关注点分离,MVC 的最大优势之一,经常因缺乏执行而成为弱点。

React 则利用了一种与双向数据绑定相对的模式,称为“单向数据流”(稍后将详细讨论),通过像 Forget 这样的系统优先甚至强制实现了系统中的单向数据流。这些方法使得 UI 更新更加可预测,使我们能够更清晰地分离关注点,并最终有利于高速增长的软件团队。

紧耦合

在某些 MVC 实现中,模型(Model)、视图(View)和控制器(Controller)可能会紧密耦合,这样一来,如果要修改或重构其中一个,就很难不影响到其他部分。React 鼓励更加模块化和解耦的方法,采用其基于组件的模型,支持将依赖项与其 UI 表示靠近并互相支持。

对于这种模式的细节我们不需要深入讨论,因为这是一本关于 React 的书。但在这里,从我们的意图和目的来看,模型概念上是数据源,视图则是消耗和渲染这些数据的用户界面。Backbone 提供了便捷的 API 来处理这些模型和视图,并提供了一种连接模型和视图的方式。在其时间内,这种解决方案非常强大和灵活。它也是一种可扩展使用的解决方案,允许开发者在隔离环境中测试他们的代码。

作为例子,这里是我们早前的按钮示例,这次使用的是 Backbone:

const LikeButton = Backbone.View.extend({
  tagName: "button",
  attributes: {
    type: "button",
  },
  events: {
    click: "onClick",
  },
  initialize() {
    this.model.on("change", this.render, this);
  },
  render() {
    this.$el.text(this.model.get("liked") ? "Liked" : "Like");
    return this;
  },
  onClick() {
    fetch("/like", {
      method: "POST",
      body: JSON.stringify({ liked: !this.model.get("liked") }),
    })
      .then(() => {
        this.model.set("liked", !this.model.get("liked"));
      })
      .catch(() => {
        this.model.set("failed", true);
      })
      .finally(() => {
        this.model.set("pending", false);
      });
  },
});

const likeButton = new LikeButton({
  model: new Backbone.Model({
    liked: false,
  }),
});

document.body.appendChild(likeButton.render().el);

注意LikeButton是如何扩展Backbone.View的,以及它有一个返回thisrender方法?我们将继续在 React 中看到类似的render方法,但我们不要过早地为此感到激动。此外,值得注意的是,Backbone 并没有为render方法提供实际的实现。而是通过 jQuery 手动变异 DOM,或者使用像 Handlebars 这样的模板系统。

Backbone 提供了一个可链接的 API,允许开发者将逻辑放置在对象的属性中。与我们之前的例子相比较,我们可以看到 Backbone 显著改进了创建交互式按钮并在响应事件时更新用户界面的舒适度。

它还以更结构化的方式通过将逻辑组合在一起来执行此操作。还要注意,Backbone 通过将其更容易接近以测试此按钮,因为我们可以创建一个LikeButton实例,然后调用其render方法来测试它。

我们这样测试这个组件:

test("LikeButton initial state", () => {
  const likeButton = new LikeButton({
    model: new Backbone.Model({
      liked: false, // Initial state set to not liked
    }),
  });
  likeButton.render(); // Ensure render is called to reflect the initial state
  // Check the text content to be "Like" reflecting the initial state
  expect(likeButton.el.textContent).toBe("Like");
});

我们甚至可以测试按钮在其状态改变后的行为,例如点击事件的情况:

test("LikeButton", async () => {
  // Mark the function as async to handle promise
  const likeButton = new LikeButton({
    model: new Backbone.Model({
      liked: false,
    }),
  });
  expect(likeButton.render().el.textContent).toBe("Like");

  // Mock fetch to prevent actual HTTP request
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ liked: true }),
    })
  );

  // Await the onClick method to ensure async operations are complete
  await likeButton.onClick();

  expect(likeButton.render().el.textContent).toBe("Liked");

  // Optionally, restore fetch to its original implementation if needed
  global.fetch.mockRestore();
});

因此,Backbone 在当时是一个非常流行的解决方案。另一种选择是编写大量代码,这些代码很难测试和理解,没有保证代码能够以可靠的方式按预期工作。因此,Backbone 是一个非常受欢迎的解决方案。尽管在早期因其简单性和灵活性而广受欢迎,但它并非没有批评。以下是与 Backbone.js 相关的一些负面因素:

冗长和样板代码

Backbone.js 经常受到批评的一个原因是开发人员需要编写大量样板代码。对于简单的应用程序,这可能不是大问题,但随着应用程序的增长,样板代码也会增加,导致潜在的冗余和难以维护的代码。

缺乏双向数据绑定

与其同时代的一些框架不同,Backbone.js 没有提供内置的双向数据绑定。这意味着如果数据变化,DOM 不会自动更新,反之亦然。开发人员通常需要编写自定义代码或使用插件来实现这种功能。

事件驱动架构

模型数据的更新可能会触发应用程序中的大量事件。这种事件级联可能变得难以管理,导致情况不明确,即改变单个数据片段如何影响整个应用程序,使得调试和维护变得困难。为了解决这些问题,开发人员经常需要使用谨慎的事件管理实践,以防止更新的涟漪效应遍布整个应用程序。

缺乏可组合性

Backbone.js 缺乏内置功能以轻松嵌套视图,这使得组合复杂用户界面变得困难。相比之下,React 通过 children 属性允许无缝嵌套组件,使得构建复杂的 UI 层次结构变得简单得多。Marionette.js 作为 Backbone 的扩展,试图解决一些这些组合问题,但它没有提供像 React 的组件模型那样集成的解决方案。

尽管 Backbone.js 面临一些挑战,但重要的是要记住,没有工具或框架是完美的。最佳选择通常取决于项目的具体需求和开发团队的偏好。还值得注意的是,Web 开发工具如何依赖于强大的社区以蓬勃发展,不幸的是,Backbone.js 在近年来的流行度有所下降,特别是随着 React 的出现。有人会说 React 击败了它,但我们现在暂且不作评价。

KnockoutJS

让我们将这种方法与当时流行的另一种解决方案:KnockoutJS 进行比较。KnockoutJS 在 2010 年代初开发,是一个提供创建“可观察对象”和“绑定”的库,利用依赖跟踪来处理状态变化。

KnockoutJS 可能是最早的反应式 JavaScript 库之一,其中反应性被定义为在可观察的方式下响应状态变化的值更新。这种风格的反应性的现代版本有时被称为“信号”,并且在诸如 Vue.js、SolidJS、Svelte、Qwik、现代 Angular 等库中很常见。我们在 第十章 中会更详细地讨论这些内容。

观察者概念上是数据源,绑定是概念上的用户界面,用于消费和渲染这些数据:观察者就像模型,而绑定则像视图。

然而,作为我们之前讨论的 MVC 模式的一种演变,KnockoutJS 更多地按照模型-视图-视图模型或 MVVM 风格的模式工作(参见 图 1-2)。让我们更详细地了解这种模式。

mvvm

图 1-2. MVVM 模式

MVVM 模式

MVVM 模式是一种在具有丰富用户界面的应用程序中特别流行的架构设计模式,例如那些使用 WPF 和 Xamarin 等平台构建的应用程序。MVVM 是传统模型-视图-控制器(MVC)模式的一种演变,专门针对现代 UI 开发平台,其中数据绑定是一个突出的特性。以下是 MVVM 组件的详细介绍:

模型

  • 表示应用程序的数据和业务逻辑。

  • 负责检索、存储和处理数据。

  • 通常与数据库、服务或其他数据源和操作通信。

  • 不知道视图和视图模型。

视图

  • 表示应用程序的用户界面。

  • 向用户显示信息并接收用户输入。

  • 在 MVVM 中,视图是被动的,不包含任何应用程序逻辑。相反,它通过数据绑定机制声明性地绑定到 ViewModel,通过自动反映更改。

视图模型

  • 充当模型和视图之间的桥梁。

  • 提供数据和命令供视图绑定。这里的数据通常是一个已经准备好显示的格式。

  • 处理用户输入,通常通过命令模式。

  • 包含展示逻辑,并将模型中的数据转换为可以被视图轻松显示的格式。

  • 值得注意的是,ViewModel 不知道具体使用它的视图,从而实现了解耦的架构。

MVVM 模式的关键优势是关注点分离,类似于 MVC,这导致:

可测试性

ViewModel 与 View 的解耦使得更容易为展示逻辑编写单元测试,而无需涉及 UI。

可重用性

ViewModel 可以在不同的视图或平台上重用。

可维护性

通过清晰的分离,更容易管理、扩展和重构代码。

数据绑定

该模式在支持数据绑定的平台上表现突出,减少了更新 UI 所需的样板代码量。

由于我们讨论了 MVC 和 MVVM 模式,让我们快速对比它们,以便我们可以理解它们之间的区别(参见表 1-1)。

表 1-1. MVC 和 MVVM 模式的比较

标准 MVC MVVM
主要目的 主要用于 Web 应用程序,将用户界面与逻辑分离。 专为富 UI 应用程序量身定制,特别是具有双向数据绑定的桌面或单页应用程序。
组件 模型:数据和业务逻辑。视图:用户界面。控制器:管理用户输入,更新视图。 模型:数据和业务逻辑。视图:用户界面元素。视图模型:模型与视图之间的桥梁。
数据流 用户输入由控制器管理,更新模型,然后更新视图。 视图直接绑定到视图模型。视图中的更改会自动反映在视图模型中,反之亦然。
解耦 视图通常与控制器紧密耦合。 视图模型具有高解耦性,因为它不知道使用它的具体视图。
用户交互 由控制器处理。 通过视图模型中的数据绑定和命令处理。
平台适用性 在 Web 应用程序开发中常见(例如 Ruby on Rails,Django,ASP.NET MVC)。 适合支持强大数据绑定的平台(例如 WPF,Xamarin)。

从这个简要比较中,我们可以看出 MVC 和 MVVM 模式之间真正的区别在于耦合和绑定:在没有控制器介入的模型和视图之间,数据的所有权更清晰,更接近用户。React 通过其单向数据流进一步改进了 MVVM 模式,稍后我们将讨论这一点,通过使状态由需要的特定组件拥有来实现更窄的数据所有权。现在,让我们回到 KnockoutJS 以及它如何与 React 相关。

KnockoutJS 导出了用于处理这些可观察对象和绑定的 API。让我们看看如何在 KnockoutJS 中实现喜欢按钮。这将帮助我们更好地理解“为什么选择 React”。这是我们按钮的 KnockoutJS 版本:

function createViewModel({ liked }) {
  const isPending = ko.observable(false);
  const hasFailed = ko.observable(false);
  const onClick = () => {
    isPending(true);
    fetch("/like", {
      method: "POST",
      body: JSON.stringify({ liked: !liked() }),
    })
      .then(() => {
        liked(!liked());
      })
      .catch(() => {
        hasFailed(true);
      })
      .finally(() => {
        isPending(false);
      });
  };
  return {
    isPending,
    hasFailed,
    onClick,
    liked,
  };
}

ko.applyBindings(createViewModel({ liked: ko.observable(false) }));

在 KnockoutJS 中,“视图模型”是一个 JavaScript 对象,它包含我们使用data-bind属性绑定到页面各个元素的键和值。在 KnockoutJS 中没有“组件”或“模板”,只有一个视图模型和一种将其绑定到浏览器元素的方法。

我们的函数createViewModel是如何在 Knockout 中创建视图模型的。然后,我们使用ko.applyBindings将视图模型连接到主机环境(浏览器)。ko.applyBindings函数接受一个视图模型,并找到所有具有data-bind属性的浏览器元素,Knockout 使用这些属性将它们绑定到视图模型上。

浏览器中的一个按钮将绑定到此视图模型的属性,如下所示:

<button
  data-bind="click: onClick, text: liked ? 'Liked' : isPending ? [...]
></button>

请注意,出于简化的原因,此代码已被截断。

我们使用我们的createViewModel函数将 HTML 元素绑定到我们创建的“视图模型”,网站变得交互式。正如您可以想象的那样,显式订阅可观察对象的更改,然后根据这些更改更新用户界面是一项繁重的工作。KnockoutJS 在当时是一个很棒的库,但它也需要大量样板代码来完成工作。

此外,视图模型通常变得非常庞大和复杂,这导致了对重构和代码优化的不确定性逐渐增加。最终,我们得到了冗长的单块视图模型,难以测试和理解。尽管如此,KnockoutJS 在当时非常流行,并且是一个很棒的库。它也相对容易在隔离环境中进行测试,这是一个重大的优势。

为了记录下来,这是我们如何在 KnockoutJS 中测试这个按钮的方法:

test("LikeButton", () => {
  const viewModel = createViewModel({ liked: ko.observable(false) });
  expect(viewModel.liked()).toBe(false);
  viewModel.onClick();
  expect(viewModel.liked()).toBe(true);
});

AngularJS

AngularJS 是由 Google 在 2010 年开发的。它是一个开创性的 JavaScript 框架,对 Web 开发格局产生了重大影响。它与我们讨论过的库和框架形成鲜明对比,通过整合几个创新功能,这些功能的波及效应可以在后续的库中看到,包括 React。通过详细比较 AngularJS 与其他库,并查看其关键特性,让我们试图理解它为 React 铺平的道路。

双向数据绑定

双向数据绑定是 AngularJS 的一个显著特性,极大地简化了 UI 与底层数据之间的交互。如果模型(底层数据)发生变化,视图(UI)会自动更新以反映这些变化,反之亦然。这与像 jQuery 这样的库形成了鲜明对比,后者需要开发人员手动操作 DOM 来反映数据的任何变化,并捕获用户输入以更新数据。

让我们考虑一个简单的 AngularJS 应用程序,其中双向数据绑定发挥了关键作用:

<!DOCTYPE html>
<html>
  <head>
    <script
    src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js">
    </script>
  </head>
  <body ng-app="">
    <p>Name: <input type="text" ng-model="name" /></p>
    <p ng-if="name">Hello, {{name}}!</p>
  </body>
</html>

在这个应用程序中,ng-model指令将输入字段的值绑定到变量name。当您在输入字段中输入时,模型name会更新,并且视图——在这种情况下是问候语"Hello, {{name}}!"——会实时更新。

模块化架构

AngularJS 引入了一种模块化架构,允许开发人员逻辑上分离其应用程序的组件。每个模块可以封装一个功能,并可以独立开发、测试和维护。有人会称之为 React 组件模型的前身,但这有争议。

这是一个快速的例子:

var app = angular.module("myApp", [
  "ngRoute",
  "appRoutes",
  "userCtrl",
  "userService",
]);

var userCtrl = angular.module("userCtrl", []);
userCtrl.controller("UserController", function ($scope) {
  $scope.message = "Hello from UserController";
});

var userService = angular.module("userService", []);
userService.factory("User", function ($http) {
  //...
});

在上面的例子中,myApp模块依赖于几个其他模块:ngRouteappRoutesuserCtrluserService。每个依赖模块可以在自己的 JavaScript 文件中,并且可以与主myApp模块分开开发。这个概念与 jQuery 和 Backbone.js 显著不同,后者在这种意义上没有“模块”的概念。

我们使用一种称为依赖注入的模式将这些依赖项(appRoutesuserCtrl 等)注入到我们的根 app 中。毋庸置疑,这种模式在 JavaScript 模块标准化之前很流行。从那时起,importexport 语句迅速取代了它。为了与 React 组件对比这些依赖项,让我们再多谈一点关于依赖注入。

依赖注入

依赖注入(DI)是一种设计模式,对象接收其依赖项而不是创建它们。AngularJS 在其核心引入了这一设计模式,这在当时并不是其他 JavaScript 库的常见特性。这对模块和组件的创建和管理产生了深远影响,推动了更高的模块化和可重用性。

下面是 AngularJS 中 DI 如何工作的一个例子:

var app = angular.module("myApp", []);

app.controller("myController", function ($scope, myService) {
  $scope.greeting = myService.sayHello();
});

app.factory("myService", function () {
  return {
    sayHello: function () {
      return "Hello, World!";
    },
  };
});

在这个示例中,myService 是一个通过 DI 注入到 myController 控制器中的服务。控制器不需要知道如何创建这个服务。它只需声明服务作为依赖项,AngularJS 负责创建和注入它。这简化了依赖管理,增强了组件的可测试性和可重用性。

与 Backbone.js 和 Knockout.js 的比较

在 AngularJS 推出时,Backbone.js 和 Knockout.js 是两个流行的库。这两个库都有各自的优势,但它们缺少 AngularJS 内置的一些功能。

例如,Backbone.js 给开发者更多的代码控制权,并且比 AngularJS 更少地表达了观点。这种灵活性既是优势也是劣势:它允许更多的定制化,但也需要更多的样板代码。AngularJS 利用其双向数据绑定和依赖注入,提供了更多的结构。它有更多的观点,促进了开发速度的增长:这是我们在现代框架如 Next.js、Remix 等中看到的。这是 AngularJS 遥遥领先于其时代的一种方式。

Backbone 也没有直接处理视图(DOM)的变化,通常将其留给开发者处理。AngularJS 利用其双向数据绑定来处理 DOM 变化,这是一个重大优势。

Knockout.js 主要关注数据绑定,并且缺少 AngularJS 提供的一些其他强大工具,比如 DI 和模块化架构。作为一个全面的框架,AngularJS 为构建单页应用程序(SPA)提供了更全面的解决方案。尽管 AngularJS 已经停止开发,但今天其更新的变种 Angular 提供了相同甚至增强的全面优势,使其成为大规模应用的理想选择。

AngularJS 的权衡

AngularJS(1.x)在引入时代表了 Web 开发实践的重大飞跃。然而,随着 Web 开发领域的快速演变,AngularJS 的某些方面被视为限制或弱点,导致其相对衰退。其中一些包括:

性能

AngularJS 在大规模应用程序中存在性能问题,特别是在复杂数据绑定的情况下。AngularJS 中的脏检查循环(digest cycle)作为变更检测的核心特性,可能导致大型应用程序中更新缓慢和用户界面延迟。双向数据绑定虽然在许多情况下创新和有用,但也导致了性能问题。

复杂性

AngularJS 引入了一系列新概念,包括指令、控制器、服务、依赖注入、工厂等。虽然这些特性使 AngularJS 强大,但也使其复杂且难以学习,特别是对于初学者。例如,“这应该是一个工厂还是一个服务?”是一个常见的争论,让许多开发团队感到困惑。

到 Angular 2+ 的迁移问题

当 Angular 2 宣布时,它与 AngularJS 1.x 不兼容,并要求使用 Dart 和/或 TypeScript 编写代码。这意味着开发人员必须重写大部分代码以升级到 Angular 2,这被视为一大障碍。Angular 2+ 的引入实质上分裂了 Angular 社区,引发混乱,也为 React 开辟了道路。

模板中的复杂语法

AngularJS 允许在模板属性中使用复杂的 JavaScript 表达式,例如 on-click="$ctrl.some.deeply.nested.field = 123",这种做法会导致呈现和业务逻辑混合在标记中,因而具有挑战性,使得解析和管理这些交织的代码变得繁琐。此方法在可维护性上带来了挑战。

此外,调试更加困难,因为模板层并非设计用来处理复杂逻辑,而且从这些内联表达式引起的任何错误可能难以定位和解决。此外,这样的做法违反了关注点分离原则,这是一种基本的设计哲学,提倡在应用程序的不同方面进行明确处理,以提高代码质量和可维护性。

理论上,模板应该调用控制器方法执行更新,但没有限制这样做。

缺乏类型安全

AngularJS 中的模板无法与 TypeScript 等静态类型检查器配合工作,这使得在开发过程的早期阶段难以及时捕获错误。这是一个重大缺陷,特别是对于大规模应用程序,其中类型安全对于可维护性和可扩展性至关重要。

$scope 模型令人困惑

在 AngularJS 中,由于其在绑定数据和在不同上下文中的行为中的角色,$scope对象经常被发现是混淆的根源,因为它充当视图和控制器之间的粘合剂,但其行为并不总是直观或可预测的。

这导致了复杂性,特别是对于新手来说,在理解数据在模型和视图之间如何同步方面。此外,$scope在嵌套控制器中可以继承来自父作用域的属性,这使得跟踪特定$scope属性最初是在哪里定义或修改变得困难。

这种继承可能会导致应用程序中意外的副作用,特别是在处理父子作用域可以无意中相互影响的嵌套作用域时。作用域层次结构的概念及其基础的原型继承往往与 JavaScript 中更传统和熟悉的词法作用域规则相矛盾,增加了学习复杂性的另一层面。

例如,React 将状态与需要它的组件放置在一起,因此完全避免了这个问题。

有限的开发工具

与 React 的 DevTools(例如 Replay.io)相比,AngularJS 并未提供丰富的开发工具用于调试和性能分析,尤其是在支持 React 应用程序的时间旅行调试方面。

进入 React

大约在这个时候,React 开始崭露头角。React 提出的核心思想之一是基于组件的架构。尽管实现方式不同,但其潜在思想相似:通过组合可重用组件来构建 Web 和其他平台的用户界面是最佳选择。

虽然 AngularJS 使用指令将视图绑定到模型,React 引入了 JSX 和一个根本上更简单的组件模型。然而,如果没有 AngularJS 通过 Angular 模块推广组件化架构奠定的基础,一些人可能会认为转向 React 模型的过程可能不会那么顺利。

在 AngularJS 中,双向数据绑定模型是行业标准;然而,它也有一些缺点,例如在大型应用程序上可能存在性能问题。React 从中吸取教训,并引入了单向数据流模式,使开发人员更能控制他们的应用程序,并更容易理解数据随时间的变化。

正如我们将在第三章中了解的那样,React 还引入了虚拟 DOM 的概念,通过最小化直接 DOM 操作来提高性能。另一方面,AngularJS 通常直接操作 DOM,这可能导致性能问题以及我们最近通过 jQuery 讨论的其他不一致状态问题。

话虽如此,AngularJS 代表了网页开发实践的重大转变。如果不提到 AngularJS,我们就会遗漏掉一个重要的点:当 AngularJS 推出时,它不仅革新了网页开发的格局,还为未来的框架和库的发展铺平了道路,其中包括 React。

让我们探讨一下 React 如何融入这一切,以及在历史的这一时刻 React 是如何诞生的。在当时,UI 的更新仍然是一个相对困难且尚未解决的问题。即使今天也远未完全解决,但 React 显著降低了这一难度,并启发了像 SolidJS、Qwik 等其他库去解决这些问题。Meta 的 Facebook 也不例外,面对 UI 复杂性和规模问题。因此,Meta 开发了一些内部解决方案,与当时已存在的解决方案互补。其中最早的是 BoltJS:这是一个工具,Facebook 的工程师称之为“将一堆喜欢的东西拼凑在一起”。组合了一系列工具,使得对 Facebook 网页用户界面的更新更加直观。

大约在这个时候,Facebook 的工程师 Jordan Walke 提出了一个激进的想法,打破了当时的现状,完全用新的部分替换了网页更新时的最小部分。正如我们之前看到的,JavaScript 库通过一种称为双向数据绑定的范式管理视图(用户界面)和模型(概念上的数据源)之间的关系。鉴于这种模型的局限性,正如我们之前讨论过的,Jordan 的想法是使用一种称为单向数据流的范式。这是一个简单得多的范式,更容易保持视图和模型的同步。这就是 React 诞生的单向架构的基础。

React 的价值主张

好了,历史课结束了。希望现在我们有足够的背景来开始理解为什么 React 存在了。考虑到在规模上轻易陷入不安全、不可预测和低效的 JavaScript 代码坑中有多容易,我们需要一个解决方案,引导我们走向成功之路,无意间赢得胜利。让我们详细讨论一下 React 如何做到这一点。

声明式与命令式代码

React 在 DOM 上提供了声明式抽象。我们将在本书后面更详细地讨论它是如何做到这一点的,但基本上它为我们提供了一种编写表达我们想看到的内容的代码的方式,然后负责它如何发生,确保我们的用户界面以一种安全、可预测和高效的方式创建并运行。

让我们考虑一下我们之前创建的列表应用程序。在 React 中,我们可以这样重写它:

function MyList() {
  const [items, setItems] = useState(["I love"]);

  return (
    <div>
      <ul>
        {items.map((i) => (
          <li key={i /* keep items unique */}>{i}</li>
        ))}
      </ul>
      <NewItemForm onAddItem={(newItem) => setItems([...items, newItem])} />
    </div>
  );
}

注意在return语句中,我们实际上写了类似 HTML 的东西:看起来就像我们想要看到的样子。我想看到一个带有NewItemForm和列表的框。这些是怎么出现的?这是由 React 来解决的。我们是批量添加列表项以一次性添加它们的块吗?还是逐个添加?React 处理如何完成这些操作,而我们只描述想要完成什么。在后续章节中,我们将深入了解 React,并探索它在写作时的具体实现。

我们是否依赖类名来引用 HTML 元素?我们是否在 JavaScript 中使用getElementById?不是的。React 在幕后为我们创建了唯一的“React 元素”,它用于检测更改并进行增量更新,因此我们无需从用户代码中读取类名和其他可能不存在的标识符:我们的唯一数据源专门是 JavaScript 与 React 配合使用。

我们将我们的MyList组件导出到 React 中,React 会在屏幕上以安全、可预测且高效的方式展示它——没有任何问题。这个组件的工作只是返回一个描述这个 UI 片段应该如何展示的说明。它通过使用一个虚拟 DOM(vDOM)来实现这一点,这是对预期 UI 结构的轻量级描述。然后 React 在更新发生之后比较虚拟 DOM 与更新之前的虚拟 DOM,并将其转换为对真实 DOM 的小而高效的更新,使其与虚拟 DOM 匹配。这就是 React 如何能够更新 DOM 的方式。

虚拟 DOM

虚拟 DOM 是一个编程概念,它将实际 DOM 表示为 JavaScript 对象。如果现在这些内容有点深奥,别担心:第三章专门讨论了这个问题,并更详细地解释了事情。现在,重要的是知道虚拟 DOM 允许开发者在不直接操作实际 DOM 的情况下更新 UI。React 使用虚拟 DOM 跟踪组件的更改,并仅在必要时重新渲染组件。这种方法比每次更改都更新整个 DOM 树更快、更高效。

在 React 中,虚拟 DOM 是实际 DOM 树的轻量级表示。它是一个普通的 JavaScript 对象,描述了 UI 元素的结构和属性。React 创建并更新虚拟 DOM 以匹配实际 DOM 树,对虚拟 DOM 进行的任何更改都会使用协调(reconciliation)过程应用于实际 DOM。

第四章专门讨论了这个问题,但是在我们这里的上下文讨论中,让我们通过几个示例来简单总结一下。为了理解虚拟 DOM 的工作原理,让我们回顾一下我们的点赞按钮示例。我们将创建一个 React 组件,显示一个点赞按钮和点赞数量。当用户点击按钮时,点赞数量应增加 1。

这是我们组件的代码:

import React, { useState } from "react";

function LikeButton() {
  const [likes, setLikes] = useState(0);

  function handleLike() {
    setLikes(likes + 1);
  }

  return (
    <div>
      <button onClick={handleLike}>Like</button>
      <p>{likes} Likes</p>
    </div>
  );
}

export default LikeButton;

在这段代码中,我们使用 useState 钩子创建了一个状态变量 likes,它保存了喜欢的数量。回顾一下我们可能已经知道的关于 React,钩子是一种特殊的函数,允许我们在函数组件中使用 React 的特性,如状态和生命周期方法。Hooks 使我们能够重用有状态的逻辑,而无需更改组件层次结构,这样可以轻松地提取和共享 Hooks,甚至将其作为独立的开源包分享给社区。

我们还定义了一个函数 handleLike,当点击按钮时会将 likes 的值增加一。最后,我们使用 JSX 渲染 Like 按钮和喜欢的数量。

现在,让我们更仔细地看一下此示例中虚拟 DOM 的工作方式。

当首次渲染 LikeButton 组件时,React 创建一个反映实际 DOM 树的虚拟 DOM 树。虚拟 DOM 包含一个 div 元素,其中包含一个 button 元素和一个 p 元素:

{
  $$typeof: Symbol.for('react.element'),
  type: 'div',
  props: {},
  children: [
    {
      $$typeof: Symbol.for('react.element'),
      type: 'button',
      props: { onClick: handleLike },
      children: ['Like']
    },
    {
      $$typeof: Symbol.for('react.element'),
      type: 'p',
      props: {},
      children: [0, ' Likes']
    }
  ]
}

p 元素的 children 属性包含 Likes 状态变量的值,初始设置为零。

当用户点击 Like 按钮时,会调用 handleLike 函数,它更新 likes 状态变量。然后,React 创建一个反映更新状态的新虚拟 DOM 树:

{
  type: 'div',
  props: {},
  children: [
    {
      type: 'button',
      props: { onClick: handleLike },
      children: ['Like']
    },
    {
      type: 'p',
      props: {},
      children: [1, ' Likes']
    }
  ]
}

注意虚拟 DOM 树包含与之前相同的元素,但 p 元素的 children 属性已更新以反映喜欢的新值,从 0 变为 1。接下来是 React 中称为 协调 的过程,其中新的虚拟 DOM 与旧的进行比较。让我们简要讨论这个过程。

计算新的虚拟 DOM 树后,React 执行称为协调的过程,以了解新树与旧树之间的差异。协调是将旧虚拟 DOM 树与新虚拟 DOM 树进行比较,并确定哪些部分的实际 DOM 需要更新的过程。如果你对 具体 如何进行感兴趣,第四章 对此进行了详细的讨论。现在,让我们考虑我们的 Like 按钮。

在我们的示例中,React 比较旧虚拟 DOM 树与新虚拟 DOM 树,发现 p 元素已更改:具体来说,其 props 或 state 或两者都已更改。这使得 React 能够将组件标记为“脏”或“应更新”。然后,React 计算一组最小有效更新,以在实际 DOM 上对新 vDOM 的状态进行协调,并最终更新实际 DOM 以反映对虚拟 DOM 所做的更改。

React 仅更新实际 DOM 的必要部分,以最小化 DOM 操作的数量。这种方法比每次更改时更新整个 DOM 树要快得多,更高效。

虚拟 DOM 已经成为现代 Web 的一个强大而有影响力的发明,像 Preact 和 Inferno 这样的新库在 React 证明其有效性后也采纳了它。我们将在第四章中更多地介绍虚拟 DOM,但现在让我们继续下一节。

组件模型

React 极力鼓励“组件化思维”:即将你的应用程序拆分为较小的组件,并将它们添加到一个更大的树中以组合你的应用程序。组件模型是 React 的一个关键概念,也是使 React 如此强大的原因。让我们来讨论为什么:

  • 它鼓励在所有地方重复使用相同的东西,这样如果它出问题了,你只需在一个地方修复它,所有地方都会修复。这被称为 DRY(不要重复自己)开发,是软件工程的关键概念。例如,如果我们有一个Button组件,我们可以在应用的许多地方使用它,如果我们需要改变按钮的样式,我们可以在一个地方做这个改变,然后所有地方都会改变。

  • 如果 React 能够重复识别特定组件并跟踪特定组件随时间的更新,它更容易追踪组件并执行性能优化,如记忆化、批处理和其他优化。这称为keying。例如,如果我们有一个Button组件,我们可以给它一个key属性,React 将能够随时间跟踪Button组件并“知道”何时更新它,或者何时跳过更新并继续对用户界面进行最小化的更改。大多数组件具有隐式键,但如果需要,我们也可以显式提供它们。

  • 它帮助我们分离关注点并将逻辑放置在逻辑影响到的用户界面部分附近。例如,如果我们有一个RegisterButton组件,我们可以将按钮被点击时的逻辑放在与RegisterButton组件同一文件中,而不是需要在不同文件之间跳转来查找按钮被点击时的逻辑。RegisterButton组件会包装一个更简单的Button组件,并负责处理按钮被点击时的逻辑。这被称为composition

React 的组件模型是支撑该框架流行和成功的基本概念。这种开发方法具有多个好处,包括增加模块化、更容易调试和更高效的代码重用。

不可变状态

React 的设计哲学强调一种范式,即我们的应用程序状态被描述为一组不可变的值。每次状态更新都被视为一个新的、独特的快照和内存引用。这种不可变的状态管理方法是 React 价值主张的核心部分,对于开发强大、高效且可预测的用户界面具有几个优势。

通过强制不可变性,React 确保 UI 组件在任何给定时间点反映特定状态。当状态发生变化时,你不是直接进行变异,而是返回一个表示新状态的新对象。这使得跟踪变化、调试和理解应用程序行为更加容易。由于状态转换是离散的且不相互干扰,因此由共享可变状态引起的微妙 bug 的可能性显著降低。

在接下来的章节中,我们将探讨 React 如何批量更新状态并异步处理它们以优化性能。由于状态必须以不可变方式处理,这些“事务”可以安全地聚合和应用,而不会因一个更新损坏另一个状态。这带来更可预测的状态管理,并可以改善应用程序性能,特别是在复杂状态转换期间。

使用不可变状态进一步强化了软件开发中的最佳实践。它鼓励开发人员在处理数据流时采用函数式思维,减少副作用,使代码更易于理解。不可变数据流的清晰性简化了理解应用程序运行方式的心智模型。

不可变性还支持强大的开发者工具,例如 Replay.io 等工具的时光旅行调试,开发人员可以前后移动查看应用程序状态变化,检查任意时间点的 UI。只有保持每个状态更新为独特且未修改的快照,才能实现这一点。

React 对不可变状态更新的承诺是一个深思熟虑的设计选择,带来了许多好处。它符合现代函数式编程原则,实现了高效的 UI 更新、优化性能、减少 bug 的可能性,并改善了整体开发人员体验。这种状态管理方法支撑了 React 许多先进功能的基础,并将继续作为 React 发展的基石。

发布 React

单向数据流是我们多年来构建 Web 应用程序的一次彻底转变,遭遇了怀疑。Facebook 作为一个资源丰富、用户众多、工程师众多且意见不一的大公司,这种向上攀升的过程曲折艰辛。经过深入审查,React 在内部取得了成功。它先是被 Facebook 采纳,然后是被 Instagram 采纳。

然后在 2013 年开源,并释放到世界,但遭遇了大量抨击。人们严厉批评 React 使用 JSX,指责 Facebook “将 HTML 放入 JavaScript” 并破坏了关注点分离。Facebook 因“重新思考最佳实践”并打破 Web 而闻名。最终,在像 Netflix、Airbnb 和 纽约时报 这样的公司缓慢而稳定的采用后,React 成为了构建 Web 用户界面的事实标准。

本故事中略去了一些细节,因为它们超出了本书的范围,但在深入了解细节之前了解 React 的背景是很重要的:特别是 React 被创建来解决的技术问题类别。如果您对 React 的故事更感兴趣,YouTube 上有一部关于 React 历史的完整纪录片,名为 React.js: The Documentary,由 Honeypot 免费提供。

鉴于 Facebook 在巨大规模下亲眼目睹了这些问题,React 开创了一种基于组件的构建用户界面的方法,可以解决这些问题以及更多问题,其中每个组件都是一个可以重复使用并与其他组件组合以构建更复杂用户界面的自包含代码单元。

React 发布为开源软件一年后,Facebook 发布了 Flux:一种用于管理 React 应用程序中数据流的模式。Flux 是对管理大规模应用程序中数据流挑战的回应,也是 React 生态系统的关键部分。让我们来看看 Flux 以及它如何融入 React 中。

Flux 架构

Flux 是一种用于构建客户端 Web 应用程序的架构设计模式,由 Facebook(现在是 Meta)推广(见图 1-3)。它强调单向数据流,使应用程序内部数据流更加可预测。

flux

图 1-3. Flux 架构

以下是 Flux 架构的关键概念:

操作

操作是包含新数据和标识类型属性的简单对象。它们代表系统的外部和内部输入,如用户交互、服务器响应和表单输入。操作通过中央调度器分派到各种存储区:

// Example of an action object
{
  type: 'ADD_TODO',
  text: 'Learn Flux Architecture'
}

调度器

调度器是 Flux 架构的中央枢纽。它接收操作并将它们分派到应用程序中注册的存储区。它管理一个回调列表,每个存储区都向调度器注册自身和其回调。当分派操作时,它被发送到所有注册的回调:

// Example of dispatching an action
Dispatcher.dispatch(action);

存储

存储区包含应用程序状态和逻辑。它们与 MVC 架构中的模型有些相似,但它们管理许多对象的状态。它们向调度器注册并提供处理操作的回调。当存储区的状态更新时,它会发出更改事件以通知视图发生了变化:

// Example of a store
class TodoStore extends EventEmitter {
  constructor() {
    super();
    this.todos = [];
  }

  handleActions(action) {
    switch (action.type) {
      case "ADD_TODO":
        this.todos.push(action.text);
        this.emit("change");
        break;
      default:
      // no op
    }
  }
}

视图

视图是 React 组件。它们监听来自存储的更改事件,并在它们依赖的数据发生变化时更新自身。它们还可以创建新的操作以更新系统状态,形成数据流的单向循环。

Flux 架构通过系统中的单向数据流促进了更容易跟踪随时间变化的变化。这种可预测性后来可以作为编译器进一步优化代码的基础,就像 React Forget(稍后详细介绍)的情况一样。

Flux 架构的优势

Flux 架构带来了许多有助于管理复杂性和提高 Web 应用程序可维护性的好处。以下是一些显著的优势:

单一真实数据源

Flux 强调应用程序状态的单一真实数据源,存储在 stores 中。这种集中式状态管理使应用程序的行为更加可预测且更易于理解。它消除了多个相互依赖的真实数据源可能带来的复杂性,从而减少了应用程序各处的错误和不一致状态。

可测试性

Flux 明确定义的结构和可预测的数据流使应用程序极易测试。系统中不同部分(如 actions、dispatcher、stores 和 views)的关注点分离允许单元测试每个部分独立进行。此外,在数据流是单向且状态存储在特定、可预测位置时,编写测试也更加容易。

关注点分离(SoC)

Flux 清晰地分离了系统不同部分的关注点,正如之前所描述的。这种分离使得系统更加模块化,更易于维护和推理。每个部分都有明确定义的角色,而单向数据流则清晰地展示了这些部分如何相互作用。

Flux 架构为构建稳健、可扩展和可维护的 Web 应用提供了坚实的基础。其强调单向数据流、单一真实数据源和关注点分离,导致开发的应用更易于开发、测试和调试。

总结:那么,为什么 React 如此受欢迎呢?

React 之所以备受青睐,是因为它使开发人员能够以更大的可预测性和可靠性构建用户界面,我们可以声明性地表达屏幕上我们想要的东西,而 React 则通过高效的增量 DOM 更新来处理如何。它还鼓励我们以组件思维来分离关注点并更轻松地重用代码。在 Meta 经受过实战考验,并设计用于大规模使用。它还是开源且免费使用。

React 还拥有庞大且活跃的生态系统,开发人员可以获取各种工具、库和资源。这个生态系统包括用于测试、调试和优化 React 应用程序的工具,以及用于常见任务(如数据管理、路由和状态管理)的库。此外,React 社区积极参与,提供许多在线资源、论坛和社群,帮助开发人员学习和成长。

React 是平台无关的,这意味着它可以用于构建广泛的平台的 Web 应用程序,包括桌面、移动和虚拟现实。这种灵活性使 React 成为开发人员的首选,他们需要为多个平台构建应用程序,因为它允许他们使用单一的代码库来构建可以在多个设备上运行的应用程序。

总结一下,React 的价值主张集中在其基于组件的架构、声明式编程模型、虚拟 DOM、JSX、广泛的生态系统、平台无关性以及 Meta 的支持。这些特性使 React 成为需要构建快速、可伸缩和可维护 Web 应用程序的开发人员的理想选择。无论您是构建简单的网站还是复杂的企业应用程序,React 都可以帮助您比许多其他技术更有效地实现目标。让我们来复习一下。

章节复习

在本章中,我们简要介绍了 React 的历史背景,它最初的价值主张,以及如何解决规模化时不安全、不可预测和低效的用户界面更新问题。我们还谈到了组件模型以及它为 Web 上的界面带来的革命性。让我们回顾一下我们所学到的。理想情况下,通过这一章节,您对 React 的起源、其主要优势和价值主张有了更多了解。

复习问题

让我们确保您完全掌握了我们讨论的主题。请花一些时间回答以下问题:

  1. 创建 React 的动机是什么?

  2. React 如何改进之前的 MVC 和 MVVM 等模式?

  3. Flux 架构有什么特别之处?

  4. 什么是声明性编程抽象的好处?

  5. 虚拟 DOM 在进行高效的 UI 更新中扮演了什么角色?

如果您在回答这些问题时遇到困难,这一章节可能值得再读一遍。如果没有问题,让我们来探索下一章。

接下来

在 第二章 中,我们将深入探讨这种声明性抽象,它允许我们表达我们希望在屏幕上看到的内容:JSX 的语法和内部工作原理——这种在早期使 React 陷入困境的 JavaScript 中的 HTML 语言,但最终证明是在 Web 上构建用户界面的理想方式,影响了许多未来用于构建用户界面的库。

第二章:JSX

在第一章中,我们了解了 React 的基础知识及其起源故事,将其与当时其他流行的 JavaScript 库和框架进行了比较。我们了解了 React 的真正价值主张以及为什么它如此重要。

在本章中,我们将学习 JSX,这是 JavaScript 的一种语法扩展,允许我们在 JavaScript 代码中编写类似 HTML 的代码。当 React 在 2013 年推出时,它是人们首先注意到并且受到了严厉批评的东西,因此很有意义早期就专注于它。因此,让我们深入了解这种语言扩展,它的工作原理以及如何概念化地编写我们自己的代码。

让我们开始讨论,首先了解 JSX 代表什么。我们已经知道 JS 代表 JavaScript。这是否意味着 JSX 是 JavaScript 的第 10 版?就像 Mac OS X 一样?它是 JS Xtra 吗?我们可能认为 JSX 中的 X 意味着 10 或 Xtra,这两者都是不错的猜测!但是,JSX 中的 X 实际上代表JavaScript Syntax eXtension。有时它也被称为JavaScript XML

JavaScript XML?

如果你长时间在网络上徘徊,你可能还记得约在 2000 年左右的AJAXAsynchronous JavaScript and XML这个术语。AJAX 本质上是利用现有技术的新方式,创建了高度互动的网页,可以异步和局部地更新,而不是当时的常态:每次状态变化都加载整个新页面。

在浏览器中使用XMLHttpRequest等工具,它将发起一个异步(即非阻塞)的 HTTP 请求。传统上,对该请求的响应将是一个 XML 格式的响应。而今天,我们更倾向于使用 JSON 来响应。这很可能是为什么fetch已经取代了XMLHTTPRequest的原因之一,因为XMLHttpRequest中带有 XML 这个名称。

JSX 是 JavaScript 的一种语法扩展,允许开发人员在他们的 JavaScript 代码中编写类似 HTML 的代码。它最初由 Meta 开发用于 React,但后来也被其他库和框架采纳。JSX 不是一种独立的语言,而是一种由编译器或转译器转换为普通 JavaScript 代码的语法扩展。当 JSX 代码被编译时,它被转换为普通的 JavaScript 代码。我们稍后将详细介绍这些细节。

虽然 JSX 语法看起来类似于 HTML,但它们之间有一些关键的区别。例如,JSX 使用大括号{}来嵌入 JavaScript 表达式到类似 HTML 的代码中。此外,JSX 属性是用驼峰命名法而不是 HTML 属性:HTML 中的onclick在 JSX 中是onClick。HTML 元素的写法是小写,而不是像自定义 JSX 元素或组件那样的首字母大写:div是 HTML,Div是一个 React 组件。

此外,我们还应该提到,可以在不使用 JSX 的情况下创建 React 应用程序,但代码往往难以阅读、推理和维护。但如果我们愿意,我们可以。让我们看一个使用 JSX 和不使用 JSX 表达的 React 组件。

这是一个带有 JSX 的列表示例:

const MyComponent = () => (
  <section id="list">
    <h1>This is my list!</h1>
    <p>Isn't my list amazing? It contains amazing things!</p>
    <ul>
      {amazingThings.map((t) => (
        <li key={t.id}>{t.label}</li>
      ))}
    </ul>
  </section>
);

这是一个不使用 JSX 的相同列表的示例:

const MyComponent = () =>
  React.createElement(
    "section",
    { id: "list" },
    React.createElement("h1", {}, "This is my list!"),
    React.createElement(
      "p",
      {},
      "Isn't my list amazing? It contains amazing things!"
    ),
    React.createElement(
      "ul",
      {},
      amazingThings.map((t) =>
        React.createElement("li", { key: t.id }, t.label)
      )
    )
  );

为了清晰起见,我们使用了早期的 JSX 转换来说明 React 如何在没有 JSX 的情况下编写。我们将在本章的后续部分详细介绍转换,但现在让我们确立一个转换是将语法 A 转换为语法 B 的东西。

现在,React 在 React 17 中引入了一个新的转换,自动导入一些特殊函数以实现基本相同的功能。这在整体方案中只是一个小细节,但使用新的转换器,我们可以这样表达列表,而不使用 JSX:

import { jsx as _jsx } from "react/jsx-runtime";
import { jsxs as _jsxs } from "react/jsx-runtime";

const MyComponent = () =>
  _jsxs("section", {
    id: "list",
    children: [
      _jsx("h1", {
        children: "This is my list!",
      }),
      _jsx("p", {
        children: "Isn't my list amazing? It contains amazing things!",
      }),
      _jsx("ul", {
        children: amazingThings.map((t) =>
          _jsx(
            "li",
            {
              children: t.label,
            },
            t.id
          )
        ),
      }),
    ],
  });

无论如何,你能看出 JSX 和不使用 JSX 的示例之间的差异吗?你可能会发现第一个示例使用 JSX 比后者更易于阅读和维护。前者是 JSX,后者是普通的 JS。让我们讨论一下它的权衡。

JSX 的好处

在 Web 开发中使用 JSX 有几个好处:

更易于阅读和编写

JSX 语法更易于阅读和编写,特别是对于熟悉 HTML 的开发者来说。

改进的安全性

JSX 代码可以编译成更安全的 JavaScript 代码,生成没有危险字符(如<>)的 HTML 字符串,这些字符可能创建新元素。在这种情况下,这些 HTML 字符串将尖括号替换为小于号和大于号,以提高安全性。这个过程称为消毒。

强类型

JSX 允许强类型,这有助于在出错之前捕获错误。这是因为 JSX 可以使用 TypeScript 来表达,即使没有 TypeScript,它仍然可以通过使用类似于 JSDoc 风格的注释和propTypes来获得增强的类型安全性。

鼓励基于组件的架构

JSX 鼓励基于组件的架构,这有助于使代码更加模块化和易于维护。

广泛使用

JSX 广泛应用于 React 社区,并且也受其他库和框架的支持。

JSX 的缺点

使用 JSX 也有一些缺点:

学习曲线

不熟悉 JSX 的开发者可能会觉得学习和理解困难。

需要工具支持

JSX 代码在执行之前必须编译为常规的 JavaScript 代码,这为开发工具链增加了额外的步骤。其他替代方案,比如 Vue.js,例如,可以在包含为页面中的<script>标签的情况下立即在浏览器环境中工作。

关注点混合

一些开发者认为 JSX 通过将类似 HTML 的代码与 JavaScript 代码结合在一起,混合了关注点,使得难以将呈现与逻辑分离。

部分 JavaScript 兼容性

JSX 支持内联表达式,但不支持内联块。也就是说,在 JSX 元素树中,我们可以有内联表达式,但不能有 if 或 switch 块。对于刚接触 JSX 的工程师来说,这可能有些难以理解。

尽管存在一些缺点,JSX 已经成为 Web 开发者的流行选择,尤其是那些使用 React 的开发者。它提供了一种强大而灵活的方式来创建组件和构建用户界面,并且得到了一个庞大而活跃的社区的支持。除了在 React 中的使用之外,JSX 还被其他库和框架所采用,包括 Vue.js、Solid、Qwik 等。这表明 JSX 不仅限于 React,在未来的几年里其受欢迎程度可能会继续增长,甚至可能通过影响诸如 iOS 空间中的 SwiftUI 等实现,突破了 React 和 Web 生态系统的界限。

总的来说,JSX 是一个强大而灵活的工具,可以帮助我们构建动态和响应式的用户界面。JSX 的设计目标是简化表达、展示和维护 React 组件的代码,同时保留强大的功能,如迭代、计算和内联执行。

JSX 在传递到浏览器之前会转换为普通的 JavaScript。它是如何实现这一点的?让我们来看看它的内部工作原理!

内部工作原理

如何制作语言扩展?它们是如何工作的?要回答这些问题,我们需要了解一些关于编程语言的知识。具体来说,我们需要探索像这样的代码如何输出 3

const a = 1;
let b = 2;

console.log(a + b);

了解这些将有助于我们更好地理解 JSX,从而帮助我们更好地理解 React,从而增强我们在 React 中的熟练程度。

代码如何工作?

我们刚刚看到的代码片段实际上只是文本。计算机如何解释并执行它?首先,它不是一个大而聪明的 RegExp(正则表达式),可以在文本文件中识别关键字。我曾试图通过这种方式构建一种编程语言,但失败了,因为正则表达式通常很难正确编写,更难以阅读和理解,并且由于可读性问题而难以维护。例如,以下是一个用于识别有效电子邮件地址的正则表达式。乍一看,几乎不可能知道它的目的:

\[(?:[a-z0-9!#\$%&'\*\+-/=\?\^_`{\|}~]+(?:\.[a-z0-9!#\$%&'\*\+-/=\?\^_`{\|}~]+)\
*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0
e-\x7f])\*")@(?:(?:a-z0-9?\.)\*?a-z0-9?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4]
[0-9]|[01]?[0-9][0-9]?|[a-z0-9-]\*?[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\
x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])\+)\])\]

那个正则表达式甚至都不完全有效,因为完整版本无法适应页面!这就是为什么不使用正则表达式,而是使用编译器来编译代码的原因。编译器是一种将用高级编程语言编写的源代码根据特定规则转换为语法树(字面上,类似 JavaScript 对象的树数据结构)的软件。编译代码的过程涉及几个步骤,包括词法分析、解析、语义分析、优化和代码生成。让我们更详细地探讨每个步骤,并讨论编译器在现代软件开发领域中的作用。

编译器使用一个三步骤过程(至少在 JavaScript 中是这样)在这里发挥作用。这些步骤被称为词法分析解析代码生成。让我们更详细地看看这些步骤:

词法分析

本质上是将一串字符分解成有意义的标记。当一个标记生成器是有状态的,并且每个标记包含关于其父级和/或子级的状态时,标记生成器被称为词法分析器。这是我们在这里讨论的目的的一个必要简化:词法分析实质上是有状态的词法分析。

词法分析器具有词法规则,在某些情况下,确实使用正则表达式或类似的方法来检测关键标记,如变量名、对象键和值等,在代表编程语言的文本字符串中。然后,词法分析器将这些关键字映射到某种可枚举值,具体取决于其实现。例如,const变成0let变成1function变成2,等等。

一旦字符串被标记化或词法分析,我们就进入下一步,解析。

解析

将标记转换为语法树的过程。语法树是表示代码结构的数据结构。例如,我们之前看过的代码片段将被表示为一个语法树,如下所示:

{
type: "Program",
body: [
    {
    type: "VariableDeclaration",
    declarations: [
        {
        type: "VariableDeclarator",
        id: {
            type: "Identifier",
            name: "a"
        },
        init: {
            type: "Literal",
            value: 1,
            raw: "1"
        }
        }
    ],
    kind: "const"
    },
    {
    type: "VariableDeclaration",
    declarations: [
        {
        type: "VariableDeclarator",
        id: {
            type: "Identifier",
            name: "b"
        },
        init: {
            type: "Literal",
            value: 2,
            raw: "2"
        }
        }
    ],
    kind: "let"
    },
    {
    type: "ExpressionStatement",
    expression: {
        type: "CallExpression",
        callee: {
        type: "Identifier",
        name: "console"
        },
        arguments: [
        {
            type: "BinaryExpression",
            left: {
            type: "Identifier",
            name: "a"
            },
            right: {
            type: "Identifier",
            name: "b"
            },
            operator: "+"
        }
        ]
    }
    }
]
}

字符串通过解析器变成了一个 JSON 对象。作为程序员,当我们有这样的数据结构时,我们可以做一些非常有趣的事情。语言引擎使用这些数据结构来完成第三步,即代码生成的过程。

代码生成

这是编译器从抽象语法树(AST)生成机器代码的地方。这涉及将 AST 中的代码转换为一系列指令,这些指令可以直接由计算机处理器执行。然后,JavaScript 引擎执行生成的机器代码。总的来说,将 AST 转换为机器代码的过程是复杂的,并涉及许多不同的步骤。然而,现代编译器非常复杂,可以生成在各种硬件架构上高效运行的高度优化代码。

有几种类型的编译器,每种都具有不同的特征和用例。一些最常见的编译器类型包括:

本地编译器

这些编译器生成的机器码可以直接被目标平台的处理器执行。本地编译器通常用于创建独立应用程序或系统级软件。

交叉编译器

这些编译器生成的机器码运行在与编译器不同的平台上。交叉编译器通常用于嵌入式系统开发或针对特定硬件的目标。

即时(JIT)编译器

这些编译器在运行时将代码转换为机器码,而不是预先编译。JIT(即时编译)编译器通常用于虚拟机,例如 Java 虚拟机,并且相比传统的解释器能提供显著的性能优势。

解释器

这些程序直接执行源代码,无需编译。解释器通常比编译器慢,但提供更大的灵活性和易用性。

为了高效执行 JavaScript 代码,许多现代环境,包括 Web 浏览器,都使用 JIT 编译器。在这些系统中,JavaScript 源代码可能首先被转换为中间表示形式,例如字节码。然后 JIT 编译器动态地将这些字节码编译为机器码,随着程序的运行。这种即时编译允许引擎根据实时信息(如变量类型和频繁执行的代码路径)进行优化。某些引擎采用多阶段编译,从快速的非优化编译开始执行,然后针对频繁执行的代码段进行更优化的编译。这种动态方法使得 JavaScript 引擎能够在广泛的应用中实现令人印象深刻的性能。

运行时通常与引擎交互,为特定环境提供更多上下文辅助功能和特性。目前最流行的 JavaScript 运行时是常见的 Web 浏览器,如 Google Chrome:它使用 Chromium 运行时与引擎交互。类似地,在服务器端我们使用 Node.js 运行时,它仍然使用 v8 引擎。在野外还有哪些更多的引擎和运行时可以识别?

运行时为 JavaScript 引擎提供上下文,例如浏览器运行时提供的window对象和document对象。如果你之前同时使用过浏览器和 Node.js,你可能注意到 Node.js 没有全局的window对象。这是因为它是不同的运行时,因此提供了不同的上下文。Cloudflare 创建了一个名为Workers的类似运行时,其唯一责任是在全球分布的边缘服务器上执行 JavaScript,而 Bun 和 Deno 是更多的替代运行时,但我们偏离了主题。这与 JSX 有何关系?

使用 JSX 扩展 JavaScript 语法

现在我们了解了如何扩展 JavaScript 语法,那么 JSX 是如何工作的?我们应该如何做?要扩展 JavaScript 语法,我们需要一个能够理解我们新语法的不同引擎,或者在达到引擎之前处理我们的新语法。前者几乎不可能实现,因为引擎需要大量思考来创建和维护,因为它们往往被广泛使用。如果我们决定选择这个选项,可能需要数年甚至数十年才能使用我们的扩展语法!然后我们必须确保我们的“定制特殊引擎”在所有地方都被使用。我们将如何说服浏览器供应商和其他利益相关者转向我们不受欢迎的新东西呢?这不会奏效。

后者更快:让我们探讨如何在达到引擎之前处理我们的新语法。为了做到这一点,我们需要创建自己的词法分析器和解析器,它能够理解我们的扩展语言:也就是说,获取代码文本字符串并理解它。然后,我们可以不像传统那样生成机器码,而是将语法树生成成当前所有引擎都能理解的普通旧式 JavaScript。这正是 JavaScript 生态系统中的Babel以及其他工具如 TypeScript、Traceur 和 swc 所做的事情(见图 2-1)。

创建新的 JSX 引擎 vs. 使用 JS 预处理器

图 2-1. 创建新的 JSX 引擎与使用 JS 预处理器

因此,JSX 不能直接在浏览器中使用,而是需要一个“构建步骤”,其中自定义解析器对其运行,然后将其编译成语法树。然后,这段代码再转换为最终的分发包中的普通 JavaScript。这称为转译:转换,然后编译的代码。

为了明确起见,转译是将源代码从一种语言转换为具有相似抽象级别的另一种语言的过程。这也是为什么它被称为源到源的编译

形式上,它是一种类型的翻译器。这个通用术语可以指编译器、汇编器或解释器等。从概念上讲,它与编译几乎完全相同,只是目标语言是类似抽象级别的源语言。

例如,TypeScript 是一种高级语言,当进行转译时,会转换成 JavaScript(另一种高级语言)。Babel 将 ES6 JavaScript 代码转译为 ES5 JavaScript 代码就是另一个例子。

现在我们了解了如何构建我们自己的 JavaScript 扩展,让我们看看我们可以用这种特定扩展 JSX 做些什么。

JSX pragma

一切都始于 <,这个字符在 JavaScript 中独立使用时是一个无法识别的字符,通常在比较操作之外使用时会引发 SyntaxError: Unexpected token '<'。在 JSX 中,这个“JSX 编译指示”可以被转译成一个函数调用。编译指示是编译器提供的一个指令,用于为编译器提供文件内容之外的额外信息,通常是在语言本身传达不了的方面。

JavaScript 中的一些例子是我们有时会在旧模块的顶部看到的“use strict”编译指示,以及在 React 服务器组件(RSCs)上下文中最近的“use client”编译指示。更多信息请参阅 第九章。

当解析器看到 < 编译指示时,调用函数的名称是可配置的,默认情况下为函数 React.createElement 或者在新的转换中为 _jsxs,如前所述。预期此函数的签名如下:

function pragma(tag, props, ...children)

换句话说,它接收 tagpropschildren 作为参数。这里是 JSX 如何映射到常规 JavaScript 语法的示例。以下 JSX 代码:

<MyComponent prop="value">contents</MyComponent>

将变成以下 JavaScript 代码:

React.createElement(MyComponent, { prop: "value" }, "contents");

请注意标签 (MyComponent)、属性 (prop="value") 和子元素 ("contents") 之间的映射。这本质上是 JSX 编译指示的作用:对多个递归函数调用的语法糖。JSX 编译指示实际上是一个别名:< 而不是 React.createElement

表达式

JSX 最强大的功能之一是能够在元素树中执行代码。要像我们在 “Under the Hood” 中所做的那样迭代列表,我们可以像在本章节早先使用的 map 一样在大括号内放置可执行的代码。如果我们想在 JSX 中显示两个数字的和,我们会这样做:

const a = 1;
const b = 2;

const MyComponent = () => <Box>Here's an expression: {a + b}</Box>;

这将呈现 Here's an expression: 3,因为大括号内的内容作为表达式执行。使用 JSX 表达式,我们可以迭代列表并执行各种表达式,包括带有三元操作符的条件检查、字符串替换等。

这是另一个使用三元操作符进行条件检查的示例:

const a = 1;
const b = 2;

const MyComponent = () => <Box>Is b more than a? {b > a ? "YES" : "NO"}</Box>;

这将呈现 Is b more than a? YES,因为比较是一个被评估的表达式。值得一提的是,JSX 表达式确实是表达式。不可能在 JSX 元素树中执行语句。以下不会起作用:

const MyComponent = () => <Box>Here's an expression: {
    const a = 1;
    const b = 2;

    if (a > b) {
        3
    }
}</Box>;

这不起作用是因为语句不返回任何内容并且被视为副作用:它们在不产生值的情况下设置状态。在语句和计算之后,我们如何在内联中打印一个值?请注意,在示例中,我们仅仅在第 6 行中放入了数字 3。我们的渲染器怎么知道我们打算打印 3?这就是为什么表达式被评估,而语句不会。

章节回顾

好的,我们已经在 JSX 主题上覆盖了相当多的内容。我们应该对这个主题感到相当自信(甚至可以说是流利的),以至于我们可以自信地解释其中的各个方面。

复习问题

确保你完全掌握了我们讨论的主题。花点时间回答以下问题:

  1. JSX 是什么?它的一些优缺点是什么?

  2. JSX 和 HTML 有什么区别?

  3. 文本如何变成机器码?

  4. JSX 表达式是什么,它们有什么好处?

如果你回答这些问题有困难,可能需要再读一遍这一章。如果没有,让我们探索下一章。

接下来是什么

现在我们对 JSX 已经相当流利,让我们将注意力转向 React 的下一个方面,看看我们如何从中获取最多的知识,进一步提高我们的流利程度。让我们探索虚拟 DOM。

第三章:虚拟 DOM

在本章中,我们将深入探讨虚拟 DOM(有时称为 vDOM)的概念及其在 React 中的重要性。我们还将探讨 React 如何利用虚拟 DOM 使 Web 开发更轻松和高效。

随着 Web 应用程序变得越来越复杂,管理“真实 DOM”变得越来越困难,正如我们很快将看到的那样,以及我们在第一章中粗略涵盖的那样。React 的虚拟 DOM 为此问题提供了解决方案。

在本章中,我们将探讨 React 虚拟 DOM 的工作原理、其相对真实 DOM 的优势以及其实现方式。我们还将探讨 React 如何利用虚拟 DOM 优化真实 DOM 的性能,并将所有内容整合在一起。

通过一系列的代码示例和详细的解释,我们将理解虚拟 DOM 在 React 中的作用,以及如何利用其优势创建健壮和高效的 Web 应用程序。让我们开始吧!

虚拟 DOM 简介

虚拟 DOM 像 DOM 一样,是一个以 JavaScript 对象建模的 HTML 文档:这确实是文档对象模型(DOM)的含义。DOM 本身是浏览器运行时对文档的模型。虚拟 DOM 是这个模型的一个轻量级副本,其主要区别在于,虽然真实 DOM 由Node对象组成,但虚拟 DOM 由作为描述的纯 JS 对象组成。它允许 Web 开发人员以更高效和更高性能的方式创建用户界面,正如我们将在本章中发现的那样。

在 React 中,每当我们通过setState或其他机制告诉它对 UI 进行更改时,首先更新虚拟 DOM,然后更新真实 DOM 以匹配虚拟 DOM 中的更改。这个过程称为协调,是第四章的主题。

首先更新虚拟 DOM 的原因是,更新真实 DOM 可能会有些慢和昂贵。我们将在下一节中详细讨论这一点,但要点是,每当对真实 DOM 进行更改时,浏览器必须重新计算页面的布局,重绘屏幕,并执行其他可能耗时的操作。

例如,仅仅读取元素的offsetWidth就可以触发重新排列,这是浏览器重新计算文档所有或部分布局的过程,可能会影响性能,并使直接 DOM 交互不那么高效。

const btn = document.getElementById("myButton");
const width = btn.offsetWidth; // This can trigger a reflow

另一方面,更新虚拟 DOM 要快得多,因为它不涉及任何实际页面布局的更改。相反,它是一个简单的 JavaScript 对象,可以通过各种算法方法快速和高效地操作,这些方法可以充分利用可用的 JavaScript 引擎,并随着时间的推移增加其效率,与浏览器和其他主机环境解耦。

当更新虚拟 DOM 时,React 使用一种差异算法来识别旧版本和新版本虚拟 DOM 之间的差异。然后,该算法确定更新真实 DOM 所需的最小变化集,并以批处理和优化的方式应用这些变化,以最小化性能影响。

在本章中,我们将探讨虚拟 DOM 和真实 DOM 的区别,真实 DOM 的缺陷,以及虚拟 DOM 如何帮助创建更好的用户界面。我们还将深入探讨 React 实现的虚拟 DOM 及其用于高效更新的算法。

真实 DOM

当一个 HTML 页面被加载到浏览器中时,它会被解析并转换为节点和对象的树状结构,即对象模型(DOM):只是一个大的 JavaScript 对象。DOM 是网页的实时表示,意味着它会随着用户与页面的交互而不断更新。

下面是一个简单 HTML 页面的真实 DOM 的示例:

<!DOCTYPE html>
<html>
  <head>
    <title>Example Page</title>
  </head>
  <body>
    <h1 class="heading">Welcome to my page!</h1>
    <p>This is an example paragraph.</p>
    <ul>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </ul>
  </body>
</html>

在这个例子中,真实 DOM 被表示为一个树状结构,每个 HTML 元素在页面中都有相应的节点。这是树结构的简化版本,目的是为了更好地理解。实际 DOM 拥有更多每个节点的属性和方法。不过,这应该有助于我们理解文档如何被建模为一个对象:

const dom = {
  type: "document",
  doctype: "html",
  children: [
    {
      type: "element",
      tagName: "html",
      children: [
        {
          type: "element",
          tagName: "head",
          children: [
            {
              type: "element",
              tagName: "title",
              children: "Example Page",
            },
          ],
        },
        {
          type: "element",
          tagName: "body",
          children: [
            {
              type: "element",
              tagName: "h1",
              innerHTML: "Welcome to my page!",
              children: [],
              className: "heading",
            },
            {
              type: "element",
              tagName: "p",
              children: "This is an example paragraph.",
            },
            {
              type: "element",
              tagName: "ul",
              children: [
                {
                  type: "element",
                  tagName: "li",
                  children: "Item 1",
                },
                {
                  type: "element",
                  tagName: "li",
                  children: "Item 2",
                },
                // ...you can fill in the rest
              ],
            },
          ],
        },
      ],
    },
  ],
};

树中的每个节点表示一个 HTML 元素,它包含允许通过 JavaScript 进行操作的属性和方法。例如,我们可以使用 document.querySelector() 方法从真实 DOM 中检索特定节点,并修改其内容:

// Retrieve the <h1> node
const h1Node = document.querySelector(".heading");

// Modify its contents
if (h1Node) {
  h1Node.innerHTML = "Updated Heading!";
}

console.log(h1Node);

在这个例子中,我们使用 document.querySelector() 方法检索具有 "heading" 类的 h1 元素。然后,通过将其 innerHTML 属性设置为 "Updated Heading!",我们修改了元素的内容。这将把页面上显示的文本从 "Welcome to my page!" 修改为 "Updated Heading!"

这似乎并不复杂,但这里有几点需要注意。首先,我们使用 document.querySelector() 方法从真实 DOM 中检索元素。此方法接受 CSS 选择器作为参数,并返回与选择器匹配的第一个元素。在这种情况下,我们传入了类选择器 .heading,它匹配具有 "heading" 类的 h1 元素。

这里有一个小小的危险,因为 document.querySelector 方法虽然是一个强大的工具,可以根据 CSS 选择器在真实 DOM 中选择元素,但使用该方法可能会存在一个性能问题,特别是在处理大型和复杂文档时。该方法必须从文档顶部开始向下遍历以找到所需的元素,这可能是一个耗时的过程。

当我们使用 CSS 选择器调用document.querySelector()时,浏览器必须搜索整个文档树以找到匹配的元素。这意味着搜索可能会很慢,特别是如果文档很大且结构复杂。此外,浏览器还必须评估选择器本身,这可能是一个复杂的过程,取决于选择器的复杂性。

相比之下,document.getElementById不像 CSS 选择器那样需要验证,并且由于id属性预期是唯一的,因此通常更为高效。

就运行时间复杂度而言,使用大 O 符号,getElementById在现代浏览器中通常被近似为 O(1),因为它们很可能使用了散列机制,如哈希表,用于高效的 ID 到元素映射。虽然理想的哈希表查找在平均情况下是 O(1),但重要的是要考虑到最坏情况,如哈希冲突,可能导致更长的查找时间。由于浏览器实际上并不强制 ID 的唯一性,这些哈希冲突是很可能发生的。

然而,由于现代浏览器中具有高级的散列函数和调整大小策略,这些情况很少发生。

注意

对于我们这些没有上过计算机学校的人,也许不太理解大 O 符号,这是开发人员用来衡量一段代码运行速度快慢的一个方便工具,特别是当代码要处理的数据量增加时。基本上,大 O 符号提供了算法的高层次理解,涵盖了时间复杂度(随着输入规模增加而执行时间增长的情况)和空间复杂度(随着输入规模增加而内存使用量增长的情况)。通常用 O(1)、O(n)、O(n log n)或 O(n²)等术语来表示,其中 n 是输入的大小。因此,当开发人员谈论代码“高效”或“可扩展”时,他们通常指的就是这些大 O 值,目标是选择时间和空间复杂度较低的算法,以确保他们的软件在处理越来越多的数据时仍然性能良好。

另外,由于 ID 应该是唯一的,它们并不太适合在页面上有多个可重复使用的组件。这就是querySelector发挥作用的地方,它可以用于选择具有相同类名的多个元素,例如。

尽管如此,querySelector可以接受广泛的 CSS 选择器范围,其复杂性是可变的。在最坏的情况下,该方法可能需要遍历整个 DOM 以确保匹配或不存在匹配项,其复杂度可以是 O(n),其中 n 是 DOM 中的元素数量。然而,如果选择器更为具体或者在 DOM 树中早期找到匹配项,实际运行时间可以低于 O(n)。然而,仍然需要额外的计算成本来解析和验证选择器本身。

值得注意的是,在小型文档或者在文档树的特定区域搜索元素时,document.getElementByIddocument.querySelector 之间的性能差异可能微乎其微。然而,在更大更复杂的文档中,这种差异可能变得更加明显。

有人会说,整个“CPU 效率”论点被夸大了,并且不值得担心。这可能是真的,也可能不是,但没有人可以质疑 React 虚拟 DOM 在组件化逻辑和避免在如此易变的 DOM 环境中管理状态方面所提供的附加价值。我们说 DOM 易变,是因为它受到许多因素的影响,包括用户交互、网络请求、客户端脚本和其他可能随时更改它的事件。通过虚拟 DOM,React 保护我们免受这种环境的影响。

我们深入探讨这些微妙的细节,因为要真正精通 React,理解 DOM 的整体复杂性是很重要的。智能地使用 DOM 并不是一件小事,而使用 React,我们可以选择是自己在其中航行并偶尔踩中地雷,还是使用一种工具来安全地使用虚拟 DOM 来导航 DOM。

虽然我们已经讨论了在这里选择元素的一些细微差别,但我们还没有机会深入探讨直接使用 DOM 的风险。让我们快速地做一下,以充分理解 React 虚拟 DOM 所提供的价值。

实际 DOM 的缺陷

实际 DOM 存在一些缺陷,这些缺陷可能使构建高性能 Web 应用程序变得困难。其中一些缺陷包括性能问题、跨浏览器兼容性和安全漏洞,直接操作 DOM 可能会引发跨站脚本(XSS)漏洞。

性能

实际 DOM 的最大问题之一是其性能。每当对 DOM 进行更改,比如添加或删除元素,或者更改元素的文本或属性时,浏览器都必须重新计算布局并重绘页面的受影响部分。对于大型和复杂的网页,这可能是一个缓慢且资源密集的过程。

正如前面提到的,阅读 DOM 元素的offsetWidth属性可能看起来是一个简单的操作,但实际上它可能会触发浏览器进行昂贵的布局重新计算。这是因为offsetWidth是一个计算属性,依赖于元素及其祖先的布局,这意味着浏览器需要确保布局信息是最新的,才能返回准确的值。

在最坏的情况下,用大 O 符号表示读取元素的offsetWidth属性的时间复杂度将被估算为(O(n))。这是因为访问此属性可能会触发浏览器的重新布局,涉及页面上多个元素的布局位置重新计算。在这个上下文中,(n)代表受重新布局影响的 DOM 元素数量。尽管直接属性访问很快,但相关的副作用,如重新布局,可能会使操作随着页面上元素数量的增加而扩展。

如果你想避免像offsetWidth这样的布局属性可能引发的重新布局问题,我们可以采用一些技巧来提升操作性能。以下是一种方法,利用getBoundingClientRect()方法可以批量进行布局读取和写入操作:

// Accessing layout properties in a more performant way
function getOffsetWidthWithoutTriggeringReflow(element) {
  let width;

  // Batch all reading operations
  const rect = element.getBoundingClientRect();
  width = rect.width;

  // ... any other reading operations

  // Followed by writing operations, if any

  return width;
}

const element = document.querySelector(".myElement");
const width = getOffsetWidthWithoutTriggeringReflow(element);
console.log(width);

通过使用getBoundingClientRect(),我们可以在一次调用中检索多个布局属性,从而减少触发多次重新布局的可能性。此外,通过分批次分别读取和写入操作,我们可以进一步减少布局抖动,即由于频繁交叉读写布局属性导致的重复和不必要的布局重新计算(见图 3-1)。这种抖动会显著降低网页的性能,导致用户体验迟钝。通过策略性地访问布局属性和批量操作,我们可以保持网页交互的流畅和响应。

布局抖动

图 3-1. 布局抖动

然而,即使使用getBoundingClientRect()也可能在存在待处理的布局更改时引发重新布局。在这里的关键是尽量减少强制浏览器重新计算布局的次数,而且在这样做时,尽量一次性获取尽可能多的信息。

React 使用虚拟 DOM 作为真实 DOM 操作之间的中间层,从而为我们自动处理所有这些。

考虑以下例子,其中我们有一个包含单个div元素的简单 HTML 文档:

<!DOCTYPE html>
<html>
  <head>
    <title>Reading offsetWidth example</title>
    <style>
      #my-div {
        width: 100px;
        height: 100px;
        background-color: red;
      }
    </style>
  </head>
  <body>
    <div id="my-div"></div>
    <script>
      var div = document.getElementById("my-div");
      console.log(div.offsetWidth);
    </script>
  </body>
</html>

当我们在浏览器中加载这个文档并打开开发者控制台时,我们可以看到div元素的offsetWidth属性被记录在控制台中。然而,我们看不到浏览器在计算offsetWidth值时所做的幕后工作。

要理解这项工作,我们可以使用开发工具中的性能面板记录浏览器加载和渲染页面时的活动时间轴。通过这样做,我们可以看到浏览器在处理文档时执行了几个布局和绘制操作。特别是,我们可以看到有两个布局操作对应于脚本中读取offsetWidth的操作。

每个布局操作都需要一些时间来完成(在这种情况下大约为 2 毫秒),即使它们只是读取属性值。这是因为浏览器需要确保布局信息是最新的,然后才能返回准确的值,这需要它执行整个文档的完整布局。尽管 2 毫秒可能看起来不算什么大不了的事情,但在规模上会累积起来。

总体而言,当读取像 offsetWidth 这样依赖于布局的属性时,我们应该小心,因为它们可能导致意外的性能问题。如果我们需要多次读取这些属性的值,我们应考虑将该值缓存到变量中,以避免触发不必要的布局重新计算。或者,我们可以使用 requestAnimationFrame API 将属性的读取延迟到下一个动画帧中,此时浏览器已执行了必要的布局计算。

要了解更多关于真实 DOM 的意外性能问题,请看一些示例。考虑以下 HTML 文档:

<!DOCTYPE html>
<html>
  <head>
    <title>Example</title>
  </head>
  <body>
    <ul id="list">
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </ul>
  </body>
</html>

假设我们要使用 JavaScript 向列表中添加新项目。我们可能会编写以下代码:

const list = document.getElementById("list");
const newItem = document.createElement("li");
newItem.textContent = "Item 4";
list.appendChild(newItem);

注意这里我们使用 getElementById 而不是 querySelector,因为:

  • 我们知道 ID 是什么。

  • 我们知道性能的权衡。

让我们继续。

此代码选择具有 ID "list"ul 元素,创建一个新的 li 元素,将其文本内容设置为 "Item 4",并将其附加到列表中。当我们运行此代码时,浏览器必须重新计算布局并重新绘制页面的受影响部分,以显示新项目。

这个过程可能会很慢且资源密集,特别是对于更大的列表。例如,假设我们有一个包含 1,000 个项目的列表,并且我们想在列表的末尾添加一个新项目。我们可能会编写以下代码:

const list = document.getElementById("list");
const newItem = document.createElement("li");
newItem.textContent = "Item 1001";
list.appendChild(newItem);

当我们运行此代码时,浏览器必须重新计算布局并重新绘制整个列表,即使只添加了一个项目。这可能需要大量的时间和资源,特别是在较慢的设备或较大的列表上。

为了进一步说明这个问题,请考虑以下示例:

<!DOCTYPE html>
<html>
  <head>
    <title>Example</title>
    <style>
      #list li {
        background-color: #f5f5f5;
      }
      .highlight {
        background-color: yellow;
      }
    </style>
  </head>
  <body>
    <ul id="list">
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </ul>
    <button onclick="highlight()">Highlight Item 2</button>
    <script>
      function highlight() {
        const item = document.querySelector("#list li:nth-child(2)");
        item.classList.add("highlight");
      }
    </script>
  </body>
</html>

在这个例子中,我们有一个包含三个项目和一个按钮的列表,当点击按钮时会突出显示第二个项目。当点击按钮时,浏览器必须重新计算布局并重新绘制整个列表,即使只有一个项目发生了变化。这可能导致 UI 中可见的延迟或闪烁,这对用户来说可能很烦人。

总体而言,真实 DOM 的性能问题对我们来说可能是一个重大挑战,特别是在处理大型和复杂的网页时。虽然有一些技术可以减轻这些问题,比如优化选择器、使用事件委托、批处理读写 DOM 操作或使用 CSS 动画,但它们可能会很复杂且难以实现。

因此,我们许多人已经将虚拟 DOM 视为解决这些问题的方案。虚拟 DOM 使我们能够通过抽象出真实 DOM 的复杂性并提供更轻量级的 UI 表示方式,创建更高效和性能更好的 UI。

但是… 是否真的有必要为了节省几毫秒?嗯,CPU/处理性能是一个关键因素,可以极大地影响应用程序的成功。在今天数字化的时代,用户期望快速响应的网站,因此我们作为 Web 开发者,优化 CPU 效率以确保应用程序平稳、响应迅速是至关重要的。Google Web 开发博客上的一篇优秀文章“毫秒决定百万”进一步证明了这些说法的可信度。

直接 DOM 操作可能触发布局重新计算(称为 reflows)和重绘,从而增加 CPU 使用率和处理时间,这可能导致用户的延迟甚至崩溃。对于处理能力有限的设备(如智能手机或平板电脑)上的用户尤其成问题,这些设备可能具有有限的处理能力和内存。在世界的许多地方,用户可能正在使用较老或较不具备能力的设备访问我们的 Web 应用程序,这可能进一步加剧问题。

通过优化 CPU 效率,我们可以创建适用于各种设备用户的应用程序,无论其处理能力或内存如何。这可以提高用户参与度,增加转化率,最终实现更成功的在线存在。

React 的虚拟 DOM 已经实现了构建 CPU 效率高的 Web 应用程序;使用其高效的渲染算法有助于减少处理时间并提高整体性能。

跨浏览器兼容性

真实 DOM 的另一个问题是跨浏览器兼容性。不同的浏览器对文档的建模方式不同,这可能导致 Web 应用程序中的不一致性和 bug。React 发布时这种情况更为普遍,现在已经少见。但这确实曾经使开发者难以创建在不同浏览器和平台上无缝运行的 Web 应用程序。

跨浏览器兼容性的主要问题之一是,并非所有浏览器都支持特定的 DOM 元素和属性。因此,我们不得不花费额外的时间和精力实现解决方案和回退机制,以确保应用程序在所有目标平台上正确运行。

这正是 React 使用其合成事件系统解决的问题。SyntheticEvent 是对浏览器原生事件的包装,旨在确保在不同浏览器中的一致性。它通过以下机制解决浏览器之间的不一致性:

统一的界面

在原始的 JavaScript 中,由于不一致性,处理浏览器事件可能会很棘手。例如,访问事件属性可能因浏览器而异。有些浏览器可能使用 event.target,而其他浏览器则使用 event.srcElementSyntheticEvent 抽象了这些差异,提供了一种一致的交互方式,确保开发者无需编写特定于浏览器的代码:

// Without React, developers might need checks for
// browser-specific properties
const targetElement = event.target || event.srcElement;

// In React, thanks to SyntheticEvent, it's consistent
function handleClick(event) {
  const target = event.target;
  // ... rest of the code
}

通过将原生事件包装到 SyntheticEvent 系统中,React 保护开发者免受原生浏览器事件系统的许多不一致性和怪癖的困扰。

事件委托

React 并不直接将事件侦听器附加到元素上,而是在根级别监听事件。这种方法避开了在旧版本浏览器中某些元素可能不支持某些事件的问题。

跨功能增强

原生浏览器事件表现出不一致性的一个领域是它们如何在不同输入元素上处理某些事件。一个显著的例子是 onChange 事件:

  • 在原始的 JavaScript 中,onChange 事件的行为在输入类型之间有所不同:

    • 对于 <input type="text"> 元素,在某些浏览器中,onChange 事件可能仅在输入框失去焦点后触发,而不是在值更改时立即触发。

    • 对于 <select> 元素,每当选择选项时可能会触发事件,即使选项与先前的选项相同。

    • 在其他情况下,特别是在旧版浏览器中,某些表单元素的 onChange 事件可能无法可靠触发所有用户交互。

  • React 的 SyntheticEvent 系统规范了这些输入元素上 onChange 事件的行为。在 React 中:

    • 文本输入框 (<input type="text">) 的 onChange 事件在每次击键时都会触发,提供实时反馈。

    • 对于 <select> 元素,每当选择新选项时,它可靠地触发。

    • React 确保 onChange 事件在其他表单元素上也提供一致的体验。

      通过这种方式,React 使开发者摆脱了处理这些原生不一致性的困扰,让他们可以专注于应用逻辑,而不必担心特定于浏览器的怪癖。

访问原生事件

如果开发者需要原始的浏览器事件,则可以通过 event.nativeEvent 获得,确保灵活性而不牺牲抽象化的好处。

总之,SyntheticEvent 提供了一个稳定的事件系统,消除了原生浏览器事件的怪异和差异。这只是 React 利用其虚拟 DOM 提供便利的一个具体方式。

到目前为止,我们一直在讨论直接操作 DOM 可能导致性能问题和跨浏览器兼容性问题。现在让我们探讨一种更高效的本地方法来处理这些问题,即使用文档片段,这可以被认为是理解 React 虚拟 DOM 的一种本地前提。

文档片段

如我们所见,直接操作 DOM 可能会消耗大量性能,特别是涉及多个变更时。每次更新 DOM,浏览器可能需要进行布局重新计算、UI 重绘和视图更新,这可能会减慢应用程序的运行速度。这就是文档片段发挥作用的地方。

文档片段 是一个轻量级容器,用于保存 DOM 节点。它的作用类似于一个临时的暂存区,你可以在其中进行多个更改,而不影响主 DOM。完成后,可以将文档片段附加到 DOM 中,触发一次回流和重绘。在这一点上,文档片段与 React 的虚拟 DOM 非常相似。

因为文档片段是轻量级容器,允许我们批量更新,所以它们带来了多种性能优势:

批量更新

而不是对实时 DOM 进行多次单独的更新,你可以将所有更改批量处理在文档片段中。这意味着只进行一次回流和重绘,而不管在片段内部做了多少元素或更改。

内存效率

当节点添加到文档片段时,它们会从 DOM 中当前的父节点中移除。这有助于优化内存使用,特别是在重新排序大型文档部分时。

没有冗余的渲染

由于文档片段不是活动文档树的一部分,对其进行的更改不会影响实时文档,并且样式和脚本直到片段附加到实际 DOM 之后才会应用。这避免了冗余的样式重新计算和脚本执行。

考虑一个场景,你需要向列表中添加多个列表项:

const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i + 1}`;
  fragment.appendChild(li);
}
document.getElementById("myList").appendChild(fragment);

在这个例子中,首先将 100 个列表项附加到文档片段中。只有在所有项目都添加完毕后,片段才会附加到主列表中。这样就只需一次更新实时 DOM,而不是 100 次单独的更新。

通过这种方式,文档片段提供了一种有效地批量处理多个更改来操作 DOM 的方式,从而减少昂贵的回流和重绘次数。对于希望在其 Web 应用程序中实现最佳性能的开发人员来说,利用文档片段可以实现更流畅的交互和更快的渲染时间。

React 的虚拟 DOM 可以被类比为文档片段概念的高级实现。以下是一个简短的联系:

批量更新

类似于文档片段,React 的虚拟 DOM 将多个变更批量处理在一起。不是在每次状态或属性变化时直接修改实时 DOM,而是先在虚拟 DOM 中编译这些变更。

高效的差异比较

然后,React 确定当前虚拟 DOM 与实际 DOM 之间的差异(或“差异”)。这个差异过程确保只对实际 DOM 进行必要的更改,类似于文档片段如何减少直接的 DOM 操作。

单一渲染

一旦识别出差异,React 就会在单个批处理中更新实际 DOM,类似于附加完整填充的文档片段。这最小化了昂贵的回流和重绘。

本质上,尽管文档片段提供了在更新实时 DOM 之前对一组更改进行分组和优化的方法,但 React 的虚拟 DOM 更进一步,通过智能差异化和批量更新整个应用程序的 UI,确保渲染的最大效率。此外,React 将所有这些文档片段的内容转化为我们作为日常开发人员无需关注的实现细节,使我们能够更专注地构建我们的产品。接下来,让我们详细了解虚拟 DOM 的工作原理。

虚拟 DOM 的工作原理

虚拟 DOM 是一种技术,有助于减轻真实 DOM 的缺陷。通过在内存中创建 DOM 的虚拟表示,可以对虚拟表示进行更改,而无需直接修改真实 DOM,类似于文档片段。这使得框架或库能够以更高效和更具性能的方式更新真实 DOM,而不会导致浏览器重新计算页面布局和重绘元素。

虚拟 DOM 还通过提供一个一致的 API 来抽象不同浏览器实现之间的真实 DOM 差异,从而帮助改进元素和其更新的编写体验。例如,如果在另一个运行时中 document.appendChild 不同,那么在使用 JSX 和虚拟 DOM 时就不重要。这使得开发人员能够更轻松地创建能够在不同浏览器和平台上无缝运行的 Web 应用程序。

React 使用虚拟 DOM 来构建用户界面。在本节中,我们将探讨 React 虚拟 DOM 的实现原理。

React 元素

在 React 中,用户界面被表示为 React 元素 的树,这些元素是组件或 HTML 元素的轻量级表示。它们是使用 React.createElement 函数创建的,可以嵌套以创建复杂的用户界面。

这里是一个 React 元素的示例:

const element = React.createElement(
  "div",
  { className: "my-class" },
  "Hello, world!"
);

这创建了一个表示具有 classNamemy-class 和文本内容 Hello, world!<div> 元素的 React 元素。

从这里,我们可以看到如果使用 console.log(element),实际创建的元素是这样的:

{
  $$typeof: Symbol(react.element),
  type: "div",
  key: null,
  ref: null,
  props: {
    className: "my-class",
    children: "Hello, world!"
  },
  _owner: null,
  _store: {}
}

这是 React 元素的一种表示。React 元素是 React 应用程序的最小构建块,它描述了应该出现在屏幕上的内容。每个元素是一个简单的 JavaScript 对象,描述了它所表示的组件,以及任何相关的 props 或属性。

代码块中显示的 React 元素表示为具有多个属性的对象:

$$typeof

这是 React 使用的一个符号,用来确保一个对象是有效的 React 元素。在这种情况下,它是 Symbol(react.element)$$typeof 可以有其他值,取决于元素的类型:

Symbol(react.fragment)

当元素表示 React 片段时。

Symbol(react.portal)

当元素表示 React 门户时。

Symbol(react.profiler)

当元素表示 React 性能分析器时。

Symbol(react.provider)

当元素表示 React 上下文提供程序时。

通常情况下,$$typeof用作类型标记,标识 React 元素的类型。我们将在本书的后续部分详细讨论这些内容。

type

此属性表示元素表示的组件类型。在本例中,它是"div",表示这是一个<div>DOM 元素,称为“主机组件”。React 元素的type属性可以是字符串或函数(或类,但我们不讨论它,因为它正在被逐步淘汰)。如果是字符串,则表示 HTML 标签名称,如"div""span""button"等。当它是一个函数时,它表示一个自定义 React 组件,实质上只是一个返回 JSX 的 JavaScript 函数。

这是一个具有自定义组件类型的元素的示例:

const MyComponent = (props) => {
  return <div>{props.text}</div>;
};

const myElement = <MyComponent text="Hello, world!" />;

在本例中,myElementtype属性为MyComponent,这是一个定义自定义组件的函数。作为 React 元素对象,myElement的值将是:

{
  $$typeof: Symbol(react.element),
  type: MyComponent,
  key: null,
  ref: null,
  props: {
    text: "Hello, world!"
  },
  _owner: null,
  _store: {}
}

注意,类型设置为MyComponent函数,该函数是元素表示的组件类型,而props包含传递给组件的属性,在本例中为{ text: "Hello, world!" }

当 React 遇到具有函数类型的元素时,它将使用该函数调用元素的props,并将返回值用作元素的children,在本例中是一个div。这就是如何渲染自定义 React 组件的方式:React 会不断地深入遍历元素,直到达到标量值,然后将它们呈现为文本节点,或者如果达到nullundefined,则不会呈现任何内容。

这是一个具有字符串类型的元素的示例:

const myElement = <div>Hello, world!</div>;

在本例中,myElementtype属性为"div",这是一个表示 HTML 标签名的字符串。当 React 遇到具有字符串类型的元素时,它将创建一个相应的 HTML 元素,并在该元素内部呈现其子元素。

ref

此属性允许父组件请求对底层 DOM 节点的引用。通常在需要直接操作 DOM 的情况下使用。在本例中,refnull

props

此属性是一个对象,包含传递给组件的所有属性和 props。在本例中,它有两个属性:className指定元素的类名,children包含元素的内容。

_owner

此属性仅在 React 的非生产版本中可访问,它用于内部跟踪创建此元素的组件。这些信息用于确定在其 props 或状态更改时应由哪个组件负责更新元素。

这里有一个示例,演示了 _owner 属性的使用方式:

function Parent() {
  return <Child />;
}

function Child() {
  const element = <div>Hello, world!</div>;
  console.log(element._owner); // Parent
  return element;
}

在这个例子中,Child 组件创建一个表示 <div> 元素的 React 元素,并带有文本 "Hello, world!"。此元素的 _owner 属性设置为创建 Child 组件的 Parent 组件。

React 使用这些信息来确定在其 props 或状态更改时应由哪个组件负责更新元素。在这种情况下,如果 Parent 组件更新其状态或接收新的 props,则 React 将更新 Child 组件及其关联的元素。

需要注意 _owner 属性是 React 的内部实现细节,不应依赖于应用程序代码。

_store

React 元素对象的 _store 属性是一个对象,由 React 内部使用,用于存储有关元素的额外数据。存储在 _store 中的具体属性和值不是公共 API 的一部分,不应直接访问。

下面是 _store 属性可能的一个示例:

{
  validation: null,
  key: null,
  originalProps: { className: 'my-class', children: 'Hello, world!' },
  props: { className: 'my-class', children: 'Hello, world!' },
  _self: null,
  _source: { fileName: 'MyComponent.js', lineNumber: 10 },
  _owner: {
    _currentElement: [Circular], _debugID: 0, stateNode: [MyComponent]
  },
  _isStatic: false,
  _warnedAboutRefsInRender: false,
}

正如您所见,_store 包括各种属性,如 validationkeyoriginalPropsprops_self_source_owner_isStatic_warnedAboutRefsInRender。这些属性由 React 内部使用,用于跟踪元素状态和上下文的各个方面。

例如,在开发模式下,_source 用于跟踪创建元素的文件名和行号,这对于调试非常有帮助。_owner 用于跟踪创建元素的组件,如前所述。propsoriginalProps 用于存储传递给组件的 props。

再次强调,需要注意 _store 是 React 的内部实现细节,不应在应用程序代码中直接访问,因此我们在这里不会深入讨论。

虚拟 DOM 与真实 DOM

React.createElement 函数和 DOM 的内置 createElement 方法类似,它们都创建新元素;但是,React.createElement 创建 React 元素,而 document.createElement 创建 DOM 节点。它们在实现上有很大差异,但在概念上是相似的。

React.createElement 是 React 提供的一个函数,用于在内存中创建新的虚拟元素,而 document.createElement 是 DOM API 提供的一个方法,也是在内存中创建新元素,直到使用诸如 document.appendChild 或类似的 API 将其附加到 DOM 中。这两个函数的第一个参数都是标签名,而 React.createElement 还接受额外的参数来指定 props 和子元素。

例如,让我们比较如何使用两种方法创建 <div> 元素:

// Using React's createElement
const divElement = React.createElement(
  "div",
  { className: "my-class" },
  "Hello, World!"
);

// Using the DOM API's createElement
const divElement = document.createElement("div");
divElement.className = "my-class";
divElement.textContent = "Hello, World!";

React 中的虚拟 DOM 与真实 DOM 在概念上类似,两者都表示元素的树状结构。当渲染 React 组件时,React 创建一个新的虚拟 DOM 树,将其与先前的虚拟 DOM 树进行比较,并计算更新旧树以匹配新树所需的最小变更。这称为 协调过程。以下是 React 组件中如何工作的示例:

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

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

为了清晰起见,此组件也可以这样表达:

function App() {
  const [count, setCount] = React.useState(0);

  return React.createElement(
    "div",
    null,
    React.createElement("h1", null, "Count: ", count),
    React.createElement(
      "button",
      { onClick: () => setCount(count + 1) },
      "Increment"
    )
  );
}

createElement 调用中,第一个参数是 HTML 标签或 React 组件的名称,第二个参数是属性对象(如果不需要属性,则为 null),任何额外的参数表示子元素。

当首次渲染组件时,React 创建一个类似于以下的虚拟 DOM 树:

div
├─ h1
│  └─ "Count: 0"
└─ button
   └─ "Increment"

当按钮被点击时,React 创建一个新的虚拟 DOM 树,看起来像这样:

div
├─ h1
│  └─ "Count: 1"
└─ button
   └─ "Increment"

React 然后计算出只有 h1 元素的文本内容需要更新,并且只更新真实 DOM 中的这部分。

在 React 中使用虚拟 DOM 允许对真实 DOM 进行高效的更新,同时使 React 能够与其他直接操作 DOM 的库无缝协作。

高效更新

当 React 组件的状态或属性发生变化时,React 创建一个新的 React 元素树,表示更新后的用户界面。然后将此新树与之前的树进行比较,以确定更新真实 DOM 所需的最小变更集,使用差分算法。

此算法将新的 React 元素树与之前的树进行比较,并识别两者之间的差异。这是一个递归比较。如果节点已更改,React 将更新真实 DOM 中对应的节点。如果节点已添加或删除,React 将在真实 DOM 中添加或删除相应的节点。

Diffing 涉及逐个比较新旧树以找出哪些部分发生了变化。

React 的差分算法经过高度优化,旨在最小化需要对真实 DOM 进行的更改数量。算法工作如下:

  • 如果两个树的根级节点不同,React 将用新树替换整个树。

  • 如果根级节点相同,且节点的属性已更改,React 将更新节点的属性。

  • 如果节点的子节点不同,React 将只更新已更改的子节点。React 不会重新创建整个子树,只会更新已更改的节点。

  • 如果节点的子节点相同,但其顺序已更改,React 将重新排序真实 DOM 中的节点,而不实际重新创建它们。

  • 如果从树中移除了节点,React 将从真实 DOM 中移除它。

  • 如果树中添加了新节点,React 将其添加到真实 DOM 中。

  • 如果节点类型已更改(例如,从 div 更改为 span),React 将删除旧节点并创建新类型的新节点。

  • 如果节点具有 key 属性,React 将使用它来确定是否应替换节点。当您需要重置组件的状态时,这将非常有用。

React 的差异算法是高效的,允许 React 快速更新真实 DOM 并进行最小的更改。这有助于提高 React 应用程序的性能,并使构建复杂动态用户界面变得更加容易。

不必要的重新渲染

尽管 React 的差异算法确实在有效地通过最小化所需的更改来更新真实 DOM 中扮演了关键角色,但开发者可能会遇到一个常见挑战:不必要的重新渲染。

这是 React 的设计方式:当组件的状态更改时,React 重新渲染组件及其所有后代。重新渲染意味着 React 递归调用每个函数组件,将其 props 作为参数传递给每个函数组件。React 不会跳过其 props 没有更改的组件,而是调用所有因为父组件的状态或 props 改变而变化的子组件。这是因为 React 不知道依赖于更改的组件的状态的哪些组件,因此必须重新渲染它们以确保 UI 保持一致。

这可能会带来一些显著的性能挑战,特别是在处理大型和复杂的用户界面时。例如,在下面的片段中,ChildComponent 每次 ParentComponent 的状态更改时都会重新渲染,即使传递给 ChildComponent 的 props 没有变化:

import React, { useState } from "react";

const ChildComponent = ({ message }) => {
  return <div>{message}</div>;
};

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <ChildComponent message="This is a static message" />
    </div>
  );
};

export default ParentComponent;

在这个例子中:

  • ParentComponent 具有一个状态变量 count,每次单击按钮时增加。

  • ChildComponent 接收一个名为 message 的静态 prop。由于此 prop 不会更改,理想情况下,我们不希望 ChildComponent 每次 ParentComponent 的状态更改时都重新渲染。

  • 然而,由于 React 的默认行为,ChildComponent 在每次 ParentComponent 重新渲染时也会重新渲染,而这种情况在每次状态更改时都会发生。

  • 这是低效的,因为 ChildComponent 并不依赖于 ParentComponentcount 状态。

  • 由于 ChildComponent 的 props 和 state 没有变化,渲染是无意义的:它可能返回了与上次相同的结果,因此这是多余的努力。

这是我们经常需要优化的问题,特别是在较大的应用程序中,许多组件可能会不必要地重新渲染,从而可能导致性能问题。解决这个问题需要思考如何管理组件重新渲染,确保在组件层次结构的更高级别的状态或属性变化不会导致后代组件普遍而不必要的重新渲染。通过合理构建组件和使用 React 的优化功能如memouseMemo,开发人员可以更好地管理重新渲染,并保持应用程序的高性能。

我们将在第五章中更详细地讨论这一点。

章节复习

本章中,我们探讨了在 Web 开发中真实 DOM 与虚拟 DOM 的差异,以及在 React 中使用后者的优势。

我们首先讨论了真实 DOM 及其限制,例如渲染时间慢和跨浏览器兼容性问题,这些问题可能会使开发人员难以创建能够在不同浏览器和平台上无缝工作的 Web 应用程序。为了说明这一点,我们分析了如何使用真实 DOM API 创建简单的网页,以及随着页面复杂性增加,这些 API 如何迅速变得笨重和难以管理。

接下来,我们深入研究了虚拟 DOM 及其如何解决真实 DOM 的许多限制。我们探讨了 React 如何利用虚拟 DOM 通过减少对真实 DOM 的更新来提高性能,这在渲染时间方面非常昂贵。我们还看到了 React 如何使用元素来比较虚拟 DOM 与先前版本,并计算更新真实 DOM 的最有效方式。

为了说明虚拟 DOM 的好处,我们分析了如何使用 React 组件创建相同的简单网页。我们将这种方法与真实 DOM 方法进行了比较,并看到随着页面复杂性的增加,React 组件更加简洁和易于管理。

我们还探讨了React.createElementdocument.createElement之间的区别,以及如何使用 JSX 创建组件,JSX 提供类似 HTML 的语法,使得更容易理解虚拟 DOM 的结构。

最后,我们讨论了 React 的差异算法如何导致不必要的重新渲染,尤其是在处理大型和复杂用户界面时,这可能是一个重要的性能挑战,并提到了第五章,在那里我们将探讨如何通过使用 React 的memouseMemo功能来优化这一问题。

总体而言,我们了解了在 Web 开发中使用虚拟 DOM 的好处,以及 React 如何利用这一概念使构建 Web 应用程序变得更加轻松和高效。

复习问题

让我们花点时间回答以下问题:

  1. 什么是 DOM,它与虚拟 DOM 有什么区别?

  2. 文档片段是什么,它们与 React 的虚拟 DOM 在哪些方面相似和不同?

  3. DOM 存在哪些问题?

  4. 虚拟 DOM 如何提供更快的用户界面更新方式?

  5. React 渲染是如何工作的?由此可能引发的潜在问题是什么?

接下来

在 第四章,我们将深入探讨 React 协调和其 Fiber 架构。

第四章:内部协调

要真正精通 React,我们需要理解它的功能 做什么。到目前为止,我们已经理解了 JSX 和 React.createElement。我们还在某种程度上理解了虚拟 DOM。让我们在本章中探讨它在 React 中的实际应用,并理解 ReactDOM.createRoot(element).​ren⁠der() 做了什么。具体来说,我们将探讨 React 如何构建其虚拟 DOM,然后通过称为协调的过程更新真实 DOM。

理解协调过程

简而言之,React 的虚拟 DOM 是我们期望的 UI 状态的蓝图。React 接受这个蓝图,并通过一种称为 协调 的过程,在给定的主机环境(通常是 Web 浏览器,但也可能是其他环境,如 shell、iOS 和 Android 等)中将其变为现实。

考虑以下代码片段:

import { useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <main>
      <div>
        <h1>Hello, world!</h1>
        <span>Count: {count}</span>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </div>
    </main>
  );
};

以下代码片段包含了我们想要的 UI 状态的声明性描述:一个元素树。我们的团队成员和 React 都可以阅读并理解,我们试图创建一个带有增量按钮的计数器应用程序。为了理解协调,让我们了解当 React 面对这样一个组件时内部做了什么。

首先,JSX 变成了一棵 React 元素树。这就是我们在第三章中看到的。当调用时,App 组件返回一个 React 元素,其子元素是进一步的 React 元素。对我们来说,React 元素是不可变的,表示 UI 的期望状态。它们不是实际的 UI 状态。React 元素是由 React.createElement 或 JSX < 符号创建的,因此这将被转译为:

const App = () => {
  const [count, setCount] = useState(0);

  return React.createElement(
    "main",
    null,
    React.createElement(
      "div",
      null,
      React.createElement("h1", null, "Hello, world!"),
      React.createElement("span", null, "Count: ", count),
      React.createElement(
        "button",
        { onClick: () => setCount(count + 1) },
        "Increment"
      )
    )
  );
};

这将给我们提供一个创建的 React 元素树,看起来像这样:

{
  type: "main",
  props: {
    children: {
      type: "div",
      props: {
        children: [
          {
            type: "h1",
            props: {
              children: "Hello, world!",
            },
          },
          {
            type: "span",
            props: {
              children: ["Count: ", count],
            },
          },
          {
            type: "button",
            props: {
              onClick: () => setCount(count + 1),
              children: "Increment",
            },
          },
        ],
      },
    },
  },
}

这个片段代表了来自我们 Counter 组件的虚拟 DOM。由于这是第一次渲染,此树现在使用最少的调用到命令式 DOM API 提交给浏览器。React 如何确保最少的调用到命令式 DOM API?通过将 vDOM 更新批处理为一个真实 DOM 更新,并尽可能少地触及 DOM,这是前几章讨论的原因。让我们深入探讨一些细节,以完全理解批处理的工作方式。

批处理

在第三章中,我们讨论了浏览器中的文档片段作为 DOM 内置 API 的一部分:轻量级容器,保存像临时暂存区域一样的 DOM 节点集合,允许您进行多个更改,而不影响主 DOM,直到最后将文档片段附加到 DOM,触发单一回流和重绘。

以类似的方式,React 在协调过程中批量更新真实 DOM,将多个虚拟 DOM 更新合并为单个 DOM 更新。这样做减少了真实 DOM 需要更新的次数,因此有助于提升 Web 应用的性能。

要理解这一点,让我们考虑一个组件,在快速连续更新其状态多次:

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

  const handleClick = () => {
    setCount((prevCount) => prevCount + 1);
    setCount((prevCount) => prevCount + 1);
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

在这个示例中,handleClick函数快速连续调用setCount三次。如果没有批处理,React 将分别三次更新实际 DOM,尽管count的值只改变了一次。这将是低效且慢。

然而,由于 React 批处理更新,它使得一次更新 DOM 为count + 3而不是每次为count + 1分别更新三次。

为了计算对 DOM 的最高效批量更新,React 将创建一个新的 vDOM 树,作为当前 vDOM 树的一个分支,带有更新后的值,其中count3。这棵树将需要与当前在浏览器中的内容进行调和,有效地将0变为3。然后 React 将计算出仅需一次更新 DOM,使用新的 vDOM 值3而不是手动三次更新 DOM。这就是批处理如何适应图片的一部分,这也是我们即将深入探讨的更广泛主题:调和,或者调和下一个预期 DOM 状态与当前 DOM 的过程。

在我们了解现代 React 在内部执行之前,让我们探讨 React 在 16 版本之前使用的遗留“栈”调和器进行调和的方式。这将帮助我们理解今天流行的 Fiber 调和器的必要性。

注意

此时,值得一提的是,我们即将讨论的所有主题都是 React 的实现细节,随着时间的推移可能会发生变化。在这里,我们将 React 的工作机制与 React 的实际应用隔离开来。我们的目标是通过理解 React 的内部机制,更有效地在应用程序中使用 React。

先前的艺术

早期,React 使用栈数据结构进行渲染。为了确保我们理解一致,让我们简要讨论栈数据结构。

栈调和器(遗留)

在计算机科学中,栈是一种遵循后进先出(LIFO)原则的线性数据结构。这意味着最后添加到栈中的元素将是第一个被移除的。栈具有两个基本操作,push 和 pop,分别允许从栈顶添加和移除元素。

栈可以被视为一组垂直排列的元素,其中最顶部的元素是最近添加的元素。这里有一个栈的 ASCII 示例,包含三个元素:

-----
| 3 |
|___|
| 2 |
|___|
| 1 |
|___|

在这个示例中,最近添加的元素是3,位于栈顶。第一个添加的元素1位于栈底。

在这个栈中,push 操作将一个元素添加到栈顶。在代码中,这可以使用 JavaScript 中的数组和push方法执行,如下所示:

const stack = [];

stack.push(1); // stack is now [1]
stack.push(2); // stack is now [1, 2]
stack.push(3); // stack is now [1, 2, 3]

pop 操作从栈中移除顶部元素。在代码中,可以使用数组和 pop 方法来执行 JavaScript 中的这个操作,如下所示:

const stack = [1, 2, 3];

const top = stack.pop(); // top is now 3, and stack is now [1, 2]

在这个例子中,pop 方法从栈中移除顶部元素(3)并返回它。现在栈数组中包含剩余的元素(12)。

React 的原始协调器是一种基于栈的算法,用于比较旧的和新的虚拟树,并相应地更新 DOM。虽然栈协调器在简单情况下工作良好,但随着应用程序规模和复杂性的增长,它带来了一些挑战。

让我们快速看一下为什么会这样。为此,我们将考虑一个需要进行更新的列表示例:

  1. 一个非必要的计算昂贵的组件会消耗 CPU 并进行渲染。

  2. 用户在 input 元素中键入。

  3. Button 如果输入有效则变为可用。

  4. 包含 Form 组件持有状态,因此会重新渲染。

在代码中,我们会这样表达:

import React, { useReducer } from "react";

const initialState = { text: "", isValid: false };

function Form() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const handleChange = (e) => {
    dispatch({ type: "handleInput", payload: e.target.value });
  };

  return (
    <div>
      <ExpensiveComponent />
      <input value={state.text} onChange={handleChange} />
      <Button disabled={!state.isValid}>Submit</Button>
    </div>
  );
}

function reducer(state, action) {
  switch (action.type) {
    case "handleInput":
      return {
        text: action.payload,
        isValid: action.payload.length > 0,
      };
    default:
      throw new Error();
  }
}

在此情况下,栈协调器会按顺序渲染更新而无法暂停或延迟工作。如果计算昂贵的组件阻塞渲染,则用户输入将在屏幕上显示出可观的延迟。这会导致糟糕的用户体验,因为文本字段将无响应。相反,能够识别用户输入作为高优先级更新,而不是渲染非必要的昂贵组件,并更新屏幕以反映输入,延迟渲染计算昂贵的组件,会更加愉快。

如果被用户输入等高优先级渲染工作打断,则需要能够中止当前渲染工作。为了做到这一点,React 需要对某些类型的渲染操作有优先级的概念,以区分它们。

栈协调器未对更新进行优先级排序,这意味着较不重要的更新可能会阻塞更重要的更新。例如,对工具提示的低优先级更新可能会阻塞对文本输入的高优先级更新。虚拟树的更新按接收顺序执行。

在 React 应用中,虚拟树的更新可以有不同的重要性级别。例如,对表单输入的更新可能比更新显示帖子上点赞数的指示器更重要,因为用户直接与输入交互并期望它响应迅速。

在栈协调器中,更新按接收顺序执行,这意味着较不重要的更新可能会阻塞更重要的更新。例如,如果点赞计数器更新在表单输入更新之前接收,点赞计数器更新将首先执行并可能阻塞表单输入更新。

如果点赞计数器更新需要很长时间执行(例如,因为它正在执行昂贵的计算),这可能会导致用户界面中的明显延迟或卡顿,特别是如果用户在更新期间与应用程序交互。

栈协调器的另一个挑战是它不允许更新被中断或取消。这意味着即使栈协调器具有更新优先级的概念,也无法保证能够很好地处理各种优先级,因为在安排高优先级更新时无法放弃不重要的工作。

在任何网络应用程序中,不是所有的更新都是平等的:一个随机意外出现的通知并不像响应我点击按钮那样重要,因为后者是一个有意的动作,需要立即反应,而前者甚至可能是不期望的,甚至可能不受欢迎。

在栈协调器中,更新无法被中断或取消,这意味着有时会以牺牲用户交互的方式进行不必要的更新,例如显示 toast,这可能导致在虚拟树和 DOM 上执行不必要的工作,从而对应用程序的性能产生负面影响。

栈协调器在应用程序增大并复杂化时提出了许多挑战。主要的挑战集中在卡顿和用户界面反应迟缓上。为了解决这些问题,React 团队开发了一种新的协调器称为 Fiber 协调器,它基于一种称为 Fiber 树的不同数据结构。让我们在下一节中探讨这种数据结构。

Fiber 协调器

Fiber 协调器涉及使用一种称为“Fiber”的不同数据结构,它表示协调器的单个工作单位。Fibers 是从我们在第三章中涵盖的 React 元素创建的,其主要区别在于它们是有状态和长寿命的,而 React 元素是短暂的和无状态的。

Redux 的维护者、著名 React 专家 Mark Erikson 将 Fibers 描述为“React 在某一时刻表示实际组件树的内部数据结构”。事实上,这是一个理解 Fibers 的好方法,这也符合 Mark 的品牌形象,因为他目前全职致力于使用 Replay 进行时间旅行调试 React 应用程序:这是一种工具,允许您回放和重现应用程序状态进行调试。如果您尚未了解,请访问Replay.io获取更多信息。

与 vDOM 是元素树类似,React 在协调过程中使用 Fiber 树,正如其名称所示,它是一棵 Fibers 树,直接模拟 vDOM。

Fiber 作为数据结构

React 中的 Fiber 数据结构是 Fiber 协调器的关键组成部分。Fiber 协调器允许优先级更新并行执行,从而改善 React 应用程序的性能和响应能力。让我们更详细地探讨 Fiber 数据结构。

在其核心,Fiber 数据结构是 React 应用程序中组件实例及其状态的表示。如前所述,Fiber 数据结构被设计为可变实例,并且可以在协调过程中根据需要进行更新和重新排列。

每个 Fiber 节点的实例都包含有关其所代表组件的信息,包括其 props、state 和子组件。Fiber 节点还包含其在组件树中的位置信息,以及由 Fiber 调节器使用的元数据,以优先和执行更新。

这是一个简单的 Fiber 节点示例:

{
  tag: 3, // 3 = ClassComponent
  type: App,
  key: null,
  ref: null,
  props: {
    name: "Tejas",
    age: 30
  },
  stateNode: AppInstance,
  return: FiberParent,
  child: FiberChild,
  sibling: FiberSibling,
  index: 0,
  //...
}

在本例中,我们有一个代表 AppClassComponent 的 Fiber 节点。Fiber 节点包含有关组件以下信息:

tag

在这种情况下,它是 3,React 用它来识别类组件。每种类型的组件(类组件、函数组件、Suspense 和错误边界、片段等)都有自己的数字 ID 作为 Fiber。

type

App 指的是此 Fiber 所代表的功能或类组件。

props

({name: "Tejas", age: 30}) 代表组件的输入 props 或函数的输入参数。

stateNode

此 Fiber 所代表的 App 组件的实例。

在组件树中的位置:returnchildsiblingindex 分别为 Fiber 调节器提供了一种“遍历树”的方式,识别父级、子级、兄弟节点和 Fiber 的索引。

Fiber 调节涉及比较当前 Fiber 树和下一个 Fiber 树,并确定哪些节点需要更新、添加或移除。

在协调过程中,Fiber 调节器为虚拟 DOM 中的每个 React 元素创建一个 Fiber 节点。有一个名为 createFiberFrom​Ty⁠peAndProps 的函数执行此操作。当然,另一种说“类型和 props”的方法是称其为 React 元素。正如我们所记得的,React 元素就是这样的:类型和 props:

{
  type: "div",
  props: {
    className: "container"
  }
}

此函数返回一个从元素派生的 Fiber。一旦创建了 Fiber 节点,Fiber 调节器使用一个 工作循环 更新用户界面。工作循环从根 Fiber 节点开始,沿着组件树向下工作,如果需要更新,则标记每个 Fiber 节点为“脏”。一旦到达末端,它会向上走,创建一个在内存中的新 DOM 树,与浏览器分离开来,最终会提交(刷新)到屏幕上。这由两个函数表示:beginWork 向下遍历,标记组件为“需要更新”,而 completeWork 向上遍历,构建一个与浏览器分离的真实 DOM 元素树。这种屏幕外渲染过程可以随时中断和丢弃,因为用户看不到它。

Fiber 架构受游戏世界中称为“双缓冲”的概念启发,在此概念中,下一个屏幕在屏幕外准备并然后“刷新”到当前屏幕。为了更好地理解 Fiber 架构,让我们在继续之前更详细地了解这个概念。

双缓冲

双缓冲是计算机图形和视频处理中用于减少闪烁和提高感知性能的技术。该技术涉及创建两个缓冲区(或内存空间)来存储图像或帧,并定期在它们之间切换以显示最终图像或视频。

以下是双缓冲在实践中的工作方式:

  1. 第一个缓冲区填充了初始图像或帧。

  2. 当第一个缓冲区正在显示时,第二个缓冲区会更新新数据或图像。

  3. 当第二个缓冲区准备好时,它将与第一个缓冲区交换并显示在屏幕上。

  4. 这个过程继续进行,第一个和第二个缓冲区定期交换以显示最终图像或视频。

通过使用双缓冲,可以减少闪烁和其他视觉伪影,因为最终图像或视频是连续显示而无中断或延迟。

Fiber 协调类似于双缓冲,当更新发生时,当前 Fiber 树被分叉并更新以反映给定用户界面的新状态。这称为渲染。然后,当备用树准备好并准确反映用户期望看到的状态时,它与当前树交换,类似于视频缓冲区在双缓冲中的交换。这称为协调的提交阶段提交

通过使用工作中的树,Fiber 协调器提供了一些好处:

  • 它可以避免对真实 DOM 进行不必要的更新,从而提高性能并减少闪烁。

  • 它可以在屏幕外计算 UI 的新状态,并且如果需要进行更新,则可以丢弃它。

  • 由于协调是在屏幕外进行的,因此甚至可以在不影响用户当前看到的内容的情况下暂停和恢复。

使用 Fiber 协调器,从 JSX 元素的用户定义树派生出两棵树:一棵包含“当前” Fibers 的树,另一棵包含工作中的 Fibers。让我们更深入地探讨这些树。

Fiber 协调

Fiber 协调分为两个阶段:渲染阶段和提交阶段。这种两阶段的方法,如图 4-1 所示,允许 React 在提交到 DOM 并向用户显示新状态之前随时丢弃渲染工作:它使渲染变得可中断。稍微详细一点,使渲染感觉可中断的是 React 调度器每隔 5 毫秒将执行控制权归还给主线程的启发式方法,这比 120 帧每秒的设备上的单帧还要小。

Fiber 协调器中的协调流程

图 4-1. Fiber 调和器中的调和流程

我们将在第七章中更深入地探讨调度器的细节,因为我们将探索 React 的并发特性。但现在,让我们走过调和的这些阶段。

渲染阶段

渲染阶段current树中发生状态更改事件时开始。React 通过递归地遍历每个 Fiber 并设置标志来在alternate树中进行更改(请参见图 4-2)。正如我们之前提到的,这在 React 内部的一个名为beginWork的函数中发生。

渲染阶段的调用顺序

图 4-2. 渲染阶段的调用顺序

beginWork

beginWork负责在工作中的树上设置有关 Fiber 节点是否应更新的标志。它设置了一堆标志,然后递归地转到下一个 Fiber 节点,做同样的事情,直到到达树的底部。当它完成时,我们开始在 Fiber 节点上调用completeWork并向上走。

beginWork的签名如下:

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null;

更多关于completeWork的内容稍后再说。现在,让我们深入研究beginWork。其签名包括以下参数:

current

与当前树中的 Fiber 节点对应的工作中进度节点的引用。这用于确定先前版本和树的新版本之间的变化,以及需要更新的内容。这个引用永远不会被改变,只用于比较。

workInProgress

正在更新的工作中树中的 Fiber 节点。如果更新并由函数返回,此节点将被标记为“脏”。

renderLanes

渲染通道是 React 的 Fiber 调和器中的一个新概念,取代了旧的renderExpirationTime。它比旧的renderExpirationTime概念更复杂,但它可以让 React 更好地优先处理更新,并使更新过程更高效。由于renderExpirationTime已被弃用,我们将在本章重点关注renderLanes

它本质上是一个位掩码,表示正在处理更新的“通道”。通道是一种根据其优先级和其他因素对更新进行分类的方式。当对 React 组件进行更改时,根据其优先级和其他特征,为其分配一个通道。更改的优先级越高,分配给它的通道就越高。

renderLanes值被传递给beginWork函数,以确保更新按正确顺序处理。分配给优先级较高通道的更新在分配给优先级较低通道的更新之前处理。这确保了高优先级更新,例如影响用户交互或可访问性的更新,尽快得到处理。

除了优先处理更新外,renderLanes 还帮助 React 更好地管理并发。React 使用一种称为“时间切片”的技术,将长时间运行的更新分解为更小、更易管理的块。renderLanes 在这个过程中发挥着关键作用,因为它允许 React 确定哪些更新应该首先处理,哪些更新可以推迟到以后。

渲染阶段完成后,将调用 getLanesToRetrySynchronouslyOnError 函数,以确定在渲染阶段是否创建了任何延迟更新。如果存在延迟更新,则 updateComponent 函数启动一个新的工作循环来处理它们,使用 beginWorkgetNextLanes 处理更新,并根据它们的通道对其进行优先处理。

我们将在第七章中深入探讨渲染通道,即关于并发的即将到来的章节。现在,让我们继续跟随 Fiber 协调流程。

completeWork

completeWork 函数将更新应用于工作中的 Fiber 节点,并构建一个表示应用程序更新状态的新实际 DOM 树。它在浏览器可见性平面之外构建这棵树。

如果宿主环境是浏览器,这意味着执行诸如 document.​crea⁠teElementnewElement.appendChild 之类的操作。请记住,这些元素树尚未附加到浏览器文档中:React 只是在屏幕外创建 UI 的下一个版本。在屏幕外进行这项工作使其可中断:React 正在计算的下一个状态尚未绘制到屏幕上,因此可以在安排了一些更高优先级的更新时丢弃它。这就是 Fiber 协调器的全部目的。

completeWork 的签名如下:

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null;

在这里,签名与 beginWork 的签名相同。

completeWork 函数与 beginWork 函数密切相关。虽然 beginWork 负责在 Fiber 节点上设置关于“应该更新”状态的标志,completeWork 负责构建一个新的树以提交到宿主环境。当 completeWork 达到顶部并构建了新的 DOM 树时,我们说“渲染阶段完成了”。现在,React 进入提交阶段。

提交阶段

提交阶段(见图 4-3)负责使用在渲染阶段对虚拟 DOM 所做的更改来更新实际 DOM。在提交阶段,新的虚拟 DOM 树被提交到宿主环境,工作中的树被当前树替换。在这个阶段,也会运行所有的效果。提交阶段分为两部分:变异阶段和布局阶段。

带有 FiberRootNode 的提交阶段

图 4-3. 带有 FiberRootNode 的提交阶段

变异阶段

突变阶段是提交阶段的第一部分,负责使用对虚拟 DOM 所做更改来更新实际 DOM。在此阶段,React 识别需要进行的更新,并调用名为 commitMutationEffects 的特殊函数。该函数将在渲染阶段对备用树中的 Fiber 节点所做的更新应用到实际 DOM 中。

这是 commitMutationEffects 可能实现的完整伪代码示例:

function commitMutationEffects(Fiber) {
  switch (Fiber.tag) {
    case HostComponent: {
      // Update DOM node with new props and/or children
      break;
    }
    case HostText: {
      // Update text content of DOM node
      break;
    }
    case ClassComponent: {
      // Call lifecycle methods like componentDidMount and componentDidUpdate
      break;
    }
    // ... other cases for different types of nodes
  }
}

在变异阶段,React 还调用其他特殊函数,例如 commitUn​mountcommitDeletion,以移除不再需要的 DOM 节点。

布局阶段

布局阶段是提交阶段的第二部分,负责计算 DOM 中更新节点的新布局。在此阶段,React 调用名为 commitLayoutEffects 的特殊函数。该函数计算 DOM 中更新节点的新布局。

类似于 commitMutationEffectscommitLayoutEffects 也是一个巨大的 switch 语句,根据正在更新的节点类型调用不同的函数。

一旦布局阶段完成,React 就成功更新了实际 DOM,以反映在渲染阶段对虚拟 DOM 所做的更改。

通过将提交阶段分为两部分(突变和布局),React 能够以高效的方式将更新应用于 DOM。通过与协调器中的其他关键函数协作,提交阶段有助于确保 React 应用程序在变得更复杂并处理更多数据时仍然快速、响应迅速且可靠。

效果

在 React 协调过程的提交阶段,副作用按特定顺序执行,具体取决于效果类型。提交阶段可能会发生多种类型的效果,包括:

放置效果

这些效果发生在将新组件添加到 DOM 时。例如,如果向表单添加新按钮,则会发生放置效果,将按钮添加到 DOM 中。

更新效果

这些效果发生在使用新属性或状态更新组件时。例如,如果按钮的文本更改,则会发生更新效果,以更新 DOM 中的文本。

删除效果

这些效果发生在组件从 DOM 中移除时。例如,如果从表单中移除按钮,则会发生删除效果,将按钮从 DOM 中移除。

布局效果

这些效果发生在浏览器有机会绘制之前,用于更新页面的布局。布局效果通过函数组件中的 useLayoutEffect 钩子和类组件中的 componentDidUpdate 生命周期方法管理。

与这些提交阶段效果相反,被动效果是用户定义的效果,计划在浏览器完成绘制后运行。被动效果使用 useEffect 钩子管理。

被动效应非常适合执行对页面初始渲染不关键的操作,例如从 API 获取数据或执行分析跟踪。由于被动效应不会在渲染阶段执行,因此不会影响计算将用户界面带入开发者所需状态所需的最小更新时间。

把所有内容显示在屏幕上

React 在两棵树之上维护一个名为FiberRootNode的数据结构,指向其中一棵树:currentworkInProgressFiberRootNode 是一个关键数据结构,负责管理协调过程的提交阶段。

当对虚拟 DOM 进行更新时,React 更新workInProgress树,同时保持当前树不变。这使得 React 能够继续渲染和更新虚拟 DOM,同时保持应用程序的当前状态。

当渲染过程完成时,React 调用一个名为commitRoot的函数,负责将对workInProgress树所做的更改提交到实际的 DOM。commitRootFiberRootNode的指针从当前树切换到workInProgress树,使workInProgress树成为新的当前树。

从此刻起,任何未来的更新都基于新的当前树。这个过程确保应用程序保持一致的状态,并且更新被正确高效地应用。

所有这些看起来在浏览器中瞬间发生。这是协调的工作。

章节复习

在本章中,我们探讨了 React 协调的概念,并了解了 Fiber 协调器。我们还学习了有关 Fibers 的知识,它们与强大的调度程序一起实现了高效和可中断的渲染。我们还学习了渲染阶段和提交阶段,这是协调过程的两个主要阶段。最后,我们了解了FiberRootNode:负责管理协调过程提交阶段的关键数据结构。

复习问题

让我们自问几个问题,以测试我们对本章概念的理解:

  1. 什么是 React 协调?

  2. Fiber 数据结构的作用是什么?

  3. 为什么我们需要两棵树?

  4. 当应用程序更新时会发生什么?

如果我们能回答这些问题,我们就能更好地理解 Fiber 协调器和 React 中的协调过程。

接下来

在第五章中,我们将探讨 React 中的常见问题并探索一些高级模式。我们将回答关于何时使用useMemo和何时使用React.lazy的问题。我们还将探讨如何使用useReduceruseContext来管理 React 应用程序中的状态。

我们到那里见!

第五章:常见问题和强大模式

现在我们更了解 React 在幕后的工作原理,让我们深入探讨它在编写 React 应用程序时的实际应用。在本章中,我们将探讨关于记忆化、惰性加载和性能的常见 React 问题的答案,以提升我们对这些概念的熟练程度。让我们开始谈论记忆化。

使用 React.memo 进行记忆化

记忆化是计算机科学中用于通过缓存先前计算的结果来优化函数性能的技术。简单来说,记忆化根据函数的输入存储其输出,因此如果再次使用相同的输入调用函数,则返回缓存的结果而不是重新计算输出。这显著减少了执行函数所需的时间和资源,特别是对于计算开销大或频繁调用的函数。记忆化依赖于函数的纯度,即对于给定的输入,函数可预测地返回相同的输出。一个纯函数的例子是:

function add(num1, num2) {
  return num1 + num2;
}

当给定参数 12 时,此 add 函数始终返回 3,因此可以安全地进行记忆化。如果函数依赖于像网络通信之类的副作用,则无法进行记忆化。例如考虑:

async function addToNumberOfTheDay(num) {
  const todaysNumber = await fetch("https://number-api.com/today")
    .then((r) => r.json())
    .then((data) => data.number);
  return num + todaysNumber;
}

给定输入 2,这个函数每天都会返回一个不同的结果,因此无法进行记忆化。也许这是一个愚蠢的例子,但通过这个例子,我们粗略理解了基本的记忆化。

在处理昂贵计算或渲染大量项目列表时,记忆化特别有用。考虑一个函数:

let result = null;
const doHardThing = () => {
  if (result) return result;

  // ...do hard stuff

  result = hardStuff;
  return hardStuff;
};

调用 doHardThing 一次可能需要几分钟来完成困难任务,但第二次、第三次、第四次或n次调用实际上不会执行困难任务,而是返回存储的结果。这就是记忆化的要点。

在 React 的上下文中,可以使用 React.memo 组件对功能组件应用记忆化。此函数返回一个新组件,仅当其 props 发生变化时才重新渲染。基于第四章,理想情况下现在我们知道“重新渲染”意味着重新调用函数组件。如果包装在 React.memo 中,该函数在协调期间不会再次调用,除非其 props 发生了变化。通过对功能组件进行记忆化,我们可以防止不必要的重新渲染,从而提高 React 应用程序的整体性能。

我们已经知道 React 组件是被调用以进行协调的函数,如第四章中讨论的那样。React 递归调用函数组件以创建 vDOM 树,然后用作协调的两个 Fiber 树的基础。有时,由于函数组件内的强烈计算或将其应用于 DOM 的放置或更新效果中的强烈计算,渲染(即调用组件函数)可能需要很长时间,如第四章中所述。这会减慢我们的应用程序并呈现迟滞的用户体验。记忆化是一种通过存储昂贵计算的结果并在传递给函数相同输入或组件相同 props 时返回它们的方式来避免这种情况。

要理解为什么React.memo很重要,让我们考虑一个常见的场景,我们有一个需要在组件中呈现的项目列表。例如,假设我们有一个待办事项列表,我们希望在组件中显示,如下所示:

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

现在,让我们将此组件组合到另一个根据用户输入重新渲染的组件中:

function App() {
  const todos = Array.from({ length: 1000000 });
  const [name, setName] = useState("");

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <TodoList todos={todos} />
    </div>
  );
}

在我们的App组件中,在每次input字段中的键入时,TodoList都将重新渲染:每次在协调期间状态变化发生时,TodoList函数组件都将使用其 props 重新调用。这可能会导致性能问题,但这是 React 工作的核心:当组件中发生状态更改时,从该组件向下的每个函数组件都会在协调期间重新调用。

如果待办事项列表很大,并且组件频繁重新渲染,这可能导致应用程序性能瓶颈。优化此组件的一种方法是使用React.memo进行记忆化:

const MemoizedTodoList = React.memo(function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
});

通过使用React.memo包装TodoList组件,React 仅在其 props 更改时重新渲染组件。周围的状态变化不会影响它。这意味着如果待办事项列表保持不变,组件将不会重新渲染,并且将使用其缓存的输出。这可以节省大量资源和时间,特别是当组件复杂且待办事项列表很大时。

让我们考虑另一个例子,我们有一个包含多个昂贵渲染的嵌套组件的复杂组件:

function Dashboard({ data }) {
  return (
    <div>
      <h1>Dashboard</h1>
      <UserStats user={data.user} />
      <RecentActivity activity={data.activity} />
      <ImportantMessages messages={data.messages} />
    </div>
  );
}

如果data属性经常变化,这个组件可能很昂贵,特别是如果嵌套组件也很复杂。我们可以使用React.memo来优化这个组件,以记忆化每个嵌套组件:

const MemoizedUserStats = React.memo(function UserStats({ user }) {
  // ...
});

const MemoizedRecentActivity = React.memo(function RecentActivity({
  activity,
}) {
  // ...
});

const MemoizedImportantMessages = React.memo(function ImportantMessages({
  messages,
}) {
  // ...
});

function Dashboard({ data }) {
  return (
    <div>
      <h1>Dashboard</h1>
      <MemoizedUserStats user={data.user} />
      <MemoizedRecentActivity activity={data.activity} />
      <MemoizedImportantMessages messages={data.messages} />
    </div>
  );
}

通过记忆化每个嵌套组件,React 仅会重新渲染已更改的组件,并且缓存的输出将用于未更改的组件。这可以显著提高 Dashboard 组件的性能并减少不必要的重新渲染。因此,我们可以看到 React.memo 是优化 React 函数组件性能的重要工具。对于渲染开销大或具有复杂逻辑的组件来说,这尤其有用。

熟悉 React.memo

让我们简要介绍一下 React.memo 的工作原理。当 React 中发生更新时,你的组件将与其上一个渲染返回的虚拟 DOM 结果进行比较。如果这些结果不同 — 即,如果其 props 发生变化 — 调和器会在元素已存在于宿主环境(通常是浏览器 DOM)中时运行更新效果,或者在其不存在时运行放置效果。如果其 props 相同,则组件仍会重新渲染并且 DOM 仍会更新。

这就是 React.memo 的好处所在:在组件的 props 在渲染之间保持不变时避免不必要的重新渲染。由于我们可以在 React 中做到这一点,这引发了一个问题:我们应该对多少内容进行记忆化,以及多频繁地记忆化呢?如果我们记忆化每个组件,整体应用可能会更快,对吗?

记忆化的组件仍然会重新渲染

React.memo 执行的是所谓的 比较,以确定 props 是否发生了更改。问题在于,虽然 JavaScript 中可以相当准确地比较标量类型,但无法对非标量类型进行比较。为了进行高质量的讨论,让我们简要地分析一下什么是标量和非标量类型,以及它们在比较操作中的行为。

标量(原始类型)

标量类型,也称为原始类型,是基础类型。这些类型表示单一的、不可分割的值。与数组和对象等更复杂的数据结构不同,标量没有属性或方法,并且它们天生是不可变的。这意味着一旦设置了标量值,就不能在不完全创建新值的情况下进行更改。JavaScript 有几种标量类型,包括数字、字符串、布尔值,以及像符号、BigInt、undefined 和 null 这样的其他类型。每种类型都有其独特的用途。例如,尽管数字很好理解,符号提供了一种创建唯一标识符的方式,而 undefined 和 null 允许开发人员在不同的上下文中表示值的缺失。在比较标量值时,我们通常对它们的实际内容或值感兴趣。

非标量(引用类型)

超越标量的简单性,我们遇到了非标量或引用类型。这些类型不存储数据,而是存储数据在内存中的引用或指针。这种区别至关重要,因为它影响了这些类型在代码中如何比较、操作和交互。在 JavaScript 中,最常见的非标量类型是对象和数组。对象允许我们使用键值对存储结构化数据,而数组提供有序的集合。在 JavaScript 中,函数也被视为引用类型。非标量的一个关键特征是多个引用可以指向相同的内存位置。这意味着通过一个引用修改数据可能会影响指向同一数据的其他引用。在比较时,非标量类型是通过它们的内存引用而不是它们的内容进行比较的。这有时会对不熟悉这种微妙差异的人产生意外结果。例如,两个具有相同内容但不同内存位置的数组在使用严格相等运算符进行比较时将被视为不相等。

考虑以下示例:

// Scalar types
"a" === "a"; // string; true
3 === 3; // number; true

// Non-scalar types
[1, 2, 3] === [1, 2, 3]; // array; false
{ foo: "bar"} === { foo: "bar" } // object; false

通过这种数组比较方式,数组、对象和其他非标量类型都是通过引用进行比较的:也就是说,左侧数组在计算机内存中的引用位置是否等于右侧的内存位置。这就是为什么比较返回false。对象也是如此。通过对象比较,我们在左侧和右侧分别在内存中创建了两个不同的对象——当然它们不相等,它们是存在于内存中两个不同位置的两个不同对象!它们只是具有相同的内容。

这就是为什么使用React.memo可能会很棘手。考虑一个名为List的函数组件,它以一个项数组作为 prop 并渲染它们:

const List = React.memo(function List({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
});

现在,想象一下在父组件中使用这个组件,并在每次父组件渲染时传递一个新的数组实例:

function ParentComponent({ allFruits }) {
  const [count, setCount] = React.useState(0);
  const favoriteFruits = allFruits.filter((fruit) => fruit.isFavorite);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <List items={favoriteFruits} />
    </div>
  );
}

每当点击Increment按钮时,ParentComponent都会重新渲染。尽管传递给List的项值没有变化,但每次都会创建一个新的数组实例,每次都是['apple', 'banana', 'cherry']。由于React.memo对 props 执行浅比较,它会将这个新数组实例视为与上一次渲染的数组不同的 prop,导致List组件不必要地重新渲染。

要修复这个问题,我们可以使用useMemo钩子来对数组进行记忆:

function ParentComponent({ allFruits }) {
  const [count, setCount] = React.useState(0);
  const favoriteFruits = React.useMemo(
    () => allFruits.filter((fruit) => fruit.isFavorite),
    []
  );

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <List items={favoriteFruits} />
    </div>
  );
}

现在,数组只创建一次,并在重新渲染时保持相同的引用,从而防止List组件不必要地重新渲染。

这个示例强调了在使用React.memo和非标量 props 时理解引用比较的重要性。如果不小心使用,我们可能会在优化之外无意中引入性能问题。

React.memo也经常被另一种非标量类型——函数所绕过。考虑以下情况:

<MemoizedAvatar
  name="Tejas"
  url="https://github.com/tejasq.png"
  onChange={() => save()}
/>

尽管 props 看起来没有改变或依赖于封闭状态,但如果我们比较 props,我们会看到以下内容:

"Tejas" === "Tejas"; // <- `name` prop; true
"https://github.com/tejasq.png" === "https://github.com/tejasq.png";

(() => save()) === (() => save()); // <- `onChange` prop; false

再次强调,这是因为我们通过引用比较函数。请记住,只要 props 不同,我们的组件就不会被记忆化。我们可以通过在 MemoizedAvatar 的父组件中使用 useCallback 钩子来解决这个问题:

const Parent = ({ currentUser }) => {
  const onAvatarChange = useCallback(
    (newAvatarUrl) => {
      updateUserModel({ avatarUrl: newAvatarUrl, id: currentUser.id });
    },
    [currentUser]
  );

  return (
    <MemoizedAvatar
      name="Tejas"
      url="https://github.com/tejasq.png"
      onChange={onAvatarChange}
    />
  );
};

现在我们可以确信 onAvatarChange 只有在其依赖数组中的某个东西(第二个参数)更改时才会改变。有了这个,我们的记忆化完全完成并可靠。这是记忆化具有函数作为 props 的组件的推荐方式。

很好!现在这意味着我们的记忆化组件将不会不必要地重新渲染。对吗?错了!我们还需要注意一件事。

这是一个指导方针,而不是一个规则

React 使用 React.memo 作为向其调和器的提示,表明如果它们的 props 保持不变,我们不希望我们的组件重新渲染。这个函数只是给 React 一个提示。最终,React 的操作由 React 决定。React.memo 一致地避免从父级开始的重新渲染级联,这是它的唯一目的。它不能保证组件永远不会重新渲染。回想一下本书开头的话,React 旨在成为我们用户界面的声明性抽象,在这里我们描述我们想要的是什么,React 找到最佳实现如何React.memo 是这个过程的一部分。

React.memo 不能保证始终避免重新渲染,因为 React 可能会因为各种原因重新渲染记忆化组件,比如组件树的更改或应用程序的全局状态的更改。

要更好地理解这一点,让我们看一些来自 React 源代码的代码片段。

首先,让我们看一下 React.memo 的实现:

function memo(type, compare) {
  return {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };
}

在这个实现中,React.memo 返回一个新对象,表示记忆化组件。该对象具有一个 $$typeof 属性,用于标识它为记忆化组件,一个 type 属性,引用原始组件,以及一个 compare 属性,指定用于记忆化的比较函数。

接下来,让我们看一下调和器中如何使用 React.memo

function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes
): null | Fiber {
  if (current === null) {
    const type = Component.type;
    if (
      isSimpleFunctionComponent(type) &&
      Component.compare === null &&
      // SimpleMemoComponent codepath doesn't resolve outer props either.
      Component.defaultProps === undefined
    ) {
      let resolvedType = type;
      if (__DEV__) {
        resolvedType = resolveFunctionForHotReloading(type);
      }
      // If this is a plain function component without default props,
      // and with only the default shallow comparison, we upgrade it
      // to a SimpleMemoComponent to allow fast path updates.
      workInProgress.tag = SimpleMemoComponent;
      workInProgress.type = resolvedType;
      if (__DEV__) {
        validateFunctionComponentInDev(workInProgress, type);
      }
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        resolvedType,
        nextProps,
        renderLanes
      );
    }
    if (__DEV__) {
      const innerPropTypes = type.propTypes;
      if (innerPropTypes) {
        // Inner memo component props aren't currently validated in createElement
        // We could move it there, but we'd still need this for lazy code path.
        checkPropTypes(
          innerPropTypes,
          nextProps, // Resolved props
          "prop",
          getComponentNameFromType(type)
        );
      }
      if (Component.defaultProps !== undefined) {
        const componentName = getComponentNameFromType(type) || "Unknown";
        if (!didWarnAboutDefaultPropsOnFunctionComponent[componentName]) {
          console.error(
            "%s: Support for defaultProps will be removed from components " +
              "in a future release. Use JavaScript default parameters instead.",
            componentName
          );
          didWarnAboutDefaultPropsOnFunctionComponent[componentName] = true;
        }
      }
    }
    const child = createFiberFromTypeAndProps(
      Component.type,
      null,
      nextProps,
      null,
      workInProgress,
      workInProgress.mode,
      renderLanes
    );
    child.ref = workInProgress.ref;
    child.return = workInProgress;
    workInProgress.child = child;
    return child;
  }
  if (__DEV__) {
    const type = Component.type;
    const innerPropTypes = type.propTypes;
    if (innerPropTypes) {
      // Inner memo component props aren't currently validated in createElement
      // We could move it there, but we'd still need this for lazy code path.
      checkPropTypes(
        innerPropTypes,
        nextProps, // Resolved props
        "prop",
        getComponentNameFromType(type)
      );
    }
  }
  // This is always exactly one child
  const currentChild = ((current.child: any): Fiber);
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes
  );
  if (!hasScheduledUpdateOrContext) {
    // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
    // Default to shallow comparison
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

这里是正在发生的事情的详细解释:

1. 初始检查

函数 updateMemoComponent 接受几个参数,包括当前和工作中的 Fiber,组件,新 props,以及渲染 lanes(如前所述,指示更新的优先级和时机)。初始检查(if (current === null))确定这是否是组件的初始渲染。如果 currentnull,则表示组件是首次挂载。

2. 类型和快速路径优化

然后,它检查组件是否是一个简单的函数组件,并且是否符合快速更新路径的条件,通过检查Component.compareComponent.defaultProps。如果满足这些条件,它将工作中的 Fiber 的标签设置为SimpleMemoComponent,表示这是一种更简单的组件类型,可以更高效地更新。

3. 开发模式检查

在开发模式下(__DEV__),该函数执行额外的检查,如验证属性类型和警告有关函数组件中已弃用功能(如defaultProps)的内容。

4. 创建新的 Fiber

如果是初始渲染,将使用createFiberFromTypeAndProps创建一个新的 Fiber。这个 Fiber 表示 React 渲染器的工作单元。它设置引用并返回子 Fiber(新的 Fiber)。

5. 更新现有的 Fiber

如果组件正在更新(current !== null),它执行类似的开发模式检查。然后,它通过浅比较(shallowEqual)或自定义比较函数(如果提供了)来比较旧的属性和新的属性,以判断组件是否需要更新。

6. 提前退出更新

如果属性相等且引用未更改,则可以通过bailoutOnAlreadyFinishedWork提前退出更新,这意味着此组件无需进一步的渲染工作。

7. 更新工作中的 Fiber

如果需要更新,函数将使用PerformedWork标记工作中的 Fiber,并基于当前子 Fiber 创建一个新的工作中子 Fiber,但带有新的 props。

总结一下,这个函数负责确定 memoized 组件(使用React.memo包装的组件)是否需要更新,或者是否可以跳过更新以优化性能。它处理初始渲染和更新,根据是否创建新的 Fiber 或更新现有的 Fiber 执行不同的操作。

以下部分告诉我们有关React.memo组件何时重新渲染或不重新渲染的条件:

没有先前的渲染(初始挂载)

如果current === null,则该组件是首次挂载,因此无法跳过渲染。将创建一个新的 Fiber 并返回,用于组件的渲染。

简单函数组件优化

如果组件是一个简单的函数组件(没有默认属性且没有自定义比较函数),React 将优化它为SimpleMemoComponent。这使得 React 可以使用快速更新路径,因为它可以假定组件仅依赖于其属性,而浅比较就足以确定是否应该更新。

比较函数

如果有先前的渲染,仅当比较函数返回 false 时,组件才会更新。如果提供了比较函数,则此比较函数可以自定义,否则默认为浅比较(shallowEqual)。如果比较函数确定新的 props 等于先前的 props,并且 ref 也相同,则组件不会重新渲染,函数将退出渲染过程。

开发中的默认 props 和 prop 类型

在开发模式(__DEV__)下,会对 defaultPropspropTypes 进行检查。在函数组件中使用 defaultProps 会在开发模式下触发警告,因为 React 的未来版本计划废弃函数组件上的 defaultProps。Prop 类型会被检查以进行验证。

退出条件

如果没有计划的更新或上下文变化(hasScheduledUpdateOr​Con⁠textfalse),比较函数认为旧 props 和新 props 相等,并且 ref 没有改变,那么函数将返回 bailoutOnAlreadyFinishedWork 的结果,从而有效地跳过重新渲染。

然而,如果有计划的上下文更新,组件将重新渲染,即使其 props 没有变化。这是因为上下文更新被认为是超出组件 props 范围之外的内容。状态变化、上下文变化和计划更新也可以触发重新渲染。

执行的工作标志

如果需要更新,则在 workInProgress Fiber 上设置 PerformedWork 标志,表示此 Fiber 在当前渲染期间已执行工作。

因此,如果使用 React.memo 组件,在旧 props 和新 props 之间的比较(使用自定义的比较函数或默认的浅比较)确定 props 相等,并且没有由于状态或上下文变化而计划更新,那么组件将不会重新渲染。如果 props 被确定为不同,或者存在状态或上下文变化,组件将重新渲染。

使用 useMemo 进行记忆化

React.memouseMemo 钩子都是记忆化的工具,但目的完全不同。React.memo 记忆化整个组件以防止其重新渲染。useMemo 则记忆化组件内的特定计算,以避免昂贵的重新计算并保持结果的一致引用。

让我们简要地深入了解 useMemo。考虑一个组件:

const People = ({ unsortedPeople }) => {
  const [name, setName] = useState("");
  const sortedPeople = unsortedPeople.sort((a, b) => b.age - a.age);

  // ... rest of the component
};

由于其排序操作,此组件可能会潜在地减慢我们的应用程序。排序操作的时间复杂度通常为平均情况和最坏情况下的 O(n log n)。例如,如果我们的列表有一百万人,每次渲染可能会涉及显著的计算开销。在计算机科学术语中,排序操作的效率很大程度上取决于项数 n,因此时间复杂度为 O(n log n)。

为了优化此过程,我们将使用 useMemo 钩子,避免在每次渲染时对 people 数组进行排序,尤其是在 unsortedPeople 数组未更改时。

组件的当前实现存在显著的性能问题。每次状态更新时(即在输入字段内每次按键时),组件都会重新渲染。如果输入一个包含 5 个字符的名称,而我们的列表包含 100 万人,组件将重新渲染 5 次。每次渲染时,它将对列表进行排序,这涉及到大约 100 万 × log(100 万)的操作,由于排序的时间复杂度。这相当于仅仅输入一个五字符的名称就需要执行许多百万次操作!幸运的是,可以使用useMemo钩子来减轻这种效率低下,确保只在unsortedPeople数组变化时执行排序操作。

让我们稍微改写一下这段代码片段:

const People = ({ unsortedPeople }) => {
  const [name, setName] = useState("");
  const sortedPeople = useMemo(
    // Spreading so we don't mutate the original array
    () => [...unsortedPeople].sort((a, b) => b.age - a.age),
    [unsortedPeople]
  );

  return (
    <div>
      <div>
        Enter your name:{" "}
        <input
          type="text"
          placeholder="Obinna Ekwuno"
          onChange={(e) => setName(e.target.value)}
        />
      </div>
      <h1>Hi, {name}! Here's a list of people sorted by age!</h1>
      <ul>
        {sortedPeople.map((p) => (
          <li key={p.id}>
            {p.name}, age {p.age}
          </li>
        ))}
      </ul>
    </div>
  );
};

好多了!我们将sortedPeople的值包装在传递给useMemo第一个参数的函数中。我们传递给useMemo的第二个参数表示一个值数组,如果变化,则重新排序数组。由于数组只包含unsortedPeople,因此它只会在人员列表发生更改时重新排序数组,而不是每当有人在姓名输入字段中键入时。这是如何使用useMemo避免不必要重新渲染的绝佳示例。

Memo 化的考虑

尽管将所有变量声明包装在useMemo组件内部可能很诱人,但这并不总是有利的。useMemo特别适用于记忆化计算密集型操作或保持对象和数组的稳定引用。对于标量值(如字符串、数字或布尔值),通常不需要使用useMemo。这是因为这些标量值在 JavaScript 中是通过它们的实际值传递和比较的,而不是通过引用。因此,每次设置或比较标量值时,你都在处理的是实际值,而不是可能会变化的内存位置的引用。

在这些情况下,加载和执行useMemo函数可能比其试图优化的实际操作更昂贵。例如,考虑以下示例:

const MyComponent = () => {
  const [count, setCount] = useState(0);
  const doubledCount = useMemo(() => count * 2, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled count: {doubledCount}</p>
      <button onClick={() => setCount((oldCount) => oldCount + 1)}>
        Increment
      </button>
    </div>
  );
};

在这个例子中,doubledCount变量使用useMemo钩子进行了记忆化。然而,由于count是一个标量值,没有必要进行记忆化。相反,我们可以直接在 JSX 中计算加倍后的计数:

const MyComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled count: {count * 2}</p>
      <button onClick={() => setCount((oldCount) => oldCount + 1)}>
        Increment
      </button>
    </div>
  );
};

现在,doubledCount不再是记忆化的,但组件仍然执行相同的计算,内存消耗更少,开销更小,因为我们没有导入和调用useMemo。这是一个很好的例子,说明在不必要时如何避免使用useMemo

然而,可能会出现额外的性能问题,因为我们在每次渲染时重新创建按钮上的onClick处理程序,因为它是通过内存引用传递的。但这真的是个问题吗?让我们仔细看看。

有人建议我们应该使用useCallback来记忆化onClick处理程序:

const MyComponent = () => {
  const [count, setCount] = useState(0);
  const doubledCount = useMemo(() => count * 2, [count]);
  const increment = useCallback(
    () => setCount((oldCount) => oldCount + 1),
    [setCount]
  );

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled count: {doubledCount}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

应该这样做吗?答案是否定的。在这里将增量函数进行记忆化并没有好处,因为<button>是一个浏览器原生元素,而不是可以调用的 React 函数组件。此外,它下面也没有更多的组件可以继续渲染。

此外,在 React 中,内置或“主机”组件(如divbuttoninput等)在处理 props 时与自定义组件略有不同,包括函数 props。

这是在内置组件上处理函数 props 时发生的情况:

直接传递

当您将函数 prop(如onClick处理程序)传递给内置组件时,React 会将其直接传递给实际的 DOM 元素。它不会为这些函数创建任何包装器或执行任何额外的工作。

然而,在onClick和基于事件的 props 的情况下,React 使用事件委托来处理事件,而不是直接将事件处理程序附加到 DOM 元素上。这意味着当你为像<button>这样的内置 React 元素提供一个onClick处理程序时,React 并不直接将onClick处理程序附加到按钮的 DOM 节点上。相反,React 在顶层使用单个事件侦听器监听所有事件。这个侦听器附加到文档的根部(或 React 应用程序的根部),并依赖事件冒泡来捕获来自各个元素的事件。这种方法是高效的,因为它减少了事件处理程序的内存占用和初始设置时间。React 不必为每个元素上每个事件实例附加和管理单独的处理程序,而是可以使用单个真实事件侦听器处理特定类型的所有事件(例如点击)。当事件发生时,React 会将其映射到适当的组件,并按照预期的传播路径调用您定义的处理程序。因此,即使事件是在顶层捕获的,它们的行为也会表现得好像它们是直接附加到特定元素上的。在编写 React 应用程序时,这种事件委托系统通常是透明的;您定义onClick处理程序的方式与直接附加处理程序时相同。然而,在幕后,React 正在为您优化事件处理。

重新渲染行为

由于函数 props 的变化不会导致内置组件重新渲染,除非它们是属于已经重新渲染的更高级组件的一部分。例如,如果父组件重新渲染并为内置组件提供一个新的函数作为 props,那么内置组件将重新渲染,因为其 props 已经改变。然而,这种重新渲染通常是快速的,通常不需要优化,除非分析显示它是一个问题。

没有函数的虚拟 DOM 比较

对于内置组件,虚拟 DOM 的比较是基于函数属性的标识。如果传递一个内联函数(例如 onClick={() => do​Something()}),每次组件渲染时都会是一个新函数,但 React 不会对函数进行深度比较以检测更改。新函数简单地替换旧函数在 DOM 元素上,因此我们在内置组件中获得了性能上的节省。

事件池化

React 使用事件池化来减少事件处理程序的内存开销。传递给事件处理程序的事件对象是一个合成事件,它是池化的,意味着它被重用于不同的事件,以减少垃圾回收的开销。

这与自定义组件形成了鲜明的对比。对于自定义组件,如果将一个新的函数作为属性传递,子组件可能会重新渲染,如果它是一个纯组件或者应用了记忆化(如React.memo),因为它检测到了属性的变化。但是对于宿主组件,React 不提供这种内置的记忆化,因为这样做在大多数情况下没有好处。React 输出的实际 DOM 元素没有记忆化的概念;它们只是在属性改变时更新为新的函数引用。

实际应用中,这意味着虽然您应该谨慎地将新的函数实例传递给可能昂贵重新渲染的自定义组件,但对于内置组件来说,这并不是那么值得关注。然而,始终注意您创建新函数和传递它们的频率,因为不必要的函数创建可能会导致垃圾回收的频繁触发,在非常高频率的更新场景中可能会成为性能问题。

因此,在这里 useCallback 根本没有帮助,实际上比没有使用还要糟糕:它不仅不提供任何价值,而且还给我们的应用程序增加了额外的开销。这是因为 useCallback 必须被导入、调用并传递依赖项,然后它必须比较依赖项,以确定是否应该重新计算函数。所有这些都具有运行时复杂性,可能对我们的应用程序造成更多的伤害而不是帮助。

useCallback 的一个很好的例子是什么?当你有一个组件可能经常重新渲染并且将回调传递给子组件时,特别是如果该子组件使用了React.memoshouldComponentUpdate进行了优化时,useCallback特别有用。回调函数的记忆化确保了当父组件重新渲染时,子组件不会不必要地重新渲染。

下面是 useCallback 有益的一个例子:

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

const ExpensiveComponent = React.memo(({ onButtonClick }) => {
  // This component is expensive to render and we want
  // to avoid unnecessary renders
  // We're just simulating something expensive here
  const now = performance.now();
  while (performance.now() - now < 1000) {
    // Artificial delay -- block for 1000ms
  }

  return <button onClick={onButtonClick}>Click Me</button>;
});

const MyComponent = () => {
  const [count, setCount] = useState(0);
  const [otherState, setOtherState] = useState(0);

  // This callback is memoized and will only change if count changes
  const incrementCount = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []); // Dependency array

  // This state update will cause MyComponent to rerender
  const doSomethingElse = () => {
    setOtherState((s) => s + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <ExpensiveComponent onButtonClick={incrementCount} />
      <button onClick={doSomethingElse}>Do Something Else</button>
    </div>
  );
};

在这个例子中:

  • ExpensiveComponent 是一个包裹在 React.memo 中的子组件,这意味着它仅在其 props 发生变化时重新渲染。这是一个你希望避免在每次渲染时传递新函数实例的情况。

  • MyComponent 有两个状态:countotherState

  • incrementCount是一个更新count的回调函数。它通过use​Call⁠back进行了记忆化,这意味着当MyComponent由于otherState的更改而重新渲染时,ExpensiveComponent不会重新渲染。

  • doSomethingElse函数改变了otherState,但不需要使用useCallback进行记忆化,因为它不会传递给Expensive​Compo⁠nent或任何其他子组件。

通过使用useCallback,我们确保ExpensiveComponentMyComponent重新渲染时不会不必要地重新渲染,原因与count无关。在子组件的渲染是一个耗时操作且你希望通过减少渲染次数来优化性能的情况下,这是有益的。

这是一个很好的例子,展示了如何使用useCallback来避免不必要的重新渲染,确保传递给昂贵组件的函数仅创建一次,并且在重新渲染过程中保持相同的引用。这可以防止昂贵组件的不必要重新渲染。useCallback本质上是函数的useMemo

让我们考虑另一个例子:

const MyComponent = () => {
  const dateOfBirth = "1993-02-19";
  const isAdult =
    new Date().getFullYear() - new Date(dateOfBirth).getFullYear() >= 18;

  if (isAdult) {
    return <h1>You are an adult!</h1>;
  } else {
    return <h1>You are a minor!</h1>;
  }
};

我们这里没有使用useMemo,主要是因为组件是无状态的。这是好事!但是如果有一些输入会触发类似这样的重新渲染,那该怎么办呢:

const MyComponent = () => {
  const [birthYear, setBirthYear] = useState(1993);
  const isAdult = new Date().getFullYear() - birthYear >= 18;

  return (
    <div>
      <label>
        Birth year:
        <input
          type="number"
          value={birthYear}
          onChange={(e) => setBirthYear(e.target.value)}
        />
      </label>
      {isAdult ? <h1>You are an adult!</h1> : <h1>You are a minor!</h1>}
    </div>
  );
};

现在我们在每次按键时重新计算new Date()。让我们用useMemo修复这个问题:

const MyComponent = () => {
  const [birthYear, setBirthYear] = useState(1993);
  const today = useMemo(() => new Date(), []);
  const isAdult = today.getFullYear() - birthYear >= 18;

  return (
    <div>
      <label>
        Birth year:
        <input
          type="number"
          value={birthYear}
          onChange={(e) => setBirthYear(e.target.value)}
        />
      </label>
      {isAdult ? <h1>You are an adult!</h1> : <h1>You are a minor!</h1>}
    </div>
  );
};

这是好的,因为today将在组件重新渲染时引用相同的对象,并且我们假设组件将始终在同一天重新渲染。

注意

在这里有一个小的边缘情况,如果用户在使用此组件时,他们的时钟在午夜时分归零,但这是一个我们现在可以忽略的罕见边缘情况。当然,当涉及到真实的生产代码时,我们会做得更好。

此示例引发了一个更大的问题:我们是否应该将isAdult的值包装在useMemo中?如果这样做会发生什么?答案是我们不应该,因为isAdult是一个标量值,除了内存分配之外不需要任何计算。我们确实多次调用.getFullYear,但我们信任 JavaScript 引擎和 React 运行时来处理性能问题。这是一个简单的赋值,没有进一步的计算,例如排序、过滤或映射。

在这种情况下,我们不应该使用useMemo,因为它更可能减慢我们应用程序的速度,而不是加快它,因为useMemo本身的开销,包括导入它、调用它、传递依赖项,然后比较依赖项以查看是否应重新计算值。所有这些都具有可能对我们应用程序造成更多伤害而不是帮助的运行时复杂性。相反,我们分配并信任 React 在必要时通过其自身的优化智能重新渲染我们的组件。

即使在面对重计算时,我们的应用程序现在也享受着更快的重新渲染性能好处——但我们能做得更多吗?在下一节中,让我们看看到目前为止我们已经涵盖的所有内容,可能在几年后都不会再重要,基于 React 团队正在致力于为我们自动考虑记忆化的一些激动人心的事物,使我们能够忘记细节,而专注于我们的应用程序。

忘记这一切

React Forget 是一个旨在自动化 React 应用程序中记忆化的新工具链,有可能使像 useMemouseCallback 这样的钩子变得多余。通过自动处理记忆化,React Forget 帮助优化组件重新渲染,改善用户体验(UX)和开发者体验(DX)。这种自动化将 React 的重新渲染行为从对象身份更改转变为语义值更改,无需深度比较,从而提升性能。React Forget 于 2021 年的 React Conf 中推出,目前在 Meta(Facebook、Instagram 等)内部已投入使用,并且“在内部已超出预期”。

如果有足够的兴趣,我们将在未来的版本中涵盖 React Forget。请通过社交媒体(尤其是 𝕏,之前是 Twitter)发帖并标记作者 @tejaskumar_ 来告知我们。

懒加载

当我们的应用程序增长时,我们积累了大量 JavaScript。然后,我们的用户下载这些庞大的 JavaScript 捆绑包——有时会达到数十兆字节——但实际上只使用其中的一小部分代码。这是一个问题,因为它减慢了用户的初始加载时间,也减慢了用户后续的页面加载时间,特别是当我们无法访问提供这些捆绑包的服务器,并且无法添加必需的缓存头等时。

运用过多 JavaScript 的一个主要问题是它会减慢页面加载时间。JavaScript 文件通常比其他类型的网页资产(如 HTML 和 CSS)要大,执行时需要更多处理时间。这可能导致页面加载时间变长,尤其是在较慢的互联网连接或旧设备上。

例如,请考虑以下加载大型 JavaScript 文件的代码片段:

<!DOCTYPE html>
<html>
  <head>
    <title>My Website</title>
    <script src="https://example.com/large.js"></script>
  </head>
  <body>
    <!-- Page content goes here -->
  </body>
</html>

在这个例子中,large.js 文件在页面的 <head> 部分加载,这意味着它将在页面上的任何其他内容之前执行。这可能导致页面加载时间变慢,尤其是在较慢的互联网连接或旧设备上。解决这个问题的常见方法是使用 async 属性异步加载 JavaScript 文件:

<!DOCTYPE html>
<html>
  <head>
    <title>My Website</title>
    <script async src="https://example.com/large.js"></script>
  </head>
  <body>
    <!-- Page content goes here -->
  </body>
</html>

在这个例子中,large.js 文件使用 async 属性进行异步加载。这意味着它将与页面上的其他资源并行下载,有助于提高页面加载时间。

发送太多 JavaScript 的另一个问题是可能增加数据使用量。JavaScript 捆绑包通常比其他类型的 Web 资产更大,这意味着它们需要通过网络传输更多的数据。对于拥有有限数据计划或较慢的互联网连接的用户来说,这可能是一个问题,因为它可能导致成本增加和页面加载时间变慢。

为了缓解这些问题,我们可以采取几个步骤来减少向用户传送的 JavaScript 量。一种方法是使用代码分割,只加载特定页面或功能所需的 JavaScript。这可以通过只加载必要的代码来帮助减少页面加载时间和数据使用量。

例如,考虑以下使用代码分割来加载特定页面所需的 JavaScript 的代码片段:

import("./large.js").then((module) => {
  // Use module here
});

在这个例子中,import 函数被用来在需要时异步加载 large.js 文件。这可以通过只加载必要的代码来帮助减少页面加载时间和数据使用量。

另一种方法是使用懒加载来延迟加载非关键 JavaScript,直到页面加载完成后再加载。这可以通过只在需要时加载非关键代码来帮助减少页面加载时间和数据使用量。

例如,考虑以下使用懒加载来延迟加载非关键 JavaScript 的代码片段:

<!DOCTYPE html>
<html>
  <head>
    <title>My Website</title>
  </head>
  <body>
    <!-- Page content goes here -->
    <button id="load-more">Load more content</button>
    <script>
      document.getElementById("load-more").addEventListener("click", () => {
        import("./non-critical.js").then((module) => {
          // Use module here
        });
      });
    </script>
  </body>
</html>

在这个例子中,import 函数被用来在“加载更多内容”按钮被点击时异步加载 non-critical.js 文件。这可以帮助减少页面加载时间和数据使用量,只在需要时加载非关键代码。

幸运的是,React 提供了一个解决方案,使这一切更加简单:使用 React.lazySuspense 进行懒加载。让我们看看如何使用它们来提高我们应用的性能。

懒加载是一种技术,允许我们仅在需要时加载组件,就像前面示例中的动态导入一样。对于具有许多不需要在初始渲染时加载的组件的大型应用程序非常有用。例如,如果我们有一个大型应用程序,侧边栏可以折叠,并且具有指向其他页面的链接列表,如果侧边栏在首次加载时是折叠状态,我们可能不希望加载完整的侧边栏。相反,我们可以在用户切换侧边栏时再加载它。

让我们来探索以下代码示例:

import Sidebar from "./Sidebar"; // 22MB to import

const MyComponent = ({ initialSidebarState }) => {
  const [showSidebar, setShowSidebar] = useState(initialSidebarState);

  return (
    <div>
      <button onClick={() => setShowSidebar(!showSidebar)}>
        Toggle sidebar
      </button>
      {showSidebar && <Sidebar />}
    </div>
  );
};

在这个例子中,如果 <Sidebar /> 是 22 MB 的 JavaScript。这是一大块需要下载、解析和执行的 JavaScript,在侧边栏首次渲染时如果折叠了,这并不是必需的。相反,只有在 showSidebar 为 true 时,我们才能使用 React.lazy 来懒加载组件。也就是说,只在需要时加载:

import { lazy, Suspense } from "react";
import FakeSidebarShell from "./FakeSidebarShell"; // 1kB to import

const Sidebar = lazy(() => import("./Sidebar"));

const MyComponent = ({ initialSidebarState }) => {
  const [showSidebar, setShowSidebar] = useState(initialSidebarState);

  return (
    <div>
      <button onClick={() => setShowSidebar(!showSidebar)}>
        Toggle sidebar
      </button>
      <Suspense fallback={<FakeSidebarShell />}>
        {showSidebar && <Sidebar />}
      </Suspense>
    </div>
  );
};

而不是静态导入./Sidebar,我们动态导入它——也就是说,我们将一个返回导入模块的 promise 的函数传递给lazy。动态导入返回一个 promise,因为模块可能不会立即可用。它可能需要首先从服务器下载。React 的lazy函数触发导入,只有在需要渲染底层组件(在这种情况下是Sidebar)时才会调用。这样,我们避免在实际渲染<Sidebar />之前将 22 MB 的侧边栏传输过来。

你可能还注意到了另一个新的导入:Suspense。我们使用Suspense来包装树中的组件。Suspense是一个组件,允许我们在 promise 正在解析时显示一个 fallback 组件(即侧边栏正在下载时)。在片段中,我们展示了一个轻量级版本的重型侧边栏作为 fallback 组件,而重型侧边栏正在下载中。这是在侧边栏加载时为用户提供即时反馈的好方法。

现在,当用户点击按钮切换侧边栏时,他们会看到一个“骨架 UI”,可以在侧边栏加载和渲染时进行定位。

使用 Suspense 实现更大的 UI 控制

React Suspense 的工作方式类似于 try/catch 块。你知道你可以从代码中的任何位置throw一个异常,然后在其他地方(甚至是不同的模块中)使用catch块捕获它吗?嗯,Suspense 以类似的方式(但不完全相同)工作。你可以在组件树的任何位置放置延迟加载和异步基元,然后在树中的任何上层使用Suspense组件捕获它们,即使你的 Suspense 边界在完全不同的文件中。

了解了这些,我们有能力选择在哪里显示我们的 22 MB 侧边栏的加载状态。例如,我们可以在侧边栏加载时隐藏整个应用程序——这是一个相当糟糕的想法,因为我们为了一个侧边栏而阻止了整个应用程序的信息传递给用户——或者我们可以仅显示侧边栏的加载状态。让我们看看如何做前者(即使我们不应该这样做)只是为了理解Suspense的能力:

import { lazy, Suspense } from "react";

const Sidebar = lazy(() => import("./Sidebar"));

const MyComponent = () => {
  const [showSidebar, setShowSidebar] = useState(false);

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <div>
        <button onClick={() => setShowSidebar(!showSidebar)}>
          Toggle sidebar
        </button>
        {showSidebar && <Sidebar />}
        <main>
          <p>Hello hello welcome, this is the app's main area</p>
        </main>
      </div>
    </Suspense>
  );
};

通过将整个组件包裹在Suspense中,我们在所有异步子组件(即 promises)解析之前渲染fallback。这意味着整个应用程序在侧边栏加载之前都是隐藏的。如果我们希望等到所有准备就绪后再向用户显示界面,这是很有用的,但在这种情况下可能不是最佳选择,因为用户会不知所措,无法与应用程序进行交互。

这就是为什么我们应该只使用Suspense来包装需要延迟加载的组件,就像这样:

import { lazy, Suspense } from "react";

const Sidebar = lazy(() => import("./Sidebar"));

const MyComponent = () => {
  const [showSidebar, setShowSidebar] = useState(false);

  return (
    <div>
      <button onClick={() => setShowSidebar(!showSidebar)}>
        Toggle sidebar
      </button>
      <Suspense fallback={<p>Loading...</p>}>
        {showSidebar && <Sidebar />}
      </Suspense>
      <main>
        <p>Hello hello welcome, this is the app's main area</p>
      </main>
    </div>
  );
};

Suspense 边界是一个非常强大的原语,可以修复布局移动,并使用户界面更加响应和直观。这是一个非常好的工具。此外,如果在fallback中使用高质量的骨架 UI,我们可以进一步指导用户了解正在发生的事情,并在我们的延迟加载组件加载时引导他们了解即将与之交互的界面。充分利用这一点是改善我们应用性能的好方法,并流畅地利用 React 的最佳方式。

接下来,我们将看看另一个许多 React 开发者问的有趣问题:何时应该使用useState而不是useReducer

useState 与 useReducer

React 暴露了两个用于管理状态的钩子:useStateuseReducer。这两个钩子都用于在组件中管理状态。它们之间的区别在于,useState更适合管理单个状态片段,而useReducer更适合管理更复杂的状态。让我们看看如何在组件中使用useState来管理状态:

import { useState } from "react";

const MyComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

在这个例子中,我们使用useState来管理一个单独的状态:count。但如果我们的状态稍微复杂呢?

import { useState } from "react";

const MyComponent = () => {
  const [state, setState] = useState({
    count: 0,
    name: "Tejumma",
    age: 30,
  });

  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Name: {state.name}</p>
      <p>Age: {state.age}</p>
      <button onClick={() => setState({ ...state, count: state.count + 1 })}>
        Increment
      </button>
    </div>
  );
};

现在我们可以看到,我们的状态变得有些复杂。我们有一个count、一个name和一个age。通过点击按钮,我们可以增加count,这会将状态设置为一个新对象,该对象具有与上一个状态相同的属性,但count增加了1。这在 React 中非常常见。但问题是,这可能会引发 bug 的可能性。例如,如果我们没有仔细地展开旧状态,可能会意外地覆盖一些状态的属性。

有趣的事实:useState在内部使用useReducer。你可以将useState看作是对useReducer的更高级抽象。实际上,如果你愿意,你可以用useReducer重新实现useState

说真的,你只需要这样做:

import { useReducer } from "react";

function useState(initialState) {
  const [state, dispatch] = useReducer(
    (state, newValue) => newValue,
    initialState
  );
  return [state, dispatch];
}

让我们看看相同的例子,但是使用useReducer实现:

import { useReducer } from "react";

const initialState = {
  count: 0,
  name: "Tejumma",
  age: 30,
};

const reducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
};

const MyComponent = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Name: {state.name}</p>
      <p>Age: {state.age}</p>
      <button onClick={() => dispatch({ type: "increment" })}>Increment</button>
    </div>
  );
};

现在,有些人会说这比useState多了一些冗余,许多人也会同意,但这在抽象堆栈中向下一个级别时是可以预料的:抽象级别越低,代码越冗长。毕竟,抽象的目的是在大多数情况下用语法糖替换复杂逻辑。既然我们可以用useState做与useReducer一样的事情,为什么不总是使用useState,因为它更简单呢?

使用useReducer回答这个问题有三个主要好处:

  • 它将更新状态的逻辑与组件分离。它的伴随reducer函数可以独立测试,并且可以在其他组件中重用。这是保持我们组件简洁和清晰,同时遵循单一责任原则的一个好方法。

    我们可以这样测试 reducer:

    describe("reducer", () => {
      test("should increment count when given an increment action", () => {
        const initialState = {
          count: 0,
          name: "Tejumma",
          age: 30,
        };
        const action = { type: "increment" };
        const expectedState = {
          count: 1,
          name: "Tejumma",
          age: 30,
        };
        const actualState = reducer(initialState, action);
        expect(actualState).toEqual(expectedState);
      });
    
      test("should return the same object when given an unknown action",
        () => {
        const initialState = {
          count: 0,
          name: "Tejumma",
          age: 30,
        };
        const action = { type: "unknown" };
        const expectedState = initialState;
        const actualState = reducer(initialState, action);
        expect(actualState).toBe(expectedState);
      });
    });
    

    在这个例子中,我们正在测试两种情况:一种是分派增量操作到 reducer,另一种是分派未知操作。

    在第一个测试中,我们创建了一个初始状态对象,其计数值为0,并创建了一个增量操作对象。我们期望结果状态对象中的计数值增加到1。我们使用toEqual匹配器来比较预期和实际的状态对象。

    在第二个测试中,我们创建了一个初始状态对象,其计数值为0,并创建了一个未知操作对象。然后我们期望结果状态对象与初始状态对象相同。我们使用toBe匹配器来比较预期和实际的状态对象,因为我们测试的是引用相等性。

    通过这种方式测试我们的 reducer,我们可以确保它在给定不同输入场景时行为正确并产生预期的输出。

  • 使用useReducer,我们的状态及其变化始终是明确的,有人会认为useState可能会通过多层 JSX 树来混淆组件的整体状态更新流程。

  • useReducer是一种事件源模型,意味着它可以用于建模应用程序中发生的事件,然后我们可以在某种审计日志中跟踪这些事件。这个审计日志可以用来重放应用程序中的事件以重现错误或实现时间旅行调试。它还能支持一些强大的模式,如撤销/重做、乐观更新和跟踪用户界面上常见用户操作的分析。

虽然useReducer是一个很好的工具,但并不总是必需的。事实上,对于大多数用例来说,它通常是过度复杂的。那么我们何时应该使用useState而不是useReducer?答案是取决于您的状态的复杂性。但希望通过所有这些信息,您能在应用程序中更明智地选择使用哪个。

Immer 和人性化设计

Immer,一个流行的 React 库,在处理应用程序中复杂状态管理时特别有用。当您的状态形状是嵌套或复杂的时候,传统的状态更新方法可能会变得冗长且容易出错。Immer 通过允许您使用可变草稿状态来处理这些复杂性,同时确保生成的状态是不可变的,有助于管理这些复杂性。

在 React 应用程序中,状态管理通常使用useStateuseReducer钩子处理。虽然useState适用于简单的状态,但useReducer更适用于复杂的状态管理,这正是 Immer 最擅长的地方。

当使用 useReducer 时,提供的 reducer 函数应当是纯的,并始终返回一个新的状态对象。当处理嵌套状态对象时,这可能导致冗长的代码。然而,通过在 use-immer 库中通过 useImmerReducer 将 Immer 集成到 useReducer 中,您可以编写看似直接改变状态的 reducer 函数,实际上是在 Immer 提供的草稿状态上操作。这样,您可以编写更简单、更直观的 reducer 函数:

import { useImmerReducer } from "use-immer";

const initialState = {
  user: {
    name: "John Doe",
    age: 28,
    address: {
      city: "New York",
      country: "USA",
    },
  },
};

const reducer = (draft, action) => {
  switch (action.type) {
    case "updateName":
      draft.user.name = action.payload;
      break;
    case "updateCity":
      draft.user.address.city = action.payload;
      break;
    // other cases...
    default:
      break;
  }
};

const MyComponent = () => {
  const [state, dispatch] = useImmerReducer(reducer, initialState);

  // ...
};

在此示例中,useImmerReducer 显著简化了 reducer 函数,允许直接赋值以更新嵌套状态属性,在传统的 reducer 中则需要使用 spreadObject.assign 操作。

此外,Immer 不仅仅局限于 useReducer。每当您拥有复杂的状态对象并希望在更新状态时确保不可变性时,您还可以在 useState 中使用它。Immer 提供了一个 produce 函数,您可以使用它根据当前状态和一组指令创建下一个状态:

import produce from "immer";
import { useState } from "react";

const MyComponent = () => {
  const [state, setState] = useState(initialState);

  const updateName = (newName) => {
    setState(
      produce((draft) => {
        draft.user.name = newName;
      })
    );
  };

  // ...
};

updateName 函数中,Immer 的 produce 函数接受当前的 state 和一个接收状态的 draft 的函数。在此函数内部,您可以像处理可变对象一样处理草稿,而 Immer 确保生成的状态是一个新的不可变对象。

Immer 在简化状态更新方面的能力,特别是在复杂或嵌套的状态结构中,使其成为 React 状态管理钩子的绝佳伴侣,促进更干净、更可维护和更少错误的代码。

强大的模式

软件设计模式是软件开发中常用的解决方案,用于解决重复出现的问题。它们提供了一种解决已被其他开发人员遇到并解决过的问题的方法,节省了软件开发过程中的时间和精力。通常以模板或指南的形式表达,用于创建可在不同情境中使用的软件。软件设计模式通常使用共同的词汇和符号描述,这使得它们更易于理解和开发人员之间的沟通。它们可以用于提高软件系统的质量、可维护性和效率。

软件设计模式之所以重要有几个原因:

可重用性

设计模式提供了解决常见问题的可重用方案,可以节省软件开发中的时间和精力。

标准化

设计模式提供了解决问题的标准方法,使开发人员更易于理解和相互沟通。

可维护性

设计模式提供了一种易于维护和修改的代码结构方式,可以提高软件系统的持久性。

效率

设计模式提供了解决常见问题的高效方案,可以提高软件系统的性能。

通常,软件设计模式是随着时间的推移自然而然地响应现实需求而产生的。这些模式解决了工程师遇到的具体问题,并成为“工程师工具库”中用于不同用例的工具。一个模式并不一定比另一个更差;每种模式都有其适用的场景。

大多数模式帮助我们确定理想的抽象级别:我们如何编写像美酒般经久的代码,而不是累积额外的状态和配置,以至于代码变得难以阅读和/或难以维护。这就是为什么在选择设计模式时常见的考虑因素是控制:我们将多少控制权交给用户,而我们的程序又处理了多少控制权。

接下来,让我们深入探讨一些流行的 React 模式,按照这些模式出现的大致时间顺序。

展示组件/容器组件

在 React 设计模式中,常见的一种模式是组合两个组件:展示组件容器组件。展示组件负责渲染 UI,而容器组件则处理 UI 的状态。以计数器为例,实现该模式的计数器如下所示:

const PresentationalCounter = (props) => {
  return (
    <section>
      <button onClick={props.increment}>+</button>
      <button onClick={props.decrement}>-</button>
      <button onClick={props.reset}>Reset</button>
      <h1>Current Count: {props.count}</h1>
    </section>
  );
};

const ContainerCounter = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(0);

  return (
    <PresentationalCounter
      count={count}
      increment={increment}
      decrement={decrement}
      reset={reset}
    />
  );
};

在这个例子中,我们有两个组件:PresentationalCounter(一个展示组件)和ContainerCounter(一个容器组件)。展示组件负责渲染 UI,而容器组件则处理状态。

为什么要用这种模式?这种模式非常有用,因为它遵循单一责任原则,极大地鼓励我们在应用程序中分离关注点,使其更具扩展性,通过更模块化、可重用甚至可测试。我们将组件的外观与功能分开,结果呢?PresentationalCounter可以在其他有状态容器之间传递,并保持我们想要的外观,而ContainerCounter可以被替换为另一个有状态容器,并保留我们想要的功能。

我们也可以单独对ContainerCounter进行单元测试,而使用 Storybook 或类似工具对PresentationalCounter进行可视化测试。我们也可以将更喜欢视觉工作的工程师或工程团队分配给PresentationalCounter,而将更喜欢数据结构和算法的工程师分配给ContainerCounter

由于这种解耦的方法,我们有更多的选择。因此,容器/展示组件模式因其灵活性而广受欢迎,并且今天仍在使用。然而,使用 hooks 的引入使得在组件中添加状态变得更加方便,而不需要容器组件来管理状态。

如今,在许多情况下,容器/展示模式可以用 hooks 替代。尽管我们仍然可以利用这种模式,即使使用 React Hooks,它在较小的应用程序中也很容易被认为是过度工程化。

高阶组件

根据Wikipedia 关于高阶函数的定义

在数学和计算机科学中,高阶函数(HOF)是至少满足以下条件之一的函数:接受一个或多个函数作为参数(即过程参数,是一个过程的参数,本身又是一个过程),或者以函数作为其结果返回。

在 JSX 世界中,高阶组件(HOC)基本上是这样的:一个接受另一个组件作为参数并返回一个由两者组合而成的新组件。HOC 非常适合跨组件共享行为

例如,许多 Web 应用程序需要异步从某些数据源请求数据。加载和错误状态通常是不可避免的,但我们有时会忘记在软件中考虑它们。如果我们手动为组件添加loadingdataerror props,那么我们遗漏几个的机会就更高了。让我们考虑一个基本的待办事项列表应用程序:

const App = () => {
  const [data, setData] = useState([]);

  useEffect(() => {
    fetch("https://mytodolist.com/items")
      .then((res) => res.json())
      .then(setData);
  }, []);

  return <BasicTodoList data={data} />;
};

此应用程序存在一些问题。我们没有考虑加载或错误状态。让我们来解决这个问题:

const App = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState([]);
  const [error, setError] = useState([]);

  useEffect(() => {
    fetch("https://mytodolist.com/items")
      .then((res) => res.json())
      .then((data) => {
        setIsLoading(false);
        setData(data);
      })
      .catch(setError);
  }, []);

  return isLoading ? (
    "Loading..."
  ) : error ? (
    error.message
  ) : (
    <BasicTodoList data={data} />
  );
};

糟糕。这很快变得非常混乱。此外,这只解决了一个组件的问题。我们需要为与外部数据源交互的每个组件添加这些状态(即加载、数据和错误)吗?这是一个横切关注点,而 HOC 正是它发挥作用的地方。

与其为每个与异步外部数据源交互的组件重复加载、错误和数据模式,我们可以使用一个 HOC 工厂来处理这些状态。让我们考虑一个解决这个问题的withAsync HOC 工厂:

const TodoList = withAsync(BasicTodoList);

withAsync将处理加载和错误状态,并在数据可用时渲染任何组件。让我们看看它的实现:

const withAsync = (Component) => (props) => {
  if (props.loading) {
    return "Loading...";
  }

  if (props.error) {
    return error.message;
  }

  return (
    <Component
      // Pass through whatever other props we give `Component`.
      {...props}
    />
  );
};

所以现在,当任何Component被传递到withAsync中时,我们得到一个新的组件,根据其 props 呈现适当的信息。这使我们的初始组件变得更加可行:

const TodoList = withAsync(BasicTodoList);

const App = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState([]);
  const [error, setError] = useState([]);

  useEffect(() => {
    fetch("https://mytodolist.com/items")
      .then((res) => res.json())
      .then((data) => {
        setIsLoading(false);
        setData(data);
      })
      .catch(setError);
  }, []);

  return <TodoList loading={isLoading} error={error} data={data} />;
};

不再有嵌套的三元操作符,而TodoList本身可以根据其是否正在加载、是否有错误或是否有数据来显示适当的信息。由于withAsync的 HOC 工厂处理这种横切关注点,我们可以用它包装任何与外部数据源交互的组件,并获得一个响应loadingerror props 的新组件。考虑一个博客:

const Post = withAsync(BasicPost);
const Comments = withAsync(BasicComments);

const Blog = ({ req }) => {
  const { loading: isPostLoading, error: postLoadError } = usePost(
    req.query.postId
  );
  const { loading: areCommentsLoading, error: commentLoadError } = useComments({
    postId: req.query.postId,
  });

  return (
    <>
      <Post
        id={req.query.postId}
        loading={isPostLoading}
        error={postLoadError}
      />
      <Comments
        postId={req.query.postId}
        loading={areCommentsLoading}
        error={commentLoadError}
      />
    </>
  );
};

export default Blog;

在这个示例中,PostComments都使用withAsync的高阶组件模式,分别返回更新后的BasicPostBasicComments版本,现在响应loadingerror属性。这种横切关注点的行为在withAsync的实现中进行了集中管理,因此我们在这里使用 HOC 模式时可以“免费”处理加载和错误状态。

然而,与展示性组件和容器组件类似,由于钩子提供了额外的便利性,HOCs 经常被抛弃。

组合 HOCs

将多个 HOC 组合在一起是 React 中的一种常见模式,它允许开发人员跨组件混合和匹配功能和行为。以下是一个示例,展示了如何组合多个 HOC:

假设你有两个 HOC,withLoggingwithUser

// withLogging.js
const withLogging = (WrappedComponent) => {
  return (props) => {
    console.log("Rendered with props:", props);
    return <WrappedComponent {...props} />;
  };
};

// withUser.js
const withUser = (WrappedComponent) => {
  const user = { name: "John Doe" }; // Assume this comes from some data source
  return (props) => <WrappedComponent {...props} user={user} />;
};

现在,假设你想要将这两个 HOC 组合在一起。一种方法是嵌套它们:

const EnhancedComponent = withLogging(withUser(MyComponent));

然而,嵌套的高阶组件(HOC)调用可能难以阅读和维护,特别是随着 HOC 数量的增加。想象一下这在你的应用程序中随着时间的推移会是怎样的情况:

const EnhancedComponent = withErrorHandler(
  withLoadingSpinner(
    withAuthentication(
      withAuthorization(
        withPagination(
          withDataFetching(
            withLogging(withUser(withTheme(withIntl(withRouting(MyComponent)))))
          )
        )
      )
    )
  )
);

哎呀!更好的方法是创建一个实用函数,将多个 HOC 组合成一个单一的 HOC。这样的实用函数可能看起来像这样:

// compose.js
const compose =
  (...hocs) =>
  (WrappedComponent) =>
    hocs.reduceRight((acc, hoc) => hoc(acc), WrappedComponent);

// Usage:
const EnhancedComponent = compose(withLogging, withUser)(MyComponent);

在这个compose函数中,使用reduceRight从右到左应用每个 HOC 到WrappedComponent上。这样一来,你可以将你的 HOC 列在一个平面列表中,这样更容易阅读和维护。compose函数是函数式编程中常见的实用工具,像 Redux 这样的库也提供了它们自己的compose实用函数用于此目的。

要重访我们之前的丑陋示例,使用新的compose实用程序后,它看起来会更像这样:

const EnhancedComponent = compose(
  withErrorHandler,
  withLoadingSpinner,
  withAuthentication,
  withAuthorization,
  withPagination,
  withDataFetching,
  withLogging,
  withUser,
  withTheme,
  withIntl,
  withRouting
)(MyComponent);

是不是更好了?缩减了缩进,增强了可读性,更易于维护。链中的每个 HOC 都包装了前一个 HOC 生成的组件,并为其添加了自己的行为。这样一来,你可以从简单组件和 HOC 构建复杂组件,每个组件都专注于一个单一关注点。这使得你的代码更模块化、更易于理解和测试。

HOC 与 hooks

自从引入 hooks 以来,HOC 已经变得不那么流行了。Hooks 提供了一种更方便的方法来向组件添加功能,并解决了一些 HOC 存在的问题。例如,HOC 可能会在错误使用时导致 ref 转发问题和不必要的重新渲染。Table 5-1 展示了两者之间的详细比较。

表格 5-1. HOC 与 hooks 的比较

功能 HOCs Hooks
代码重用 用于在多个组件之间共享逻辑非常出色。 用于从组件内提取和共享逻辑,或在相似组件之间共享逻辑理想。
渲染逻辑 可以控制包装组件的渲染。 不会直接影响渲染,但可以在函数组件中使用以管理与渲染相关的副作用。
属性操作 可以注入和操作属性,提供额外的数据或函数。 不能直接注入或操作属性。
状态管理 可以在包装组件之外管理和操作状态。 设计用于在函数组件内管理局部状态。
生命周期方法 可以封装与包装组件相关的生命周期逻辑。 useEffect 和其他 hooks 可以处理函数组件内的生命周期事件。
组合的便利性 可以一起组合,但如果管理不当,可能导致“包装地狱”。 易于组合,可以与其他钩子同时使用,而不增加组件的层次。
测试的便利性 由于需要额外的包装组件,测试可能会更复杂。 通常比高阶组件(HOCs)更容易测试,因为它们可以更容易地被隔离。
类型安全性 在 TypeScript 中,正确地进行类型定义可能会比较棘手,特别是在深度嵌套的高阶组件(HOCs)中。 更好的类型推断和在 TypeScript 中更容易进行类型定义。

表格 5-1 提供了高阶组件(HOCs)和钩子(hooks)的并排比较,展示了它们各自的优势和使用案例。虽然高阶组件(HOCs)仍然是一个有用的模式,但由于其简单性和易用性,钩子(hooks)通常在大多数使用案例中更受青睐。

从这个表格中,我们可以看出,在 React 中,高阶组件(HOCs)和钩子(hooks)对于在组件之间共享逻辑非常关键,然而它们针对的使用案例略有不同。高阶组件(HOCs)在跨多个组件共享逻辑方面表现出色,特别擅长控制包装组件的渲染和操作属性,提供额外的数据或函数给组件。它们可以管理包装组件之外的状态,并封装与包装组件相关的生命周期逻辑。然而,如果管理不当,特别是当许多高阶组件(HOCs)嵌套在一起时,它们可能导致“包装地狱”。这种嵌套也可能使测试变得更加复杂,而在 TypeScript 中的类型安全性可能会变得棘手,特别是在深度嵌套的高阶组件(HOCs)中。

另一方面,钩子(hooks)非常适合在组件内或类似组件之间提取和共享逻辑,而不会增加额外的组件层次,因此避免了“包装地狱”的场景。与高阶组件(HOCs)不同,钩子(hooks)不会直接影响渲染,并且不能直接注入或操作属性。它们设计用于在函数组件中管理局部状态,并使用 useEffect Hook 等处理生命周期事件。钩子(hooks)促进了组合的便利性,并且通常比高阶组件(HOCs)更容易测试,因为它们可以更容易地被隔离。此外,与 TypeScript 结合使用时,钩子(hooks)提供了更好的类型推断和更容易的类型定义,因此可以减少与类型错误相关的 bug。

尽管高阶组件(HOCs)和钩子(hooks)都提供了重用逻辑的机制,但钩子(hooks)在管理状态、生命周期事件和其他 React 特性方面提供了更直接、不那么复杂的方法。另一方面,高阶组件(HOCs)提供了一种更结构化的方式将行为注入组件中,在较大的代码库或尚未采用钩子的代码库中可能非常有益。每种方式都有其自身的优势,选择使用高阶组件(HOCs)还是钩子(hooks)将主要取决于项目的具体需求以及团队对这些模式的熟悉程度。

我们可以考虑一些我们经常使用的 React 高阶组件吗?是的,我们可以!React.memo就是我们在本章中刚介绍过的一个高阶组件!让我们再看一个例子:React.forwardRef。这是一个将引用转发给子组件的高阶组件。让我们看一个例子:

const FancyInput = React.forwardRef((props, ref) => (
  <input type="text" ref={ref} {...props} />
));

const App = () => {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return (
    <div>
      <FancyInput ref={inputRef} />
    </div>
  );
};

在这个例子中,我们使用React.forwardRef来将引用转发给FancyInput组件。这允许我们在父组件中访问输入元素的focus方法。这是 React 中的一个常见模式,也是如何使用高阶组件来解决那些难以用常规组件解决的问题的一个很好的例子。

渲染属性

由于我们已经讨论了 JSX 表达式,一个常见的模式是拥有那些作为函数的属性,这些函数接收组件范围的状态作为参数以促进代码重用。这里有一个简单的例子:

<WindowSize
  render={({ width, height }) => (
    <div>
      Your window is {width}x{height}px
    </div>
  )}
/>

注意到有一个名为render的属性,它接收一个函数作为值。这个属性甚至输出一些实际渲染的 JSX 标记。但为什么呢?原来WindowSize在内部做了一些魔法来计算用户窗口的大小,然后调用props.render来返回我们声明的结构,利用封闭状态来渲染窗口大小。

让我们来看一下WindowSize,以更深入地理解这一点:

const WindowSize = (props) => {
  const [size, setSize] = useState({ width: -1, height: -1 });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return props.render(size);
};

从这个例子中,我们可以看到WindowSize使用事件侦听器在每次调整大小时将一些内容存储在状态中,但组件本身是无头的:它对要呈现的 UI 没有任何意见。相反,它将控制权委托给渲染它的父组件,并调用提供的渲染属性,有效地将控制反转给其父组件来完成渲染工作。

这有助于依赖窗口大小进行渲染的组件在接收此信息时不重复使用useEffect块,并使我们的代码更加 DRY(不要重复自己)。这种模式不再那么流行,已经被 React Hooks 有效地取代。

作为函数的子组件

由于children是一个属性,一些人更喜欢完全放弃render属性名称,而是只使用children。这将使WindowSize的使用看起来像这样:

<WindowSize>
  {({ width, height }) => (
    <div>
      Your window is {width}x{height}px
    </div>
  )}
</WindowSize>

一些 React 作者更喜欢这样做,因为这更符合代码的意图:在这种情况下,WindowSize看起来有点像一个 React 上下文,我们显示的内容似乎就像是消费这个上下文的子元素。不过,React Hooks 消除了对这种模式的需求,所以可能要谨慎使用。

控制属性

React 中的控制属性模式是一种策略性的状态管理方法,扩展了受控组件的概念。它提供了一种灵活的机制来确定组件内部状态的管理方式。为了理解这一点,让我们首先了解受控组件。

受控组件是不维护自身内部状态的组件。相反,它们从父组件作为 prop 接收其当前值,这是它们状态的唯一真相来源。当状态应该更改时,受控组件使用回调函数(通常是 onChange)通知父组件。因此,父组件负责管理状态并更新受控组件的值。

例如,受控 <input> 元素如下所示:

function Form() {
  const [inputValue, setInputValue] = React.useState("");

  function handleChange(event) {
    setInputValue(event.target.value);
  }

  return <input type="text" value={inputValue} onChange={handleChange} />;
}

控制属性模式进一步扩展了受控组件的原则,允许组件可以通过 props 外部控制或者在内部管理自己的状态,提供可选的外部控制。遵循控制属性模式的组件接受状态值和更新该状态的函数作为 props。这种双重能力使得父组件可以选择性地控制子组件的状态,但也允许子组件在未受控时独立操作。

控制属性模式的一个示例是一个切换按钮,可以由其父组件控制或管理其自身状态:

function Toggle({ on, onToggle }) {
  const [isOn, setIsOn] = React.useState(false);

  const handleToggle = () => {
    const nextState = on === undefined ? !isOn : on;
    if (on === undefined) {
      setIsOn(nextState);
    }
    if (onToggle) {
      onToggle(nextState);
    }
  };

  return (
    <button onClick={handleToggle}>
      {on !== undefined ? on : isOn ? "On" : "Off"}
    </button>
  );
}

Toggle 组件中,isOn 表示内部状态,而 on 是外部控制属性。如果父组件提供了 on prop,则组件可以以受控模式运行。如果没有,则退回到其内部状态 isOnonToggle prop 是一个回调,允许父组件响应状态变化,提供父组件与 Toggle 组件状态同步的机会。

此模式增强了组件的灵活性,提供了受控和非受控两种操作模式。它允许父组件在必要时接管控制,同时在未明确控制时让组件保持对其自身状态的自治。

属性集合

我们经常需要打包一整套属性在一起。例如,在创建拖放用户界面时,有很多属性需要管理:

onDragStart

告知浏览器在用户开始拖动元素时应执行的操作

onDragOver

识别一个放置区域

onDrop

当元素被拖放到此元素上时执行一些代码

onDragEnd

当元素拖动完成时,告知浏览器应执行的操作

此外,默认情况下,数据/元素不能被放置在其他元素中。要允许元素被放置到另一个元素上,我们必须阻止元素的默认处理。这可以通过在可能的放置区域的 onDragOver 事件上调用 event.preventDefault 方法来实现。

由于这些属性通常一起使用,并且 onDragOver 通常默认为 event => { event.preventDefault(); moreStuff(); },我们可以将这些属性集合在一起,并在各种组件中重复使用,如下所示:

export const droppableProps = {
  onDragOver: (event) => {
    event.preventDefault();
  },
  onDrop: (event) => {},
};

export const draggableProps = {
  onDragStart: (event) => {},
  onDragEnd: (event) => {},
};

现在,如果我们有一个期望行为类似放置区域的 React 组件,我们可以像这样在其上使用属性集合:

<Dropzone {...droppableProps} />

这是属性集合模式,它使许多属性可重复使用。在可访问组件中广泛使用,包括许多aria-*属性。然而,仍然存在一个问题,即如果我们编写一个自定义的onDragOver属性并覆盖该集合,我们将失去使用集合时的event.preventDefault调用。

这可能会导致意外行为,从而无法将组件放置在Dropzone上:

<Dropzone
  {...droppableProps}
  onDragOver={() => {
    alert("Dragged!");
  }}
/>

幸运的是,我们可以使用属性获取器来解决这个问题。

属性获取器

属性获取器本质上是将属性集合与自定义属性合并。从我们的示例中,我们希望在droppableProps集合的onDragOver处理程序中保留event.preventDefault调用,并同时添加自定义的alert("Dragged!");调用。我们可以使用属性获取器来实现这一点。

首先,我们将把droppableProps集合改为属性获取器:

export const getDroppableProps = () => {
  return {
    onDragOver: (event) => {
      event.preventDefault();
    },
    onDrop: (event) => {},
  };
};

到目前为止,除了我们之前导出属性集合的位置,我们现在导出一个返回属性集合的函数。这就是属性获取器。由于这是一个函数,它可以接收参数,比如自定义的onDragOver。我们可以像这样将自定义的onDragOver与默认的组合起来:

const compose =
  (...functions) =>
  (...args) =>
    functions.forEach((fn) => fn?.(...args));

export const getDroppableProps = ({
  onDragOver: replacementOnDragOver,
  ...replacementProps
}) => {
  const defaultOnDragOver = (event) => {
    event.preventDefault();
  };

  return {
    onDragOver: compose(replacementOnDragOver, defaultOnDragOver),
    onDrop: (event) => {},
    ...replacementProps,
  };
};

现在,我们可以像这样使用属性获取器:

<Dropzone
  {...getDroppableProps({
    onDragOver: () => {
      alert("Dragged!");
    },
  })}
/>

这个自定义的onDragOver将与我们的默认onDragOver组合在一起,两件事都会发生:event.preventDefault()alert("Dragged!")。这就是属性获取器模式。

复合组件

有时,我们会有类似这样的手风琴组件:

<Accordion
  items={[
    { label: "One", content: "lorem ipsum for more, see https://one.com" },
    { label: "Two", content: "lorem ipsum for more, see https://two.com" },
    { label: "Three", content: "lorem ipsum for more, see https://three.com" },
  ]}
/>

这个组件的目的是渲染类似于此的列表,只是在任何给定时间只能打开一个项目:

  • One

  • Two

  • Three

此组件的内部工作将如下所示:

export const Accordion = ({ items }) => {
  const [activeItemIndex, setActiveItemIndex] = useState(0);

  return (
    <ul>
      {items.map((item, index) => (
        <li onClick={() => setActiveItemIndex(index)} key={item.id}>
          <strong>{item.label}</strong>
          {index === activeItemIndex && i.content}
        </li>
      ))}
    </ul>
  );
};

但如果我们想在项目TwoThree之间添加一个自定义分隔符怎么办?如果我们想让第三个链接变成红色或其他颜色?我们可能会诉诸某种类型的 hack,比如这样:

<Accordion
  items={[
    { label: "One", content: "lorem ipsum for more, see https://one.com" },
    { label: "Two", content: "lorem ipsum for more, see https://two.com" },
    { label: "---" },
    { label: "Three", content: "lorem ipsum for more, see https://three.com" },
  ]}
/>

但那看起来不符合我们的期望。所以我们可能会在当前的 hack 基础上做更多的 hack:

export const Accordion = ({ items }) => {
  const [activeItemIndex, setActiveItemIndex] = useState(0);

  return (
    <ul>
      {items.map((item, index) =>
        item === "---" ? (
          <hr />
        ) : (
          <li onClick={() => setActiveItemIndex(index)} key={item.id}>
            <strong>{item.label}</strong>
            {index === activeItemIndex && i.content}
          </li>
        )
      )}
    </ul>
  );
};

现在这段代码能让我们自豪吗?我不确定。这就是为什么我们需要复合组件:它们允许我们组合一组相互连接但具有独立状态的组件,但它们可以被原子化地呈现,从而使我们能够更好地控制元素树。

使用复合组件模式表达的手风琴将如下所示:

<Accordion>
  <AccordionItem item={{ label: "One" }} />
  <AccordionItem item={{ label: "Two" }} />
  <AccordionItem item={{ label: "Three" }} />
</Accordion>

如果我们决定探索如何在 React 中实现这种模式,我们可能会考虑两种方式:

  • 使用React.cloneElement处理子组件

  • 使用 React 上下文

React.cloneElement被视为遗留 API,因此让我们尝试使用 React 上下文来实现这一点。首先,我们将从每个手风琴部分都可以读取的上下文开始:

const AccordionContext = createContext({
  activeItemIndex: 0,
  setActiveItemIndex: () => 0,
});

然后,我们的Accordion组件将仅向其子组件提供上下文:

export const Accordion = ({ items }) => {
  const [activeItemIndex, setActiveItemIndex] = useState(0);

  return (
    <AccordionContext.Provider value={{ activeItemIndex, setActiveItemIndex }}>
      <ul>{children}</ul>
    </AccordionContext.Provider>
  );
};

现在,让我们创建离散的AccordionItem组件,以便作为上下文的消费者并对其做出响应:

export const AccordionItem = ({ item, index }) => {
  // Note we're using the context here, not state!
  const { activeItemIndex, setActiveItemIndex } = useContext(AccordionContext);

  return (
    <li onClick={() => setActiveItemIndex(index)} key={item.id}>
      <strong>{item.label}</strong>
      {index === activeItemIndex && i.content}
    </li>
  );
};

现在我们有了多个部分组成的 Accordion,使其成为一个复合组件,我们对 Accordion 的使用从这里开始:

<Accordion
  items={[
    { label: "One", content: "lorem ipsum for more, see https://one.com" },
    { label: "Two", content: "lorem ipsum for more, see https://two.com" },
    { label: "Three", content: "lorem ipsum for more, see https://three.com" },
  ]}
/>

到这里:

<Accordion>
  {items.map((item, index) => (
    <AccordionItem key={item.id} item={item} index={index} />
  ))}
</Accordion>

这样做的好处是,我们有了更多的控制权,同时每个 AccordionItem 都知道 Accordion 的更大状态。因此,如果我们想在 TwoThree 之间包含一条水平线,我们可以在 map 中跳出并手动操作:

<Accordion>
  <AccordionItem key={items[0].id} item={items[0]} index={0} />
  <AccordionItem key={items[1].id} item={items[1]} index={1} />
  <hr />
  <AccordionItem key={items[2].id} item={items[2]} index={2} />
</Accordion>

或者,我们可以做一些更混合的东西,比如:

<Accordion>
  {items.slice(0, 2).map((item, index) => (
    <AccordionItem key={item.id} item={item} index={index} />
  ))}
  <hr />
  {items.slice(2).map((item, index) => (
    <AccordionItem key={item.id} item={item} index={index} />
  ))}
</Accordion>

这就是复合组件的好处:它们将渲染的控制权反转给父组件,同时在子组件之间保留了上下文状态感知。同样的方法可以用于标签 UI,其中标签知道当前标签状态,同时具有不同层次的元素嵌套。

另一个好处是,这种模式促进了关注点分离,有助于应用程序随着时间的推移显著扩展。

状态减少器

React 中的状态减少器模式是由 Kent C. Dodds(@kentcdodds)发明和推广的,他是 React 领域中最杰出和熟练的工程师和教育者之一,是该领域真正享誉世界的专家。这种模式提供了一个强大的方式来创建灵活和可定制的组件。让我们用一个现实世界的例子来说明这个概念:一个切换按钮组件。这个例子将演示如何增强基本的切换组件,使消费者能够定制其状态逻辑,在某些业务原因下禁用特定日期的切换按钮。

首先,我们使用 useReducer 钩子创建一个基本的切换组件。该组件维护其自己的状态,确定切换是否处于 OnOff 位置。初始状态设置为 false,表示处于 Off 状态:

import React, { useReducer } from "react";

function toggleReducer(state, action) {
  switch (action.type) {
    case "TOGGLE":
      return { on: !state.on };
    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
}

function Toggle() {
  const [state, dispatch] = useReducer(toggleReducer, { on: false });

  return (
    <button onClick={() => dispatch({ type: "TOGGLE" })}>
      {state.on ? "On" : "Off"}
    </button>
  );
}

要实现状态减少器模式,Toggle 组件被修改为接受一个 stateReducer 属性。这个属性允许定制或扩展组件的内部状态逻辑。组件的 internalDispatch 函数将内部减少器逻辑与 stateReducer 属性提供的外部减少器结合起来:

function Toggle({ stateReducer }) {
  const [state, dispatch] = useReducer(
    (state, action) => {
      const nextState = toggleReducer(state, action);
      return stateReducer(state, { ...action, changes: nextState });
    },
    { on: false }
  );

  return (
    <button onClick={() => internalDispatch({ type: "TOGGLE" })}>
      {state.on ? "On" : "Off"}
    </button>
  );
}

Toggle.defaultProps = {
  stateReducer: (state, action) => state, // Default reducer does nothing special
};

从这段代码片段中,我们可以看到 stateReducer 属性用于定制组件的内部状态逻辑。stateReducer 函数被调用时传入当前状态和动作对象;然而,我们在动作对象中添加了一个额外的元数据属性:changes。这个 changes 属性包含了组件的下一个状态,该状态是由内部减少器计算得出的。这允许外部减少器访问组件的下一个状态,并基于此做出决策。

让我们看看Toggle组件如何利用基于这种模式的自定义行为。在下面的例子中,App组件使用了Toggle,但提供了一个自定义的stateReducer。这个 reducer 包含逻辑,防止在周三将开关关闭,因为在这个应用程序的位置,周三是一个普遍的“不能关”的日子。这说明了状态 reducer 模式如何允许在不改变组件本身的情况下灵活修改组件行为:

function App() {
  const customReducer = (state, action) => {
    // Custom logic: prevent toggle off on Wednesdays
    if (new Date().getDay() === 3 && !changes.on) {
      return state;
    }
    return action.changes;
  };

  return <Toggle stateReducer={customReducer} />;
}

通过这个例子,我们看到了状态 reducer 模式在创建高度灵活和可重用组件中的威力。通过允许外部逻辑与组件的内部状态管理集成,我们可以满足各种行为和用例的需求,增强组件的实用性和多功能性。

哎呀!这是一章啊!让我们总结一下我们学到了什么。

章节复习

在本章中,我们讨论了 React 的各个方面,包括记忆化、延迟加载、reducers 和状态管理。我们探讨了不同方法在这些主题上的优势和潜在缺点,以及它们如何影响 React 应用程序的性能和可维护性。

我们首先讨论了 React 中的记忆化及其优化组件渲染的好处。我们看了看React.memo函数及其如何用于防止组件不必要的重新渲染。我们还检查了一些记忆化可能遇到的问题,例如陈旧状态和需谨慎管理依赖关系的需要。

接下来,我们谈到了 React 中的延迟加载及其如何延迟加载某些组件或资源直到它们真正需要的时候。我们看了看React.lazySuspense组件及其如何在 React 应用程序中实现延迟加载。我们还讨论了延迟加载的权衡,例如增加的复杂性和潜在的性能问题。

接着,我们转向 reducers 及其在 React 中用于状态管理的使用。我们探讨了useStateuseReducer之间的区别,并讨论了使用集中式 reducer 函数来管理状态更新的优势。

在我们的讨论中,我们使用了来自我们自己实现的代码示例来说明我们讨论的概念。我们探讨了这些示例在内部运行的方式以及它们如何影响 React 应用程序的性能和可维护性。

通过使用代码示例和深入的解释,我们深入了解了这些主题及其在实际 React 应用程序中的应用。

复习问题

让我们自问一些问题,以测试我们对本章学习的概念的理解:

  1. 什么是 React 中的记忆化,它如何用于优化组件渲染?

  2. 使用useReducer进行 React 状态管理有什么优势,它与useState有什么不同?

  3. 如何利用 React.lazySuspense 组件在 React 应用中实现懒加载?

  4. 使用 memoization 在 React 中可能出现的一些潜在问题是什么,以及如何减轻这些问题?

  5. useCallback 钩子如何用于在 React 组件中将函数作为 props 进行记忆化?

接下来

在下一章中,我们将探讨 React 在服务器端的应用——深入研究服务器端渲染、其优势和权衡、水合、框架等等。到时见!

第六章:服务器端的 React

React 自其创立以来发生了很大的变化。尽管它起初是一个客户端库,但随着时间的推移,基于服务器端的渲染(SSR)的需求因我们将在本章中了解的原因而不断增长。我们将一起探索服务器端的 React,并理解它与仅客户端 React 的区别,以及如何利用它来提升我们的 React 应用程序。

正如我们在早期章节中讨论的那样,React 最初由 Meta 开发,以满足高效和可扩展用户界面的需求。在第三章中,我们看到它通过虚拟 DOM 实现这一点,这使得开发人员能够轻松地创建和管理 UI 组件。React 的客户端方法解锁了全网快速响应的用户体验。然而,随着 Web 的不断发展,客户端渲染的局限性变得越来越明显。

客户端渲染的局限性

自 2013 年首次作为开源软件发布以来,使用 React 构建用户界面。最终,这种方法的一些局限性开始显现。这些局限性最终导致我们将更多的关注点转移到服务器端。

SEO

客户端渲染的一个显著局限性是,搜索引擎爬虫可能无法正确索引内容,因为其中一些不执行 JavaScript,或者那些执行 JavaScript 的爬虫可能不会按照我们的预期执行。

考虑到各种搜索引擎爬虫的多样实现,以及许多爬虫是专有且对公众不可知的,这使得仅客户端渲染在特定网站或应用程序的覆盖范围方面显得有些值得怀疑。

话虽如此,2015 年的一篇文章(来自 Search Engine Land)描述了一些测试,以测试各种搜索引擎如何处理仅客户端应用程序,以下是它们提到的内容:

我们进行了一系列测试,验证了 Google 能够执行和索引多种实现的 JavaScript。我们还确认了 Google 能够渲染整个页面并读取 DOM,从而索引动态生成的内容。

本文发现,截至撰写本文时,Google 和 Bing 已经足够先进,能够索引仅客户端的网站,但最终这只是一个研究项目,在一个广阔而不可知的专有海洋中仅仅是一个研究项目。

因此,尽管仅客户端应用程序在现代搜索引擎中可能表现良好,但没有基于服务器的对应方案存在固有风险。在传统的 Web 应用程序中,当用户或搜索引擎爬虫请求页面时,服务器会渲染页面的 HTML 并将其返回。HTML 包含所有内容、链接和数据,使得搜索引擎爬虫可以轻松读取和索引内容,用于搜索引擎结果,因为页面的所有内容仅仅是文本,即标记。

然而,在使用像 React 这样的库或框架构建的客户端渲染应用程序中,服务器返回一个几乎为空的 HTML 文件,其唯一任务是从同一服务器或备用服务器上的单独 JavaScript 文件中加载 JavaScript。然后,JavaScript 文件在浏览器中下载和执行,动态渲染页面内容。这种方法提供了流畅的用户体验,类似于本地应用程序,但在搜索引擎优化(SEO)和性能方面存在缺点:在第一次请求时,我们没有下载任何有用的东西给人类读者,而是必须在页面加载后立即进行另一个请求,获取将为整个站点提供动力的 JavaScript。这被称为网络瀑布效应。

因此,客户端仅渲染的另一个缺点是性能问题。让我们来谈谈这个问题。

性能

客户端渲染的应用程序可能会因性能问题而受到影响,特别是在网络较慢或设备性能较弱的情况下。在渲染内容之前需要下载、解析和执行 JavaScript,这可能导致内容渲染的显著延迟。这个“交互时间”是一个关键指标,直接影响用户参与度和跳出率(用户放弃页面的速率)。如果加载时间过长,用户可能会离开页面,这种行为进一步会对页面的 SEO 排名产生负面影响。

此外,如果设备性能低,CPU 可用性较少,仅客户端渲染还会导致糟糕的用户体验。这是因为设备可能没有足够的处理能力来快速执行 JavaScript,导致应用程序运行缓慢且响应迟钝。这可能会导致用户感到沮丧和不良的用户体验。如果我们在服务器上执行这些 JavaScript,并向客户端发送最少的数据或标记,那么低功率客户端不需要做太多工作,因此用户体验会更好。

从更广泛的角度来看,客户端渲染应用程序中的 SEO 和性能问题突显了遵循 web 标准和最佳实践的重要性。它们也强调了服务器端渲染或静态网站生成作为提供内容的可靠替代方案的需求,尤其是对于内容丰富的站点或应用程序而言。

渐进增强原则,即向所有浏览器传递基本内容和功能,而将高级特性作为增强功能,与这些选择非常契合。通过在服务器端渲染核心内容,确保所有用户和搜索引擎都可以访问基本内容和功能,而不受 JavaScript 执行的影响。然后,客户端 JavaScript 可以通过增加交互性、更丰富的动画和其他高级功能,增强用户体验。仅将整个体验设计为仅基于客户端 JavaScript 是没有意义的,因为这不是 Web 的原始设计。JavaScript 的角色是增强网页,而不是成为网页。

考虑以下例子:

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

const Home = () => {
  const [data, setData] = useState([]);

  useEffect(() => {
    fetch("https://api.example.com/data")
      .then((response) => response.json())
      .then((data) => setData(data));
  }, []);

  return (
    <div>
      {data.map((item) => (
        <div key={item.id}>{item.title}</div>
      ))}
    </div>
  );
};

export default Home;

在这个例子中,我们从一个 API 获取数据,并在客户端上渲染它。我们可以通过使用useEffect钩子来获取数据,并使用useState钩子将数据存储在状态中,来判断这是客户端操作。useEffect钩子仅在浏览器(客户端)中执行。

一个严重的限制是,一些搜索引擎爬虫可能无法看到这些内容,除非我们实现服务器端渲染。否则,他们可能会看到一个空白屏幕或回退信息,这可能会导致较差的 SEO。

客户端应用程序仅仅是网络瀑布的另一个常见问题,即在初始页面加载时,需要下载、解析和执行大量 JavaScript,导致网站或 Web 应用在相当长时间内不可响应,尤其是在网络连接受限的情况下。

在这个例子中,我们正在向外部 API 端点(https://api.example.com/data)发起数据获取请求以检索一些数据。这种获取发生在我们的初始 JavaScript 包被下载、解析和执行之后,而这只会发生在初始 HTML 加载之后。这是一个网络瀑布,导致性能不佳。如果我们可视化它,看起来会像是图 6-1。

frea 0601

图 6-1. 数据获取请求

真是糟糕。使用服务器端渲染,我们可以做得更好,使用户能够立即看到有用的内容,从而修改图表如下所示:

Load HTML (full UI, with data fetched on the server)

实际上,首次加载已经包含了对用户有用的信息,因为我们在服务器上获取了数据并渲染了我们的组件。这里没有瀑布效应,用户立即获得所有信息。这就是服务器端渲染的价值所在。

截至 React 18 版本,React 和 React DOM 的捆绑大小分别为 6.4 kB 和 130.2 kB。这些大小是在撰写本文时的最新 React 版本,实际使用中可能因 React 的版本和配置而有所不同。这意味着即使在生产环境中,我们的用户也必须单独下载约 136 kB 的 JavaScript 仅用于 React(即 React + React DOM),然后才能下载、解析和执行我们应用程序其余的代码。这可能导致初始页面加载较慢,尤其是在较慢的设备和网络上,可能会让用户感到沮丧。此外,因为 React 基本上控制了 DOM,在仅客户端应用程序中没有 React,我们的用户没有选择,只能等待 React 和 React DOM 首先加载,然后才加载应用程序的其余部分。

相比之下,服务器呈现的应用程序将在任何 JavaScript 下载之前向客户端流式传输呈现的 HTML,使用户可以立即获取有意义的内容。然后,在初始页面呈现后,可能在用户还在通过称为“hydration”过程的用户界面定位自己时,加载相关的 JavaScript。接下来的部分将更详细讨论这一点。

最初通过流式呈现的 HTML,然后通过 JavaScript 注入 DOM,使用户能够更早地与应用程序进行交互,从而获得更好的用户体验:用户可以立即使用,而无需等待可能甚至不需要加载的任何额外内容。

安全性

仅客户端渲染也可能存在安全问题,特别是在处理敏感数据时。这是因为应用程序的所有代码都会下载到客户端的浏览器中,使其容易受到跨站请求伪造(CSRF)等攻击的影响。

谈到 CSRF,一种常见的减少其影响的方法是控制向用户提供网站或 Web 应用程序的服务器。如果我们控制了这个服务器,我们可以从服务器发送适当的反 CSRF 令牌作为可信来源到客户端,然后客户端通过表单或类似方式提交令牌回服务器,服务器可以验证请求来自正确的客户端。这是一种常见的减少 CSRF 攻击的方式。

尽管从我们控制的静态站点服务器上提供仅客户端应用程序并以此方式减少 CSRF 攻击是技术上可行的,但总体上并不是最佳的网站服务方式,因为我们到目前为止讨论的其他权衡。如果我们确实控制了服务器,那么为什么不从那里添加 SSR 呢?

最终,这就是我们要说的:

  • 如果我们没有访问服务器端的权限,但在一个团队中工作,只需要git push前端客户端代码,然后它就会神奇地部署到某个地方,这里存在固有的 CSRF 风险。

  • 如果我们可以访问服务器端,并且我们的网站或 Web 应用仍然仅限于客户端,我们已经可以相当好地缓解 CSRF,并且其周围的安全风险也随之消失。

  • 如果我们可以访问服务器端,并且我们的网站或 Web 应用仍然仅限于客户端,那么我们有充分理由为其添加服务器端渲染,因为我们可以访问服务器,从而实现我们已经涵盖的关于 SEO 和性能的其他好处。

让我们变得更加实际,考虑以下示例:

import React, { useState } from "react";

const Account = () => {
  const [balance, setBalance] = useState(100);

  const handleWithdrawal = async (amount) => {
    // Assume this request goes to a server to process the withdrawal
    const response = await fetch("/withdraw", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      credentials: "include",
      body: JSON.stringify({ amount }),
    });

    if (response.ok) {
      const updatedBalance = await response.json();
      setBalance(updatedBalance);
    }
  };

  return (
    <div>
      <h1>Account Balance: {balance}</h1>
      <button onClick={() => handleWithdrawal(10)}>Withdraw $10</button>
      <button onClick={() => handleWithdrawal(50)}>Withdraw $50</button>
      <button onClick={() => handleWithdrawal(100)}>Withdraw $100</button>
    </div>
  );
};

export default Account;

在这段代码中,handleWithdrawal 发送 POST 请求到一个假设的服务器端点 /withdraw 处理提款。如果此端点未正确验证请求的来源并且不需要任何形式的反 CSRF 令牌,则可能存在 CSRF 风险。

攻击者可以创建一个恶意网页,欺骗用户点击按钮,然后代表用户向 /withdraw 端点发送 POST 请求,可能导致从用户账户未经授权地提取资金。这是因为浏览器会自动在请求中包含 cookie,服务器使用这些 cookie 来验证用户身份。如果服务器不验证请求的来源,可能会被欺骗处理请求并将资金发送到攻击者的账户。

如果此组件在客户端渲染,则可能会容易受到 CSRF 攻击的影响,因为服务器和客户端之间没有共享的公共秘密或契约。用诗意的语言来说,客户端和服务器彼此不认识。这可能允许攻击者窃取资金或操纵应用程序的数据。

如果我们采用服务器端渲染,我们可以通过在服务器上使用特殊生成的秘密令牌来渲染组件,然后将包含秘密令牌的 HTML 发送给客户端,来缓解这些安全问题。客户端随后将此令牌发送回发出令牌的服务器,建立一个安全的双向契约。这样服务器就可以验证请求来自于预授权的正确客户端,而不是一个可能是恶意攻击者的未知客户端。

服务器端渲染的兴起

出于这些原因,服务器端渲染已经成为提高 Web 应用性能和用户体验的一种可以说是更优越的技术。通过服务器渲染,应用程序可以针对速度和可访问性进行优化,从而实现更快的加载时间、更好的 SEO 和提高的用户参与度。

服务器端渲染的好处

让我们更深入地探讨服务器端渲染的好处。当我们进一步理解仅客户端渲染的局限性时,这些好处应该会立即显现:

使用服务器端渲染的首次有意义绘制时间更快。

这是因为服务器可以渲染初始的 HTML 标记并将其发送到客户端,客户端可以立即显示。这与仅在客户端渲染不同,后者必须等待 JavaScript 被下载、解析和执行后才能渲染应用程序。

服务器渲染改善了 Web 应用程序的可访问性。

网速慢或设备性能低的用户,如果收到完全渲染的 HTML 而不是等待客户端 JavaScript 加载和渲染页面,可能会有更好的体验。

服务器渲染可以提升 Web 应用程序的 SEO。

当搜索引擎爬虫索引您的站点时,它们可以看到完全渲染的 HTML,这样更容易理解您站点的内容和结构。

服务器渲染还可以提升 Web 应用程序的安全性。

通过在服务器端渲染核心内容,可以确保所有用户和搜索引擎都可以访问基本内容和功能,而不受 JavaScript 执行的影响。然后,客户端 JavaScript 可以通过为支持它们的浏览器和设备添加互动性、更丰富的动画和其他高级功能,来增强用户体验。

然而,服务器渲染的 HTML 是静态的,缺乏初始加载时的互动性。它不包含任何事件监听器或其他动态功能。要启用用户交互和其他动态功能,必须使用必要的 JavaScript 代码“hydrate”静态 HTML。让我们更好地理解 hydration 的概念。

Hydration

Hydration 是一个术语,用于描述将事件监听器和其他 JavaScript 功能附加到在服务器上生成并发送到客户端的静态 HTML 的过程。hydration 的目标是使服务器渲染的应用程序在加载到浏览器后变得完全交互,从而为用户提供快速流畅的体验。

在一个 React 应用程序中,hydration 是指在客户端下载了服务器渲染的 React 应用程序之后发生的过程。

加载客户端 bundle

当浏览器渲染静态 HTML 时,它也会下载并解析包含应用程序代码的 JavaScript bundle。该 bundle 包括 React 组件和其他必要的应用程序功能代码。

附加事件监听器

一旦加载了 JavaScript bundle,React 通过将事件监听器和其他动态功能附加到 DOM 元素来“hydrate”静态 HTML。通常使用 react-dom 中的 hydrateRoot 函数来完成此操作,该函数接受根 React 组件和 DOM 容器作为参数。hydration 本质上将静态 HTML 转换为完全交互式的 React 应用程序。

完成 hydration 过程后,应用程序变得完全可交互,可以响应用户输入,获取数据,并在必要时更新 DOM。

在水合作用期间,React 将静态 HTML 中的 DOM 元素结构与通过 JSX 定义的 React 组件结构进行匹配。关键是 React 组件生成的结构必须与静态 HTML 的结构匹配。如果不匹配,React 将无法正确附加事件监听器,并且不会意识到哪个 React 元素直接映射到哪个 DOM 元素,导致应用程序的行为不符预期。

通过结合服务器端渲染和水合作用,开发者可以创建加载快速、提供平滑交互用户体验的 Web 应用程序。

水合作用有害

在水合作用是将服务器渲染的 HTML 变为交互式的一种绝佳方式,但也有人批评它的速度慢于必要,通常提到可恢复性作为一个更优的替代方案(见图 6-2)。让我们稍微探讨一下这个问题。

水合作用

图 6-2. 水合作用

在水合作用中,我们在服务器上渲染 React 应用程序,然后将渲染输出传递给客户端。但是,在这个时间点上,什么都不是交互式的。从这里开始,浏览器需要下载客户端捆绑包,附加事件监听器,并有效地“重新渲染”客户端。这是一项繁重的工作,有时会导致内容出现在用户面前与用户实际可以使用站点之间存在延迟。

或者,可恢复性的工作方式略有不同,如图 6-3 所示。

可恢复性

图 6-3. 可恢复性

使用可恢复性,整个应用程序在服务器上进行渲染并流式传输到浏览器。除了初始标记外,所有交互行为都被序列化并发送到客户端。从那时起,客户端已经获得了关于如何根据需要进行交互的所有信息,因此可以在服务器离开的地方继续。它不需要水合(即,在客户端附加事件监听器和渲染页面),而是可以反序列化服务器提供的内容并相应地做出反应。跳过水合步骤可以导致更快的交互时间(TTI)和更好的用户体验。

虽然可恢复性有明显的好处,工程社区对于实施复杂性是否值得这个好处存在疑问。确实,这比水合作用更复杂,目前尚不清楚好处是否超过成本:是的,交互时间会快几毫秒,但实施可恢复性的复杂性是否值得?这是 React 社区仍在辩论的问题。

创建服务器渲染

如果您有现有的客户端 React 应用程序,您可能想知道如何将服务器渲染添加到其中。幸运的是,向现有的 React 应用程序添加服务器渲染相对简单。一种方法是使用服务器渲染框架,如 Next.js 或 Remix。虽然这些框架确实是提供服务器渲染 React 应用程序的最佳方式,但这种抽象可能会让我们中更好奇的人渴望理解用于实现此目标的基本机制。

如果您是一个好奇的人,并且有兴趣了解如何手动将服务器渲染添加到客户端 React 应用程序中,或者对框架如何实现它感兴趣,请继续阅读。再次强调,这些内容可能不适合在生产中使用,而更适合于好奇心的教育目的。

将客户端 React 应用程序手动添加到服务器渲染

如果您有一个客户端应用程序,这是如何向其添加服务器渲染的方式。首先,在项目的根目录中创建一个server.js文件。此文件将包含您的服务器代码:

// server.js

// Importing necessary modules
const express = require("express"); // Importing Express.js library
const path = require("path"); // Importing Path module to handle file paths
const React = require("react"); // Importing React library
// Importing ReactDOMServer for server-side rendering
const ReactDOMServer = require("react-dom/server");

// Importing the main App component from the src directory
const App = require("./src/App");

// Initializing an Express application
const app = express();

// Serving static files from the 'build' directory
app.use(express.static(path.join(__dirname, "build")));

// Handling all GET requests
app.get("*", (req, res) => {
  // Rendering the App component to an HTML string
  const html = ReactDOMServer.renderToString(<App />);

  // Sending an HTML response that includes the rendered App component
  res.send(`
 <!DOCTYPE html>
 <html>
 <head>
 <title>My React App</title>
 </head>
 <body>
 <!-- Injecting the rendered App component -->
 <div id="root">${html}</div>
 <!-- Linking to the main JavaScript bundle -->
 <script src="/static/js/main.js"></script>
 </body>
 </html>
 `);
});

// Starting the server on port 3000
app.listen(3000, () => {
  // Logging a message to the console once the server is running
  console.log("Server listening on port 3000");
});

在此示例中,我们使用 Express 创建一个服务器,该服务器从./build目录中提供静态文件,然后在服务器上渲染我们的 React 应用程序。我们还使用ReactDOMServer将我们的 React 应用程序渲染为 HTML 字符串,然后将其注入到发送给客户端的响应中。

在这种情况下,我们假设我们的客户端 React 应用程序有某种类型的build脚本,该脚本将客户端 JavaScript 捆绑包输出到名为build的目录中,我们在代码片段中引用该目录。这对于水合作用很重要。所有这些部分都准备就绪后,让我们继续启动我们的服务器:

node server.js

运行此命令应该会在端口 3000 上启动我们的服务器,并输出Server listening on port 3000

通过这些步骤,我们现在拥有了一个服务器渲染的 React 应用程序。通过这种“窥探内部”的方法来进行服务器渲染,我们可以更深入地了解服务器渲染的工作原理,以及它如何使我们的 React 应用程序受益。

如果我们打开浏览器并访问http://localhost:3000,我们应该看到一个服务器渲染的应用程序。我们可以通过查看页面的源代码来确认它确实是服务器渲染的,这应该显示实际的 HTML 标记,而不是空白文档。

为了完整起见,这就是 HTML 标记的样子:

<!DOCTYPE html>
<html>
  <head>
    <title>My React App</title>
  </head>
  <body>
    <div id="root">
      <div>
        <h1>Hello, world!</h1>
        <p>This is a simple React app.</p>
      </div>
    </div>
    <script src="/static/js/main.js"></script>
  </body>
</html>

这是发送到客户端的 HTML 标记。它包含我们的 React 应用程序的完全渲染的 HTML,可以被搜索引擎索引,并且可以更有效地由网络连接较慢或不可靠的用户访问。这可以为我们的 React 应用程序带来更好的 SEO 和改进的可访问性。

水合作用

当服务器渲染的输出到达用户端时,我们通过将 <script> 标签加载到文件末尾来进行 hydration。正如讨论的那样,hydration 是将事件监听器和其他 JavaScript 功能附加到在服务器上生成并发送到客户端的静态 HTML 上的过程。hydration 的目标是使服务器渲染的应用程序在加载到浏览器后能够完全交互。

如果我们想要探索应用程序客户端端 bundle 的 hydration 步骤,它将如下所示:

// Import necessary libraries
import React from "react";
import { hydrateRoot } from "react-dom/client";
// Assuming App is the main component of your application
import App from "./App";

// Hydrate the app on the client side
hydrateRoot(document, <App />);

通过服务器渲染和客户端 hydration,我们的应用程序完全交互,可以响应用户输入、获取数据并根据需要更新 DOM。

React 中的服务器渲染 API

在前一节中,我们通过 Express 和 ReactDOMServer 手动向客户端 React 应用程序添加了服务器渲染。具体来说,我们使用了 ReactDOMServer.renderToString() 将我们的 React 应用程序渲染为 HTML 字符串。这是向 React 应用程序添加服务器渲染的最基本方法。然而,还有其他向 React 应用程序添加服务器渲染的方法。让我们深入了解 React 暴露的服务器渲染 API,并了解何时以及如何使用它们。

让我们详细考虑 renderToString API,探讨其使用、优势、劣势以及在何时适合在 React 应用程序中使用它。

  • 它是什么

  • 工作原理

  • 它如何适应我们对 React 的日常使用

首先让我们谈谈它是什么。

renderToString

renderToString 是 React 提供的一种服务端渲染 API,允许你在服务器上将一个 React 组件渲染成 HTML 字符串。该 API 是同步的,并返回一个完全渲染的 HTML 字符串,随后可以作为响应发送到客户端。renderToString 在服务器渲染的 React 应用中常用于提升性能、SEO 和可访问性。

使用方式

要使用 renderToString,您需要从 react-dom/server 包中导入 renderToString 函数。然后,您可以使用一个 React 组件作为其参数调用该函数。然后它将作为一个字符串返回完全渲染的 HTML。以下是使用 renderToString 渲染简单 React 组件的示例:

import React from "react";
import { renderToString } from "react-dom/server";

function App() {
  return (
    <div>
      <h1>Hello, world!</h1>
      <p>This is a simple React app.</p>
    </div>
  );
}

const html = renderToString(<App />);
console.log(html);

在此示例中,我们创建一个简单的 App 组件,并使用组件作为参数调用 renderToString。该函数返回完全渲染的 HTML,可以发送到客户端。

工作原理

该函数遍历 React 元素树,将它们转换为真实 DOM 元素的字符串表示,最终输出一个字符串。

在这里值得提醒的是,在 React 中,<div> 被转换为:

React.createElement("div", {}, "Hello, world!");

输出为:

{
  type: "div",
  props: {},
  children: ["Hello, world!"]
}

我们在前几章已经涵盖了这一点,但对于我们即将进行的讨论,这里值得回顾一下。从根本上讲,JSX 转换为 HTML 的流程如下:

JSX -> React.createElement -> React element -> renderToString(React element) -> HTML

renderToString作为一个 API 是同步和阻塞的,意味着它不能被中断或暂停。如果从根部开始的组件树很深,它可能需要相当多的处理。由于服务器通常为多个客户端提供服务,除非有某种缓存阻止这种情况发生,否则可能为每个客户端调用renderToString,并迅速阻塞事件循环并使系统超载。

从代码角度来看,renderToString将这个:

React.createElement(
  "section",
  { id: "list" },
  React.createElement("h1", {}, "This is my list!"),
  React.createElement(
    "p",
    {},
    "Isn't my list amazing? It contains amazing things!"
  ),
  React.createElement(
    "ul",
    {},
    amazingThings.map((t) => React.createElement("li", { key: t.id }, t.label))
  )
);

转换为这个:

<section id="list">
  <h1>This is my list!</h1>
  <p>Isn't my list amazing? It contains amazing things!</p>
  <ul>
    <li>Thing 1</li>
    <li>Thing 2</li>
    <li>Thing 3</li>
  </ul>
</section>

因为 React 是声明式的,React 元素是声明式的抽象,它们的树可以转换为任何其他东西的树——在这种情况下,React 元素的树被转换为 HTML 元素树的字符串表示。

缺点

虽然renderToString提供了几个优点,但它也有一些缺点:

性能

renderToString的主要缺点之一是对于大型 React 应用程序来说可能会很慢。因为它是同步的,它可能会阻塞事件循环并使服务器无响应。如果您有许多并发用户的高流量应用程序,这可能会特别成问题。

此外,renderToString返回一个完全渲染的 HTML 字符串,对于大型应用程序来说可能会占用大量内存。这可能导致服务器的内存使用增加,响应时间变慢,或者在高负载下导致服务器进程崩溃。

缺乏流支持

renderToString不支持流,这意味着整个 HTML 字符串必须在发送到客户端之前生成。这可能导致首字节时间(TTFB)较慢,客户端开始接收 HTML 的时间较长。这种限制对于内容较多的大型应用程序尤其有问题,因为客户端必须等待整个 HTML 字符串生成后才能显示任何内容。

对于更大的应用程序或renderToString的缺点变得问题严重的情况,React 提供了替代的服务器端渲染 API,如renderToPipeableStreamrenderToReadableStream。这些 API 分别返回一个 Node.js 流和一个浏览器流,而不是完全渲染的 HTML 字符串,这可以提供更好的性能和对流的支持。我们将在下一节中更详细地介绍这些内容。

renderToPipeableStream

renderToPipeableStream是在 React 18 中引入的一个服务器端渲染 API。它提供了一种更高效和灵活的方式来将大型 React 应用程序渲染到 Node.js 流。它返回一个可以传输到响应对象的流。renderToPipeableStream提供了更多控制 HTML 如何渲染的方式,并允许更好地与其他 Node.js 流集成。

此外,它完全支持 React 的并发特性,包括 Suspense,这在服务器端渲染期间解锁了更好的异步数据获取处理。由于它是一个流,因此可以通过网络进行流式传输,可以将 HTML 块异步地累积地发送到客户端,而不会阻塞。这导致更快的 TTFB 以及通常更好的性能。

要使用 renderToPipeableStream 重写我们之前的服务器,我们将执行以下操作:

// server.js

const express = require("express");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");

const App = require("./src/App");

const app = express();

app.use(express.static(path.join(__dirname, "build")));

app.get("*", (req, res) => {
  // Changes begin here
  const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
    // When our app is ready before fetching its own data,
    onShellReady: () => {
      // Tell the client we're sending HTML
      res.setHeader("Content-Type", "text/html");
      pipe(res); // pipe the output of the React stream to the response stream
    },
  });
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

让我们深入探讨 renderToPipeableStream,讨论其特性、优势和用例。我们还将提供代码片段和示例,帮助您更好地了解如何在 React 应用程序中实现此 API。

如何运作

类似于 renderToStringrenderToPipeableStream 接受一个声明式描述的 React 元素树,并将其转换为 Node.js 流,而不是将其转换为 HTML 字符串。Node.js 流是 Node.js 运行环境中的一个基本概念,它能够实现高效的数据处理和操作。流提供了一种处理数据的方式,逐块递增地处理数据,而不是一次性地将整个数据集加载到内存中。这种方法在处理无法完全加载到内存或网络上的大字符串或数据流时特别有用。

Node.js 流

在其核心,Node.js 流表示源和目标之间的数据流动。它可以被视为一个管道,通过这个管道数据流动,并且可以应用各种操作来转换或处理数据。

Node.js 流根据其数据流动的性质和方向分为四种类型:

可读流

可读流(readable stream)代表可以从中读取数据的数据源。它会发出诸如 dataenderror 等事件。可读流的示例包括从文件中读取数据、从 HTTP 请求接收数据或使用自定义生成器生成数据。

React 的 renderToPipeableStream 函数返回一个可读流,你可以从中读取 HTML 流,并将其输出到 Express 的 res 响应对象等可写流。

可写流

可写流(writable stream)代表可以写入数据的目标位置。它提供了诸如 write()end() 等方法来向流中发送数据。可写流会发出事件,如在目标可以处理更多数据时的 drain 事件,以及在写入过程中发生错误时的 error 事件。可写流的示例包括 Express 的 res 响应对象。

双工流

双工流(duplex stream)同时代表可读和可写的流。它允许双向数据流动,意味着你既可以从流中读取数据,也可以向流中写入数据。双工流通常用于网络套接字或需要双向数据流动的通信通道。

转换流

转换流是一种特殊的双工流,它在数据流经过时执行数据转换。它读取输入数据,处理它,并将处理后的数据作为输出提供。转换流可用于执行诸如压缩、加密、解压缩或数据解析等任务。

Node.js 流的一个强大特性是能够在流之间传输数据。管道操作允许直接连接可读流的输出到可写流的输入,从而创建数据的无缝流动。这极大地简化了数据处理过程并减少了内存使用。确实,这就是 React 中流式服务器端渲染的工作原理。

Node.js 中的流还支持背压处理。背压是在数据处理过程中发生的问题,描述了在数据传输期间缓冲区后面的数据堆积。当可写流无法及时处理数据时,可读流会暂停发出 data 事件,防止数据丢失。一旦可写流准备好消费更多数据,它会发出 drain 事件,提示可读流继续发出数据。

在这里不深入探讨和偏离主题,Node.js 流是一种处理数据的强大抽象,能够以可扩展且高效的方式处理数据。通过将数据分解为可管理的块并允许增量处理,流使得大数据集、文件 I/O 操作、网络通信等的处理更加高效。

React 的 renderToPipeableStream

在 React 中,将 React 组件流式传输到可写流的目的是提升服务器渲染应用程序的 TTFB 性能。而不是等待整个 HTML 标记生成完毕后再发送到客户端,这些方法允许服务器在准备就绪时开始发送 HTML 响应的分块,从而降低总体延迟。

renderToPipeableStream 函数是 React 服务器渲染器的一部分,旨在支持将 React 应用程序流式渲染到 Node.js 流。它是称为 "Fizz" 的服务器渲染器架构的一部分。

注意

我们即将深入探讨 React 实现细节,这些细节可能随时间变化而变化。再次强调,此内容仅供教育和满足读者好奇心使用。它可能并不完全匹配阅读时的 React 实现细节,但足以了解写作时的运行方式。这些内容可能不适合在生产中使用,而且并非掌握 React 使用技巧所必需,仅供教育和好奇心之用。

不要让我们从服务器渲染的上下文分散太多注意力,这里是服务器渲染工作流的简化解释:

创建请求

函数 renderToPipeableStream 接受要渲染的 React 元素和一个可选的选项对象作为输入。然后使用 createRequestImpl 函数创建一个请求对象。此请求对象封装了 React 元素、资源、响应状态和格式上下文。

开始工作

创建请求后,使用请求作为参数调用 startWork 函数。此函数启动渲染过程。渲染过程是异步的,并且可以根据需要暂停和恢复,这就是 React Suspense 的作用所在。如果组件被包裹在 Suspense 边界中,并且它启动了一些异步操作(如数据获取),则该组件(及可能的兄弟组件)的渲染可以被“挂起”,直到操作完成。

当组件被挂起时,它可以以“fallback”状态呈现,通常是加载指示器或占位符。操作完成后,组件“恢复”并以其最终状态呈现。Suspense 是一个强大的功能,使 React 能够在服务器端渲染期间更有效地处理异步数据获取和延迟加载。

优点在于我们能够立即向用户提供有意义的页面,并随着更多数据的可用性逐步增强它。这是一种强大的技术,可用于改善 React 应用程序的用户体验。

返回可管道流

renderToPipeableStream 然后返回一个对象,其中包括一个 pipe 方法和一个 abort 方法。 pipe 方法用于将渲染输出导入可写流(例如 Node.js 中的 HTTP 响应对象)。 abort 方法可用于取消任何待处理的 I/O 并将剩余内容放入客户端渲染模式。

导入到目标的流

pipe 方法与目标流一起调用时,它会检查数据是否已开始流动。如果没有,则设置 hasStartedFlowingtrue,并使用请求和目标调用 startFlowing 函数。还为目标流的 drainerrorclose 事件设置处理程序。

处理流事件

drain 事件处理程序在目标流准备好接收更多数据时再次调用 startFlowing 以恢复数据流。errorclose 事件处理程序在目标流发生错误或流被提前关闭时调用 abort 函数以停止渲染过程。

中止渲染

返回的对象上的 abort 方法可用于以原因停止渲染过程。它使用来自 react-server 模块的请求和原因调用 abort 函数。

这些函数的实际实现涉及处理更复杂的逻辑,例如渐进式渲染、错误处理以及与 React 服务器渲染器的其他部分集成。这些函数的代码可以在 React 源代码的react-serverreact-dom包中找到。

renderToPipeableStream的特性

renderToPipeableStream的功能包括:

流式传输

renderToPipeableStream返回一个可管道化的 Node.js 流,可以传送到响应对象。这允许服务器在整个页面渲染完成之前开始向客户端发送 HTML,为大型应用程序提供更快的用户体验和更好的性能。

灵活性

renderToPipeableStream提供了更多控制 HTML 渲染方式的方法。它可以轻松与其他 Node.js 流集成,允许开发人员定制渲染流水线,并创建更高效的服务器端渲染解决方案。

Suspense 支持

renderToPipeableStream完全支持 React 的并发特性,包括 Suspense。这允许开发人员在服务器端渲染期间更有效地管理异步数据获取和延迟加载,确保只有在必要数据可用时才渲染依赖于数据的组件。

如何适配

让我们看一些代码,展示这个 API 的好处。我们有一个显示狗品种列表的应用程序。该列表通过从 API 端点获取数据来填充。应用程序在服务器上使用renderTo​Pi⁠peableStream进行渲染,然后发送到客户端。让我们先看看我们的狗列表组件:

// ./src/DogBreeds.jsx

const dogResource = createResource(
  fetch("https://dog.ceo/api/breeds/list/all")
    .then((r) => r.json())
    .then((r) => Object.keys(r.message))
);

function DogBreeds() {
  return (
    <ul>
      <Suspense fallback="Loading...">
        {dogResource.read().map((profile) => (
          <li key={profile}>{profile}</li>
        ))}
      </Suspense>
    </ul>
  );
}

export default DogBreeds;

现在,让我们来看看包含DogBreeds组件的整体App

// src/App.js
import React, { Suspense } from "react";

const ListOfBreeds = React.lazy(() => import("./DogBreeds"));

function App() {
  return (
    <div>
      <h1>Dog Breeds</h1>
      <Suspense fallback={<div>Loading Dog Breeds...</div>}>
        <ListOfBreeds />
      </Suspense>
    </div>
  );
}

export default App;

注意,在这里我们使用了React.lazy,正如前几章提到的,这是为了展示renderToPipeableStream如何处理 Suspense。好的,让我们通过一个 Express 服务器将所有这些联系起来:

// server.js
import express from "express";
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import App from "./App.jsx";

const app = express();

app.use(express.static("build"));

app.get("/", async (req, res) => {
  // Define the starting HTML structure
  const htmlStart = `
 <!DOCTYPE html>
 <html lang="en">
 <head>
 <meta charset="UTF-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <title>React Suspense with renderToPipeableStream</title>
 </head>
 <body>
 <div id="root">
 `;

  // Write the starting HTML to the response
  res.write(htmlStart);

  // Call renderToPipeableStream with the React App component
  // and an options object to handle shell readiness
  const { pipe } = renderToPipeableStream(<App />, {
    onShellReady: () => {
      // Pipe the rendered output to the response when the shell is ready
      pipe(res);
    },
  });
});

// Start the server on port 3000 and log a message to the console
app.listen(3000, () => {
  console.log("Server is listening on port 3000");
});

在此代码片段中,我们正在使用流式 HTML 响应请求。我们使用renderToPipeableStream将我们的App组件渲染为一个流,然后将该流导入响应对象。我们还使用onShellReady选项,在壳准备就绪后将流导入响应对象。壳是在 React 应用程序被水合之前以及在解析包含 Suspense 边界的数据依赖之前渲染的 HTML。在我们的情况下,壳是在从 API 获取狗品种之前渲染的 HTML。让我们看看运行此代码时会发生什么。

如果我们访问http://localhost:3000,我们会得到一个带有标题“狗品种”的页面,以及我们的悬挂状态“加载狗品种….”。这是在从 API 获取狗品种之前呈现的外壳。非常酷的是,即使我们在 HTML 中不包含客户端 React 并且给页面加水,一旦从 API 获取到狗品种,悬挂状态就会被实际的狗品种替换掉。这种在数据可用时从服务器端完全交换 DOM 的操作,而无需客户端 React 参与!

让我们更详细地了解这是如何工作的。

注意

再次,我们将深入探讨这里的 React 实现细节,这些内容很可能随时间而改变。这个练习(和这本书)的重点不是着迷于单个实现细节,而是理解底层机制,这样我们可以更好地学习和推理关于 React 的内容。这不是 使用 React 的必要条件,但理解这种机制可以给我们提供提示和实用工具,以便我们在日常工作中更好地使用 React。有了这个,让我们继续前进。

当我们访问http://localhost:3000时,服务器会响应带有 HTML 外壳的内容,其中包括标题“狗品种”和悬挂状态的“加载狗品种….”。这个 HTML 看起来像这样:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React Suspense with renderToPipeableStream</title>
  </head>
  <body>
    <div id="root">
      <div>
        <h1>User Profiles</h1>
        <!--$?--><template id="B:0"></template>
        <div>Loading user profiles...</div>
        <!--/$-->
      </div>
      <div hidden id="S:0">
        <ul>
          <!--$-->
          <li>affenpinscher</li>
          <li>african</li>
          <li>airedale</li>
          [...]
          <!--/$-->
        </ul>
      </div>
      <script>
        function $RC(a, b) {
          a = document.getElementById(a);
          b = document.getElementById(b);
          b.parentNode.removeChild(b);
          if (a) {
            a = a.previousSibling;
            var f = a.parentNode,
              c = a.nextSibling,
              e = 0;
            do {
              if (c && 8 === c.nodeType) {
                var d = c.data;
                if ("/$" === d)
                  if (0 === e) break;
                  else e--;
                else ("$" !== d && "$?" !== d && "$!" !== d) || e++;
              }
              d = c.nextSibling;
              f.removeChild(c);
              c = d;
            } while (c);
            for (; b.firstChild; ) f.insertBefore(b.firstChild, c);
            a.data = "$";
            a._reactRetry && a._reactRetry();
          }
        }
        $RC("B:0", "S:0");
      </script>
    </div>
  </body>
</html>

我们在这里看到的很有趣。有一个 <template> 元素带有一个生成的 ID(在本例中是 B:0),还有一些 HTML 注释。HTML 注释用于标记外壳的开始和结束。这些标记或“洞”在 Suspense 解析后将被解决的数据填入。HTML 中的 <template> 元素提供了一种构建文档子树和保持节点的方式,而不引入 DOM 层次结构的额外包装级别。它们作为轻量级容器,用于管理节点组,通过减少 DOM 操作期间的工作量来提高性能。

还有一个 <script> 元素。这个 <script> 标签包含一个名为 $RC 的函数,用于用实际内容替换外壳。$RC 函数接受两个参数:包含标记的 <template> 元素的 ID 和包含悬挂状态的 <div> 元素的 ID。然后函数在数据可用后填充标记的渲染 UI,同时移除悬挂状态。

不幸的是,这个函数是被压缩过的,但让我们试着解压它并理解它的作用。如果我们这样做,我们会观察到:

function reactComponentCleanup(reactMarkerId, siblingId) {
  let reactMarker = document.getElementById(reactMarkerId);
  let sibling = document.getElementById(siblingId);
  sibling.parentNode.removeChild(sibling);

  if (reactMarker) {
    reactMarker = reactMarker.previousSibling;
    let parentNode = reactMarker.parentNode,
      nextSibling = reactMarker.nextSibling,
      nestedLevel = 0;

    do {
      if (nextSibling && 8 === nextSibling.nodeType) {
        let nodeData = nextSibling.data;
        if ("/$" === nodeData) {
          if (0 === nestedLevel) {
            break;
          } else {
            nestedLevel--;
          }
        } else if ("$" !== nodeData && "$?" !== nodeData && "$!" !== nodeData) {
          nestedLevel++;
        }
      }
      let nextNode = nextSibling.nextSibling;
      parentNode.removeChild(nextSibling);
      nextSibling = nextNode;
    } while (nextSibling);

    while (sibling.firstChild) {
      parentNode.insertBefore(sibling.firstChild, nextSibling);
    }

    reactMarker.data = "$";
    reactMarker._reactRetry && reactMarker._reactRetry();
  }
}

reactComponentCleanup("B:0", "S:0");

让我们进一步分解。

函数接受两个参数:reactMarkerIdsiblingId。实际上,标记是一个洞,一旦渲染组件可用,渲染组件将放入其中,而兄弟节点则是悬挂状态。

然后函数使用 removeChild 方法从其父节点上移除了兄弟元素(即悬挂状态),当数据可用时。

如果reactMarker元素存在,则运行该函数。它将reactMarker变量设置为当前reactMarker元素的前一个兄弟元素。函数还初始化了parentNodenextSiblingnestedLevel这些变量。

使用do...while循环来遍历 DOM 树,从nextSibling元素开始。只要nextSibling元素存在,循环就会继续。在循环内部,函数检查nextSibling元素是否为注释节点(通过nodeType值为8来表示):

  • 如果nextSibling元素是一个注释节点,函数会检查其数据(即注释的文本内容)。它检查数据是否等于"/$", 这表示嵌套结构的结束。如果nestedLevel值为0,则循环中断,表示已达到所需的结构结束。如果nestedLevel值不为0,则意味着当前的"/$"注释节点是嵌套结构的一部分,并且nestedLevel值递减。

  • 如果评论节点的数据不等于"/$", 函数将检查它是否等于"$", "$?""$!"。这些值表示新的嵌套结构的开始。如果遇到任何这些值,nestedLevel值将被递增。

在循环的每次迭代中,使用父节点上的removeChild方法从 DOM 中移除nextSibling元素(即 Suspense 边界)。循环继续处理 DOM 树中的下一个兄弟元素。

一旦循环完成,函数使用insertBefore方法将兄弟元素的所有子元素移动到 DOM 树中nextSibling元素的位置之前。这个过程有效地重组了围绕reactMarker元素的 DOM,并用它包裹的组件替换了 Suspense 回退。

然后,函数将reactMarker元素的数据设置为"$",这可能用于标记将来处理或引用的组件。如果reactMarker元素上存在reactRetry属性且它是一个函数,则调用该方法。

如果有些内容难以理解,不用担心。我们可以在这里总结一切:本质上,该函数等待数据依赖的 React 组件准备就绪,然后用服务器渲染的组件替换掉 Suspense 回退。它使用具有特定数据值的注释节点来确定组件的结构,并相应地操作 DOM。由于这是内联在我们的 HTML 中从服务器端传输的,我们可以使用renderToPipeableStream这样的方式流式传输数据,并使浏览器在可用时渲染 UI,甚至不包括 React 在浏览器捆绑包中或进行水合处理。

因此,与renderToString相比,renderToPipeableStream在服务器渲染时给了我们更多的控制和能力。

renderToReadableStream

我们之前介绍的 renderToPipeableStream API 在底层使用了 Node.js 流。然而,浏览器也支持流,并且浏览器流与 Node.js 流略有不同。Node.js 流主要设计用于在服务器端环境中运行,在这些环境中处理文件 I/O、网络 I/O 或任何端到端流式处理。它们遵循 Node.js 环境定义的自定义 API,并且长期以来一直是 Node.js 的核心组成部分。Node.js 流具有可读、可写、双工和转换流的不同类,并利用 dataenderror 等事件来管理流程和处理数据。

浏览器流设计用于在 Web 浏览器中的客户端环境中运行。它们通常处理来自网络请求、媒体流或浏览器中的其他数据处理任务的流数据。浏览器流遵循由 WHATWG(Web Hypertext Application Technology Working Group)定义的流标准,旨在标准化 Web 上的 API。与 Node.js 流不同,浏览器流使用诸如 read()write()pipeThrough() 等方法来控制数据流的流动并处理流数据。它们提供了更标准化和基于 Promise 的 API。以下是浏览器环境中可读流的示例:

const readableStream = new ReadableStream({
  start(controller) {
    controller.enqueue("Hello, ");
    controller.enqueue("world!");
    controller.close();
  },
});

const reader = readableStream.getReader();

async function readAllChunks(streamReader) {
  let result = "";
  while (true) {
    const { done, value } = await streamReader.read();
    if (done) {
      break;
    }
    result += value;
  }
  return result;
}

readAllChunks(reader).then((text) => {
  console.log(text);
});

尽管 Node.js 流和浏览器流都用于处理流数据,但它们在不同的环境中运行,具有略有不同的 API 和标准。Node.js 流是事件驱动的,非常适合服务器端操作,而浏览器流基于 Promise,符合现代 Web 标准,专为客户端操作定制。

为了支持这两个环境,React 提供了 renderToPipeableStream 用于 Node.js 流,以及 renderToReadableStream 用于浏览器流。renderToReadableStream API 类似于 renderToPipeableStream,但它返回的是适合浏览器的可读流,而不是 Node.js 本地流。

何时使用什么

renderToString 不是理想的选择,因为它是同步的。这在许多方面都是问题:

网络 I/O 是异步的。

我们所做的任何数据获取取决于从某处检索数据:数据库、Web 服务、文件系统等。这些操作通常是异步的:意味着它们在离散的时间点开始和结束,而不是同时进行。由于 renderToString 是同步的,它无法等待异步请求完成,必须立即将字符串发送到浏览器。这意味着服务器无法完成事情,客户端在加载任何数据之前就收到一个空壳,而客户端理想情况下在水合之后继续执行服务器留下的工作。这导致通过网络瀑布出现性能问题。

服务器为多个客户端提供服务。

如果你的服务器正在忙于将renderToString渲染为字符串,并且有 30 个客户端发送了新请求到服务器,那些新的客户端将不得不等待服务器完成当前的工作。因为renderToString是同步的,它会一直阻塞直到完成。在服务器和客户端之间的一对多关系中,阻塞意味着你的客户端等待的时间比他们本应该等待的时间长。

新的选择,如renderToPipeableStreamrenderToReadableStream,是解决这些问题的异步基于流的方法,其中renderToReadableStream是浏览器本地的,而renderToPipeableStream是服务器本地的。因此,如果问题是“在服务器上使用什么 API 最好?”,答案显然是renderToPipeableStreamrenderToReadableStream,具体取决于环境。

话虽如此,虽然renderTo*Stream似乎是一组更优秀的 API,但在写作时,这些 API 仍然没有完整的用户故事。目前许多第三方库无法与它们一起使用,特别是考虑到数据获取或 CSS 库的情况。这是因为它们概念上需要在服务器上进行“完整运行”,然后需要创建数据,并使用该数据重新渲染应用程序以实际从服务器流式传输。它们不支持应用程序在服务器上尚未完成渲染但需要开始部分水化到浏览器的情况。

这是一个 React 的问题:在写作时的最新版本 React 18 中,没有任何 API 支持任何类型的流式传输或第三方数据的部分重新注水。React 团队最近在react-dom中添加了一堆新的 API,比如prefetchDNSpreconnectpreload等等,以解决这个问题,但这些功能将只在 React 19 中发布。即使有了这些 API,仍然缺少一些重要的 API 来使renderToPipeableStream成为可行的选择。

目前唯一真正可行的选择是在调用renderToPipeableStream之前预取所有必需的数据(或在 CSS 库的情况下,使用renderToString渲染整个应用程序以“预记录”所有需要渲染的类),这样做基本上会消除renderToString的大部分优势,从而再次使其成为同步 API。

总的来说,这些都是复杂的主题,需要充分考虑,仔细规划,并进一步考虑使用哪些 API,这些选择同样取决于你当前的项目和使用场景。因此,答案再次是“这要看情况”,或者“只需使用一个框架”,并将决策推迟到更广泛的社区中。

不要自己造轮子

为 React 应用程序创建自定义服务器渲染实现可能是一项具有挑战性且耗时的任务。虽然 React 提供了一些用于服务器渲染的 API,但从头开始构建自定义解决方案可能会导致各种问题和低效率。在本节中,我们将探讨依赖于像 Next.js 和 Remix 这样的成熟框架而不是构建自己的服务器渲染解决方案的原因:

处理边缘情况和复杂性

React 应用程序可能变得非常复杂,实施服务器渲染需要解决各种边缘情况和复杂性。这些问题包括处理异步数据获取、代码分割以及管理各种 React 生命周期事件。通过使用像 Next.js 或 Remix 这样的框架,您可以避免自己处理这些复杂性的需要,因为这些框架已经为许多常见的边缘情况内置了解决方案。

其中一种边缘情况是安全性。由于服务器处理大量客户端请求,确保不会意外地将一个客户端的敏感数据泄露给另一个客户端至关重要。这就是像 Next.js、Remix 和 Gatsby 这样的框架在处理这些问题方面提供宝贵帮助的地方。想象一种情况,客户端 A 访问服务器并且他们的数据被服务器缓存。如果服务器意外地将此缓存数据提供给客户端 B,可能会暴露敏感信息。

考虑以下示例:

// server.js

// Import the express module
const express = require("express");

// Create a new express application instance
const app = express();

// Declare a variable to hold cached user data
// Initially, it is null as there's no data cached yet
let cachedUserData = null;

// Define a route handler for GET requests to "/user/:userId"
// This will respond with user data for the specified user ID
app.get("/user/:userId", (req, res) => {
  // Extract the userId from the request parameters
  const { userId } = req.params;

  // Check if there's cached user data
  // If so, respond with the cached data
  if (cachedUserData) {
    return res.json(cachedUserData);
  }

  // If not, fetch user data from a database or another source
  // The fetchUserData function is assumed to be defined elsewhere
  const userData = fetchUserData(userId);

  // Update the cache with the fetched user data
  cachedUserData = userData;

  // Respond with the fetched user data
  res.json(userData);
});

// Start the server, listening on port 3000
// Log a message to the console once the server is ready
app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

在给定的代码中,cachedUserData旨在缓存用户数据,但不管userId是否匹配,它都在所有请求中共享。每当对/user/:userId进行请求时,服务器检查cachedUserData是否有缓存数据。如果有,则返回缓存数据,而不管缓存数据的userId是否与请求的userId匹配。如果没有,则获取数据、缓存数据并返回。这意味着如果连续进行两个请求到/user/1/user/2,第二个请求将收到第一个用户的数据,这是一个重大的安全问题。

更安全的缓存策略是将数据缓存在与userId相关联的方式中,以便每个用户都有自己的缓存。一种方法是使用对象来保存缓存数据,以userId作为键。

如果我们自己动手,人为错误的风险始终存在。如果依赖于大型社区构建的框架,可以减轻这种风险。这些框架是以安全性为设计核心,并确保敏感数据得到正确处理。它们通过使用安全和隔离的数据获取方法,防止潜在的数据泄露场景。

性能优化

框架自带了许多性能优化功能。这些优化包括自动代码分割、服务器渲染和缓存。构建自定义服务器渲染解决方案可能默认不包括这些优化,并且实施它们可能是一个具有挑战性且耗时的任务。

例如,Next.js 进行的一种优化是基于路由的页面代码拆分,这在 Next.js 13 及更早版本中是默认的。在这种情况下,每个页面自动被拆分为自己的包,仅在请求页面时加载。这可以通过减少初始包大小和改善 TTFB 显著提升性能。

开发者体验和生产力

构建自定义的服务器渲染实现可能是一个复杂且耗时的工作。通过使用像 Next.js 或 Remix 这样的框架,开发者可以专注于为其应用程序构建功能和功能,而不必担心底层的服务器渲染基础设施。这可以提高生产力和整体开发者体验。

最佳实践和约定

使用 Next.js 或 Remix 这样的框架可以帮助确保项目中遵循最佳实践和约定。这些框架已经考虑到了最佳实践,并通过遵循它们的约定,您可以确保应用程序建立在坚实的基础之上:

// Example of best practices with Remix
// File: routes/posts/$postId.tsx

import { useParams } from "react-router-dom";
import { useLoaderData } from "@remix-run/react";

// Best practice: data fetching as early as possible
// Best practice: colocating data with UI
export function loader({ params }) {
  return fetchPost(params.postId);
}

function Post() {
  const { postId } = useParams();
  const post = useLoaderData();

  return (
    <div>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </div>
  );
}

export default Post;

考虑到像 Next.js 和 Remix 这样的成熟框架提供的好处和优化,可以明显看出,为 React 应用构建自定义服务器渲染解决方案并不是一个理想的方法。通过利用这些框架,您可以节省开发时间,确保遵循最佳实践,并从各自社区提供的持续改进和支持中受益。

章节复习

总结一下,服务器端渲染和水合是强大的技术,可以显著改善 Web 应用的性能、用户体验和 SEO。React 提供了一系列丰富的服务器渲染 API,例如 renderToStringrenderToPipeableStream,每个都有其独特的优势和折衷方案。通过理解这些 API,并根据应用大小、服务器环境和开发者经验等因素选择合适的 API,您可以优化 React 应用的服务器端和客户端性能。

正如本章中所示,renderToString 是一个简单直接的服务器渲染 API,适用于较小的应用。然而,由于其同步性质和可能阻塞事件循环,对于较大的应用可能不是最高效的选择。另一方面,renderToPipeableStream 是一个更高级和灵活的 API,允许更好地控制渲染过程,并与其他 Node.js 流集成,因此对于较大的应用更为合适。

复习问题

现在您已经对 React 中的服务器端渲染和水合有了坚实的理解,现在是时候用一些复习问题来测试您的知识了。如果您能自信地回答这些问题,这表明您对 React 中的机制有了坚实的理解,并且可以轻松地继续前进。如果不能,我们建议您再仔细阅读一些内容,尽管这不会对您继续阅读本书时的体验造成损害。

  1. 在 React 应用程序中使用服务器端渲染的主要优势是什么?

  2. React 中的水合是如何工作的,为什么它很重要?

  3. 什么是可恢复性?它声称比水合更为优越的是如何实现的?

  4. 客户端仅渲染的关键优势和弱点是什么?

  5. React 中 renderToReadableStreamrenderToPipeableStream API 之间的主要区别是什么?

接下来

一旦您掌握了服务器端渲染和水合,您就可以准备探索 React 开发中更多的高级主题了。在下一章中,我们将深入研究并发 React。随着 Web 应用程序变得越来越复杂,处理异步操作对于创建平滑用户体验变得越来越重要。

通过学习如何利用并发 React,您将能够创建高性能、可扩展且用户友好的应用程序,能够轻松处理复杂的数据交互。因此,请继续关注,准备好在我们继续探索并发 React 的旅程中提升您的 React 技能!

第七章:并发 React

在上一章中,我们深入探讨了使用 React 进行服务器端渲染的世界。我们探讨了服务器端渲染对于提高应用程序性能和用户体验的重要性,尤其是在现代 Web 开发背景下。我们探讨了不同的服务器渲染 API,例如 renderToStringrenderToPipeableStream,并讨论了它们的用例和优势。我们还触及了实施服务器端渲染的挑战,以及依赖像 Next.js 和 Remix 这样的成熟框架来处理这些复杂性的重要性。

我们讨论了水合和其在将服务器渲染的标记与客户端 React 组件连接中的重要性,从而创建无缝的用户体验。此外,我们还讨论了在服务器环境中管理多个客户端连接时可能出现的潜在安全问题和挑战,强调了使用能有效处理这些问题的框架的必要性。

现在,随着我们迈向下一个并发 React——我们将建立在我们迄今所学的一切的基础上。我们将深入了解 Fiber 协调器,并学习 React 的并发特性,以及它如何高效地管理更新和渲染。通过研究调度、推迟更新和渲染通道,我们将深入了解 React 核心架构所可能实现的性能优化。

再次强调,Fiber 本身以及我们即将讨论的事物是 React 中的实现细节,可能会发生变化,并且您并不需要深入了解它们来有效使用 React。然而,了解底层机制将帮助您更好地理解 React 的工作原理和有效使用方法,同时也会使您作为工程师更加有见识。

有了这些,让我们踏上我们进入并发 React 的迷人世界的旅程,继续在我们的专业知识上建立,并发现使用 React 创造高性能应用的新方法。

同步渲染的问题

总结一下,同步渲染的问题在于它阻塞了主线程,这可能导致用户体验不佳。对于具有许多组件和频繁更新的复杂应用程序尤其如此。在这种情况下,UI 可能变得无响应,导致令人沮丧的用户体验。

这个问题的典型缓解措施是将一系列更新批处理成一个更新,并尽量减少在主线程上的工作:不是对 10 个事物进行 10 次处理,而是将它们批处理并处理一次。我们在 第四章 中讨论了批处理,所以我们在这里不再详细展开,但是为了我们的讨论目的,理解批处理是解决这些问题的一种缓解措施是很重要的,但即使如此,它也有其局限性,我们将在接下来的几段中揭示。

即使通过批处理,我们所讨论的问题,由于同步渲染的设计特性,进一步加剧了。同步渲染对所有更新都没有优先级的概念。它平等地处理所有更新,无论其可见性如何。例如,使用同步渲染,您可能会阻塞主线程进行用户看不到的项目的渲染工作,比如未显示的选项卡、模态框后面的内容或加载状态中的内容。如果有 CPU 可用,仍然希望这些项目能够渲染,但希望优先渲染用户能够看到和交互的内容。在 React 拥有并发特性之前,我们经常遇到关键更新被较不重要的更新所阻塞,导致用户体验不佳。

通过并发渲染,React 可以根据更新的重要性和紧急性优先处理更新,确保关键更新不被较不重要的更新所阻塞。这使得 React 在高负载下依然能保持响应式的用户界面,从而提升用户体验。例如,当用户悬停或点击按钮时,期望立即显示相应的反馈。如果 React 正在重新渲染一长串项目列表,则悬停或活动状态的反馈会延迟到整个列表渲染完成之后才显示出来。通过并发渲染,CPU 密集型的渲染任务可以暂时让位于更重要的渲染任务,如用户交互和动画效果。

此外,有了并发渲染的能力,React 能够进行时间分片:即将渲染过程分解为较小的片段并逐步处理它们。这使得 React 能够跨多帧执行工作,并且如果需要中断工作,也可以实现。

从现在开始,我们将共同深入探讨所有这些内容。

重新审视 Fiber

如 第四章 所述,Fiber 协调器是 React 中实现并发渲染的核心机制。它在 React 16 中引入,代表了与之前的堆栈协调器相比的重大架构转变。Fiber 协调器的主要目标是提高大型和复杂 UI 的响应性能。

Fiber 协调器通过将渲染过程拆分为更小、更可管理的工作单元(称为 Fibers)来实现这一点。这使得 React 能够暂停、恢复和优先处理渲染任务,从而可以根据其重要性推迟或调度更新。这提高了应用的响应能力,并确保关键更新不会被较不重要的任务所阻塞。

调度和推迟更新

React 能够调度和推迟更新的能力对于保持应用的响应能力至关重要。Fiber 协调器通过依赖调度器和一些高效的 API 实现了这一功能。这些 API 允许 React 在空闲期间执行工作,并在最合适的时间安排更新。

我们将在接下来的章节更详细地探讨调度器,但现在可以将其视为它听起来的样子:一个接收更新并说“现在做这个”,“稍后做这个”等的系统,使用浏览器的 API,如 setTimeoutMessageChannel 等。

考虑一个实时聊天应用程序,用户可以发送和接收消息。我们将有一个聊天组件,显示消息列表,以及一个消息输入组件,用户可以输入并提交他们的消息。此外,聊天应用程序实时从服务器接收新消息。在这种情况下,我们希望优先考虑用户交互(输入和提交消息),以保持响应式体验,同时确保新消息能够高效地渲染,而不阻塞用户界面。

为了使这个例子更具体化,让我们创建一些组件。首先是消息列表:

const MessageList = ({ messages }) => (
  <ul>
    {messages.map((message, index) => (
      <li key={index}>{message}</li>
    ))}
  </ul>
);

接下来,我们有一个消息输入组件,允许用户输入和提交消息:

const MessageInput = ({ onSubmit }) => {
  const [message, setMessage] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(message);
    setMessage("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
};

最后,我们有一个聊天组件,结合了这两个组件,并处理发送和接收消息的逻辑:

const ChatApp = () => {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // Connect to the server and subscribe to incoming messages
    const socket = new WebSocket("wss://your-websocket-server.com");
    socket.onmessage = (event) => {
      setMessages((prevMessages) => [...prevMessages, event.data]);
    };

    return () => {
      socket.close();
    };
  }, []);

  const sendMessage = (message) => {
    // Send the message to the server
  };

  return (
    <div>
      <MessageList messages={messages} />
      <MessageInput onSubmit={sendMessage} />
    </div>
  );
};

在这个例子中,React 的并发渲染能力发挥作用,通过高效地管理消息列表的更新和用户与消息输入的交互。当用户输入或提交消息时,React 优先处理文本输入更新,以确保流畅的用户体验。

当从服务器接收到新消息并需要渲染时,它们会在默认/未知的渲染通道中渲染,这会同步且立即地阻塞 DOM:这会延迟任何用户输入。如果我们希望渲染新的消息列表的优先级较低,我们可以将相应的状态更新包装在 useTransition 钩子的 startTransition 函数中,如下所示:

const ChatApp = () => {
  const [messages, setMessages] = useState([]);
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    // Connect to the server and subscribe to incoming messages
    const socket = new WebSocket("wss://your-websocket-server.com");
    socket.onmessage = (event) => {
      startTransition(() => {
        setMessages((prevMessages) => [...prevMessages, event.data]);
      });
    };

    return () => {
      socket.close();
    };
  }, []);

  const sendMessage = (message) => {
    // Send the message to the server
  };

  return (
    <div>
      <MessageList messages={messages} />
      <MessageInput onSubmit={sendMessage} />
    </div>
  );
};

通过这种方式,我们向 React 发出信号,以较低的优先级安排消息列表更新,并在不阻塞用户界面的情况下渲染它们,使聊天应用程序能够在高负载下高效运行。因此,用户输入不会被打断,而新消息的渲染优先级低于用户交互,因为它们对用户体验的重要性较小。

这个例子展示了如何利用 React 的并发渲染能力来构建响应式应用程序,处理复杂的交互和频繁的更新,同时不影响性能或用户体验。我们将在本章节后面深入讨论 useTransition。现在,让我们稍微深入了解一下,React 如何精确地安排更新。

深入探讨

在 React 中,调度、优先级和延迟更新的过程对于维护响应式用户界面至关重要。该过程确保高优先级任务得到及时处理,而低优先级任务可以延迟处理,从而使 UI 在高负载下保持流畅。为了深入探讨这个主题,我们将研究几个核心概念:调度器、任务的优先级以及推迟更新的机制。

注意

在我们继续之前——让我们再次提醒自己,这里涵盖的信息包括实现细节,并非使用 React 的必要条件。然而,理解这些概念将帮助您更好地理解 React 的工作原理以及如何有效使用它,同时教授您可以应用于其他工程任务中的基础机制,提升整体技能水平。有了这个想法,让我们继续。

调度器

在 React 架构的核心,调度器是一个独立的包,提供与时间相关的实用程序,与 Fiber 协调器无关。React 在协调器内部使用这个调度器。通过使用渲染通道,调度器和协调器使任务能够通过优先级和组织方式进行协作,根据它们的紧迫性。我们将很快深入研究渲染通道。调度器在今天的 React 中的主要作用是管理主线程的让出,主要通过调度微任务来确保平滑执行。

为了更详细地了解这一点,让我们看一下 React 源代码的部分内容:

export function ensureRootIsScheduled(root: FiberRoot): void {
  // This function is called whenever a root receives an update. It does two
  // things 1) it ensures the root is in the root schedule, and 2) it ensures
  // there's a pending microtask to process the root schedule.
  //
  // Most of the actual scheduling logic does not happen until
  // `scheduleTaskForRootDuringMicrotask` runs.

  // Add the root to the schedule
  if (root === lastScheduledRoot || root.next !== null) {
    // Fast path. This root is already scheduled.
  } else {
    if (lastScheduledRoot === null) {
      firstScheduledRoot = lastScheduledRoot = root;
    } else {
      lastScheduledRoot.next = root;
      lastScheduledRoot = root;
    }
  }

  // Any time a root received an update, we set this to true until the next time
  // we process the schedule. If it's false, then we can quickly exit flushSync
  // without consulting the schedule.
  mightHavePendingSyncWork = true;

  // At the end of the current event, go through each of the roots and ensure
  // there's a task scheduled for each one at the correct priority.
  if (__DEV__ && ReactCurrentActQueue.current !== null) {
    // We're inside an `act` scope.
    if (!didScheduleMicrotask_act) {
      didScheduleMicrotask_act = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  } else {
    if (!didScheduleMicrotask) {
      didScheduleMicrotask = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
  }

  if (!enableDeferRootSchedulingToMicrotask) {
    // While this flag is disabled, we schedule the render task immediately
    // instead of waiting for a microtask.
    // TODO: We need to land enableDeferRootSchedulingToMicrotask ASAP to
    // unblock additional features we have planned.
    scheduleTaskForRootDuringMicrotask(root, now());
  }

  if (
    __DEV__ &&
    ReactCurrentActQueue.isBatchingLegacy &&
    root.tag === LegacyRoot
  ) {
    // Special `act` case: Record whenever a legacy update is scheduled.
    ReactCurrentActQueue.didScheduleLegacyUpdate = true;
  }
}

在 React 代码库中,ensureRootIsScheduled 函数在管理渲染过程中扮演着至关重要的角色。当 React 根据 root: FiberRoot 接收到更新时,调用此函数执行两个关键操作。请回忆第四章:React 根是在提交阶段发生的最后一次“交换”,以完成更新。

当调用 ensureRootIsScheduled 时,它确认将根包含在根调度列表中:跟踪需要处理的根。其次,它确保存在一个专用于处理此根调度的待处理微任务。

微任务是 JavaScript 事件循环管理中的一个概念,表示由微任务队列管理的一种任务类型。要理解微任务,首先需要基本了解 JavaScript 事件循环及其相关的任务队列:

事件循环

JavaScript 引擎使用事件循环来管理异步操作。事件循环不断检查是否有工作(如执行回调)需要完成。它操作两种任务队列:任务队列(宏任务队列)和微任务队列。

任务队列(宏任务队列)

该队列包含处理事件、执行setTimeoutsetInterval回调以及执行 I/O 操作等任务。这些任务逐个处理,只有在当前任务完成后才会继续下一个任务。

微任务队列

微任务是一种更小、更即时的任务。微任务源自诸如 promises、Object.observeMutationObserver等操作。它们存储在微任务队列中,这与常规任务队列不同。

执行

微任务在当前任务结束之前处理,然后 JavaScript 引擎从任务队列中获取下一个(宏)任务。执行完任务后,引擎会检查微任务队列中是否有任何微任务,并在继续之前执行它们。这确保微任务快速且有序地处理,就在当前脚本执行之后,但在其他任务(如渲染或处理事件)之前。

特点和用法

微任务在任务队列中具有更高的优先级,这意味着它们在继续执行下一个宏任务之前会被执行。如果一个微任务不断向队列中添加更多微任务,可能会导致任务队列永远不会被处理。这被称为饥饿

在 React 和ensureRootIsScheduled函数的背景下,微任务用于确保根调度的处理迅速且具有高优先级,就在当前脚本执行之后,但在浏览器执行其他任务(如渲染或处理事件)之前。这有助于在 React 框架内保持平滑的 UI 更新和高效的任务管理。

该函数首先将根节点添加到调度中。这涉及检查根节点是否是最后一个被调度的节点或已经存在于调度中。如果不存在,函数将根节点添加到调度末尾,并更新lastScheduledRoot指向当前根节点。如果之前没有调度过根节点(lastScheduledRoot === null),当前根节点将成为调度中的第一个和最后一个节点。

接下来,该函数将标志mightHavePendingSyncWork设置为true。该标志表示可能有同步工作待处理,这对于flushSync函数至关重要,我们将在下一节中介绍。

然后,该函数确保安排一个微任务来处理根节点的调度。这是通过调用scheduleImmediateTask(processRootScheduleInMicrotask)来实现的。这种调度既发生在 React 的act测试实用程序范围内,也发生在其范围之外,由__DEV__ReactCurrentActQueue.current指示。

该函数的另一个重要部分是条件块,检查 enableDeferRootSchedulingToMicrotask 标志。如果此标志已禁用,函数将立即安排渲染任务,而不是推迟到微任务。这部分带有一个 TODO 注释(截至撰写时),指示未来计划启用此功能,以解锁额外的功能。

最后,函数包括一个处理 React act 实用程序中传统更新的条件。这是针对测试场景的特定处理,其中更新以不同方式批处理,并记录每当安排传统更新时。

长话短说,ensureRootIsScheduled 是一个复杂的函数,集成了 React 调度和渲染逻辑的几个方面,重点是通过策略性调度任务和微任务,有效管理对 React 根的更新,并确保平滑渲染。

从中我们可以理解 React 中调度器的角色:根据工作落入的渲染 lane 进行工作调度。在接下来的部分中,我们会深入探讨 lane,但现在只需知道 lane 表示更新的优先级即可。

如果我们用代码建模调度器的行为,会像这样:

if (nextLane === Sync) {
  queueMicrotask(processNextLane);
} else {
  Scheduler.scheduleCallback(callback, processNextLane);
}

从中我们可以看到:

  • 如果下一个 lane 是Sync,那么调度器会将一个微任务排队,立即处理下一个 lane。理想情况下,我们现在应该理解微任务是什么及其如何适用。

  • 如果下一个 lane 不是Sync,那么调度器会安排一个回调并处理下一个 lane。

因此,调度器确实如其名:一个根据函数 lane 调度运行函数的系统。好了,我们已经讨论了一段时间的 lane。让我们深入了解并详细理解它们!

渲染 Lane

渲染 lane 是 React 调度系统的重要组成部分,确保任务的高效渲染和优先级排序。一个 lane 是一个工作单元,代表一个优先级级别,并可以作为 React 渲染周期的一部分进行处理。渲染 lane 的概念是在 React 18 中引入的,取代了先前使用过期时间的调度机制。让我们深入了解渲染 lane 的细节、工作原理以及其作为位掩码的底层表示。

注意

再次强调,这些是 React 中的实现细节,随时可能会更改。这里的重点是理解这些底层机制,这将有助于我们在日常工程工作中,也将帮助我们理解 React 的工作方式,并使我们能够更有效或更流畅地使用它。最好不要被细节困扰,而是坚持机制及其在实际应用中的潜力。

首先,渲染 lane 是 React 使用的轻量级抽象,用于组织和优先处理渲染过程中需要进行的更新。

例如,当你调用 setState 时,该更新被放入一个通道中。我们可以根据更新的上下文理解不同的优先级,如下所示:

  • 如果在点击处理程序内部调用 setState,它将被放入同步通道(最高优先级),并在微任务中调度。

  • 如果在 startTransition 过渡内部调用 setState,它将放入一个过渡通道(较低优先级)并在微任务中调度。

每个通道对应于特定的优先级级别,高优先级通道在低优先级通道之前处理。在 React 中,一些通道的示例包括:

SyncHydrationLane

在水合期间用户点击 React 应用时,点击事件被放入此通道。

SyncLane

当用户点击 React 应用时,点击事件被放入此通道。

InputContinuousHydrationLane

悬停事件、滚动事件以及水合期间的其他连续事件被放入此通道。

InputContinuousLane

与之前相同,但是在 React 应用程序水合后。

DefaultLane

从网络上的任何更新、像 setTimeout 这样的定时器,以及优先级无法推断的初始渲染都放入此通道。

TransitionHydrationLane

startTransition 期间的任何过渡都放入此通道。

TransitionLanes(1–15)

startTransition 后的任何过渡都放入这些通道。

RetryLanes(1–4)

任何 Suspense 重试都放入这些通道。

值得注意的是,这些通道表示了撰写时 React 的内部实现,并可能会更改。再次强调,本书的重点是理解 React 工作的机制,而不是过于依赖确切的实现细节,因此通道名称可能并不重要。更重要的是我们对机制的理解——即 React 如何使用这个概念,以及我们如何将其应用到工作中。

渲染通道的工作原理

当组件更新或新组件添加到渲染树时,React 根据其优先级使用我们之前讨论过的通道为更新分配通道。正如我们所知,优先级由更新的类型(例如,用户交互、数据获取或后台任务)和其他因素(例如组件的可见性)确定。

然后,React 使用渲染通道按以下方式安排和优先处理更新:

1. 收集更新

React 收集自上次渲染以来已安排的所有更新,并根据其优先级将它们分配到各自的通道中。

2. 流程通道

React 按其各自的通道处理更新,从最高优先级通道开始。同一通道中的更新被批处理在一起,并在单次处理中处理。

3. 提交阶段

处理所有更新后,React 进入提交阶段,在此阶段应用更改到 DOM,运行效果,并执行其他完成任务。

4. 重复

每次渲染都会重复这个过程,确保更新始终按优先级顺序处理,并且高优先级的更新不会被低优先级的更新所抢占。

React 根据这些优先级将更新分配到正确的 lane 中,使得应用程序能够在不需要开发者手动干预的情况下高效运行。

当触发更新时,React 执行以下步骤来确定其优先级并将其分配到正确的 lane 中:

1. 确定更新的上下文

React 评估触发更新的上下文。这个上下文可以是用户交互,由于状态或 props 变化而导致的内部更新,甚至是由服务器响应导致的更新。上下文在确定更新优先级方面起着关键作用。

2. 根据上下文估计优先级

根据上下文,React 估计更新的优先级。例如,如果更新是用户输入的结果,则可能具有较高的优先级,而由非关键后台进程触发的更新可能具有较低的优先级。我们已经详细讨论了不同的优先级级别,所以在这里我们不会再详细讨论。

3. 检查是否有任何优先级覆盖

在某些情况下,开发者可以使用 React 的 useTransitionuseDeferredValue hooks 明确设置更新的优先级。如果存在这样的优先级覆盖,React 将考虑提供的优先级而不是估计的优先级。

4. 将更新分配到正确的 lane 中

一旦确定了优先级,React 将更新分配给相应的 lane。这个过程使用我们刚刚查看的位掩码完成,它允许 React 高效地处理多个 lane,并确保更新被正确地分组和处理。

在整个过程中,React 依赖其内部启发式算法和更新发生的上下文来做出关于它们优先级的知情决策。这种动态分配优先级和 lane 的方法使得 React 能够在不需要开发者手动干预的情况下平衡响应性和性能,确保应用程序高效运行。

让我们详细看看 React 如何精确处理其各自 lane 中的更新。

处理 Lanes

一旦更新被分配到它们各自的 lane 中,React 将按照优先级顺序处理它们。在我们的聊天应用示例中,React 将按以下顺序处理更新:

ImmediatePriority

处理消息输入的更新,确保保持响应性并快速更新。

UserBlockingPriority

处理打字指示器的更新,为用户提供实时反馈。

NormalPriority

处理消息列表的更新,以合理的速度显示新消息和更新。

通过按优先级顺序处理更新,React 确保应用程序的最重要部分即使在负载较重的情况下也能保持响应性。

提交阶段

在处理各自车道中的所有更新后,React 进入提交阶段,在此阶段将这些更改应用于 DOM,运行效果,并执行其他的完成任务。在我们的聊天应用示例中,这可能包括更新消息输入值,显示或隐藏输入指示器,以及将新消息追加到消息列表中。React 然后继续到下一个渲染周期,重复收集更新、处理车道和提交更改的过程。

然而,这个过程比我们在本书中真正能够欣赏到的要复杂得多:有像纠缠这样的概念,它决定何时需要一起处理两条车道,以及像重基这样的进一步概念,它决定何时需要将更新重新基于已经处理过的更新之上。例如,在过渡被同步更新中断之前,需要同时运行两者时,重基就非常有用。

还有很多关于刷新效果的话题值得讨论。例如,当存在同步更新时,React 可能会在更新之前/之后刷新效果,以确保状态在同步更新之间的一致排序。

最终,这就是 React 存在的原因,以及 React 作为一个抽象层在幕后为我们处理更新问题、它们的优先级和排序问题,而我们则继续专注于我们的应用程序。

需要注意的是,虽然 React 在估算优先级方面表现良好,但并不总是完美的。作为开发者,有时您可能需要使用我们迄今为止提到的一些 API:useTransitionuseDeferredValue来覆盖默认的优先级分配,以微调应用程序的性能和响应能力。让我们更详细地探讨这些 API。

useTransition

useTransition是一个强大的 React Hook,允许你管理组件中状态更新的优先级,并防止由于高优先级更新而导致 UI 变得不响应。在处理可能对视觉有影响的更新,如加载新数据或在页面之间导航时,特别有用。

它基本上是将你在其返回的startTransition函数中包装的任何更新放入转换车道中,该车道的优先级低于之前看到的同步车道,这允许你控制更新的时机,并在其他高优先级更新争夺主线程时保持平滑的用户体验。

useTransition是一个钩子,意味着你只能在函数组件内部使用它。它返回一个包含两个元素的数组:

isPending

一个布尔值,指示是否正在进行过渡。useTransition的一个有趣之处在于当您调用startTransition时,它首先在此属性上调度同步setState({ isPending: false }),这意味着依赖于isPending的更新需要快速,否则就会失去useTransition的意义。

startTransition

你可以使用一个函数来包装那些应该延迟执行或者优先级较低的更新。

在这里值得一提的是,还有一个名为 startTransition 的 API,它不是作为钩子,而是作为一个普通函数可用。第二种启动非紧急转换的方法是使用直接从 React 导入的 startTransition 函数。这种方法不会给我们提供 isPending 标志的访问,但它适用于代码中无法使用钩子(如 useTransition )但仍希望向 React 信号低优先级更新的场合。

简单示例

下面是一个简单的示例,演示了 useTransition 的基本用法:

import React, { useState, useTransition } from "react";

function App() {
  const [count, setCount] = useState(0);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    doSomethingImportant();
    startTransition(() => {
      setCount(count + 1);
    });
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
      {isPending && <p>Loading...</p>}
    </div>
  );
}

export default App;

在这个例子中,我们使用 useTransition 来管理一个状态更新的优先级,该更新会增加一个计数器。通过将 setCount 更新包装在 startTransition 函数内部,我们告诉 React 可以延迟这个更新,防止 UI 在同时进行其他高优先级更新时变得无响应。

高级示例:导航

useTransition 在页面导航时也很有用。通过管理与导航相关的更新的优先级,你可以确保用户体验保持流畅和响应,即使处理复杂的页面转换。

考虑这个示例,我们演示如何在单页应用程序(SPA)中使用 useTransition 来管理页面转换:

import React, { useState, useTransition } from "react";

const PageOne = () => <div>Page One</div>;
const PageTwo = () => <div>Page Two</div>;

function App() {
  const [currentPage, setCurrentPage] = useState("pageOne");
  const [isPending, startTransition] = useTransition();

  const handleNavigation = (page) => {
    startTransition(() => {
      setCurrentPage(page);
    });
  };

  const renderPage = () => {
    switch (currentPage) {
      case "pageOne":
        return <PageOne />;
      case "pageTwo":
        return <PageTwo />;
      default:
        return <div>Unknown page</div>;
    }
  };

  return (
    <div>
      <nav>
        <button onClick={() => handleNavigation("pageOne")}>Page One</button>
        <button onClick={() => handleNavigation("pageTwo")}>Page Two</button>
      </nav>
      {isPending && <p>Loading...</p>}
      {renderPage()}
    </div>
  );
}

export default App;

在这个例子中,我们有两个简单的组件,代表 SPA 中的不同页面。我们使用 useTransition 来包装更改当前页面的状态更新,确保如果同时发生其他高优先级更新(如用户输入),页面转换会被延迟。

在这个例子中,你可能会想:“等等,点击用户后,页面转换不应该是即时的吗?” 是的,你是对的;然而,如果下一页需要使用 Suspense 获取一些数据,那么页面转换可能会延迟。这就是 useTransition 派上用场的地方,它允许你管理与导航相关的更新的优先级,确保用户体验保持流畅和响应,即使处理复杂的页面转换。 值得注意的是,如果下一页的数据获取发生在效果内,那么 startTransition 不会等待效果内的数据被获取;然而,当你在转换中挂起时,React 会将 isPending 状态与数据获取及数据返回时的渲染绑定在一起。

在这种情况下,当页面转换正在进行时,isPending 状态将为 true,允许我们立即在用户点击按钮时显示加载指示器。一旦转换完成,isPending 状态将为 false,并且新页面将被渲染。

深入了解

在了解了 React 的 Fiber 架构、React 调度器、优先级级别和渲染通道机制的背景知识后,我们现在可以深入探讨useTransition钩子的内部工作原理。

useTransition钩子通过创建一个转换并为该转换内的更新分配特定的优先级级别来工作。当一个更新被包装在转换中时,React 确保根据分配的优先级级别进行调度和渲染更新。

下面是使用useTransition钩子涉及的步骤概述:

  1. 在函数组件内部导入并调用useTransition钩子。

  2. 该钩子返回一个包含两个元素的数组:第一个是isPending状态,第二个是startTransition函数。

  3. 使用startTransition函数来包装希望控制时机的任何状态更新或组件渲染。

  4. isPending状态指示转换是否仍在进行中或已完成。

  5. React 确保包装在转换中的更新按照适当的优先级级别处理。这通过使用调度器和渲染通道机制来分配和管理更新实现。

通过使用useTransition,我们可以有效地控制更新的时机,并保持流畅的用户体验,即使其他优先级更高的更新也在竞争主线程。

useDeferredValue

useDeferredValue是一个 React Hook,允许将某些 UI 更新推迟到稍后,特别是在应用程序处理重负载或计算密集任务的场景中非常有用,从而帮助管理更新优先级并促进更平滑的过渡和更好的用户体验。

在初始渲染期间,返回的延迟值与提供的值相同。在后续更新中,useDeferredValue通过在更新至新值之前保持旧值来维持流畅的用户体验,特别是在涉及计算密集操作的情况下。这不涉及使用旧值和新值进行多次重新渲染,而是控制更新到新值。这种机制类似于stale-while-revalidate策略,保持过时值以保持 UI 的响应性同时等待新值。

浏览 React 的提交历史,我们可以看到useDeferredValue的首次实现大致如下:

function useDeferredValue(value) {
  const [newValue, setNewValue] = useState(value); // only stores initial value
  useEffect(() {
    // update the returned value in a transition whenever it changes,
    // "deferring" it
    startTransition(() => {
      setNewValue(value);
    });
 }, [value]);

 return newValue;
}

让我们简要讨论一下这段代码的作用。最初,它设置了一个带有传递给它的初始值的状态(newValue)。然后,该函数利用useEffect钩子来观察这个值的变化。当检测到变化时,会调用startTransition函数,这对于推迟更新至关重要。

startTransition中,使用setNewValue将状态更新为新值。使用startTransition表示给 React 这个更新不是紧急的,允许 React 优先处理其他更关键的更新。这基本上就是useDeferredValue今天的工作方式,对我们对其的心智模型应该是有帮助的。

useDeferredValue是 React 并发功能的一部分,它通过允许延迟某些状态更新来实现可中断性。

当组件重新渲染具有延迟值时,React 会在一定时间内继续显示旧值,允许高优先级更新在低优先级更新之前处理。这将渲染工作分解为较小的块,可以随时间分布,提高响应性,并确保高优先级更新(如用户交互)不会被低优先级更新延迟,从而提升积极的用户体验。

使用useDeferredValue的目的

useDeferredValue的主要目的是允许您推迟渲染较不重要的更新。当您希望优先处理更重要的更新(例如用户交互)而不是较不重要的更新(例如显示来自服务器的更新数据)时,这将特别有用。

通过使用useDeferredValue,您可以提供更流畅的用户体验,并确保您的应用程序即使在负载较重或处理复杂操作时也保持响应。

要使用useDeferredValue,您需要从 React 包中导入它,并将一个值作为其参数传递。然后,该钩子将返回该值的延迟版本,可用于您的组件中。

这里是如何在简单应用程序中使用useDeferredValue的示例:

import React, { memo useState, useDeferredValue } from "react";

function App() {
  const [searchValue, setSearchValue] = useState("");
  const deferredSearchValue = useDeferredValue(searchValue);

  return (
    <div>
      <input
        type="text"
        value={searchValue}
        onChange={(event) => setSearchValue(event.target.value)}
      />
      <SearchResults searchValue={deferredSearchValue} />
    </div>
  );
}

const SearchResults = memo(({ searchValue }) => {
  // Perform the search and render the results
})

在这个示例中,我们有一个搜索输入和一个显示结果的SearchResults组件。我们使用useDeferredValue来推迟搜索结果的渲染,使应用程序能够优先处理用户输入,并在渲染结果列表昂贵时保持响应。让我们稍微详细了解一下:

  1. 我们在组件上使用memo,以确保它不会不必要地更新,正如我们在之前的章节中讨论的那样。

  2. 当更新时,会导致性能问题,因为渲染成本高昂。

  3. 当我们给它一个延迟的属性,deferredSearchValue,因为该属性本身是在更紧急的渲染工作之后更新的,所以组件也是如此。因此,只有在没有更紧急的工作需要完成时,例如更新文本输入字段时,组件才会重新渲染。

这里可能会有人问,“为什么不只是对searchValue进行防抖或节流?”

很好的问题。让我们在这里进行对比:

防抖

包括在更新列表之前等待一段时间,等待用户完成输入,例如延迟一秒钟。

节流

定期更新列表,比如每秒钟不超过一次

虽然这些方法在某些情况下可能非常有效,但useDeferredValue作为更为精细的渲染优化解决方案显现出来,因为它可以无缝地适应用户设备的性能能力,而不是一种任意的延迟。

useDeferredValue的关键区别在于其动态延迟处理方法。它消除了设置固定延迟时间的需要。在高性能设备(如强大的笔记本电脑)上,重新渲染延迟几乎是不可察觉的,几乎是即时发生的。相反,在较慢的设备上,渲染延迟会相应调整,导致列表响应输入时稍有滞后,与设备速度成比例。

此外,useDeferredValue在其中断延迟重新渲染方面具有显著优势。在 React 处理大量列表的情况下,用户输入新字符时,React 可以暂停重新渲染,响应新输入,然后在后台恢复渲染过程。这与防抖和节流形成对比,后者尽管延迟更新,但仍可能导致在渲染过程中阻塞交互体验的不连贯。

尽管如此,防抖和节流在与渲染无直接关系的场景中仍然很有用。例如,它们可以有效地减少网络请求的频率。这些技术也可以与useDeferredValue结合使用,形成全面的优化策略。

基于这一切,我们在 React 应用中使用useDeferredValue看到了几个优势:

提升响应速度

在这个示例中,当用户在搜索框中输入时,输入字段会立即更新,而结果则被延迟处理。如果用户快速连续输入五个字符,输入字段会立即更新五次,而searchResults只有在用户停止输入后才会渲染一次。对于第 1 至第 4 个字符,SearchResults的渲染会被新值中断。

声明式优先级处理

useDeferredValue提供了一种简单而声明式的方式来管理应用中更新优先级的逻辑。通过将延迟更新的逻辑封装在钩子内部,您可以保持组件代码的清晰性,并专注于应用的核心方面。

更好的资源利用

通过延迟处理较不重要的更新,useDeferredValue允许应用更好地利用可用资源。这可以帮助减少性能瓶颈的可能性,提升应用的整体性能。

何时使用useDeferredValue

useDeferredValue在需要优先处理某些更新的情况下非常有用。一些常见的情况包括:

  • 搜索或过滤大数据集

  • 渲染复杂的可视化或动画

  • 后台从服务器更新数据

  • 处理可能影响用户交互的计算密集型操作

让我们看一个例子,useDeferredValue 特别有用。想象我们有一个大列表的项目,我们想根据用户输入进行过滤。过滤大列表可能计算开销大,因此使用 useDeferredValue 可以帮助保持应用程序的响应:

import React, { memo, useState, useMemo, useDeferredValue } from "react";

function App() {
  const [filter, setFilter] = useState("");
  const deferredFilter = useDeferredValue(filter);

  const items = useMemo(() => generateLargeListOfItems(), []);
  const filteredItems = useMemo(() => {
    return items.filter((item) => item.includes(deferredFilter));
  }, [items, deferredFilter]);

  return (
    <div>
      <input
        type="text"
        value={filter}
        onChange={(event) => setFilter(event.target.value)}
      />
      <ItemList items={filteredItems} />
    </div>
  );
}

const ItemList = memo(({ items }) => {
  // Render the list of items
});

function generateLargeListOfItems() {
  // Generate a large list of items for the example
}

在这个例子中,我们使用 useDeferredValue 来推迟渲染过滤后的列表。当用户在过滤输入中输入时,推迟的值更新频率较低,允许应用程序优先处理用户输入并保持响应。

useMemo 钩子用于记忆项目和 filteredItems 数组,防止不必要的重新渲染和重新计算。这进一步提升了应用程序的性能。

不适合使用 useDeferredValue 的情况

尽管 useDeferredValue 在某些场景下有益,但认识到其中的权衡是很重要的。通过推迟更新,显示给用户的数据可能略有过时。虽然这通常对较不重要的更新是可以接受的,但重要的是要考虑向用户显示过时数据的影响。

在决定是否使用 useDeferredValue 时,可以问自己一个好问题:“这个更新是否来自用户输入?”

React 之所以被称为 React,是因为它使我们的 Web 应用程序能够对事物做出反应。任何使用户期望得到反应的东西都不应被推迟。其他所有事情则应该推迟。

尽管 useDeferredValue 的使用可以极大地增强应用程序在负载下的响应能力,但不应视为灵丹妙药。永远记住,提高性能的最佳方式是编写高效的代码并避免不必要的计算。

并发渲染的问题

尽管并发渲染可以实现高性能和响应用户交互,但也给开发者带来了新的问题需要考虑。主要问题是很难理解更新处理的顺序,这可能导致意外行为和 bug。

其中一种 bug 称为 撕裂,其中由于更新被处理的顺序不正确,导致 UI 变得不一致。当组件依赖于在其仍在渲染时更新的某些值时,就会发生这种情况,从而导致应用程序使用不一致的数据进行渲染。让我们深入了解一下这个问题。

撕裂

撕裂 是一个 bug,在组件依赖某些在应用程序仍在渲染时更新的状态时发生。要理解这一点,让我们对比同步渲染和并发渲染。

在同步世界中,React 会遍历组件树并依次从顶部到底部渲染它们。这确保了应用程序在整个渲染过程中状态一致,因为每个组件都是使用最新的状态进行渲染的。

考虑这个例子:

import { useState, useSyncExternalStore, useTransition } from "react";

// External state
let count = 0;
setInterval(() => count++, 1);

export default function App() {
  const [name, setName] = useState("");
  const [isPending, startTransition] = useTransition();

  const updateName = (newVal) => {
    startTransition(() => {
      setName(newVal);
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => updateName(e.target.value)} />
      {isPending && <div>Loading...</div>}
      <ul>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
      </ul>
    </div>
  );
}

const ExpensiveComponent = () => {
  const now = performance.now();

  while (performance.now() - now < 100) {
    // Do nothing, just wait.
  }

  return <>Expensive count is {count}</>;
};

在我们应用的顶部,我们有 count:一个我们全局设置并通过 setInterval 在 React 渲染周期之外不断更新的变量,以便我们可以模拟一个撕裂错误,即在应用程序渲染时更新它。由于渲染是并发和可中断的,ExpensiveComponent 可能会以不同的 count 值被渲染,导致向用户显示不一致的数据或撕裂。

我们期望在 ExpensiveComponent 内部渲染的 count 值不一致,因为 React 在用户输入时“停止”渲染以优先处理更紧急的更新,比如更新文本输入字段,从而在 ExpensiveComponent 中留下了 count 的旧值,但并非总是如此。

我们的示例渲染了一个文本输入字段和五个 ExpensiveComponent 的列表。这些组件故意没有进行记忆化,以说明一个问题,因为它们会导致性能问题,我们需要这些性能问题来识别撕裂以便理解。在实际应用中,你会想要在 ExpensiveComponent 中使用 React.memo 进行包装。在这里,我们故意避免这样做来演示撕裂——你将希望在你的应用程序中避免这种情况。

ExpensiveComponent 需要花费很长时间来渲染,模拟计算密集型操作。ExpensiveComponent 还显示了 count 变量的当前值,该值每毫秒递增并从外部存储中读取,在本例中是全局命名空间。

如果我们运行这个例子,我们会看到我们渲染的五个 ExpensiveComponent 实例,在输入几个按键后,这些 ExpensiveComponent 将以不同的 count 值进行渲染。

这是因为 ExpensiveComponent 被渲染了五次,每次它被渲染时,count 的值都不同。由于 React 同时渲染组件,ExpensiveComponent 可能会以不同的 count 值被渲染,导致向用户显示不一致的数据。

这被称为撕裂,这是一种错误,可能会在应用程序仍在渲染时发生,当组件依赖于某些在更新的状态时。在这种情况下,ExpensiveComponent 依赖于 count 变量,而该变量在组件仍在渲染时更新,导致应用程序以不一致的数据进行渲染。对于 ExpensiveComponent 的五个实例,我们看到以下输出:

  • 昂贵的计数为 568

  • 昂贵的计数为 568

  • 昂贵的计数为 569

  • 昂贵的计数为 569

  • 昂贵的计数为 570

这是有道理的,因为早期的组件实例被渲染时,更新的 count 值被刷新/提交到 DOM,较低的实例继续被渲染和产生(刷新,更新)新的 count 值。

这并不是一个大问题,因为 React 最终会渲染出一致的状态。更大的问题是当你遇到这样的情况时:

<UserDetails id={user.id} />

使用这段代码,如果用户在渲染之间从全局存储中删除,则会抛出突然的错误,这可能会让用户感到意外。这就是撕裂的问题所在。

要解决这个撕裂问题,React 提供了一个名为useSyncExternalStore的钩子。让我们深入了解一下这个钩子。

useSyncExternalStore

useSyncExternalStore是一个 React Hook,允许您将外部状态与应用程序的内部状态同步。在处理可能导致撕裂的计算密集操作时特别有用,如果不正确处理可能会导致撕裂。useSyncExternalStore中的“sync”有双重含义。它是“同步”,但也是“同步的”:它在存储更改时强制同步更新。

useSyncExternalStore钩子的签名如下:

const value = useSyncExternalStore(store.subscribe, store.getSnapshot);

store.subscribe

一个接收回调函数作为其第一个且唯一参数的函数。在此函数内部,您可以订阅外部存储的更改,并在存储更改时调用回调函数。回调可以被视为调用以提示 React 使用新值重新渲染组件的调用。此函数的预期返回是一个清除函数,用于取消订阅存储。

典型的subscribe函数看起来像这样:

const store = {
  subscribe(rerender) {
    const newData = getNewData().then(rerender);
    return () => {
      // unsubscribe somehow
    };
  },
};

一个简单的用例是订阅浏览器事件,比如resize或者scroll事件,在这些事件发生时更新组件,就像这样:

const store = {
  subscribe(rerenderImmediately) {
    window.addEventListener("resize", rerenderImmediately);
    return () => {
      window.removeEventListener("resize", rerenderImmediately);
    };
  },
};

现在,我们的 React 组件将在浏览器窗口大小调整时重新渲染。但是,它如何获取新值?这就是useSyncExternalStore的第二个参数发挥作用的地方。

store.getSnapshot

一个返回外部存储当前值的函数。每当组件渲染时调用此函数,并使用返回的值来更新组件的内部状态。此函数是同步调用的,因此不应执行任何异步操作或具有任何副作用。此外,此函数确保在渲染时状态在组件的多个实例之间保持一致。

要跟随我们的窗口大小调整示例,这是我们如何获取当前窗口大小的方法:

const store = {
  subscribe(immediatelyRerenderSynchronously) {
    window.addEventListener("resize",
      immediatelyRerenderSynchronously);
    return () => {
      window.removeEventListener("resize",
        immediatelyRerenderSynchronously);
    };
  },
  getSnapshot() {
    return {
      width: window.innerWidth,
      height: window.innerHeight,
    };
  },
};

具有{ width, height }的对象是窗口当前状态的快照,这是useSyncExternalStore将返回的内容。然后,我们可以放心地在组件中使用此对象,确保其状态始终在并发渲染之间保持一致。

我们如何能够有这种信心呢?这是因为immediatelyRerenderSynchronously函数强制同步重新渲染,并且不允许 React 推迟它。这是解决撕裂问题的关键。

现在,让我们看看如何使用useSyncExternalStore来解决我们上一个示例中的撕裂问题。如果我们回想一下,由于撕裂,我们看到了一个ExpensiveComponent列表,其渲染的count具有不同的值。让我们看看如何使用useSyncExternalStore来修复这个问题。

首先,我们不想在订阅商店并且当更新发生时重新渲染 React;相反,我们希望当用户输入导致重新渲染时,有一致的状态。因此我们的subscribe函数将是空的,但是为了得到一致的状态,我们将使用getSnapshot函数来获取count的当前值并返回它:

const store = {
  subscribe() {},
  getSnapshot() {
    return count;
  },
};

这是我们之前示例中使用useSyncExternalStore的效果:

import { useState, useSyncExternalStore, useTransition } from "react";

let count = 0;
setInterval(() => count++, 1);

export default function App() {
  const [name, setName] = useState("");
  const [, startTransition] = useTransition();

  const updateName = (newVal) => {
    startTransition(() => {
      setName(newVal);
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => updateName(e.target.value)} />
      <ul>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
      </ul>
    </div>
  );
}

const ExpensiveComponent = () => {
  // Instead of reading count globally,
  // we'll use the hook to ensure consistent state
  const consistentCount = useSyncExternalStore(
    () => {},
    () => count
  );

  const now = performance.now();
  while (performance.now() - now < 100) {
    // Do nothing
  }

  return <>Expensive count is {consistentCount}</>;
};

现在,如果我们运行此示例,我们将看到ExpensiveComponent使用相同的count值重新渲染,从而防止撕裂的发生。这是因为useSyncExternalStore钩子确保在渲染时状态跨组件的多个实例之间保持一致。

我们不使用subscribe函数,因为它的目的是告诉 React 何时使用最新状态重新渲染,但在我们的情况下,我们只想要在渲染之间保持状态一致。我们使用getSnapshot函数获取count的当前值并返回它,确保在渲染时状态在组件的多个实例之间保持一致。

这是我们如何在之前示例中使用useSyncExternalStore解决撕裂问题的方式,确保在渲染时状态跨组件的多个实例之间保持一致。

这确保了当文本输入变化并且ExpensiveComponent重新渲染时,它将具有与ExpensiveComponent的其他实例相同的count值,从而防止撕裂。但是,如果我们想要在ExpensiveComponent内部以与我们在ExpensiveComponent外部更新count的相同间隔更新count,那该怎么办呢?

我们只需为此创建一个遵循相同更新规则的存储:

import { useState, useSyncExternalStore, useTransition } from "react";

let count = 0;
setInterval(() => count++, 1);

const store = {
  subscribe(forceSyncRerender) {
    // Whenever count changes,
    forceSyncRerender();
  },
  getSnapshot() {
    return count;
  },
};

export default function App() {
  const [name, setName] = useState("");
  const [, startTransition] = useTransition();

  const updateName = (newVal) => {
    startTransition(() => {
      setName(newVal);
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => updateName(e.target.value)} />
      <ul>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
        <li>
          <ExpensiveComponent />
        </li>
      </ul>
    </div>
  );
}

const ExpensiveComponent = () => {
  // Instead of reading count globally,
  // we'll use the hook to ensure consistent state
  const consistentCount = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot
  );

  const now = performance.now();
  while (performance.now() - now < 100) {
    // Do nothing
  }

  return <>Expensive count is {consistentCount}</>;
};

现在,每当count变化时,ExpensiveComponent将会重新渲染,并且我们会看到所有ExpensiveComponent实例中的count都显示相同的新值。变更检测逻辑本身可以简单也可以复杂,但关键是我们理解了useSyncExternalStore如何保证其主要功能的机制,即:

  • 确保并发渲染时状态保持一致

  • 当存储更改时强制同步重新渲染

现在我们理解了useSyncExternalStore的工作原理和如何解决撕裂问题,不仅对 React 中的并发渲染有了牢固的掌握,还知道如何解决一些相关问题。这对于作为 React 开发者而言是一个很好的起点。

这是一次相当深入的探索,但我们快要完成了。让我们来回顾一下。

章节回顾

这场全面的对话聚焦于并发 React 的深度探索,涉及多个方面,包括 Fiber 调和器、调度、推迟更新、渲染通道以及新的钩子,如useTransitionuseDeferredValue

我们首先讨论了 Fiber 协调器,这是 React 并发渲染引擎的核心。它是框架能够将工作分解为更小块并管理执行优先级的算法,使得 React 可以“中断”并支持并发渲染。这显著促进了 React 处理复杂、高性能应用程序的能力,确保用户交互在重计算过程中仍能保持响应。

然后我们转向了调度和推迟更新的概念,这本质上允许 React 优先处理某些状态更新而不是其他。React 可以推迟低优先级更新以支持高优先级更新,从而在重负载下保持流畅的用户体验。一个例子展示了聊天应用程序中如何智能调度和渲染传入消息更新,而不阻塞用户界面。

讨论随后转向了渲染通道,这是 React 并发特性的核心概念。渲染通道是 React 使用的一种机制,用于为更新分配优先级并有效管理它们的执行。它是 React 如何决定哪些更新是紧急且需要立即处理,哪些可以推迟到稍后的秘密。详细解释提到这些渲染通道如何使用位掩码来高效处理多个优先级。

然后,我们深入介绍了 React 中为并发操作引入的新钩子 useTransitionuseDeferredValue。这些钩子旨在处理过渡并提供更流畅的用户体验,特别是对于需要较长时间操作的情况。

首先讨论了 useTransition 钩子,它允许 React 在确保响应用户界面的同时过渡到不同状态,即使新状态需要一段时间准备。换句话说,它允许将更新延迟到下一个渲染周期,如果组件当前正在渲染。

我们还讨论了 useDeferredValue 钩子,它推迟了组件较不重要部分的更新,从而避免了用户体验不佳。它基本上允许 React “保持”先前的值更长时间,如果新值花费太多时间。

最后,我们深入探讨了并发中的问题,包括 tearing,并探讨了 useSyncExternalStore 如何帮助保持跨多个并发渲染一致的状态。

在整个对话中,重复出现的主题是理解 React 处理复杂、动态应用程序和重计算时的策略的“什么”和“为什么”,以及开发者如何利用这些策略来提供流畅、响应迅速的用户体验。

复习问题

让我们问自己几个问题,以测试我们对本章概念的理解:

  1. Fiber 协调器在 React 中的作用是什么,它如何有助于处理复杂的高性能应用程序?

  2. 解释 React 中调度和推迟更新的概念。它如何帮助在高负载下保持流畅的用户体验?

  3. 什么是 React 中的渲染通道,它们如何管理更新的执行?您能描述渲染通道如何使用位掩码处理多个优先级吗?

  4. useTransitionuseDeferredValue钩子在 React 中的目的是什么?描述每个钩子有益的情境。

  5. 何时使用useDeferredValue可能不合适?使用这些钩子涉及哪些权衡?

接下来

现在您深刻理解了 React 的并发特性及其内部工作原理,可以充分利用它在构建高性能应用程序方面的潜力。在第八章,我们将探讨建立在 React 之上的各种流行框架,如 Next.js 和 Remix,它们通过提供最佳实践、约定和额外功能进一步简化开发过程。

这些框架旨在帮助您轻松构建复杂的应用程序,处理许多常见问题,如服务器渲染、路由和代码拆分。通过利用这些框架的力量,您可以专注于构建应用程序的功能和功能,同时确保优化性能和用户体验。

敬请期待对这些强大框架的深入探讨,了解如何利用 React 及其生态系统构建可扩展、高性能和功能丰富的应用程序。

第八章:框架

在我们迄今为止的 React 之旅中,我们已经揭示了一系列功能和原则,这些功能和原则为其功能和多功能性做出了贡献。前一章深入探讨了异步 React 的迷人世界,这使我们能够使用useTransitionuseDeferredValue等工具来创建高度响应和用户友好的界面。我们探索了这些工具如何利用 React 的复杂调度和优先级机制,这是由 Fiber reconciler 可能实现的,以实现最佳性能。在本章中,理解这些异步模式对我们进入 React 框架的领域至关重要。

单独使用 React 本身非常强大,但随着应用程序复杂度的增加,我们经常发现自己重复使用类似的模式或需要更简化的解决方案来应对常见挑战。这就是框架的作用。React 框架是建立在 React 之上的软件库或工具包,提供额外的抽象以更有效地处理常见任务,并强制执行最佳实践。

为什么我们需要一个框架

虽然 React 提供了创建交互式用户界面的构建块,但它在许多重要的架构决策上留给开发人员。在这方面,React 并没有明确的意见,使开发人员能够以他们认为合适的方式组织他们的应用程序。然而,随着应用程序的扩展,这种自由可能会成为负担。您可能会发现自己一遍又一遍地重新发明轮子,处理诸如路由、数据获取和服务器端渲染等常见挑战。

这就是 React 框架的作用。它们提供了一个预定义的结构和常见问题的解决方案,使开发人员能够专注于其应用程序的独特之处,而不是陷入样板代码中。这可以显著加速开发过程,并通过遵循框架强制执行的最佳实践来改善代码库的质量。

要完全理解这一点,让我们试着编写我们自己的最小框架。为了做到这一点,我们需要确定我们从框架中获得而不容易从纯 React 中获得的一些关键功能。为简洁起见,我们将确定这些行中的三个关键特征,这些特征将帮助我们形成一个很好的讨论基础:

  • 服务器渲染

  • 路由

  • 数据获取

让我们从一个现有的想象中的 React 应用程序开始,并逐步添加这些功能,以了解框架对我们的意义。我们“框架化”的 React 应用程序的结构如下:

- index.js
- List.js
- Detail.js
- dist/
  - clientBundle.js

这是每个文件的样子:

// index.js

import React from "react";
import { createRoot } from "react-dom/client";
import Router from "./Router";

const root = createRoot(document);

const params = new URLSearchParams();
const thingId = params.get("id");

root.render(
  window.location.pathname === "/" ? <List /> : <Detail thingId={thingId} />
);

// List.js

export const List = () => {
  const [things, setThings] = useState([]);
  const [requestState, setRequestState] = useState("initial");
  const [error, setError] = useState(null);

  useEffect(() => {
    setRequestState("loading");
    fetch("https://api.com/get-list")
      .then((r) => r.json())
      .then(setThings)
      .then(() => {
        setRequestState("success");
      })
      .catch((e) => {
        setRequestState("error");
        setError(e);
      });
  }, []);

  return (
    <div>
      <ul>
        {things.map((thing) => (
          <li key={thing.id}>{thing.label}</li>
        ))}
      </ul>
    </div>
  );
};

// Detail.js

export const Detail = ({ thingId }) => {
  const [thing, setThing] = useState([]);
  const [requestState, setRequestState] = useState("initial");
  const [error, setError] = useState(null);

  useEffect(() => {
    setRequestState("loading");
    fetch("https://api.com/get-thing/" + thingId)
      .then((r) => r.json())
      .then(setThings)
      .then(() => {
        setRequestState("success");
      })
      .catch((e) => {
        setRequestState("error");
        setError(e);
      });
  }, []);

  return (
    <div>
      <h1>The thing!</h1>
      {thing}
    </div>
  );
};

这对所有仅客户端渲染的 React 应用程序都存在一些问题:

我们向用户发送一个只加载代码的空白页面,然后解析和执行 JavaScript。

用户在 JavaScript 启动之前会下载一个空白页面,然后他们会得到我们的应用程序。如果用户是搜索引擎,则可能看不到任何内容。如果搜索引擎爬虫不支持 JavaScript,则搜索引擎将不会索引我们的网站。

我们开始太晚获取数据。

我们的应用程序遭遇了用户体验的诅咒,称为网络瀑布:这种现象发生在网络请求连续进行并减慢应用程序速度时。我们的应用程序必须对服务器进行多次请求以实现基本功能。例如,它执行如下:

  • 下载、解析和执行 JavaScript。

  • 渲染和提交 React 组件。

  • useEffect开始获取数据。

  • 渲染和提交加载器等。

  • useEffect完成数据获取。

  • 渲染和提交数据。如果我们直接向浏览器提供带有数据的页面,那么所有这些都可以避免:如果我们像第七章中介绍的那样,在服务器端渲染 React 时发送 HTML 标记。

我们的路由器完全基于客户端。

如果浏览器请求https://our-app.com/detail?thingId=24,服务器将返回 404 页面,因为服务器上没有这样的文件。为了解决这个问题,通常会使用一个常见的技巧,当遇到 404 时渲染一个 HTML 文件,该文件加载 JavaScript 并由客户端路由接管。但这种方法对搜索引擎或者 JavaScript 支持有限的环境不起作用。

框架有助于解决所有这些问题以及更多。让我们具体探讨它们是如何做到的。

服务器端渲染

首先,框架通常会为我们提供服务器端渲染。要将服务器端渲染添加到此应用程序中,我们需要一个服务器。我们可以使用像 Express.js 这样的包自己编写一个服务器,然后部署这个服务器,这样就可以使用了。让我们探讨一下将为这样一个服务器提供动力的代码。

在我们继续之前,请注意我们仅仅出于简化和说明框架如何实现这些特性的底层机制,才使用renderToString。在真实的生产用例中,通常更好地依赖于更强大的异步 API,比如像renderToPipeableStream,就像第六章中所介绍的那样。

说完这些,让我们开始吧:

// ./server.js

import express from "express";
import { renderToString } from "react-dom/server"; // Covered in Chapter 6

import { List } from "./List";
import { Detail } from "./Detail";

const app = express();

app.use(express.static("./dist")); // Get static files like client JS, etc.

const createLayout = (children) => `<html lang="en">
<head>
 <title>My page</title>
</head>
<body>
  ${children}
 <script src="/clientBundle.js"></script>
</body>
<html>`;

app.get("/", (req, res) => {
  res.setHeader("Content-Type", "text/html");
  res.end(createLayout(renderToString(<List />)));
});

app.get("/detail", (req, res) => {
  res.setHeader("Content-Type", "text/html");
  res.end(
    createLayout(renderToString(<Detail thingId={req.params.thingId} />))
  );
});

app.listen(3000, () => {
  console.info("App is listening!");
});

这段代码就是我们为应用程序添加服务器端渲染所需的全部内容。请注意,在客户端的index.js中有其自己的客户端路由,并且我们基本上只是为服务器添加了另一个路由。框架提供同构路由,这些路由可以在客户端和服务器上工作。

路由

尽管这个服务器还可以,但它的扩展性不佳:每添加一个路由,我们就得手动添加更多的 req.get 调用。让我们让这个更具可扩展性一点。我们可以通过多种方式解决这个问题,比如使用一个配置对象将路由映射到组件,或者基于文件系统的路由。为了教育的目的(实际上也很有趣),让我们来探索基于文件系统的路由。这是 Next.js 等框架约定和观点背后推理及机制变得更加清晰的地方。当我们强制规定所有页面必须放在./pages目录中,且该目录中的所有文件名都成为路由路径时,我们的服务器可以依赖这个约定作为一个假设,从而变得更具可扩展性。

让我们用一个例子来说明这一点。首先,我们将扩展我们的目录结构。新的目录结构如下所示:

- index.js
- pages/
  - list.js
  - detail.js
- dist/
  - clientBundle.js

现在,我们可以假设pages中的所有内容都变成了一个路由。让我们更新我们的服务器以匹配这个结构:

// ./server.js

import express from "express";
import { join } from "path";
import { renderToString } from "react-dom/server"; // Covered in Chapter 6

const app = express();

app.use(express.static("./dist")); // Get static files like client JS, etc.

const createLayout = (children) => `<html lang="en">
<head>
 <title>My page</title>
</head>
<body>
  ${children}
 <script src="/clientBundle.js"></script>
</body>
<html>`;

app.get("/:route", async (req, res) => {
  // Import the route's component from the pages directory
  const exportedStuff = await import(
    join(process.cwd(), "pages", req.params.route)
  );

  // We can no longer have named exports because we need predictability
  // so we opt for default exports instead.
  // `.default` is standardized and therefore we can rely on it.
  const Page = exportedStuff.default;

  // We can infer props from the query string maybe?
  const props = req.query;

  res.setHeader("Content-Type", "text/html");
  res.end(createLayout(renderToString(<Page {...props} />)));
});

app.listen(3000, () => {
  console.info("App is listening!");
});

现在,由于我们采用了新的./pages目录约定,我们的服务器的扩展性大大提高了!太棒了!但是,现在我们被迫让每个页面的组件成为默认导出,因为我们的方法更通用,否则无法预测导入的名称。这是使用框架时的一种权衡。在这种情况下,这种权衡似乎是值得的。

数据获取

很棒!我们完成了三个目标中的两个。我们已经实现了服务器渲染和基于文件系统的路由,但我们仍然受到网络瀑布效应的影响。让我们解决数据获取的问题。首先,我们将更新我们的组件以通过 props 接收初始数据。为了简单起见,我们将只处理 List 组件,把 Detail 组件留给你做作业:

// ./pages/list.jsx
// Note the default export for filesystem-based routing.

export default function List({ initialThings } /* <- adding initial prop */) {
  const [things, setThings] = useState(initialThings);
  const [requestState, setRequestState] = useState("initial");
  const [error, setError] = useState(null);

  // This can still run to fetch data if we need it to.
  useEffect(() => {
    if (initialThings) return;
    setRequestState("loading");
    fetch("https://api.com/get-list")
      .then((r) => r.json())
      .then(setThings)
      .then(() => {
        setRequestState("success");
      })
      .catch((e) => {
        setRequestState("error");
        setError(e);
      });
  }, [initialThings]);

  return (
    <div>
      <ul>
        {things.map((thing) => (
          <li key={thing.id}>{thing.label}</li>
        ))}
      </ul>
    </div>
  );
}

很好。现在我们添加了一个初始 prop,我们需要一种方法来在服务器上获取这个页面所需的数据,然后将其传递给组件以在渲染之前使用。让我们探讨一下我们可以如何做到这一点。理想情况下,我们想要做的是这样的:

// ./server.js

import express from "express";
import { join } from "path";
import { renderToString } from "react-dom/server"; // Covered in Chapter 6

const app = express();

app.use(express.static("./dist")); // Get static files like client JS, etc.

const createLayout = (children) => `<html lang="en">
<head>
 <title>My page</title>
</head>
<body>
  ${children}
 <script src="/clientBundle.js"></script>
</body>
<html>`;

app.get("/:route", async (req, res) => {
  const exportedStuff = await import(
    join(process.cwd(), "pages", req.params.route)
  );

  const Page = exportedStuff.default;

  // Get component's data
  const data = await exportedStuff.getData();
  const props = req.query;

  res.setHeader("Content-Type", "text/html");

  // Pass props and data
  res.end(createLayout(renderToString(<Page {...props} {...data.props} />)));
});

app.listen(3000, () => {
  console.info("App is listening!");
});

这意味着我们需要从任何需要数据的页面组件导出一个名为 getData 的获取器函数!让我们调整列表来完成这一点:

// ./pages/list.jsx

// We'll call this on the server and pass these props to the component
export const getData = async () => {
  return {
    props: {
      initialThings: await fetch("https://api.com/get-list").then((r) =>
        r.json()
      ),
    },
  };
};

export default function List({ initialThings } /* <- adding initial prop */) {
  const [things, setThings] = useState(initialThings);
  const [requestState, setRequestState] = useState("initial");
  const [error, setError] = useState(null);

  // This can still run to fetch data if we need it to.
  useEffect(() => {
    if (initialThings) return;
    setRequestState("loading");
    getData()
      .then(setThings)
      .then(() => {
        setRequestState("success");
      })
      .catch((e) => {
        setRequestState("error");
        setError(e);
      });
  }, [initialThings]);

  return (
    <div>
      <ul>
        {things.map((thing) => (
          <li key={thing.id}>{thing.label}</li>
        ))}
      </ul>
    </div>
  );
}

搞定!现在我们是这样的:

  • 在每个文件的每个路由上尽可能早地获取数据

  • 作为 HTML 字符串渲染完整页面

  • 将其发送到客户端

我们已经成功地添加并理解了我们从各种框架中识别并实现的三个特性的基本版本。通过这样做,我们学会了并理解了框架实现其功能的基本机制。具体来说,我们学会了框架如何:

  • 为我们提供服务器渲染

  • 具有受文件系统影响的同构路由

  • 通过导出的函数获取数据

如果您之前使用过 Next.js 版本低于 13,那么它各种模式背后的推理应该在这一点上变得非常清晰,特别是围绕:

  • ./pages目录

  • 所有页面的导出都是默认导出

  • getServerSidePropsgetStaticProps

现在我们了解了框架在代码层面的机制以及一些约定背后的原因,让我们放大视野,总结使用框架的好处。

使用框架的好处

使用框架的好处包括:

抽象

框架强制执行一定的结构和模式来组织代码库。这导致一致性,使新开发人员更容易理解应用程序的流程。它还使我们能够专注于我们的产品和功能,而不必担心如何构建代码的细枝末节。

最佳实践

框架通常内置了最佳实践,鼓励开发人员遵循。这可以提高代码质量,减少错误。例如,框架可能鼓励您尽早获取数据—即,在服务器端—而不是等待客户端获取数据。这可以提高性能和用户体验。

性能优化

框架提供更高级别的抽象来处理常见任务,如路由、数据获取、服务器渲染等。这可以使您的代码更清晰、更易读,更易于维护,同时依赖更广泛的社区来确保这些抽象的质量。Next.js 提供的useRouter钩子就是一个例子,它使得在组件中访问路由变得容易。

社区和生态系统

许多框架都带有开箱即用的优化,如代码拆分、服务器端渲染和静态站点生成。这些可以显著提高应用程序的性能。例如,Next.js 自动对应用程序进行代码拆分,并在用户悬停在链接上时预加载下一页的代码,从而实现更快的页面转换。

流行的框架拥有庞大的社区和丰富的插件和库生态系统。这意味着如果遇到问题,你通常可以快速找到解决方案或获得帮助。

流程和一致性

使用框架的权衡

尽管框架有许多优点,但也不是没有权衡之处。了解这些可以帮助您就是否使用框架以及选择哪个框架做出明智的决定:

学习曲线

每个框架都有自己的一套概念、API 和约定,您需要学习。如果您是 React 的新手,同时尝试学习一个框架可能会让人不知所措,但仍然值得推荐。如果您已经熟悉 React,您需要投入时间学习框架的特定功能和 API。

灵活性与约定

尽管框架强制的结构和约定可以是一个福音,但它们也可能是限制性的。如果你的应用程序有独特的要求,不适合框架的模型,你可能会发现自己与框架作斗争,而不是得到它的帮助。有些情况下,你为特定用户群体建设,这些用户有快速的互联网和现代浏览器,并且不需要服务器端渲染或数据获取。在这些情况下,框架可能会显得过于笨重。

依赖性和承诺

选择一个框架就是一种承诺。你正在将你的应用程序与框架的命运联系在一起。如果框架停止维护,或者采取了与你需求不符的方向,你可能需要面临是否进行昂贵迁移或自行维护现有框架代码的困难决策。

抽象层的开销

尽管抽象层可以通过隐藏复杂性来简化开发,但它们也可能创建“魔法”,使得很难理解底层发生了什么。这可能会使调试和性能优化变得复杂。此外,每个抽象层都伴随着一些开销,可能会影响性能。例如,在 Next.js 中的服务器动作中,使用"use server"指令会以某种魔法方式使动作在服务器上运行。这是一个很好的抽象,但理解它的工作原理可能会有难度。

现在我们明白了为什么要使用 React 框架,以及涉及其中的利弊后,我们可以深入探讨 React 生态系统中具体的框架。在本章的接下来的部分中,我们将探索一些流行的选择,如 Next.js 和 Remix。每个框架都提供独特的特性和优势,了解它们将帮助你选择适合特定需求的正确工具。

流行的 React 框架

让我们探索一些流行的 React 框架,并讨论它们的特点、优势和权衡。我们将从每个框架的简要概述开始,然后详细比较它们的特性和性能。在选择项目框架时,我们还将讨论一些需要考虑的因素。

Remix

Remix 是一个强大的现代 Web 框架,利用了 React 和 Web 平台的特性。让我们从一些实际例子开始,了解它的工作原理。

一个基本的 Remix 应用程序

首先,我们将设置一个基本的 Remix 应用程序。你可以使用npm来安装 Remix:

npm create remix@2.2.0

这将在你当前的目录中创建一个新的 Remix 项目。让我们四处看看里面有什么。首先,我们有一个app目录,其中包含entry.client.tsxentry.server.tsx。还有一个root.tsx在这个目录中。

一开始,我们可以立即看到 Remix 默认支持客户端和服务器端入口点。此外,root.tsx 包含一个共享布局组件,该组件在每个页面上都会渲染。这是 Remix 提供的一个预定义结构的很好示例,帮助您快速入门。

服务器端渲染

Remix 通过其 entry.server.tsx 默认支持服务器端渲染。文件由框架为我们生成,但让我们稍微理解一下它。它看起来是这样的:

import { PassThrough } from "node:stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  loadContext: AppLoadContext
) {
  return isbot(request.headers.get("user-agent"))
    ? handleBotRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      )
    : handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer
        context={remixContext}
        url={request.url}
        abortDelay={ABORT_DELAY}
      />,
      {
        onAllReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer
        context={remixContext}
        url={request.url}
        abortDelay={ABORT_DELAY}
      />,
      {
        onShellReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

Remix 的一个很棒的特性是,这个文件在内部使用,但在此处暴露出来供我们定制。如果我们删除这个文件,Remix 将会使用其内部默认的实现。这是一个很好的逃生口,允许我们根据需要定制服务器渲染行为,而不会被框架的“魔法”所束缚。

该文件定义了在我们的 Remix 应用程序中生成和处理 HTTP 响应的方式,特别是关于如何处理来自机器人和常规浏览器的请求的不同方式。Remix 是一个用于构建现代 React 应用程序的框架,而这个文件是 Remix 应用程序的服务器端逻辑的一部分。

最初,文件从各种库中导入必要的模块和类型,例如 node:stream@remix-run/node@remix-run/reactisbotreact-dom/server。它定义了一个名为 ABORT_DELAY 的常量,其值为 5,000 毫秒,用作渲染操作的超时期限。

文件导出了一个名为 handleRequest 的默认函数,该函数接受多个参数,包括 HTTP 请求、响应状态码、响应头以及 Remix 和应用程序加载过程的上下文。在 handleRequest 内部,它检查传入请求的用户代理,以确定请求是否来自使用 isbot 库的机器人。根据请求来自机器人还是浏览器,它将处理委托给 handleBotRequesthandleBrowserRequest 函数。

这有助于 SEO 和性能。例如,如果请求来自机器人,则确保响应包含页面的渲染 HTML 内容非常重要,这正是 handleBotRequest 的作用。另一方面,如果请求来自常规浏览器,则确保响应包含页面的渲染 HTML 内容以及必要的 JavaScript 代码以水合页面非常重要,这正是 handleBrowserRequest 的作用。很酷的是,Remix 自动为我们处理了这些。

handleBotRequesthandleBrowserRequest函数在结构上非常相似,但在渲染外壳准备就绪或遇到错误时有不同的处理程序。它们返回一个解析为 HTTP 响应的承诺。他们通过renderToPipeableStream启动一个到可管道流的渲染操作,传入一个RemixServer组件以及来自请求的必要上下文和 URL。他们定义了一个超时时间,如果渲染操作时间超过ABORT_DELAY,则中止渲染操作。

在渲染操作的事件处理程序中,他们创建了一个PassThrough流和从中读取的可读流。他们为响应设置了Content-Type头为text/html。他们使用封装了流、响应头和状态码的新Response对象来解析承诺。在渲染过程中出现错误时,他们要么拒绝承诺,要么将错误记录到控制台,这取决于错误发生的渲染阶段。

该文件主要确保正确生成并返回 HTTP 响应,根据请求来自机器人还是常规浏览器的不同渲染逻辑应用,这对于现代 Web 应用程序的 SEO 和性能考虑至关重要。

如果我们没有自定义内容要添加,我们可以简单地删除这个文件,Remix 会为我们处理服务器渲染。现在让我们保留它,并探索一下 Remix 如何处理路由。

路由

在 Remix 中,每个路由都由routes目录中的一个文件表示。如果我们创建./routes/cheese.tsx,其默认导出为:

export default function CheesePage() {
  return <h1>This might sound cheesy, but I think you're really grate!</h1>;
}

然后用npm run dev运行本地开发服务器,我们会看到一个有趣标题的页面。再次看到 Remix 提供了一个预定义的结构,帮助您快速入门,而这种约定中默认导出的价值类似于我们之前基于文件系统的路由实现。当与./app/root.tsx中的共享布局组件以及服务器和客户端入口点结合使用时,这构成了大多数网站的基础。然而,对于现代 Web 应用程序,我们仍然缺少一个至关重要的组件:数据获取。让我们看看 Remix 如何处理这个问题。

数据获取

在撰写本文时,Remix 的数据获取故事涉及使用称为loaders的函数。当您导出一个名为loader的异步函数,返回某个值时,这个值通过useLoaderData钩子可在页面组件中使用。让我们通过一个示例看看这是如何工作的。

要回到我们的奶酪页面,假设我们想从 API 获取奶酪列表并在页面上显示它们。我们可以通过从./routes/cheese.tsx导出一个loader函数来实现这一点:

// Get some utils
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

// The loader
export async function loader() {
  const data = await fetch("https://api.com/get-cheeses");
  return json(await data.json());
}

export default function CheesePage() {
  const cheeses = useLoaderData();
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <ul>
        {cheeses.map((cheese) => (
          <li key={cheese.id}>{cheese.name}</li>
        ))}
      </ul>
    </div>
  );
}

通过这个例子,我们可以看到我们之前实现的数据获取的一种重复。我们可以看到 Remix 的 loader 函数与我们自己的 getData 函数相似。我们还可以看到 useLoaderData hook 与我们自己的 initialThings prop 相似。理想情况下,此时我们能够了解框架如何实现这些功能背后的共同模式和基本机制。

到目前为止,我们已经涵盖了:

  • 服务器渲染

  • 路由

  • 数据获取

但是还有一个 Remix 功能我们还没有涉及到:表单和服务器操作,或者说变异——即在服务器上改变数据,比如创建、更新或删除数据。让我们接着来探索这个。

数据变异

Remix 负责将网络带回基础,严重依赖于原生网络平台的约定和行为。这在数据变异和 Remix 对表单的使用上最为明显。让我们通过一个例子来探索这一点,扩展我们之前的奶酪例子:让奶酪列表可变。为此,让我们首先更新我们的 ./routes/cheese.tsx 文件:

// Get some utils
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

// The loader
export async function loader() {
  const data = await fetch("https://api.com/get-cheeses");
  return json(await data.json());
}

export default function CheesePage() {
  const cheeses = useLoaderData();
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <ul>
        {cheeses.map((cheese) => (
          <li key={cheese.id}>{cheese.name}</li>
        ))}
      </ul>
      <form action="/cheese" method="post">
        <input type="text" name="cheese" />
        <button type="submit">Add Cheese</button>
      </form>
    </div>
  );
}

注意我们在页面中添加了一个新的 form 元素。这个表单的 action 是 /cheese,方法是 post。这是一个标准的 HTML 表单,将向 /cheese 路由提交一个 POST 请求。此外,input 元素有一个 name 属性,没有 useStateonChange 处理程序:Remix 让浏览器管理表单的状态和行为。这是 Remix 如何依赖网络平台提供出色开发者体验的一个很好的例子,而不是让 React 管理一切。

鉴于表单的 action 属性是 /cheese,而我们已经在 ./routes/cheese.tsx 文件中,我们可以假设表单将提交到相同的路由。当这个路由使用 POST 方法访问时,我们知道表单已经提交。当这个路由默认使用 GET 方法访问时,我们知道表单尚未提交,而是显示初始 UI。

让我们更新我们的 ./routes/cheese.tsx 文件来处理这个问题:

// Get some utils
import { json, ActionFunctionArgs, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

// The loader
export async function loader() {
  const data = await fetch("https://api.com/get-cheeses");
  return json(await data.json());
}

// The form action
export async function action({ params, request }: ActionFunctionArgs) {
  const formData = await request.formData();

  await fetch("https://api.com/add-cheese", {
    method: "POST",
    body: JSON.stringify({
      name: formData.get("cheese"),
    }),
  });

  return redirect("/cheese"); // Come back to this page, but with GET this time.
}

export default function CheesePage() {
  const cheeses = useLoaderData();
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <ul>
        {cheeses.map((cheese) => (
          <li key={cheese.id}>{cheese.name}</li>
        ))}
      </ul>
      <form action="/cheese" method="post">
        <input type="text" name="cheese" />
        <button type="submit">Add Cheese</button>
      </form>
    </div>
  );
}

注意我们添加了一个新的 action 函数,它接受 paramsrequest 参数。params 参数是一个包含路由参数的对象。request 参数是一个包含请求对象的对象。我们可以使用这个对象从请求中获取表单数据,然后使用它向我们的 API 发送请求以添加新的奶酪。

然后我们返回一个重定向到相同的路由,但这次使用 GET 方法。这将导致页面重新加载,并且 loader 函数将再次被调用以获取更新后的奶酪列表。

这就是 Remix 如何充分依赖网络平台,使 JavaScript 能够在需要的地方使用,并让浏览器处理其余部分。如果这个页面没有使用 JavaScript 访问,它将正常工作,因为它依赖于网络平台。如果页面使用了 JavaScript,Remix 通过添加交互性和更好的用户体验逐步增强体验。

到目前为止,我们已经介绍了 Remix 如何:

  • 提供服务器渲染

  • 处理路由

  • 处理数据获取

  • 处理数据变化

此时,我们应该能够看到我们自己实现这些功能与 Remix 实现之间的强烈对比。这表明我们正在理解框架在实现这些功能背后的机制。

现在让我们考虑一下 Next.js,以及它如何与隔离这些功能背后的共同点做非常类似的事情。

Next.js

Next.js 是由 Vercel 开发的流行 React 框架,以其丰富的功能和简单性在创建服务器端渲染(SSR)和静态网站方面享有盛誉。它遵循“约定优于配置”的原则,减少了启动项目所需的样板代码和决策。随着 Next.js 13 的发布,引入了 Next.js 应用程序路由器是一项重大的新增功能。

让我们通过一个基本的 Next.js 应用程序来了解其工作原理。首先,让我们运行以下命令创建一个新的 Next.js 项目:

npm create next-app@14

这将提出一些问题,但最终我们将得到一个基本的 Next.js 项目。让我们四处看看里面有什么。首先,我们有一个app目录,包括page.tsxlayout.tsxerror.tsxloading.tsx

我们立即注意到的一件事是,Next.js 不像 Remix 那样暴露服务器配置,而是隐藏了大量的复杂性,旨在“避让”,让开发者专注于构建他们的应用程序。这是不同框架在解决相同问题时具有不同哲学和方法的一个很好的例子。

让我们在之前确定的三个关键特性(服务器渲染、路由和数据获取)的背景下探索 Next.js。

服务器渲染

Next.js 不仅提供服务器渲染,而且是面向服务器的。Next.js 中的每个页面和组件都是服务器组件。我们将在第九章中深入探讨服务器组件,但现在可以简单地说,服务器组件是专门在服务器上渲染的组件。目前这种理解已经足够,因为重点在于 Next.js,而不是服务器组件。对于服务器组件,第九章应该已经足够了。

在 Next.js 的背景下,这意味着什么呢?实质上,我们要基于以下原则操作:除非在给定的路由或组件顶部添加"use client"指令,否则所有编写的代码都将在服务器上执行。没有这个指令,所有代码都被视为服务器端代码。

然而,Next.js 也是以静态为先:在构建时,服务器组件尽可能地渲染成静态内容,然后部署。这种服务器为先和静态为先的结合正是 Next.js 如此强大的地方,显著地优化了性能,因为静态内容可以说是最快到达用户的,因为不需要运行时或服务器端处理;它只是文本(HTML)。从静态内容进一步发展的是服务器渲染内容,可以高度优化和缓存,但仍需要服务器来渲染内容。最后一步是通过水合处理客户端渲染内容,用于页面的交互部分。

借助这种方法,Next.js 很适合将较小的 JavaScript 包发送给用户,大部分内容都是一些静态和服务器渲染的标记的混合体。不仅页面,而且组件在服务器上的渲染粒度是 Next.js 的一个强大功能,它支持一些非常强大的数据获取和渲染模式。在深入探讨这些模式之前,让我们先了解一下 Next.js 如何处理路由。

路由

在我们新的 Next.js 项目中看到的是一个 app 目录,包含 layout.tsxpage.tsx。Next.js 遵循这种模式:你在浏览器中看到的用户页面的 URL 是该页面所在目录的名称,app 相当于根目录(/),其下的每个目录都成为一个子路径。

要理解这一点,让我们创建一个名为 cheese 的目录,并在其中添加一个 page.tsx 文件。当 ./app 下的目录有一个 page.tsx 文件时,该目录就成为一个路由。让我们向 ./app/cheese/page.tsx 添加一些内容:

export default function CheesePage() {
  return <h1>This might sound cheesy, but I think you're really grate!</h1>;
}

现在,如果我们运行开发服务器并导航到 /cheese,我们将看到一个具有有趣标题的页面。这里值得注意的是,Next.js 还有一个共享布局的概念,类似于 Remix,在这里你可以在 ./app/layout.tsx 中定义一个布局组件,它将在每个页面上呈现。然后,./app/cheese/layout.tsx 将在 /cheese 路由下的每个页面上呈现。布局通常是跨多个页面共享的路由部分,例如页眉、页脚或其他固定元素。

很好,这就是 Next.js 处理路由的方式。它有点类似于 Remix 和我们基于文件系统的路由实现,但有一个细微的差异,即不是单个文件成为页面,而是整个目录,实际页面总是要求命名为 page.tsx。除此之外,它们非常相似。

让我们谈谈数据获取。

数据获取

因为每个组件都是服务器组件,所以 Next.js 中的每个组件都能够是异步的,因此可以等待数据。让我们尝试像在前面的 Remix 示例中那样获取奶酪,但这次在 Next.js 中进行:

export default async function CheesePage() {
  const cheeses = await fetch("https://api.com/get-cheeses").then((r) =>
    r.json()
  );
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <ul>
        {cheeses.map((cheese) => (
          <li key={cheese.id}>{cheese.name}</li>
        ))}
      </ul>
    </div>
  );
}

如果你读到并且感到印象深刻——是的。这种语法是 React 工程师多年来一直希望的,而且非常自然地可以理解。这是可能的,因为这里的 CheesePage 是一个服务器组件:它不包含在客户端捆绑包中,而是在服务器上渲染。这意味着我们可以 await 数据并直接渲染到页面。

因为所有组件都是服务器组件,我们可以进一步增加粒度,不仅在页面级别获取数据,还可以在组件级别获取数据。考虑将这个页面拆分成更小的组件,其中 CheeseList 可以重复使用,也可以在其他地方使用。

我们的页面将变成这样:

import { CheeseList } from "./CheeseList";

export default function CheesePage() {
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <CheeseList />
    </div>
  );
}

我们的 CheeseList 组件将是这样的:

export async function CheeseList() {
  const cheeses = await fetch("https://api.com/get-cheeses").then((r) =>
    r.json()
  );
  return (
    <ul>
      {cheeses.map((cheese) => (
        <li key={cheese.id}>{cheese.name}</li>
      ))}
    </ul>
  );
}

这种方法的真正威力在于我们可以在组件级别获取数据,然后将其渲染到页面上。我们不导出页面级别的函数,比如 loadergetDatagetServerSidePropsgetStaticProps 或类似的东西。相反,我们只是在组件级别获取数据并将其渲染到页面上。

这些数据会怎样呢?Next.js 在我们部署时用它来静态生成页面的第一次加载,并在后续加载时进行服务器渲染。Next.js 还有许多缓存和去重机制,确保数据的完整性和性能。

最后,让我们通过探索 Next.js 如何处理数据变更来完成比较。

数据变异

Next.js 有一个 服务器动作 的概念,这些是在服务器上运行的函数。这些函数在提交表单、用户点击按钮或用户导航到页面时调用。它们是在服务器上运行的函数,不包含在客户端捆绑包中。

让我们看看如何像在 Remix 示例中那样向列表中添加一种奶酪。为此,我们将在页面上添加一个表单:

import { CheeseList } from "./CheeseList";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

export default function CheesePage() {
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <CheeseList />
      <form
        action={async (formData) => {
          "use server";
          await fetch("https://api.com/add-cheese", {
            method: "POST",
            body: JSON.stringify({
              name: formData.get("cheese"),
            }),
          });
          revalidatePath("/cheese");
          return redirect("/cheese");
        }}
        method="post"
      >
        <input type="text" name="cheese" />
        <button type="submit">Add Cheese</button>
      </form>
    </div>
  );
}

这里我们使用了一个类似 Remix 的标准 HTML 表单,只是这次的 action 属性是一个函数。这个函数是一个服务器动作,在表单提交时调用。这个函数不包含在客户端捆绑包中,而是在服务器上运行。这是通过顶部的 "use server" 指令强制执行的。

我们可以把这个函数移到任何我们想要的地方,包括放入服务器组件的主体中,如下所示:

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

export default function CheesePage() {
  async function addCheese(formData) {
    "use server";
    await fetch("https://api.com/add-cheese", {
      method: "POST",
      body: JSON.stringify({
        name: formData.get("cheese"),
      }),
    });

    revalidatePath("/cheese");
    return redirect("/cheese");
  }

  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <CheeseList />
      <form action={addCheese} method="post">
        <input type="text" name="cheese" />
        <button type="submit">Add Cheese</button>
      </form>
    </div>
  );
}

或者甚至放入一个单独的模块,如下所示:

import { addCheeseAction } from "./addCheeseAction";

export default function CheesePage() {
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <CheeseList />
      <form action={addCheese} method="post">
        <input type="text" name="cheese" />
        <button type="submit">Add Cheese</button>
      </form>
    </div>
  );
}

在这种情况下,addCheeseAction 将位于自己的文件中,并读取如下:

"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

export async function addCheeseAction(formData) {
  await fetch("https://api.com/add-cheese", {
    method: "POST",
    body: JSON.stringify({
      name: formData.get("cheese"),
    }),
  });

  revalidatePath("/cheese");
  return redirect("/cheese");
}

这里存在一个固有的问题,与 Remix 不同,所有组件都是客户端组件,服务器组件根本不支持交互,因为它们不包含在客户端捆绑包中,也从不被浏览器加载;因此,onClick 处理程序实际上从不传递给用户。为了解决这个问题,Next.js 有一个客户端组件的概念,这些组件包含在客户端捆绑包中,并由浏览器加载。这些组件不是服务器组件,因此不能是异步的,也不能有服务器动作。

让我们探讨如何添加一个奶酪,但这次使用一些服务器和客户端组件的混合方式。这也将帮助我们在表单提交时立即通过旋转器或类似方式提供反馈。为此,我们将创建一个新组件,./app/AddCheeseForm.tsx

"use client";
import { addCheeseAction } from "./addCheeseAction";

export function AddCheeseForm() {
  return (
    <form action={addCheeseAction} method="post">
      <input type="text" name="cheese" />
      <button type="submit">Add Cheese</button>
    </form>
  );
}

现在它是一个客户端组件,我们可以做交互式的事情——比如响应表单状态的变化。让我们更新我们的AddCheeseForm来实现这一点:

"use client";
import { addCheeseAction } from "./addCheeseAction";
import { useFormStatus } from "react-dom";

export function AddCheeseForm() {
  const { pending } = useFormStatus();

  return (
    <form action={addCheeseAction} method="post">
      <input disabled={pending} type="text" name="cheese" />
      <button type="submit" disabled={pending}>
        {pending ? "Loading..." : "Add Cheese"}
      </button>
    </form>
  );
}

因为我们的AddCheeseForm是一个客户端组件,我们可以使用useFormStatus来获取表单的状态。这是 React 提供的一个 hook。这个 hook 返回一个对象,其中有一个pending属性,当表单正在提交时为true,当表单没有提交时为false。我们可以利用这一点在表单提交时禁用表单,并显示加载指示器。

现在,我们可以在我们的页面中使用这个表单,就像这样一个服务器组件:

import { CheeseList } from "./CheeseList";
import { AddCheeseForm } from "./AddCheeseForm";

export default function CheesePage() {
  return (
    <div>
      <h1>This might sound cheesy, but I think you're really grate!</h1>
      <CheeseList />
      <AddCheeseForm />
    </div>
  );
}

因此,我们有了一些服务器和客户端组件。CheesePageCheeseList是服务器组件,而AddCheeseForm是客户端组件。这两个组件都是可重用的,可以在我们的应用程序的其他地方使用。关于客户端和服务器组件还有一些规则和考虑事项,但我们将在第九章中探讨这些内容。

现在,如果我们放大看,我们可以看到 Next.js 解决了与 Remix 和我们基于文件系统的路由、数据获取和数据变更类似的问题。它的方式略有不同,但底层机制有些相似。

理想情况下,通过探索这两个框架,我们能够理解为什么我们会选择框架,它们解决的问题以及它们如何为我们带来好处。

让我们通过讨论如何选择一个框架来结束。

选择一个框架

决定为您的项目选择哪个 React 框架可能是一个具有挑战性的决定,因为每个框架都提供了一套独特的特性、优势和权衡。在本节中,我们将尝试为您提供一些关于当今流行 React 框架为开发者提供可行选项的见解,并讨论学习曲线、灵活性和性能等因素,这些因素可以帮助您选择最适合您特定需求的框架。

值得注意的是,一个框架并不是比另一个框架更好或更差。每个框架都有其自身的优势和劣势,最适合您项目的框架将取决于您的具体要求和偏好。

理解您的项目需求

在我们深入探讨每个框架的细节之前,了解项目的具体需求非常重要。以下是一些需要考虑的关键问题:

  • 您的项目范围是什么?它是一个小型个人项目,还是一个具有多个功能的中型应用程序,或者是一个大规模复杂的应用程序?

  • 您希望在您的项目中包含哪些主要功能和特性?

  • 您是否需要服务器端渲染(SSR)、站点生成(SSG)或两者的组合?

  • 您是否正在构建像博客或电子商务站点这样的内容密集型站点,可能受益于优秀的 SEO?

  • 实时数据或高度动态内容是否是您应用程序的关键部分?

  • 在构建过程中,您对自定义和控制的需求有多大?

  • 您的应用程序的性能和速度有多重要?

  • 您在 React 和一般 Web 开发概念方面的熟练程度如何?

  • 您的目标用户是谁?坐在桌子前拥有快速互联网的企业人士?还是拥有各种设备和互联网速度的广大公众?

了解这些问题的答案将为您提供一个更清晰的框架需求图景。

Next.js

让我们在 Next.js 的背景下探讨一些这些参数:

学习曲线

Next.js 在内部使用 React 的最前沿技术,经常使用 React 的 canary 版本。这意味着 Next.js 经常走在潮流的前沿,可能会更具挑战性。然而,Next.js 团队在框架文档和各种功能的清晰指南方面做得非常出色,这可以帮助您快速入门。

灵活性

Next.js 设计时考虑了静态和服务器渲染内容之间的灵活性。它也支持完全客户端应用程序,尽管这不是它的主要用例。Next.js 还提供了丰富的插件和集成生态系统,可以显著加快开发过程。

性能

Next.js 强调性能优化,专注于静态生成和服务器端渲染,以及缓存。在撰写时,Next.js 发布了四种不同的用途驱动缓存,每种都旨在为多种用例提供最佳性能。然而,这种性能可能会在客户端/服务器边界之间引起摩擦,并且在何时使用哪种决策上需要考虑周到。

还值得注意的是,构建 React 的团队中的一些成员在 Vercel 工作,Next.js 的开发地,这表明 Next.js 和 React 之间存在非常紧密的开发反馈循环。

Remix

相比于 Next.js,Remix 是 React 框架领域的较新参与者,大约早了 10 年左右创建。它由 React Router 的创作者构建,强调 Web 基础知识,做出较少假设并提供了很多灵活性:

学习曲线

Remix 可能有稍平缓的学习曲线,因为它更多地依赖于 Web 基础知识,并且以许多人在更重视服务器组件之前学习的方式使用 React。

直觉性

Remix 往往退到一边,允许 Web 平台的基础知识发挥作用。这可能是一把双刃剑:一方面很好,因为直观和熟悉,但另一方面可能有点令人沮丧,因为它没有其他框架那么“神奇”。

性能

Remix 独特的路由和数据加载方法使其高效且性能优越。由于数据获取与路由绑定,仅会获取特定路由所需的必要数据,从而减少了整体数据需求。此外,其乐观的 UI 更新和渐进增强策略提升了用户体验。

权衡

选择一个框架并不是没有权衡,其中最重要的是便利性与控制之间的权衡。所有框架通过传统化的方式,大大减少了我们在应用程序中的脑力劳动和决策过程。例如,默认情况下,框架已经为问题提供了答案,比如:

  • 我们如何进行路由?

  • 静态资源放在哪里?

  • 我们应该使用服务器端渲染吗?

  • 我们从哪里获取数据?

考虑到框架在这些主题及更多方面的强烈约定化,这使得开发者失去了一些控制权。作为交换,我们获得了相当多的便利性,可以更专注于应用程序的核心部分,比如业务逻辑本身。

大多数情况下,关于框架的权衡都围绕着这一连续性展开。

那么,如何选择合适的框架呢?关键在于你的项目需求和个人偏好:

  • 如果你需要一个相对灵活的全栈框架,Next.js 可能更合适,因为它允许你在静态、服务器端或完全客户端的应用之间进行选择。

  • 如果你偏好一种服务器端优先、渐进增强且坚持 Web 基础知识的方法,Remix 可能是你的最佳选择。

无论如何,尝试在一个较小的项目或应用程序的一部分中使用其中一个是个好主意。这将帮助你更好地理解它们的工作方式,以及哪个对你来说更舒适。

开发者体验

两种框架都提供世界级的开发者体验,专注于生产力和易用性。它们都提供丰富的功能和工具,帮助开发者构建高质量的应用程序,正如我们在本章中前面所见。

随着项目复杂性和规模的增长,构建性能变得越来越关键。Next.js 和 Remix 都进行了优化,以改善构建时间。

Next.js 默认使用静态生成,这意味着页面在构建时预渲染。这可以带来更快的页面加载速度,但对于页面数量较多的站点,构建时间可能较长。

为了解决这个问题,Next.js 引入了增量静态再生(Incremental Static Regeneration,ISR),允许开发者在不进行完整重建的情况下重新生成静态页面。这一功能可以显著提高大型动态网站的构建时间。

Remix 则采用独特的构建性能理念。它选择了一种以服务器为先的架构,这意味着页面会按需由服务器渲染,并将 HTML 发送到客户端。

运行时性能

Next.js 和 Remix 都是以性能为设计核心,并提供多种优化方式,以实现快速响应的应用程序。

Next.js 自带了几种内置的性能优化。它支持自动代码分割,确保每个页面只加载必要的代码。它还有一个内置的 Image 组件,优化图像加载以提升性能。

Next.js 中的混合 SSG/SSR 模型允许开发者为每个页面选择最佳的数据获取策略,平衡性能和实时性。不需要实时数据的页面可以在构建时预渲染,从而实现更快的页面加载。对于需要实时数据的页面,可以使用服务器端渲染或 ISR。

Next.js 还为没有阻塞数据需求的页面提供了自动静态优化,确保它们作为静态 HTML 文件提供,从而实现更快的首字节时间(TTFB)。

最后,Next.js 尽可能地利用 React Server Components,使得它能向客户端发送更少的 JavaScript,从而实现更快的页面加载和其他开销。

Remix 在性能方面采用了略有不同的方法。它选择了服务器渲染而不是预渲染页面,仅向客户端流式传输客户端需要的 HTML。这可能会导致更快的 TTFB,特别是对于动态内容而言。

Remix 的一个关键特性是其强大的缓存策略。它利用浏览器的原生 fetch 和 cache API,允许开发者为不同的资源指定缓存策略。这导致更快的页面加载和更具韧性的应用程序。

Next.js 和 Remix 都为大型、复杂项目提供了引人注目的优势。它们在开发者体验、构建性能和运行时性能方面都表现出色。如果你偏好成熟的生态系统、丰富的资源和插件以及混合的 SSG/SSR 模型,以及像 ISR 这样的创新功能,那么 Next.js 可能是一个更好的选择。另一方面,如果你更喜欢采用服务器渲染方法,支持即时部署,强调利用网络平台特性如 fetch 和 cache APIs,并且涉及高级 React 概念如 Suspense 和 Server Components,那么 Remix 可能更适合你。

对于你特定项目需求来说,最合适的框架最终取决于你团队的专业知识、项目需求以及你对某些架构模式的偏好。无论选择哪种,Next.js 和 Remix 都是构建高质量、高性能 React 应用程序的坚实基础。

章节回顾

在本章的过程中,我们深入探讨了 React 框架的概念。本章使我们能够探索使用框架的基本原理、推理以及实际应用的影响。

讨论从回顾并发 React 及其对高效渲染和用户交互的影响开始。然后,我们继续探讨 React 框架的“为什么”和“什么”:为什么它们是必要的,它们提供了哪些好处,以及它们涉及了哪些权衡。

我们通过实现我们自己的基本框架来做到这一点,这使我们能够理解 React 框架背后的基本机制和概念。然后,我们探讨了基于文件系统的路由概念,在许多 React 框架中是一个常见的特性。我们还研究了数据获取以及如何在框架中实现它。

接下来,我们深入比较了不同框架,主要集中在 Next.js 和 Remix 上。每个框架都提供其独特的功能和优势,选择往往取决于项目的具体需求。我们探讨了这些框架如何解决服务器渲染、路由、数据获取和数据变更,并且将它们与我们自己的实现进行了比较。

通过这一过程,我们通过我们自己实现与框架之间的共同点,获得了对机制的理解。我们还探讨了使用框架所涉及的权衡,并通过理解其基本机制来缓解这些问题。

最后,我们讨论了如何选择一个框架,并探讨了此决策所涉及的一些权衡。我们还研究了框架的开发者体验和运行时性能,并考虑了对我们的项目可能最好的选择。

复习问题

在本章结束时,这里有一些问题帮助您复习我们所涵盖的概念:

  • 使用像 Next.js 或 Remix 这样的 React 框架的主要原因是什么,它们提供了哪些好处?

  • 使用 React 框架存在哪些权衡或不利之处?

  • 框架解决了哪些常见问题?

  • 这些框架是如何解决这些问题的呢?

接下来

在本章中,我们简要提到了 React 服务器组件,并以一种粗略的方式开始探索它们。在下一章中,我们将更加深入地关注 React 服务器组件,理解它们的价值主张,并通过编写一个最小的服务器来理解它们的工作原理,以渲染和提供 React 服务器组件。

另外,我们将探讨为什么 React 服务器组件需要像捆绑器、路由器等新一代构建工具。最终,我们将更加深入地理解 React 服务器组件及其背后的机制,在这必定是一个富有信息和教育性的深入探讨中获益良多。

第九章:React 服务器组件

在上一章中,我们深入研究了 React 框架的世界,特别是关注了 Next.js 和 Remix。我们探讨了为什么你可能首先选择使用框架,包括抽象的好处,加速开发的规范,它们为常见问题提供的全面解决方案,以及它们对提高生产力的整体影响。

我们深入探讨了 Remix 和 Next.js 的细节,通过实现我们自己的基础框架,展示了每个框架解决类似问题的常见方法,并预告了 Next.js 对服务器优先方向的支持,充分拥抱 React 服务器组件(RSC)。

谈到 RSC(React Server Components),它们是 React 生态系统中的一个有趣的趋势,旨在改善 React 应用的性能、效率和用户体验。这种先进的应用架构结合了服务器渲染的多页面应用(MPA)和客户端渲染的单页面应用(SPA)的最佳方面,提供了无缝的用户体验,而又不损害性能或可维护性。在本章中,我们将讨论 RSC 的核心概念、好处,以及 RSC 工作的基本心智模型和机制。获取最新信息,请访问react.dev

RSC 引入了一种在服务器上“运行”的组件类型,否则不包含在客户端 JavaScript 捆绑包中。这些组件可以在构建时运行,允许您从文件系统中读取、获取静态内容或访问您的数据层。通过将数据作为 props 从服务器组件传递到浏览器中的交互式客户端组件,RSC 保持了高效和高性能的应用程序。

那么,服务器组件是如何工作的呢?让我们更深入地了解一下服务器组件是什么,以便更加精准地理解。

正如刚刚描述的,服务器组件是一种仅在服务器上执行的特殊类型组件。要更好地理解这一点,让我们记住一个 React 组件只不过是一个返回 React 元素的函数:

const Component = () => <div>hi!</div>;

在这段代码片段中,Component是一个返回<div>hi!</div>的函数。当然,<div>hi!</div>也会返回另一个 React 元素,因为在 React 中,<React.createElement的别名。关于这点,我们在第二章讨论过。如果你对这有任何疑问,现在是快速复习一下然后回来的好时机。最终,所有组件都会返回虚拟 DOM。

服务器组件也不例外。如果Component在服务器端或客户端执行,它都会返回 vDOM。在第三章中,我们看到 React 元素只是具有以下模式的 JavaScript 对象:

{
  $$typeOf: Symbol("react.element"),
  type: () => ({
    $$typeOf: Symbol("react.element"),
    type: "div",
    props: {
      children: [
        {
          $$typeOf: Symbol("react.element"),
          props: {
            children: "hi!"
          },
        },
      ],
    },
  }),
}

在客户端和服务器环境中调用我们的Component函数将返回一个 React 元素,如下所示。

对于服务器组件而言,它们只在服务器上被调用,并且生成的表示元素的 JavaScript 对象被发送到客户端。当然,客户端组件就是我们通常使用的普通 React 组件。

优势

了解这一点,我们开始看到服务器组件的一些好处:

  • 它们只在我们控制计算能力的服务器端执行。这导致性能更可预测,因为我们不在不可预测的客户端设备上执行计算。

  • 它们在我们安全的服务器环境中执行,因此我们可以在服务器组件中执行安全操作,而不必担心泄漏令牌和其他安全信息。

  • 服务器组件可以是异步的,因为我们可以等待它们在服务器上执行完毕,然后再与客户端分享。

这就是服务器组件的真正力量。接下来,让我们探讨服务器组件如何与服务器端渲染互动。

服务器渲染

我们在之前的章节中详细讨论了服务器端渲染,所以我们不会在这里深入其细节。相反,我们将专注于服务器组件与服务器渲染之间的互动。

从本质上讲,服务器组件和服务器渲染可以被视为两个独立的过程,其中一个过程完全负责在服务器上渲染组件并生成一棵 React 元素树,另一个过程——服务器渲染器——进一步接管这棵 React 元素树,并将其转换为可以通过网络流式传输到客户端的标记。

如果我们考虑这两个过程,一个是将组件渲染为 React 元素,另一个是将 React 元素渲染为 HTML 字符串或流,我们开始理解这两个概念如何结合在一起。让我们称第一个过程为RSCs 渲染器,它将服务器组件转换为一棵 React 元素树,第二个过程称为服务器渲染器,它将 React 元素转换为 HTML 流。

有了这个理解,服务器组件和服务器渲染之间的相互作用可以理解如下:

  1. 在服务器上,一棵 JSX 树被转换为一棵元素树。

    这棵 JSX 树:

    <div>
      <h1>hi!</h1>
      <p>I like React!</p>
    </div>
    

    成为这棵元素树:

    {
      $$typeOf: Symbol("react.element"),
      type: "div",
      props: {
        children: [
          {
            $$typeOf: Symbol("react.element"),
            type: "h1",
            props: {
              children: "hi!"
            }
          },
          {
            $$typeOf: Symbol("react.element"),
            type: "p",
            props: {
              children: "I like React!"
            }
          },
        ],
      },
    }
    
  2. 在服务器上,这棵元素树进一步被序列化为字符串或流。

  3. 这被发送到客户端作为一个大的字符串化的 JSON 对象。

  4. 客户端上的 React 可以读取这个解析后的 JSON 并像往常一样进行渲染。

如果我们将其表现为服务器端的代码,它看起来会像这样:

// server.js
const express = require("express");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const App = require("./src/App");

const app = express();

app.use(express.static(path.join(__dirname, "build")));

app.get("*", async (req, res) => {
  // This is the secret sauce
  const rscTree = await turnServerComponentsIntoTreeOfElements(<App />);
  // This is the secret sauce

  // Render the awaited server components to a string
  const html = ReactDOMServer.renderToString(rscTree);

  // Send it
  res.send(`
 <!DOCTYPE html>
 <html>
 <head>
 <title>My React App</title>
 </head>
 <body>
 <div id="root">${html}</div>
 <script src="/static/js/main.js"></script>
 </body>
 </html>
 `);
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

此服务器端代码片段直接取自第六章,在将其传递给服务器端渲染器(我们示例中的第二个过程)之前,我们已添加了处理服务器组件的步骤。

从逻辑上讲,这正是服务器组件和服务器端渲染如何结合在一起的方式:它们是互补的过程。

再次值得注意的是,我们仅仅是出于说明目的使用了renderToString,正如在第六章中提到的那样——在绝大多数情况下,更好的做法几乎总是依赖于一个更为异步、可中断的 API,如renderToPipeableStream或类似的。

现在我们理解了服务器渲染和服务器组件之间的相互作用,让我们更深入地了解一下我们在前面的代码片段中调用的这个神奇的turnServerComponentsIntoTreeOfElements函数。它在做什么?它如何将服务器组件转换为元素树?它是一个 React 渲染器吗?让我们找出答案。

在底层

简短而可能过于简化的答案是,turnServerComponentsIntoTreeOfElements是一种 React 渲染器。它从一个高级别(比如<App />)递归地进入一个 React 树,每次调用每个组件以获取它返回的 React 元素(普通的 JavaScript 对象)。

让我们列出这个的参考实现,然后讨论它的功能:

async function turnServerComponentsIntoTreeOfElements(jsx) {
  if (
    typeof jsx === "string" ||
    typeof jsx === "number" ||
    typeof jsx === "boolean" ||
    jsx == null
  ) {
    // Don't need to do anything special with these types.
    return jsx;
  }
  if (Array.isArray(jsx)) {
    // Process each item in an array.
    return await Promise.all(jsx.map(renderJSXToClientJSX(child)));
  }

  // If we're dealing with an object
  if (jsx != null && typeof jsx === "object") {
    // If the object is a React element,
    if (jsx.$$typeof === Symbol.for("react.element")) {
      // `{ type } is a string for built-in components.
      if (typeof jsx.type === "string") {
        // This is a built-in component like <div />.
        // Go over its props to make sure they can be turned into JSON.
        return {
          ...jsx,
          props: await renderJSXToClientJSX(jsx.props),
        };
      }
      if (typeof jsx.type === "function") {
        // This is a custom React component (like <Footer />).
        // Call its function, and repeat the procedure for the JSX it returns.
        const Component = jsx.type;
        const props = jsx.props;
        const returnedJsx = await Component(props);
        return await renderJSXToClientJSX(returnedJsx);
      }
      throw new Error("Not implemented.");
    } else {
      // This is an arbitrary object (props, or something inside them).
      // It's an object, but not a React element (we handled that case above).
      // Go over every value and process any JSX in it.
      return Object.fromEntries(
        await Promise.all(
          Object.entries(jsx).map(async ([propName, value]) => [
            propName,
            await renderJSXToClientJSX(value),
          ])
        )
      );
    }
  }
  throw new Error("Not implemented");
}

虽然这段代码看起来可能有点吓人,但让我们明确一点:它只是一个大的if/else树,根据其参数返回不同的结果。让我们逐个分支地了解发生了什么,从其输入参数jsx开始。

对于第一个分支,如果我们考虑一个像这样的 React 元素:

<div>hi!</div>

子元素"hi!"只是一个字符串。如果我们将这个字符串传递给我们的服务器组件渲染器,我们希望将字符串原样返回。这个想法是返回 React 在客户端和服务器端都能理解和渲染的类型。React 可以在客户端和服务器端理解和渲染字符串、数字和布尔值,因此我们将它们保持不变。

接下来,如果我们有一个数组,让我们对其进行映射,并递归处理每个元素。数组可以是一堆子元素,就像这样:

[
  <div>hi</div>,
  <h1>hello</h1>,
  <span>love u</span>,
  (props) => <p id={props.id}>lorem ipsum</p>,
];

例如,片段将子元素表示为数组。因此,让我们通过递归调用我们的函数处理每个子元素并继续前进。

接下来变得非常有趣:我们处理对象。请记住,所有的 React 元素都是对象,但并非所有对象都是 React 元素。我们如何知道一个对象是 React 元素?它有一个$$typeOf属性,其值为一个符号——具体来说是Symbol.for('react.element')。因此,我们检查对象是否具有这个键/值对,如果有,我们将其处理为一个 React 元素。我们在代码片段的这一部分做到了这一点:

if (jsx.$$typeof === Symbol.for("react.element")) {
  if (typeof jsx.type === "string") {
    // This is a component like <div />.
    // Go over its props to make sure they can be turned into JSON.
    return {
      ...jsx,
      props: await renderJSXToClientJSX(jsx.props),
    };
  }
  if (typeof jsx.type === "function") {
    // This is a custom React component (like <Footer />).
    // Call its function, and repeat the procedure for the JSX it returns.
    const Component = jsx.type;
    const props = jsx.props;
    const returnedJsx = await Component(props);
    return renderJSXToClientJSX(returnedJsx);
  }
  throw new Error("Not implemented.");
} else {
  // This is an arbitrary object (props, or something inside of them).
  // Go over every value and process any JSX in it.
  return Object.fromEntries(
    await Promise.all(
      Object.entries(jsx).map(async ([propName, value]) => [
        propName,
        await renderJSXToClientJSX(value),
      ])
    )
  );
}

if语句的真分支内部嵌套,我们再做一次检查:jsx.type"string"还是"function"?我们这样做是因为 React 元素可以同时具有这两种类型。字符串用于内置 DOM 元素,如"div""span"等。函数用于自定义组件,如<Footer />。如果它是一个字符串,我们知道它是一个内置 DOM 元素,所以我们可以直接返回它,但递归地调用我们的函数处理它的 props —— 因为它的 props 可能包含作为子节点的并发 React 组件。如果它是一个函数,我们知道它是一个自定义组件,所以我们用其 props 调用它,并递归地调用我们的函数处理它返回的 JSX,直到最终返回一个字符串、数字、布尔值、这些类型的数组或带有字符串类型的 React 元素,它会落入另一个分支中。

注意在调用函数组件之前我们有一个await吗?因为这是在服务器端执行的,我们可以在这种情况下await函数组件!这就是服务器组件的魔力:我们可以在服务器端await它们,它们会返回一个 React 元素,然后我们可以将其传递给renderToStringrenderToPipeableStream以将其渲染为一个字符串或字符串流,我们可以发送到客户端。确实,这就是我们的函数正在做的事情:它只是递归地await所有async事务以生成一个元素树(一个 JavaScript 对象),并解决其所有数据依赖项。

最后,如果对象不是一个 React 元素,我们知道它只是一个普通对象,所以我们递归地在对象的每个值上调用我们的函数并返回结果。通常对象只是 props,所以在else分支中,我们只是递归地在每个 prop 值上调用我们的函数并返回结果,有效地展开可能作为 props 传递的任何组件,如渲染 props 中所讨论的第五章。

就是这样!这就是我们的小型最小 RSCs 渲染器。它并不完美,但它是一个很好的开始。我们可以使用它来将我们的服务器组件渲染为 React 元素,然后将其发送到客户端。

一旦我们拥有了这个,我们只需将其传递给 renderToStringrenderToPipeableStream,甚至序列化它并直接发送到浏览器,React 在客户端将能够渲染它,因为它实际上只是 React 元素的树,React 能够理解。然而,这里还有一个挑战需要解决:序列化。

序列化

当我们尝试对 React 元素进行序列化时,情况变得有些棘手。序列化 React 元素是确保您的应用程序在初始加载期间正确且高效地渲染的基本方面,因为来自服务器的相同渲染输出需要与客户端匹配,以便 React 正确地协调和差异化。当应用程序在服务器上进行渲染时,创建的 React 元素需要转换为可以发送到浏览器的 HTML 字符串。这个将 React 元素转换为字符串的过程被称为序列化

在典型的 React 应用程序中,React 元素是内存中的对象。它们是通过调用React.createElement或使用 JSX 语法创建的。这些元素表示组件的预期渲染,但它们还不是实际的 DOM 元素。它们更像是 DOM 应该如何看起来的指令:

const element = <h1>Hello, world</h1>;

当使用像ReactDOMServer.renderToString这样的函数在服务器上渲染时,这些 React 元素将被序列化为 HTML 字符串。这个序列化过程遍历 React 元素树,为每个元素生成相应的 HTML,并将其连接成单个 HTML 字符串:

const htmlString = ReactDOMServer.renderToString(element);
// htmlString will be '<h1>Hello, world</h1>'

然后,将此 HTML 字符串发送到客户端,其中它将被用作页面的初始标记。一旦客户端加载了 JavaScript 包,React 将“水合”DOM,附加事件处理程序并填充任何动态内容。

序列化步骤在多个方面都是至关重要的。首先,它允许服务器尽快将完整、准备好显示的 HTML 页面发送给客户端。这提高了页面的感知加载时间,因为用户可以更快地开始与内容交互。

此外,将 React 元素序列化为 HTML 字符串允许进行一致和可预测的初始渲染,无论环境如何都一样。生成的 HTML 是静态的,无论是在服务器上还是客户端上渲染,看起来都是一样的。这种一致性对确保流畅的用户体验至关重要,因为它防止了任何可能导致初始渲染与最终渲染不同而产生闪烁或布局变化的情况。

最后,序列化有助于在客户端执行水合作用的过程。当 JavaScript 包在客户端加载时,React 需要附加事件处理程序并填充任何动态内容。将序列化的 HTML 字符串作为初始标记确保 React 有一个坚实的基础来工作,使再水合作用过程更高效和可靠。

尽管我们需要将组件序列化为字符串,但我们不能简单地使用JSON.stringify,因为 React 元素不是常规的 JavaScript 对象。它们是具有特殊$$typeof属性的对象,React 使用该属性来识别它们,而这些属性的值是一个符号。符号不能被序列化和发送到网络,因此我们需要做些其他的事情。

实际上这并不难,多亏了 JavaScript 运行时的内置支持,包括浏览器和我们的服务器所在的 Node.js。这种内置支持以JSON.stringifyJSON.parse的形式提供给我们。这些函数可以递归地序列化或反序列化 JSON 对象,而 React 元素就是这些对象。它们的 API 如下:

JSON.stringify(object, replacer);
JSON.parse(object, replacer);

这里的replacer是一个函数,接收一个键和一个值,并在满足某些条件时返回一个替换值。在我们的情况下,我们希望将$$typeof的值替换为可序列化的类型,如字符串。下面是我们如何做到这一点:

JSON.stringify(jsxTree, (key, value) => {
  if (key === "$$typeof") {
    return "react.element"; // <- a string!!
  }

  return value; // <- return all other values as is
});

就是这样!我们完成了。要在客户端上反序列化它,我们做相反的操作:

JSON.parse(serializedJsxTree, (key, value) => {
  if (key === "$$typeof") {
    return Symbol.for("react.element"); // <- a symbol!!
  }

  return value; // <- return all other values as is
});

就是这样!我们现在可以序列化和反序列化 React 元素。我们现在可以在服务器上渲染服务器组件并将它们发送到客户端。这处理了我们的首次加载;然而,我们仍然需要处理更新和导航。让我们首先解决导航,稍后再处理更新。

导航

如果我们的 RSCs 可用的应用中有一个链接,类似于:

<a href="/blog">Blog</a>

点击这个链接将会进行全页面导航,这会导致浏览器向服务器发出请求,然后渲染页面并将其发送回浏览器。这就是多年前在 PHP 领域中我们所做的事情,它带来了一定的阻力和一种慢的感觉。我们可以做得更好:使用 RSCs,我们可以实现软导航,在路由转换之间保持状态。我们通过向服务器发送我们要导航到的 URL,服务器会返回该页面的 JSX 树给我们。然后,浏览器中的 React 重新渲染整个页面,使用新的 JSX 树,我们就有了一个新页面,而无需进行全页面刷新。这正是我们要做的。

要做到这一点,我们需要稍微调整我们的客户端代码。我们需要为应用中的所有链接添加一个事件监听器,以阻止链接的默认行为,并改为向服务器发送新页面的请求。我们可以这样做:

window.addEventListener("click", (event) => {
  if (event.target.tagName !== "A") {
    return;
  }

  event.preventDefault();
  navigate(event.target.href);
});

我们将事件监听器添加到window上是为了性能考虑:我们不希望为应用中的每个链接都添加一个事件监听器,因为这可能会导致大量的事件监听器降低速度。相反,我们在window上添加一个事件监听器,并检查点击的目标是否是一个链接。这被称为事件委托

如果用户确实点击了A元素,我们将阻止链接的默认行为,而是调用稍后将定义的navigate函数。该函数将向服务器发送新页面的请求,然后我们将在客户端上使用 React 渲染它。

让我们定义navigate函数:

async function navigate(url) {
  const response = await fetch(url, { headers: { "jsx-only": true } });
  const jsxTree = await response.json();
  const element = JSON.parse(jsxTree, (key, value) => {
    if (key === "$$typeof") {
      return Symbol.for("react.element");
    }

    return value;
  });
  root.render(element);
}

我们在这里做的事情非常简单:我们向服务器发送一个新页面的请求,将响应反序列化为一个 React 元素,然后将该元素渲染到我们应用程序的根中。这将导致 React 使用新的 JSX 树重新渲染页面,我们将获得一个新页面而无需完整页面刷新。但是 root 是什么?要理解这一点,我们需要查看我们完整的客户端 JavaScript 文件:

import { hydrateRoot } from "react-dom/client";
import { deserialize } from "./serializer.js";
import App from "./App";

const root = hydrateRoot(document, <App />); // <- this is root

window.addEventListener("click", (event) => {
  if (event.target.tagName !== "a") {
    return;
  }

  event.preventDefault();
  navigate(event.target.href);
});

async function navigate(url) {
  const response = await fetch(url);
  const jsxTree = await response.json();
  const element = deserialize(jsxTree);
  root.render(element);
}

当我们最初为页面进行水合作用时,我们从 React 那里得到一个根,并且我们可以使用该根来将新元素渲染到其中。这就是 React 在幕后的工作原理,我们只是使用 React 在内部使用的相同 API。这是一件好事,因为这意味着我们没有做任何特殊或巧妙的事情,我们只是使用 React 的公共 API。

最后,当收到一个 jsx-only 头部时,我们需要让服务器仅响应下一页的 JSX 树对象,而不是响应完整的 HTML 字符串。我们可以这样做:

app.get("*", async (req, res) => {
  const jsxTree = await turnServerComponentsIntoTreeOfElements(<App />);

  // This is the secret sauce
  if (req.headers["jsx-only"]) {
    res.end(
      JSON.stringify(jsxTree, (key, value) => {
        if (key === "$$typeof") {
          return "react.element";
        }

        return value;
      })
    );
  } else {
    const html = ReactDOMServer.renderToString(jsxTree);

    res.send(`
 <!DOCTYPE html>
 <html>
 <head>
 <title>My React App</title>
 </head>
 <body>
 <div id="root">${html}</div>
 <script src="/static/js/main.js"></script>
 </body>
 </html>
 `);
  }
});

注意当头部存在时,我们不发送 JSON 而只发送一个字符串?这是因为我们需要在客户端进行 JSON.parse,而 JSON.parse 预期一个字符串,而不是一个 JSON 对象。这只是 API 的一个怪癖,但并不太糟糕。

现在,我们有了一种在没有完整页面刷新的情况下导航到新页面的方法。我们现在可以处理启用了 RSCs 的应用程序中的导航。所有锚链接导航都可以在没有完整页面刷新的情况下顺利进行。但是更新呢?我们如何处理更新?让我们接着来解决这个问题。

进行更新

尽管 RSCs 有很多优点,但也有一些需要注意的限制,即需要考虑两种不同类型的组件(服务器和客户端)所带来的额外心理负担。这是因为并非所有组件都可以是服务器组件。

例如,考虑这个简单的计数器组件,当用户点击 + 按钮时,它会将计数器值增加 1:

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>Hello friends, look at my nice counter!</h1>
      <p>About me: I like pie! Sign my guest book!</p>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

这个组件永远不能成为服务器组件,原因有两个:

  • 它使用了 useState,这是一个仅在客户端使用的 API。这意味着服务器不知道 count 的初始值是多少,因此无法渲染初始 HTML。这是一个问题,因为服务器需要在客户端接管并渲染交互式 UI 之前渲染初始 HTML。

    在服务器端环境中,“状态”的概念是在多个客户端之间共享的。然而,在 React 中,在引入 RSCs 之前,状态是局限于当前应用程序的。这种差异存在风险。它可能导致状态在多个客户端之间泄漏,潜在地暴露敏感信息。由于这种差异及相关的安全风险,RSCs 不支持在服务器端使用 useState。这是因为服务器端状态与客户端状态在根本上是不同的。

    此外,useState中的调度器(setState)函数需要被序列化以便发送到客户端,但函数是不可序列化的,所以这是不可能的。

  • 它使用onClick,这是一个仅在客户端使用的 API。这是因为服务器不是交互式的:你不能在服务器上点击运行中的进程,因此在服务器组件中使用onClick是一种不可能的状态。此外,服务器组件的所有 props 都应该是可序列化的,因为服务器需要能够序列化 props 并将其发送到客户端,而函数是不可序列化的。

因此,现在一个简单的计数器现在需要分解为服务器部分和客户端部分,如果我们想利用服务器组件的功能,如下所示:

// Server Component
function ServerCounter() {
  return (
    <div>
      <h1>Hello friends, look at my nice counter!</h1>
      <p>
        About me: I like to count things and I'm a counter and sometimes I count
        things but other times I enjoy playing the Cello and one time at band
        camp I counted to 1000 and a pirate appeared
      </p>
      <InteractiveClientPart />
    </div>
  );
}
// Client Component
"use client";
function InteractiveClientPart() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

这是一个有些牵强的例子,但它说明了一个观点,即你不能只是拿任何 React 组件并将其转换为服务器组件。你需要考虑你的组件哪些部分是可以在服务器端渲染的,哪些部分是可以在客户端渲染的。这带来了一些摩擦,即使在这个例子中,哪些部分是服务器端可渲染的,哪些部分是客户端可渲染的显而易见,但在实际的大规模应用中,可能并不那么明显。

虽然这样做的结果在很大程度上是有益的,因为我们刚刚将计数器应用程序的一个小部分因素分离出来,该部分旨在交互,只有这部分应用程序将作为 JavaScript 包的一部分发送给用户;其余部分则不会。因此,我们通过网络传输的 JavaScript 包大大减少,这意味着更快的加载时间和更好的性能,无论是 CPU 方面,因为需要解析和执行 JavaScript 的工作更少,还是网络方面,因为需要下载的数据更少。

这就是为什么我们希望尽可能多地在服务器上渲染,以便将代码留出客户端捆绑包之外。

在底层

除了额外的心理负担之外,让我们谈谈 React 如何在底层分别处理服务器组件和客户端组件。这一点很重要,因为它将帮助我们理解如何更新我们的应用程序。

客户端组件通过在包含客户端组件的文件顶部添加"use client"指令来指定。RSC(React 服务器组件)需要下一代工具来根据这些指令的使用区分服务器端和客户端组件。

通过使用下一代捆绑器或捆绑器配置,捆绑器能够为 React 应用生成单独的模块图:一个服务器图和一个客户端图。服务器图从不捆绑到捆绑包中,因为它从不提供给用户,但所有以"use client"指令开头的文件都捆绑到一个客户端捆绑包或可以懒加载的多个组件捆绑包中。此实现细节取决于构建在 RSCs 之上的框架。

因此在概念上,我们有一个在服务器上执行的服务器图,以及在客户端需要时下载和执行的一个或多个客户端捆绑包。但是 React 如何知道何时导入和执行客户端组件呢?为了理解这一点,我们将不得不考虑一个典型的 React 树。让我们通过我们的反例来做这个。

在图 9-1 中,我们可视化了我们反例计数应用程序组件树的树形结构,其中橙色组件在服务器上渲染,绿色组件在客户端上渲染。由于树的根是服务器组件,整个树在服务器上渲染。然而,InteractiveClientPart组件是一个客户端组件,因此不会在服务器上渲染。相反,服务器为客户端组件渲染一个占位符,这是对客户端捆绑器生成的特定模块的引用。这个模块引用实际上表示,“当你到达树中的这一点时,是时候使用这个特定的模块了。”

RSCs 和客户端组件

图 9-1. 显示客户端和服务器组件的组件树

模块不一定总是仅懒加载,而是可以从初始捆绑包中加载,因为捆绑工具将一整堆模块添加到我们向用户提供的捆绑包中。它可能是getModuleFromBundleAtPosition([0,4])或类似的东西。关键是服务器发送到正确客户端模块的引用,而客户端 React 则填补空白。

当这发生时,React 将使用客户端捆绑包中的实际模块替换模块引用。这有点简化,但应该足以让我们理解机制。然后在客户端上渲染客户端组件,并且可以像往常一样与客户端组件交互。这就是为什么 RSCs 需要下一代捆绑器:它们需要能够为服务器和客户端组件生成单独的模块图。

实际操作中,这意味着在我们的反例中,服务器将呈现以下树:

{
  $$typeof: Symbol(react.element),
  type: "div",
  props: {
    children: [
      {
        $$typeof: Symbol(react.element),
        type: "h1",
        props: {
          children: "Hello friends, look at my nice counter!"
        }
      },
      {
        $$typeof: Symbol(react.element),
        type: "p",
        props: {
          children: "About me: I like to count things"
        }
      },
      {
        // The ClientPart element placeholder with a module reference
        // Pay attention to this: it's a module reference!
        $$typeof: Symbol(react.element),
        type: {
          $$typeof: Symbol(react.module.reference),
          name: "default",
          filename: "./src/ClientPart.js",
          moduleId: "client-part-1234"
        },
        props: {
          children: [
            // ...other server components and client module references
            {
              $$typeof: Symbol(react.element),
              type: {
                $$typeof: Symbol(react.module.reference),
                name: "default",
                filename: "./src/AnotherClientComponent.js"
              },
              props: {
                children: [],
              }
            },
            {
              $$typeof: Symbol(react.element),
              type: "div",
              props: {
                children: "I am a server component"
              }
            }
          ]
        }
      }
    ]
  }
}

此树将被发送到客户端,并且当 React 渲染它并遇到模块引用时,React 将智能地用客户端捆绑包中的实际模块替换模块引用。这就是 React 知道何时导入和执行客户端组件的方式。

因此,我们可以看到,捆绑器能够在服务器上仍然渲染整个树,在客户端只留下“空洞”,在服务器上递归渲染客户端组件的子组件,生成一个完整的树。然后,客户端通过下载和执行客户端捆绑包来填补任何必要的空洞。

服务器组件也可以包装成悬念边界,框架将它们从服务器流式传输给用户,当它们变得“就绪”时:也就是说,它们需要的任何数据被获取并且任何其他所需操作都被异步完成。

好了,希望现在我们理解了客户端组件如何从服务器组件中分离,从而实现在面向 RSCs 的应用程序中进行更新。带有 "use client" 标记的客户端组件可以包含本地化状态和事件处理程序,如 onClick,没有问题。

鉴于我们现在已经在客户端组件中闭环,并理解了服务器端如何执行服务器组件以及客户端组件如何包含在客户端捆绑包中,我们需要讨论一些关于这些主题的微妙之处。

细微差别

有一个普遍的误解,即服务器组件仅在服务器上执行,客户端组件仅在客户端上执行。这并不正确。服务器组件确实只在服务器上执行并输出表示 React 元素的对象,但客户端组件并不仅在客户端上执行。

要更深入理解这一点,让我们讨论一下“组件执行”究竟是什么意思。当我们说“组件执行”时,我们指的是调用表示组件的函数。例如,假设我们有一个像这样的组件:

function MyComponent() {
  return <div>hello world</div>;
}

当我们说“MyComponent执行”时,我们的意思是调用 MyComponent 函数及其 props,并返回一个 React 元素——这是一个看起来像这样的纯 JavaScript 对象:

{
  $$typeof: Symbol(react.element),
  type: "div",
  props: {
    children: "hello world"
  }
}

这就是我们说“组件执行”时的意思。

在服务器渲染期间,客户端组件在服务器上执行并输出表示 React 元素的对象。然后,这些元素被序列化为 HTML 字符串并发送到客户端,浏览器在那里呈现 HTML 标记。因此,客户端组件也在服务器上执行,并返回一些表示 React 元素的对象,然后服务器将它们序列化为 HTML 发送到客户端。

为了更准确地表示这一点,我们可以作出以下真实的陈述:

  • 服务器组件在服务器上执行,输出表示 React 元素的对象。

  • 客户端组件在服务器上执行,输出表示 React 元素的对象。

  • 在服务器上存在一个表示所有 React 元素的大对象,包括客户端和服务器组件。

  • 这被转换为字符串并发送到客户端。

  • 从这一点来看,服务器组件永远不会在客户端执行。

  • 客户端组件专门在客户端上执行。

从这个角度来看,服务器和客户端组件的执行边界变得更加清晰。我们可能在这里钻牛角尖,但值得添加更多细节来充分理解和欣赏这两种类型组件之间的相互作用。

服务器组件的规则

现在我们理解了服务器组件的内部工作原理,让我们讨论一些在使用服务器组件时需要遵循的规则,或者更广泛地说,在处理服务器组件时需要牢记的事情。

可序列化才是王道

对于服务器组件,所有的 props 必须是可序列化的。这是因为服务器需要能够序列化 props 并将其发送到客户端,正如我们之前讨论的那样。因此,在服务器组件中,props 不能是函数或其他不可序列化的值。这使得我们之前在第五章讨论过的渲染属性模式实际上已经过时。

现在,在我们理解了 RSC(服务器组件)是如何在服务器上渲染,然后在软导航时发送到客户端的基础上,我们应该理解为什么存在这个规则。假设我们有这样一个服务器组件:

function ServerComponent() {
  return <ClientComponent onClick={() => alert("hi")} />;
}

这将导致错误。然而,我们可以通过将onClick属性封装在ClientComponent中来解决这个问题。

没有有副作用的 Hooks

服务器与客户端是完全不同的环境。它不是交互式的,没有 DOM,也没有窗口。因此,在服务器组件中不支持有副作用的钩子。

一些框架,如 Next.js,有严格的 lint 规则,完全禁止在服务器组件中使用所有的 hooks,但这并非完全必要。RSCs 可以使用不依赖于状态、效果或仅浏览器 API 的 hooks。例如,useRef hook 在服务器组件中使用是完全可以的,因为它不依赖于状态、效果或仅浏览器 API。然而,这未必全是坏事,因为这让我们更倾向于更安全地处理组件。

状态不等同于状态

服务器组件中的状态与客户端组件中的状态并不相同。这是因为服务器组件在服务器上渲染,客户端组件在客户端上渲染。这意味着服务器组件中的状态可能在客户端之间共享,因为服务器-客户端关系是广播式关系,而不是单播(一个客户端,一个状态),因此在客户端之间泄漏状态的风险很高。

结合钩子规则,这意味着通过useStateuseReducer或类似方式需要状态的任何组件最适合作为客户端组件。

客户端组件不能导入服务器组件

客户端组件不能导入服务器组件。这是因为服务器组件仅在服务器上执行,而客户端组件在两者上都执行,包括在浏览器上。

这意味着,如果我们有这样一个客户端组件:

"use client";
import { ServerComponent } from "./ServerComponent";

function ClientComponent() {
  return (
    <div>
      <h1>Hey everyone, check out my great server component!</h1>
      <ServerComponent />
    </div>
  );
}

当客户端组件试图导入服务器组件时,会导致错误。这是不可能的,因为服务器组件仅在服务器上执行,我们在这里导入的服务器组件可能会进一步导入在客户端运行时环境中不可用的内容,比如 Node.js API。这会在客户端上引发错误。

就我们所知,服务器组件可能是这样的:

import { readFile } from "node:fs/promises";

export async function ServerComponent() {
  const content = await readFile("./some-file.txt", "utf-8");
  return <div>{content}</div>;
}

当我们试图在客户端运行此代码时,因为客户端组件导入了它,我们会得到一个错误,因为在浏览器中并不存在 readFile 函数和 "node:fs/promises" 模块。这就是为什么客户端组件不能导入服务器组件的原因。

然而,客户端组件可以通过 props 组合 服务器组件。例如,我们可以重新编写我们的客户端组件如下:

"use client";

function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hey everyone, check out my great server component!</h1>
      {children}
    </div>
  );
}

然后,无论父服务器组件包含这个客户端组件的什么情况下,我们都可以这样做:

import { ServerComponent } from "./ServerComponent";

async function TheParentOfBothComponents() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

这将有效,因为客户端组件并没有显式地从客户端组件导入服务器组件,而是父服务器组件将服务器组件作为 prop 传递给客户端组件。导入语句被禁止的唯一原因是为了防止客户端捆绑包中包含服务器组件的可能性,而捆绑工具只关注导入语句,而不是 prop 组合。

客户端组件并不差

值得注意的是,在引入服务器组件之前,React 中只有客户端组件这一类型的组件。这意味着我们所有现有的组件都是客户端组件,这没问题。客户端组件并不差,而且它们不会消失。它们仍然是 React 应用程序的核心,也是我们将要编写的最常见的组件类型。

我们在这里提到这一点是因为有些人对此存在一些困惑,并且有人认为服务器组件是客户端组件的一种更优秀的替代品。这是不正确的。服务器组件是我们可以额外使用的一种新类型的组件,但它们并不是客户端组件的替代品。

服务器动作

服务器组件是 React 中一个强大的新特性,但它们并不是唯一的新特性。RSC(服务器组件)还与一个新的指令 "use server" 配合工作,标记了可以从客户端代码调用的服务器端函数。我们称这些函数为 服务器动作

任何异步函数的体的第一行都可以有 "use server",以向 React 和打包工具表明此函数可以从客户端代码调用,但必须仅在服务器上执行。在客户端调用服务器动作时,将向服务器发出网络请求,其中包括传递的任何参数的序列化副本。如果服务器动作返回一个值,该值将被序列化并返回给客户端。

与其逐个标记函数为 "use server",你也可以在文件顶部添加指令,将文件中的所有导出标记为服务器操作,这些操作可以在任何地方使用,包括在客户端代码中导入。

表单和突变

在 第八章 中,我们讨论了 Next.js 和 Remix 如何处理表单和突变。React 也正在添加(或已经添加了)这些功能的一流基元。考虑这个表单:

// App.js

async function requestUsername(formData) {
  'use server';
  const username = formData.get('username');
  // ...
}

export default App() {
  <form action={requestUsername}>
    <input type="text" name="username" />
    <button type="submit">Request</button>
  </form>
}

在这个例子中,requestUsername 是一个传递给 <form> 的服务器操作。当用户提交这个表单时,将向服务器函数 requestUsername 发送网络请求。在表单中调用服务器操作时,React 将表单的 FormData 作为服务器操作的第一个参数提供。

将服务器操作传递给表单操作后,React 可以逐步增强表单。这意味着在 JavaScript bundle 加载之前就可以提交表单。

在表单之外

值得注意的是,服务器操作是暴露的服务器端点,可以在客户端代码的任何地方调用。

当在表单之外使用服务器操作时,我们可以在过渡中调用服务器操作,这使我们可以显示加载指示器,显示乐观状态更新,并处理意外错误。以下是表单之外的服务器操作示例:

"use client";

import incrementLike from "./actions";
import { useState, useTransition } from "react";

function LikeButton() {
  const [isPending, startTransition] = useTransition();
  const [likeCount, setLikeCount] = useState(0);

  const incrementLink = async () => {
    "use server";
    return likeCount + 1;
  };

  const onClick = () => {
    startTransition(async () => {
      // To read a server action return value, we await the promise returned.
      const currentCount = await incrementLike();
      setLikeCount(currentCount);
    });
  };

  return (
    <>
      <p>Total Likes: {likeCount}</p>
      <button onClick={onClick} disabled={isPending}>
        Like
      </button>;
    </>
  );
}

因此,我们可以看到服务器操作是 React 中的一个强大的新功能,允许我们从客户端代码中调用服务器端函数。这实际上只适用于库或框架,因为在原始的 React 中使用这些指令有些繁琐,并且需要大量的工作来连接这些东西。然而,这是一个强大的功能,可以实现许多有趣的用例。

React 服务器组件的未来

RSC 预计会随时间演变和改进。React 团队正在积极完善实现,解决潜在问题,并扩展功能集。一些正在进行开发的领域包括:

更好的捆绑器集成

React 团队正在与捆绑器开发人员合作,确保在像 Webpack、Rollup 等捆绑器中更好地支持 RSC。这将使构建与 RSC 兼容的框架和应用程序变得更加容易。

生态系统支持

随着 RSC 的推广,可能会出现更多工具、库和框架来支持和扩展这种新的应用架构。这将使开发人员更容易在其项目中采用 RSC,并从其性能和效率改进中受益。

RSC 代表了 React 生态系统中的重大进展,提供了改进的性能、简化的数据获取和更好的用户体验。随着 RSC 的不断演进和采用,预计它们将成为构建现代、高效和用户友好的 React 应用程序的重要工具。通过全面了解 RSC,您现在可以充分装备,探索并在自己的项目中尝试这一前沿实验性功能。

章节回顾

在本章中,我们专注于 React 服务器组件(RSCs),这是 React 生态系统中的一个重大进展,旨在提升 React 应用的性能、效率和用户体验。RSCs 代表了一种创新的应用架构,结合了服务器渲染的多页面应用(MPAs)和客户端渲染的单页面应用(SPAs)的优点。这种方法可以在不妥协性能或可维护性的情况下提供无缝的用户体验。我们深入探讨了 RSCs 的核心概念、优势以及其背后的思维模型和机制。一个关键亮点是引入了一种新类型的组件,它在服务器上运行,在客户端 JavaScript 包中排除,并可以在构建时运行。这一进展导致了更高效和有效的应用结构。

值得注意的是,在撰写本文时,RSC(React Server Components)在 React 和 Web 工程领域是一个热门话题,我们所涵盖的一些细节可能已经发生了变化。因此,我们建议查看 react.dev 和 React 的各种社区渠道以获取最新信息。

复习问题

  1. React 服务器组件的主要价值是什么?

  2. 客户端组件能导入服务器端组件吗?为什么?

  3. 服务器组件与传统的仅客户端 React 应用之间有哪些权衡?

  4. 什么是模块引用,React 在调和过程中如何处理它们?

  5. 服务器动作如何使 React 应用更易访问?

接下来

在接下来的章节中,我们将走一条略有不同的道路。到目前为止,我们大部分时间都深入研究了 React 的世界,探索了其复杂的内部工作机制、创新的状态管理策略、异步渲染能力,最终还有其强大的框架。现在,我们要退后一步,拓宽我们的视野。

我们将超越 React,深入探讨与 React 主导并行发展甚至对其有所回应的替代 UI 库和框架的世界。这些替代方案不仅吸收了一些 React 的最佳特性,还引入了自己的独特创新,为我们在 UI 开发中带来了令人兴奋的新范式和可能性。

在接下来的探索中,我们将深入探讨其他一些 UI 库的工作原理和理念,例如 Vue、Angular、Solid、Qwik 和 Svelte。我们将研究它们在状态管理、处理副作用方面的独特策略,以及在性能和开发者体验方面与 React 的比较。每种替代方案都有其一系列的优缺点,这些可能使其更适合特定类型的项目或开发者偏好:

Vue

Vue 提供了一个渐进式框架,可以逐步采用,这意味着您可以从小处开始,并根据需要逐渐采用更多的 Vue 特性。Vue 以其优雅的 API 和开发者体验而闻名。它引入了一个简单但强大的响应式模型,其核心概念是在渲染期间跟踪的响应性依赖。

Angular

Angular 是一个完整且主观的框架,学习曲线较陡,但提供了开箱即用的强大解决方案。其依赖注入系统和声明式模板为应用程序结构和状态管理提供了与 React 不同的方法。

Solid

Solid 是另一个在 JavaScript 社区中引起关注的竞争者。它承诺通过类似于 React 的编程模型实现精细粒度的响应性,并专注于更快、更高效的渲染。Solid 如何跟踪依赖关系可能对寻求运行时更高效性能的开发人员来说是一种全新的体验。

Qwik 框架

Qwik 通过“可预测”的预取重点优化加载性能,引入了一种独特的视角,展示了我们如何为了最佳用户体验结构化和传递 JavaScript。

Svelte

Svelte 通过在构建时将组件编译为直接操作 DOM 的命令式代码而引人注目,导致更快的初始加载时间和平滑的更新。其响应性模型以反应式语句为特征,与 React 采用的虚拟 DOM 差异化策略形成鲜明对比。

在探索这些框架和库的同时,我们将把 React 的知识作为一个基准。这不仅有助于我们更好地理解其他库,还通过比较和对比深化了我们对 React 的理解。

准备好揭示这些替代 UI 库在响应性、状态管理、副作用等方面的独特方法。通过研究这些替代方案,我们可以获得洞见,这些洞见可能会影响我们解决问题的方式,无论我们选择使用哪个库或框架。JavaScript 的世界是如此广阔多样,我们即将全面投入其中。

快系好安全带!旅程即将变得更加刺激。

第十章:React 的替代方案

在上一章中,我们深入讨论了 React 服务器组件(RSCs)的新兴主题。我们探讨了它们的工作原理、使用时机以及为什么它们需要像下一代捆绑器、路由器等强大的工具。我们进一步区分了服务器组件和服务器渲染,并甚至从头开始实现了一个简单的 RSC 渲染器,以便理解其底层机制。

随着我们开始探索 React 的替代方案,对框架和服务器组件的角色和功能的理解将提供宝贵的背景信息。本章中讨论的每个库都附带其关联的框架,并且我们在 React 中涵盖的原则和权衡将同样适用于这些生态系统。

当我们将注意力从 React 及其生态系统转向时,让我们深入了解前端开发生态系统中的一些流行替代品:Vue.js、Angular、Svelte、Solid 和 Qwik。每个库和框架都介绍了其自己的响应性模型和 UI 开发思路。了解这些不同的模型可以拓宽我们的视野,并为解决项目中的问题提供更多工具。

Vue.js

Vue.js 是一个流行的用于构建用户界面的 JavaScript 框架。由 Evan You 开发,他是一位曾在 AngularJS 项目上工作的前 Google 工程师。Vue.js 旨在提取 Angular 的优点,但在更轻量、更易维护和少观点化的包中实现。

Vue 的一个最显著特点是非侵入式的响应式系统。组件状态由响应式 JavaScript 对象组成。当您修改它们时,视图会更新。它使状态管理简单直观,但理解其工作原理也很重要,以避免一些常见的陷阱。

在 Vue 的响应性模型中,它拦截对象属性的读取和写入。Vue 2 由于浏览器支持的限制,仅使用 getter/setter,但在 Vue 3 中,响应式对象使用代理,而 ref 使用 getter/setter。从 Vue 文档中,以下是一些伪代码,说明了它们的工作原理:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}

这有点过于简化,但在这里我们演示了一个简单的响应式系统,利用代理实现。reactive 函数接受一个对象并返回该对象的代理,拦截 getset 操作。在 get 操作时,它调用 track 函数并返回请求的属性。在 set 操作时,它更新值并调用 trigger 函数。

另一方面,ref 函数将一个值封装在对象中,并为该值提供响应式的 getset 操作,类似于代理,但具有不同的结构,确保在访问或修改期间适当地调用 tracktrigger 函数。

这是一个非常简单的反应性系统示例,但它展示了 Vue 响应性模型的基本原则。这种响应性模型甚至可以用于更新 DOM。我们可以实现简单的“反应式渲染”如下:

import { ref, watchEffect } from "vue";

const count = ref(0);

watchEffect(() => {
  document.body.innerHTML = `count is: ${count.value}`;
});

// updates the DOM
count.value++;

实际上,这与 Vue 组件保持状态和 DOM 同步的方式非常接近——每个组件实例创建一个反应性效果来渲染和更新 DOM。当然,Vue 组件使用比innerHTML更高效的方法来更新 DOM,但这应该足以让你对它的工作原理有一个基本的了解。

ref()computed()watchEffect() API 都是 Vue Composition API 的一部分。

信号

还有许多其他框架引入了类似 Vue Composition API 中refs的响应性原语,称之为“信号”,我们将在本章讨论。

从根本上说,信号与 Vue 的refs是同一种响应性原语。它是一个值容器,提供访问时的依赖跟踪,以及在变异时触发副作用。这种基于响应性原语的范式在前端世界并不新鲜:它可以追溯到十多年前的 Knockout observables 和 Meteor Tracker 的实现。Vue Options API 和 React 状态管理库 MobX 也是基于相同原理,但是隐藏在对象属性背后。

尽管信号并非作为某种东西的必要特征,但今天这个概念经常与渲染模型一起讨论,其中更新通过精细的订阅进行。由于使用虚拟 DOM,Vue 目前依赖编译器来实现类似的优化。然而,Vue 还在探索一种新的受 Solid 启发的编译策略(Vapor Mode),不依赖于虚拟 DOM,并更充分利用 Vue 内置的响应性系统。

简单性

Vue 最大的优势在于它的简单性。使用 Vue 很容易入门:你可以简单地在 HTML 文件中使用<script>标签引入 Vue 库,然后开始编写 Vue 组件。Vue 还提供了一个 CLI 工具来搭建新项目,这可以是开始更复杂应用的好方法。

虽然我们在这里只是浅尝辄止 Vue.js,但很明显,Vue 强大的响应性系统、基于模板的语法和良好结构的组件模型使它成为许多开发者的一个吸引人的选择。

Angular

Angular,由 Google 开发和维护,是 JavaScript 框架领域中另一个著名的参与者。Angular 是一个完整且具有主见的框架,为前端各种问题提供了自己的解决方案,从渲染和状态管理到路由和表单处理。

Angular 引入了与 React 不同的响应性模型。Angular 不使用虚拟 DOM 的差异化和调和过程,而是使用称为变更检测的系统。

在 Angular 中,每个组件都有一个变更检测器,使用一个名为 Zone.js 的库来检查组件视图中的变更。在我们继续之前,让我们稍微详细讨论一下这个。

变更检测

变更检测是 Angular 用来检查应用程序状态是否已更改以及是否需要更新任何 DOM 的过程。在高层次上,Angular 从上到下遍历组件,寻找变更。Angular 定期运行其变更检测机制,以便将数据模型的更改反映在应用程序的视图中。变更检测可以通过手动触发或通过异步事件触发。

变更检测高度优化和高性能,但如果应用程序频繁运行变更检测,仍可能导致减速。这种变更检测系统是一个强大且灵活的工具,Angular 提供了几种策略来优化其行为,以适应不同场景的性能需求。

Angular 也使用模板语法,类似于 Vue,但它提供了更强大的指令和结构来操作 DOM,比如 *ngIf 条件性地渲染元素和 *ngFor 渲染列表。这与使用 JSX 的 React 不同,后者使用内置 JavaScript 表达式来渲染动态数据。

信号

Angular 正在进行一些根本性的变更,放弃脏检查,并引入自己的响应性原语实现。Angular 信号 API 如下所示:

const count = signal(0);

count(); // access the value
count.set(1); // set new value
count.update((v) => v + 1); // update based on previous value

// mutate deep objects with same identity
const state = signal({ count: 0 });
state.mutate((o) => {
  o.count++;
});

与 Vue 的引用不同,Angular 的基于 getter 的 API 风格在 Vue 组件中使用时提供了一些有趣的权衡。

  • ().value 略少冗长,但更新值更为冗长。

  • 无需解包引用:访问值始终需要 ()。这样可以确保在任何地方访问值的一致性。这也意味着可以将原始信号传递为组件的属性。

Angular 是一种类似瑞士军刀的工具,提供了广泛的工具来构建复杂的应用程序。它的固执己见既是其代码库一致性和结构的优点,也是新开发人员面临的学习曲线和灵活性的限制。

Svelte

Svelte 是构建用户界面的一种激进新方法。与传统框架不同,Svelte 是一个编译器,将您的声明式组件转换为高效的命令式代码,精确地更新 DOM。因此,您可以用更少的代码编写高性能、响应式的 Web 应用程序。

Svelte 的响应模型非常简单但功能强大。在 Svelte 中,响应式语句使用简单的语法编写,这让人想起电子表格公式。这里是一个基本的 Svelte 组件:

<script>
let count = 0;

function increment() {
    count += 1;
}
</script>

<div>{count}</div>
<button on:click={increment}>
  Click me
</button>

在这个例子中,标记中的{count}语法将在count变量更改时自动更新。这类似于 React 的 JSX,但有一个关键的区别:在 Svelte 中,这种响应性是自动的。您不需要调用设置函数或使用任何特殊的 API 来更新 DOM;您只需分配给变量,Svelte 就会处理其余的工作。

Svelte 还提供了一种响应式语句语法,允许您根据您的响应式数据计算值:

<script>
let count = 0;
let doubleCount = 0;

$: doubleCount = count * 2;

function increment() {
    count += 1;
}
</script>

<div>{doubleCount}</div>
<button on:click={increment}>
  Click me
</button>

在这个例子中,当count变化时,doubleCount会自动更新。这类似于 Vue 中的计算属性,但语法可能更简单。

Svelte 采用的编译器方法具有几个优势。通常情况下,这会导致更快的运行时性能,因为没有虚拟 DOM 的差异和修补步骤。相反,Svelte 生成直接更新 DOM 的代码。

然而,这种方法也存在一些权衡。Svelte 的编译器中心性意味着一些基于虚拟 DOM 的框架提供的动态能力(如动态组件类型)可能更加笨拙或冗长地表达出来。此外,由于 Svelte 生态系统比 React、Vue 和 Angular 更小且更年轻,因此可用的资源、库和社区解决方案可能较少。

符文

符文是影响 Svelte 编译器的符号。虽然今天 Svelte 使用let= export关键字和$:标签来指代特定事物,符文则使用函数语法来实现相同的功能,甚至更多。

例如,要声明一个响应式状态片段,我们可以使用$state符文:

<script>
`-   let count = 0;`
+   let count = $state(0);

    function increment() {
        count += 1;
    }
</script>

<button on:click={increment}>
    clicks: {count}
</button>

随着应用程序复杂度的增加,弄清哪些值是响应式的,哪些不是,可能会变得棘手。当前的启发式方法仅适用于组件顶层的let声明,这可能会引起混淆。例如,如果您需要将某些内容转换为存储以便在多个位置使用,则在.svelte文件内部和.js文件内部的代码行为不一致可能会使重构代码变得困难。

有了符文,响应性不再局限于您的.svelte文件的边界之内。假设我们想要封装计数器逻辑,以便在组件之间重复使用。今天,您可以在.js.ts文件中使用自定义存储:

import { writable } from "svelte/store";

export function createCounter() {
  const { subscribe, update } = writable(0);

  return {
    subscribe,
    increment: () => update((n) => n + 1),
  };
}

因为这实现了存储契约——返回值具有subscribe方法——我们可以通过在存储名称前加$来引用存储值:

<script>
+   import { createCounter } from './counter.js';
+
+   const counter = createCounter();
`-   let count = 0;`
`-`
`-   function increment() {`
`-       count += 1;`
`-   }`
</script>

`-<button on:click={increment}>`
`-   clicks: {count}`
+<button on:click={counter.increment}>
+   clicks: {$counter}
</button>

这样做虽然有效,但感觉有点奇怪!当你开始做更复杂的事情时,存储 API 可能会变得非常难以管理。而有了符文,一切变得简单得多:

-import { writable } from 'svelte/store';

export function createCounter() {
`-   const { subscribe, update } = writable(0);`
+   let count = $state(0);

    return {
`-       subscribe,`
`-       increment: () => update((n) => n + 1)`
+       get count() { return count },
+       increment: () => count += 1
   };
}
<script>
    import { createCounter } from './counter.js';

    const counter = createCounter();
</script>

<button on:click={counter.increment}>
`-   clicks: {$counter}`
+   clicks: {counter.count}
</button>

注意,我们在返回对象中使用了get属性,以便counter.count始终指向当前值,而不是函数被调用时的值。

运行时响应性

今天,Svelte 使用编译时响应性。这意味着,如果你有一些使用$:标签的代码,以便在依赖项更改时自动重新运行,这些依赖项在 Svelte 编译组件时确定:

<script>
    export let width;
    export let height;

    // the compiler knows it should recalculate `area`
    // when either `width` or `height` change...
    $: area = width * height;

    // ...and that it should log the value of `area`
    // when _it_ changes
    $: console.log(area);
</script>

这运作良好……直到它不工作。假设我们将代码重构如下:

// @errors: 7006 2304
const multiplyByHeight = (width) => width * height;
$: area = multiplyByHeight(width);

因为$: area = ...声明只能看到width,所以当height变化时不会重新计算。因此,代码很难重构,理解 Svelte 何时选择更新哪些值在一定复杂程度之后可能变得相当棘手。

Svelte 5 引入了$derived$effect符文,它们在评估表达式时确定其依赖关系:

<script>
    let { width, height } = $props(); // instead of `export let`

    const area = $derived(width * height);

    $effect(() => {
        console.log(area);
    });
</script>

$state$derived$effect类似,您也可以在.js.ts文件中使用它们。

信号放大

与其他框架一样,Svelte 也意识到 Knockout 一直是正确的。

Svelte 5 的响应性由信号驱动,这本质上是 Knockout 在 2010 年所做的。最近,信号已经被 Solid(稍后详细介绍)推广,并被多个其他框架采纳。在 Svelte 5 中,信号是一个底层实现细节,而不是直接与之交互的内容。

Solid

Solid 是一个用于构建用户界面的声明性 JavaScript 库。它类似于 React,提供了一个组件模型基础,但 Solid 基于响应式原语。Solid 不使用虚拟 DOM,而是使用细粒度的响应性系统来自动跟踪依赖关系并直接更新 DOM,这可能会导致更有效的更新。

这是一个简单的 Solid 组件示例:

import { createSignal } from "solid-js";

function Component() {
  const [count, setCount] = createSignal(0);

  return (
    <>
      <div>{count()}</div>
      <button onClick={() => setCount(count() + 1)}>Increment</button>
    </>
  );
}

在这个示例中,createSignal创建了一个响应式原语,类似于 React 中的useState。关键区别在于count是一个返回当前值并隐式注册依赖关系的函数。当调用setCount时,它会触发依赖于count的任何 UI 部分的更新,而不重新调用函数组件。

要与 React 对比,在 React 中,组件(在这种情况下是Component)会被重新调用,包括其块内的所有逻辑。因此,count值本身不是响应式的。在 Solid 中,Component函数永远不会被重新调用,但count值本身是响应式的,并在调用setCount时变化。这被称为细粒度响应性,与 React 的粗粒度响应性完全相反。

Solid 的细粒度响应性系统意味着它可以最小化不必要的更新,并避免需要差异化步骤,从而实现非常高的性能。然而,由于它是一个相对较新且使用较少的库,可能没有像一些更成熟选项那样多的资源和社区解决方案。

Solid 的createSignal() API 设计强调了读/写分离。信号作为只读 getter 和单独的 setter 公开:

const [count, setCount] = createSignal(0);

count(); // access the value
setCount(1); // update the value

注意 count 信号如何在不使用 setter 的情况下传递。这确保状态除非显式公开 setter,否则不能被修改。

Solid 重新点燃了关于信号的讨论,并且这个概念已被许多其他框架和库采纳,正如我们之前看到的。关于信号的所有前述内容都来自于 Solid 的作者 Ryan Carniato 的工作,他以某种方式凭借 2010 年的概念单枪匹马地改变了整个前端生态系统。

Qwik

Qwik 是一个独特的框架,旨在优化 Web 页面的加载,并优先考虑用户交互和响应能力。与传统框架不同,它将 Web 页面视为可以独立通过网络加载并按需交互的组件集合。这种方法显著减少了页面的初始加载时间,提升了整体用户体验。

使用 Qwik 构建的 Web 应用和站点带有极小且恒定的初始 JavaScript(~1 kB)。Qwik 站点加载的初始 JavaScript 量是恒定的,因为它是 Qwik 加载器。这就是为什么在某些圈子中称 Qwik 为“O(1) 框架”的原因,意味着无论应用程序大小如何,它的加载时间都是恒定的。

起初,Qwik 只加载最少量的 JavaScript,然后根据需要加载组件和其他行为。这种方法使得 Qwik 能够优先加载最重要的组件,从而实现更快的初始加载时间和更响应的用户体验。

Qwik 的一个重要特性是可恢复性。我们在服务器端 React 的章节中粗略地介绍了可恢复性(第六章),但回顾一下:可恢复性是通过将页面的初始状态的服务器渲染快照发送到客户端来实现的。当用户打开页面时,他们与这个静态快照进行交互,直到他们需要更多的互动为止。然后,随着用户继续操作,各种行为按需加载。这种机制为用户提供了即时的交互机会,这是许多其他框架中不多见的特性。

可恢复性远远优于水合(也在第六章中讨论),因为它不需要两次渲染组件。它还避免了用户界面的“奇异谷”,即在初始服务器渲染标记到达浏览器并且 JavaScript 加载并水合页面之前的一段时间内,网站不具有交互性。Qwik 立即启动。

在将 Qwik 与其他流行的框架如 React、Vue、Svelte 或 Solid 进行比较时,会发现几个区别。虽然 React 和 Vue 也采用了基于组件的方法,但如果我们在代码拆分方面不小心或不有意识地进行处理,有时可能会一次性向客户端发送整个 JavaScript 捆绑包,有时会达到几兆字节。这个过程可能导致更长的初始加载时间,特别是对于大型应用程序而言。另一方面,Qwik 只在需要时加载组件和事件处理程序,从而实现更快的初始加载时间和更响应迅速的用户体验。Qwik 还精于预取,对于延迟加载的元素进行预取,使得所有内容在初始加载时都被预取,但只有在需求时才进行解析和执行。

Qwik,像 Svelte 和 Solid 一样,专注于性能,但通过不同的方式实现这一目标。Svelte 将组件编译为高效的命令式代码,直接操作 DOM,而 Solid 则使用响应式细粒度的响应模型来处理其组件。Qwik 使用响应式原语,专注于优化组件加载,确保尽快提供最重要的组件。

在开发者体验方面,Qwik 提供了一个简单直观的 API,使得定义和使用组件变得非常容易。Qwik 组件在语法和结构上几乎与 React 组件相同,因为它们也是使用 JSX(或 TSX)表示的。这种相似性使得开发者可以轻松开始使用 Qwik,特别是如果他们已经熟悉 React 的话。

此外,Qwik 与 React 具有互操作性,允许开发者通过 qwikify 实用程序在 Qwik 应用程序中使用 React 组件。这种互操作性对于希望使用 Qwik 但也想利用丰富的 React 库和工具生态系统的开发者来说是一个重大优势。

Qwik 通过其基于组件和事件驱动的架构提出了现代 Web 开发的新方法。它的重点在于可恢复性和优先加载,使其在与 React、Vue、Svelte 和 Solid 等其他框架相比显得与众不同。虽然每个工具都有其优势和使用案例,但 Qwik 的独特特性使其成为 Web 开发框架领域中令人兴奋的新选择。对于寻求高性能、以用户为中心和高效构建 Web 应用程序的开发者和团队来说,Qwik 可能是一个正确的选择。

Qwik 唯一的缺点是它仍然相对较新,并且没有像 React、Vue 或 Angular 那样成熟的生态系统。但是,它正在获得关注,并且拥有一个不断增长的开发者和贡献者社区。随着 Qwik 的持续发展,看到它与其他框架的比较及如何用于构建更强大的应用程序将是非常有趣的。

常见模式

所有这些技术——React、Angular、Qwik、Solid 和 Svelte——都是用于为 Web 创建丰富、交互式用户界面的解决方案。尽管它们在哲学、方法论和实现细节上各有不同,但它们共享几个反映它们共同目标的共同点。

基于组件的架构

这些框架和库之间的一个主要共同点是采用组件化架构。在组件化架构中,UI 被拆分成独立的组件,每个组件负责用户界面的特定部分。

组件封装了自己的状态和逻辑,并可以组合在一起构建复杂的 UI。这种模块化促进了代码重用、关注点分离和提高了可维护性。在这些框架中,组件可以是函数式的,并且通常可以组合、扩展或装饰以创建更复杂的组件。

声明式语法

React、Angular、Qwik、Solid 和 Svelte 都使用声明式语法来定义 UI。在声明式方法中,开发者指定了给定状态下 UI 的外观,框架负责更新 UI 以匹配该状态。这抽象了繁琐且容易出错的命令式 DOM 操作。

所有这些技术都提供了自己的模板语言来编写声明式 UI。React、Qwik 和 Solid 使用 JSX;Angular 使用其自己的基于 HTML 的模板语法;而 Svelte 则有其受 HTML 启发的语言。

更新

所有这些库和框架都提供了一种机制来响应应用程序状态的更新,并相应地更改 UI。React 和 Vue 使用虚拟 DOM 差异算法来进行这些更新。而 Svelte 则将组件编译为直接更新 DOM 的命令式代码。Angular 使用基于 Zones 和可观察对象的变更检测机制。

不久之后,几乎所有人都将使用 vDOM,而其他人将使用各种信号。

尽管方法不同,目标是相同的:在响应状态变化时有效地更新 UI,抽象出复杂的 DOM 操作,使开发者能够专注于应用程序逻辑。

生命周期方法

React、Angular、Solid 和 Svelte 提供了生命周期方法或钩子,这些方法在组件生命周期的不同阶段调用,比如组件首次创建、更新和即将从 DOM 中移除时。开发者可以利用这些方法来运行副作用、清理资源或根据 props 的变化进行更新。

生态系统与工具

这些框架和库都由丰富的工具、库和资源支持。它们都支持现代 JavaScript 特性和工具,包括 ES6 语法、模块以及 Webpack 和 Babel 等构建工具。它们还具有出色的 TypeScript 支持,允许开发者编写类型安全的代码并利用 TypeScript 强大的功能。

大多数这些技术还配备或可用复杂的开发者工具,可以帮助调试和应用程序分析。React 和 Angular 的开发者工具扩展是这类工具的绝佳例子。

虽然 React、Angular、Qwik、Solid 和 Svelte 各自有其独特的优势和理念,但它们都分享以下共同目标:提供基于组件的架构,实现声明式 UI 的创建,为状态变化提供响应性,简化事件处理,提供生命周期方法或类似概念,并支持丰富的生态系统和现代 JavaScript 工具链。这些共同的特性和概念体现了 Web 开发朝向更模块化、声明式和响应式范式的演变。

React 不是响应式

“响应式”这个术语被用来描述编程世界中的许多事物,但通常用来描述自动根据数据变化更新的系统。响应式编程范式基本上是构建能响应变化并自动传播这些变化的系统。这就是为什么像 Vue.js 和 Svelte 这样的框架经常被描述为具有响应性。然而,React 并不遵循传统的响应式模型,它的方法是完全不同的。

React 是一个以声明方式构建用户界面的库——声明式意味着我们编写 React 的人只描述我们想要的是什么,React 处理如何。它允许开发者根据当前应用状态描述 UI,React 会在状态变化时更新 UI。这种描述听起来像 React 是响应式的,但当你深入了解其实现细节时,就会发现 React 的模型与传统的响应式编程模型有很大不同。

要理解为什么 React 在传统意义上不是响应式的,让我们首先看看系统中传统响应式看起来如何。在传统的响应式系统中,依赖关系在代码运行时会自动跟踪。当一个响应式依赖发生变化时,所有依赖于它的计算都会自动重新运行以反映这一变化。通常使用数据绑定、可观察对象或信号与插槽等技术来实现这一点。

例如,信号是一种可以用来创建响应式值的响应式原语:在读取时,信号的读取者订阅它,在写入时,所有订阅者都会收到通知。这就是响应式编程的基础。

React 使用了一种不同的方法来管理状态及其更新。它不会自动追踪依赖关系和传播更改,而是引入了一种更明确的机制来更新状态——useState 钩子。当状态发生变化时,React 不会立即进行渲染更新,而是安排重新渲染,在重新渲染期间,整个组件函数将会以新状态再次运行。

这意味着在这个计数器的情况下:

import React, { useState } from "react";

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

  function increment() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

export default Counter;

当调用 setCount 时,将重新调用 Counter 函数,包括 useState 钩子。这与传统的响应式模型不同,传统模型中只会更新 UI 的响应部分,而在这种情况下,更新的是 <p> 中的 {count}。这称为粗粒度的响应性,与信号的细粒度响应模型形成鲜明对比。

React 经常被认为符合以下等式:

v = f(s)

也就是说,视图等于其状态的函数。这个等式本身描述了 React 的非反应性本质:视图是状态的函数,但状态变化时不会自动更新视图。相反,只有当函数重新执行时,视图才会使用新状态更新。

这就是 React 的虚拟 DOM 差异化和协调过程的关键所在。当组件的状态或属性发生变化时,React 会重新渲染组件,创建一个新的虚拟 DOM 子树。然后,它会将这个新子树与旧子树进行比较,计算出需要的最小的实际 DOM 变化,并将这些变化应用到 DOM 上。

明确设置状态并重新渲染的这种模式,与自动的反应性变化传播相对,使得更加可预测:如果把 React 拟人化,它会说,“告诉我你对状态的期望,我会照顾好它。” 它支持批量状态更新等特性,并且使得在任何时刻都更容易推理应用程序的状态,因为状态更新和结果 UI 更新是一个单一的、原子性操作。

然而,这也意味着 React 组件在传统意义上不那么具有反应性。它们不会自动响应数据的变化。相反,它们明确描述了在给定状态下 UI 应该如何看起来,而 React 负责在状态变化时重新执行函数并应用任何必要的更新,而不是直接在原地更新相应的值。

虽然 React 的方法不是指自动跟踪和传播变化,但它仍然提供了一种高效的机制来构建动态、交互式的用户界面。使用状态和属性来控制渲染提供了一个清晰且可预测的模型,帮助理解变化如何在应用程序中传播,而虚拟 DOM 系统有效地管理更新到实际 DOM。

总的来说,无论 React 的方法是否被认为是响应式,最终都归结为语义。如果你定义响应性为系统中变化的自动传播,那么不,React 不是响应式的。但如果你定义响应性为系统能够以可预测和受控的方式响应状态变化,那么是的,React 确实可以被认为是响应式的。

查看 React 和其他框架/库,很明显,在 UI 开发中,没有一种大小合适的方法来管理状态和响应性。每种工具都有其优势和权衡,并适用于不同的用例。了解这些差异在选择合适的工具时至关重要,也可以帮助编写更有效和高效的代码,无论你使用的是哪种框架或库。

React 处理状态和更新的模型提供了控制和便利的极好平衡。显式状态更新机制使开发人员能更轻松地推理他们的应用状态,而协调和差异算法则高效地将更新应用到 DOM 中。尽管不是传统意义上的“响应式”,React 的方法已被证明在构建复杂用户界面时非常有效。

不可否认的是,响应式编程模型在自动管理依赖和更新方面提供了一些引人注目的好处。但正如我们所见,React 的方法提供了自己一套优势,提供了高度的控制和可预测性。

对于完美主义者来说,现在我们将看一下在 Solid 中,一个使用响应式模型的框架中相同计数器是如何表现的:

import { createSignal } from "solid";

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

  function increment() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>{count()}</p>
      <button onClick={increment()}>Increment</button>
    </div>
  );
}

export default Counter;

在这个例子中,count是组件数据的一个响应式属性。当我们通过在我们的<p>元素内部调用count()来首次读取count时,我们隐式订阅了count的响应式值的那部分 JSX。

然后,当我们稍后调用increment()时,这将调用setCountsetCount更新值并通知所有订阅者值已更改,促使它们进行更新。这有点类似于发布/订阅模式,其中订阅者订阅发布者,发布者通知所有订阅者。

结果是细粒度的响应性:即,函数组件本身Counter从未被调用超过一次,但是细粒度、小型的响应式值会被调用。

示例 2:依赖值

考虑一个显示项目列表和项目计数的组件。在像 Svelte 这样的响应式系统中,每当列表变化时,计数会自动更新:

<script>
  let items = ['Apple', 'Banana', 'Cherry'];
  $: count = items.length;
</script>

<p>{count} items:</p>
<ul>
  {#each items as item (item)}
    <li>{item}</li>
  {/each}
</ul>

在这里,$: count = items.length; 声明了一个响应式语句。每当 items 发生变化时,count 会自动重新计算。

在 React 中,这看起来有些不同:

import React, { useState } from "react";

function ItemList() {
  const [items, setItems] = useState(["Apple", "Banana", "Cherry"]);
  const count = items.length;

  // ... update items somewhere ...

  return (
    <div>
      <p>{count} items:</p>
      <ul>
        {items.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

export default ItemList;

在这个 React 组件中,count 不是一个在items改变时自动更新的响应式值。相反,它是在渲染阶段从当前状态派生出来的值。当items改变时,我们需要调用setItems来更新状态并引起重新渲染,在这个时候count被重新计算,不是因为count是响应式的,而是因为ItemList函数组件被重新调用。

React 的未来

鉴于像信号这样的响应式原语在整个前端生态系统中的广泛采用,一些人可能会认为 React 最终会采纳类似的方法。然而,React 团队表示他们对信号“不感兴趣”,并选择了另一种方法来实现信号提供的类似性能优势。

为了更好地理解这一点,让我们通过一个例子回顾一些我们通过 React 学到的东西。考虑这个组件:

import React, { useState } from "react";
import
{ ComponentWithExpensiveChildren } from "./ExpensiveComponent";

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

  function increment() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>Increment</button>
      <ComponentWithExpensiveChildren />
    </div>
  );
}

export default Counter;

在这个非常,非常构造的例子中,我们有一个包含名为Counter状态的组件,带有一些子元素:

  • 一个 <p> 元素,显示当前计数

  • 一个 <button> 元素,用来增加计数

  • 一个 <ComponentWithExpensiveChildren> 组件,渲染一些计算量大的昂贵子元素

现在,假设我们点击按钮增加计数。会发生什么?Counter函数会被调用/重新调用/重新渲染以及其所有的子元素。这是 React 的默认行为。这意味着即使其 props 或状态没有改变,<ComponentWithExpensiveChildren>组件也会重新渲染!

这种粗粒度的响应性使得 React 的性能比可能的更低。然而,这是一个相当容易的修复:我们只需在正确的时间和地点包含memo

import React, { useState, memo } from "react";
import { ComponentWithExpensiveChildren } from "./ComponentWithExpensiveChildren";

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

  function increment() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>Increment</button>
      <MemoizedComponentWithExpensiveChildren />
    </div>
  );
}

const MemoizedComponentWithExpensiveChildren = memo(
  ComponentWithExpensiveChildren
);

export default Counter;

只要我们记得在需要的地方使用memo,这就能正常工作。实际上,这提供了与信号相同的细粒度响应性。但它不像信号那样方便,因为我们必须记得在需要的地方使用memo

我们中的许多人可能认为,信号很容易解决这个问题,但 Meta 的 React 团队认为信号和memo可能是日常使用 React 的开发人员不必考虑的实现细节。他们回顾了 React 的最初价值主张:“声明性地描述你的 UI,让 React 去处理剩下的事情。” React 团队认为,更优越的方式是开发者不需要关心信号、memo或任何细节,而是让 React 能够找出渲染 UI 的最佳方式。

为此,团队正在开发一个新的软件来做到这一点:React Forget。

React Forget

Forget 是一个针对 React 的工具链,类似于启用了其--fix标志的代码检查工具:它强制执行 React 的规则,然后通过智能记忆那些在应用程序生命周期中不会改变的值来自动优化 React 代码,例如ComponentWithExpensiveChildren

由于 React 的这些规则,Forget 编译器可以预测这些值并为我们进行记忆化。这与 Svelte 的做法类似,但 Forget 编译的是更高性能的 React 代码,而不是编译为命令式代码。

这些 React 的规则是什么?让我们回顾一下:

  1. React 组件应该是纯函数。

  2. 一些钩子和自定义事件处理程序不需要是纯函数。

  3. 纯函数中禁止的操作包括:

    • 在函数内部禁止变异变量/对象而不是新创建的。

    • 读取可能会改变的属性。

  4. 允许的操作包括:

    • 读取 props 或 state

    • 抛出错误

    • 变异新创建的对象/绑定。

  5. 惰性初始化是一个例外,允许为初始化目的进行变异。

  6. 渲染期间创建的对象或闭包在渲染完成后不应变异,除了存储在状态中的可变对象。

正是由于这些 React 的规则,Forget 编译器可以预测应用程序生命周期中不会改变的值,并为我们进行记忆化。结果是?高度优化、性能卓越的 React 代码,可以与使用信号的其他库的性能相媲美。

在撰写本文时,Forget 在 Meta 公司正在评估中,并且在 Instagram 和 WhatsApp 上的使用超出了预期。它尚未开源,但 React 团队正在考虑在不久的将来发布它作为开源软件。

Forget 与信号的对比

因为 Forget 尚未开源,所以很难以权威的方式评论它的权衡。然而,我们可以推测,如果 Forget 确实对所有不会改变的内容进行了记忆化,那么信号的细粒度反应性可能仍然优于 React Forget 的粗粒度反应性,因为信号存在于组件层次结构之外的平行宇宙。

因此,当更新发生时,React 仍然需要遍历整个组件树,并比较每个组件的 props 的新旧值,以确定哪些组件需要重新渲染。这在信号中并非如此,其中仅更新 UI 的响应部分而无需遍历树。初步数据表明,即使使用了 Forget,React 仍可能比默认使用信号的库慢,但现在还为时过早。

章节回顾

本章开始时回顾了第九章,我们详细讨论了 RSCs 的使用。然后我们深入探讨了 JavaScript 框架的广阔领域,超越了 React,包括 Angular、Vue、Svelte、Solid 和 Qwik,旨在理解这些库和框架之间的差异和相似之处。

我们从 Vue.js 入手,探讨了它如何采用声明式方法构建 UI,并通过其基于组件的架构促进了关注点的强分离。

接下来,我们深入研究了 Angular、Svelte、Solid 和 Qwik,探讨它们的独特特性和理念。我们看了它们如何使用响应式基元自动更新 UI,以响应数据变化,并探讨了它们在这方面与 React 的不同之处。

在个别考察之后,我们对这些 UI 库进行了比较,突出它们的优势、劣势和重叠之处。我们关注它们的响应模型、架构选择、开发体验和性能特征。通过代码示例,我们展示了每个库的独特特性,帮助我们更好地理解它们的差异。

我们还研究了响应性的概念以及在不同库中如何实现不同。有趣的是,我们讨论了 React 不同于传统意义上的响应式,因为它遵循一种更粗粒度的方法,其中状态变化会导致重新渲染,而不是像 Vue 或 Svelte 这样的库中的细粒度响应模型。

最后,我们看了一眼 React 未来的发展方向以及它未来几年可能的演变。我们讨论了 React 团队对响应性的方法,以及它与传统响应式编程模型的不同之处。我们还看了一下 Forget 编译器,这是一个用于 React 的工具链,通过记忆那些在应用程序生命周期中不变的值来自动优化 React 代码。

最后,让我们把飞机安全降落。

复习问题

这里有一些问题列表,帮助您跟踪本章讨论的概念理解程度。如果您能自信地回答所有问题,那很好!这表明您正在从这本书中学到东西。如果不能,可能值得重新阅读本章。

  1. React、Vue、Svelte、Solid 和 Angular 之间的响应模型有何不同?这些差异对这些库/框架的性能和开发体验有何影响?

  2. 讨论 Qwik 在最大化性能方面的独特方法。这与我们讨论过的其他 UI 库/框架的方法有何不同?

  3. 在本章讨论的每个 UI 库/框架中,它们各自的核心优势和劣势是什么?这些优势和劣势如何影响选择某个项目的库/框架?

  4. React 在传统意义上并非是响应式的。详细解释这个说法,并与像 Vue 或 Svelte 这样的“推送型”响应模型进行比较。

  5. 什么是 React Forget?它如何工作?它与信号(signals)相比如何?

接下来

当我们接近总结对 React 及其生态系统的全面探索时,我们准备综合我们所学的一切。在下一章,也是最后一章中,我们将退后一步,反思整个局势。

我们将总结本书内容,全面展示我们今天的进展及明日的预期。在此过程中,我们将汲取本书中积累的所有技术知识和见解。

从理解 React 协调器的内部工作方式和深入异步性,到处理服务器组件和理解各种 React 框架,再到与同行比较 React —— 所有这些都有其目的。现在,我们准备好串联各点,看到更大的画面,并规划前进的道路。

那么,你准备好迈入 React 和前端开发的未来了吗?敬请期待壮丽的结局!

第十一章:结论

如果你已经走到这一步,感谢你加入我一起探索 React 生态系统的旅程。希望你和我一样享受这次冒险。在我们在一起的时间里,我们追求了对 React 更深入的理解,探索了它的核心原则、内部工作机制和更广泛的生态系统。假设我们已经知道如何使用React,我们专注于理解它的机制:它实际上是如何工作的——最终目标是我们可以在未来的工程生涯中应用的实用经验。

收获

现在让我们概括一些收获:

重新思考最佳实践。

有时,我们需要重新思考一切。React 引入 JSX 和虚拟 DOM 是对现状的激进改变。它挑战了已经建立的传统,迫使我们重新思考如何构建界面。挑战现状并重新想象事物如何完成的意愿是 React 哲学的标志,因此作为工程师,我们应该始终愿意挑战现状并重新思考事物的完成方式。

完全理解 JSX 的工作原理。

如果我们受限于一种编程语言——比如说,如果我们不能在 JavaScript 中使用 HTML 样式的语法——我们作为工程师有权力通过创建一种新语言来改变这一点。这就是 JSX 的作用:一种编译成 JavaScript 的新语言。现在我们完全理解了 JSX 的工作原理和一些编译器理论,我们也可以做到这一点。

约束并不是一件坏事。

约束是创新之母。React 基本上是在 Web 的约束下诞生的创新,其中读取元素的innerWidth会导致重新布局,不同的浏览器有不同的事件 API。这里的要点是,约束并不是一件坏事。它们迫使我们跳出固有思维,提出创造性解决方案。

声明性抽象解锁了强大的能力。

通过将 JSX 的表达与协调器分离,React 开创了一种“一次编写,随处运行”的 UI 开发方法,使我们能够使用相同的代码来渲染到 DOM、服务器,甚至原生平台。这是一个强大的能力,我们可以在自己的项目中利用这一点,考虑分离关注点并确定正确的抽象级别。

解锁强大的能力使我们能够构建更灵活、易维护的应用程序。

我们揭示了许多模式,从高阶组件到渲染属性到钩子到上下文。这些模式是我们可以用来抽象逻辑、在组件之间共享行为、更有效地管理状态的强大工具。虽然这些模式引入了复杂性,但它们也解锁了强大的能力,使我们能够构建更灵活、易维护的应用程序。此外,像 HOCs 一样,这些模式早在 React 之前就存在。我们今天正在使用哪些模式,它们将成为下一代 UI 框架的基础?我们可以发明哪些模式使我们的生活更轻松?

我们可以在我们自己的项目中利用强大的能力。

我们了解到,当我们超越浏览器,通过到服务器,一系列新的可能性会展现。我们可以在服务器上渲染我们的 React 组件,使用浏览器的原生 fetch API 加载数据,并且我们可以使用原生 HTML 表单进行用户输入。这些都是我们可以在我们自己的项目中利用的强大能力,考虑到服务器端渲染的权衡和利用 web 基础知识的好处。

改善用户体验的好处。

现在,我们可以充分利用 React 的并发特性,比如useTransition,通过将工作推迟到“另一个宇宙”来改善用户体验,然后在准备就绪时将更改提交到 DOM。这是我们可以在我们自己的项目中利用的强大能力,考虑到推迟工作的权衡和改善用户体验的好处。

所有这些都是用我们知道和理解的语言完成的。

我们通过创建我们自己的框架的视角深入探讨了 Next.js 和 Remix 之间的复杂性,最终认识到这一切只是一些带有服务器和其他东西的 JavaScript:我们也可以根据充足的时间和资源构建我们自己的框架。虽然我们感谢作者的工作,但我们也可以自信地说,所有这些都是用我们知道和理解的语言完成的。

向我们的用户交付大大减少的代码。

就像从浏览器到服务器的进一步扩展一样,我们了解到通过打包工具进一步扩展浏览器可以解锁全新的可能性,利用打包工具将客户端组件与服务器组件分离,并向我们的用户交付大大减少的代码。我们还能使用哪些酷炫的编译器/打包工具技巧来改善用户体验呢?

我们可以从其他框架中汲取灵感,并应用到我们自己的项目中。

我们放大视角看了一下,即使在 React 之外,每个人也在解决同样的问题:我们如何构建用户界面,既快速、响应迅速,又具有出色的开发者体验?我们从 Vue、Solid、Qwik 等框架中探索了一些思路,我们了解到可以从这些其他框架中汲取灵感,并应用到我们自己的项目中。

随着我们对 React.js 的探索画上句点,反思我们所走过的旅程并理解这个库的变革性质是至关重要的。多年来,React 的成长证明了它的适应性、韧性和社区创新精神。从通过 JSX 引入更直观的界面构建方式,到重新构想通过虚拟 DOM 实现更高效更新,React 毫无疑问在 web 开发的领域留下了不可磨灭的印记。

我们的时间轴

本书的初章简要介绍了 React 的核心原则。在其核心,React 的理念是构建使得网页体验更加易于接近、可扩展和可维护的组件。这些自包含的工作单元——组件、纤维、元素——封装了逻辑和 UI,使得我们能够更轻松地理解应用在扩展时的运作方式。

使用 JSX,React 提供了一种声明式的 UI 开发方式。通过将我们的界面作为应用状态的函数,我们可以轻松地理解和预测数据变化对 UI 的影响。清晰的分离和单一的真相源概念无疑改变了开发人员处理 UI 构建的方式。

随着 React 的影响力逐渐扩展,它不可避免地影响了科技行业,激发了许多平台和框架。其中最显著的受影响平台之一是苹果的 SwiftUI,这是一个用于在所有苹果设备上构建用户界面的框架。

受 React 和其他技术影响,SwiftUI 采纳了类似的理念。与 iOS 开发中经常见到的经典 MVC(Model-View-Controller)设计模式不同,SwiftUI 鼓励开发人员使用称为视图的较小、组件式结构构建 UI。SwiftUI 中的每个视图都是一个自包含的单元,类似于 React 组件。

随着 UI 框架的不断发展,思想的跨越污染将继续存在。一个平台的创新可以激发另一个平台的改进,从而导致更丰富、更具连贯性的开发格局。React 对 SwiftUI 及更广泛生态系统的影响是这种共生关系的一个典型例子,它为科技界的未来合作和灵感奠定了基础。

魔术背后的机制

虚拟 DOM 和 Fiber 协调器是我们深入探讨的一些更技术性的主题。这些概念是 React 高效和高性能更新的齿轮和滑轮。虚拟 DOM 充当我们应用状态与实际 DOM 之间的中介。通过比较差异和批量更新,React 确保以最小的工作量保持 UI 与状态同步。

另一方面,协调器是这个操作的大脑。它决定何时以及如何更新组件,优化性能并确保一致性。我们探索了协调器的内部工作原理,学习了不同阶段的工作及其背后的工作。我们还看到协调器如何优先处理工作,确保首先处理最重要的更新。

高级冒险

探索高级领域时,我们研究了 React 中的高级模式。这些模式,如高阶组件、渲染属性、钩子和上下文,允许开发人员抽象逻辑、在组件之间共享行为,并更有效地管理状态。虽然这些模式引入了复杂性,但它们也解锁了强大的能力,使我们能够构建更灵活、可维护的应用程序。

服务器端 React 和并发 React 带领我们穿越了 React 应用程序的演变过程。随着对快速初始加载和交互体验需求的增加,利用服务器和异步操作变得至关重要。这些技术确保我们的应用程序保持灵敏、响应迅速和以用户为中心。

我们探讨了react-dom的服务器端,包括诸如renderToStringrenderToPipeableStream等函数,概述了每种方法的权衡。我们还探讨了 React 的一些异步能力,例如useSyncExternalStoreuseTransition,以及它们如何用于改善用户体验。

最后,我们涉足了 React Server Components,这是 React 生态系统中较新的增加,代表了该库持续的演进。通过仅在服务器上渲染组件,我们可以创建更高效的应用程序,优化性能和用户体验。

在最后的章节中,我们探索了围绕 React 的更广泛生态系统,包括各种框架和替代库。React 的成功催生了大量工具、框架和替代品,每个都带来了其自身的优势和折衷。

React 自诞生以来已经走过了漫长的道路,其旅程反映了不断发展的 Web 开发世界。当你阅读本书时,你不仅学习了一个库,还深入了解了驱动现代 Web 开发的范式和原则。

保持更新

跟上不断发展的 JavaScript 生态系统,包括围绕 React 构建的许多框架,可能会感觉像一项艰巨的任务。每年都会推出许多新工具和库,每个都具有其独特的功能、优点和折衷。作为开发人员,要做出关于未来项目使用合适框架的明智决策,不仅需要熟悉生态系统的当前状态,还需要前瞻性地理解这些工具的发展轨迹及其在 Web 开发更广泛背景中的位置。

有几种策略可以保持更新,并持续为未来项目选择合适的 React 框架做出明智决策:

关注可信的消息来源。

JavaScript 生态系统发展迅速。跟随提供高质量内容和定期更新的可信源至关重要,这些内容包括博客、YouTube 频道、新闻稿、播客或在线社区。例如,关注 Next.js 和 Remix 的官方博客和 Twitter 账号可以获取它们即将推出的功能、改进和整体路线图。

我们推荐的一些资源包括:

  • React 文档位于 react.dev

  • React 核心成员在𝕏(原 Twitter),包括但不限于:

    • @sophiebits

    • @sebmarkbage

    • @zmofei

    • @acdlite

    • @rickhanlonii

    • @dan_abramov2

  • React 社区创作者在𝕏(包括但不限于):

    • @kadikraman

    • @kentcdodds

    • @shaundai

    • @Saurav_Varma

    • @rachelnabors

参与相关社区。

Reddit、Stack Overflow、GitHub 或各种 Discord 和 Slack 群体等在线社区是关注新兴趋势和工具的绝佳场所。社区成员经常分享他们对不同框架的经验,这对于在不同工具之间做出决策时提供了有用的视角。

一些有用的社区资源包括:

  • React subreddit

  • Reactiflux Discord 服务器

  • bytes.dev 新闻简报

  • React Roundup 播客

  • “This Week in React” 新闻简报

参加会议和聚会。

参加会议和聚会有助于了解 JavaScript 和 React 生态系统的最新发展和最佳实践。即使无法亲临现场,许多活动都提供在线直播或录制的讲座以供后续观看。

一些很棒的 React 会议包括:

  • React Brussels

  • React Alicante

  • React India

  • React Day Verona

尝试不同的框架。

没有什么比实际操作更能帮助理解工具的了。花些时间用不同的框架构建小项目或原型,可以提供宝贵的见解。这可以帮助你理解每个框架的优缺点以及它们如何与你的开发风格和项目需求相匹配。

公开构建。

由 Shawn Wang(@swyx)推广,保持最新的最佳方式可能就是公开构建。这意味着与社区分享你的工作、想法和观点。可以简单地在社交媒体上发布关于你工作的内容,也可以写一篇博客或制作一个 YouTube 视频。通过分享你的工作,你可以从社区中获得反馈,这有助于提高你的技能并深入了解你正在使用的工具。

写书对我来说是学习 React 的一种很好的方式。我从社区学到了很多东西,也能够与他人分享我的知识。我强烈推荐这样做!

结束时,请记住,学习 React 不仅仅是掌握一个库;它是接纳一种思维方式的过程。这种以组件驱动开发、性能优化和持续适应 Web 不断变化需求为核心的思维方式。

Web 开发的未来光明无限,React 及其社区无疑将在塑造中发挥重要作用。无论您是经验丰富的开发者还是刚刚起步的人,您从本书中获得的技能和知识将在您继续在广阔而令人兴奋的 Web 开发领域中追求过程中发挥重要作用。

祝愿使用 React 构建更直观、高性能和用户中心的应用程序。为未来干杯,感谢您成为这一冒险的一部分!

posted @ 2025-11-18 09:36  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报